Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -10,9 +10,11 @@ on:
|
|||||||
- 'solitaire_web/**'
|
- 'solitaire_web/**'
|
||||||
- 'solitaire_sync/**'
|
- 'solitaire_sync/**'
|
||||||
- 'solitaire_core/**'
|
- 'solitaire_core/**'
|
||||||
|
- 'solitaire_data/**'
|
||||||
- 'solitaire_engine/**'
|
- 'solitaire_engine/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
|
- 'build_wasm.sh'
|
||||||
- 'solitaire_server/Dockerfile'
|
- 'solitaire_server/Dockerfile'
|
||||||
- '.gitea/workflows/docker-build.yml'
|
- '.gitea/workflows/docker-build.yml'
|
||||||
|
|
||||||
@@ -36,12 +38,9 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
|
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||||
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
|
# the Dockerfile's wasm-builder stage builds them from source inside
|
||||||
# and commits it back (CI is the single source of truth — the artifacts
|
# this image build, so the deployed image always ships fresh wasm.
|
||||||
# aren't byte-reproducible on contributor machines). That pkg/ commit then
|
|
||||||
# triggers this workflow, so the deployed image always ships fresh wasm.
|
|
||||||
# No drift check is needed here.
|
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
- name: Log in to Gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
||||||
# locally, run on every master push and pull request. Until this workflow
|
# locally, run on every master push and pull request. Until this workflow
|
||||||
# existed, nothing in CI ran the test suite at all — a direct push to
|
# existed, nothing in CI ran the test suite at all — a direct push to
|
||||||
# master (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
|
name: Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -28,8 +41,27 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Seconds-long formatting gate in its own job so a rustfmt slip fails
|
||||||
|
# here instead of after a 35-minute cold build (run 600 spent its
|
||||||
|
# whole build budget to report an unformatted file).
|
||||||
|
fmt:
|
||||||
|
runs-on: rust-host
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust 1.95.0
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.95.0
|
||||||
|
components: rustfmt
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: rust-host
|
runs-on: rust-host
|
||||||
|
needs: fmt
|
||||||
|
|
||||||
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
# 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.
|
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
||||||
@@ -40,10 +72,19 @@ jobs:
|
|||||||
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
||||||
# two+ simultaneous ld processes OOM-killed the runner again even at
|
# two+ simultaneous ld processes OOM-killed the runner again even at
|
||||||
# line-tables-only. Two jobs keeps at most two links in flight — the
|
# line-tables-only. Two jobs keeps at most two links in flight — the
|
||||||
# compile-throughput cost is small next to the cache-warm build.
|
# 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:
|
env:
|
||||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||||
CARGO_BUILD_JOBS: '2'
|
CARGO_BUILD_JOBS: '2'
|
||||||
|
CARGO_INCREMENTAL: '0'
|
||||||
|
CARGO_TARGET_DIR: /home/runner/.cache/ferrous-solitaire/target
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -53,10 +94,21 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: 1.95.0
|
toolchain: 1.95.0
|
||||||
components: clippy, rustfmt
|
components: clippy
|
||||||
|
|
||||||
- name: Cache cargo build
|
# Toolchain or lockfile bumps strand stale artifacts nothing will
|
||||||
uses: Swatinem/rust-cache@v2
|
# 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
|
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
||||||
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
||||||
@@ -67,9 +119,6 @@ jobs:
|
|||||||
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||||
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
- name: Format check
|
|
||||||
run: cargo fmt --check
|
|
||||||
|
|
||||||
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||||
# same as the web-e2e workflow's server prebuild.
|
# same as the web-e2e workflow's server prebuild.
|
||||||
- name: Clippy (deny warnings)
|
- name: Clippy (deny warnings)
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ on:
|
|||||||
- 'solitaire_server/src/**'
|
- 'solitaire_server/src/**'
|
||||||
- 'solitaire_server/e2e/**'
|
- 'solitaire_server/e2e/**'
|
||||||
- 'solitaire_wasm/**'
|
- 'solitaire_wasm/**'
|
||||||
|
- 'solitaire_web/**'
|
||||||
|
- 'solitaire_engine/**'
|
||||||
|
- 'solitaire_data/**'
|
||||||
- 'solitaire_core/**'
|
- 'solitaire_core/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
|
- 'build_wasm.sh'
|
||||||
- '.gitea/workflows/web-e2e.yml'
|
- '.gitea/workflows/web-e2e.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -24,10 +28,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: wasm32-unknown-unknown
|
||||||
|
|
||||||
- name: Cache cargo build
|
- name: Cache cargo build
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||||
|
# build them here so the served pages have real wasm to load. Tool
|
||||||
|
# versions are pinned; keep in sync with solitaire_server/Dockerfile.
|
||||||
|
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
|
||||||
|
uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
|
||||||
|
|
||||||
|
- name: Install binaryen 130 (wasm-opt, pinned)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -sSL \
|
||||||
|
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||||
|
| tar xz
|
||||||
|
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Build WASM artifacts
|
||||||
|
run: ./build_wasm.sh
|
||||||
|
|
||||||
# Prebuild the server so Playwright's `webServer` (which runs
|
# Prebuild the server so Playwright's `webServer` (which runs
|
||||||
# `cargo run -p solitaire_server`) starts from a compiled binary instead
|
# `cargo run -p solitaire_server`) starts from a compiled binary instead
|
||||||
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
|
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
|
||||||
|
|||||||
@@ -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.) —
|
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
||||||
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
||||||
scripts/*.go
|
scripts/*.go
|
||||||
|
|
||||||
|
# WASM bundles — built by build_wasm.sh locally and by the Docker wasm-builder
|
||||||
|
# stage / web-e2e workflow in CI; never committed (issue #156)
|
||||||
|
solitaire_server/web/pkg/
|
||||||
|
|||||||
+3
-5
@@ -145,7 +145,6 @@ Shared API contract types imported by both the game client (`solitaire_data`) an
|
|||||||
Owns:
|
Owns:
|
||||||
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
||||||
- `ChallengeGoal`, `LeaderboardEntry`
|
- `ChallengeGoal`, `LeaderboardEntry`
|
||||||
- `ApiError` enum
|
|
||||||
- Merge logic (pure functions, no I/O)
|
- Merge logic (pure functions, no I/O)
|
||||||
|
|
||||||
### `solitaire_data`
|
### `solitaire_data`
|
||||||
@@ -193,7 +192,7 @@ Owns:
|
|||||||
### `solitaire_wasm`
|
### `solitaire_wasm`
|
||||||
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||||||
|
|
||||||
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; 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.
|
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
|
Write merged result to disk
|
||||||
│ fires SyncCompleteEvent
|
│
|
||||||
▼
|
▼
|
||||||
Bevy main thread reads updated StatsResource
|
Bevy main thread reads updated StatsResource
|
||||||
```
|
```
|
||||||
@@ -377,7 +376,6 @@ struct StateChangedEvent;
|
|||||||
struct CardFlippedEvent(u32);
|
struct CardFlippedEvent(u32);
|
||||||
struct GameWonEvent { score: i32, time_seconds: u64 }
|
struct GameWonEvent { score: i32, time_seconds: u64 }
|
||||||
struct AchievementUnlockedEvent(AchievementRecord);
|
struct AchievementUnlockedEvent(AchievementRecord);
|
||||||
struct SyncCompleteEvent(Result<SyncResponse, String>);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layout System
|
### Layout System
|
||||||
@@ -745,7 +743,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
| Method | Path | Auth | Notes |
|
| Method | Path | Auth | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||||||
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
| 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
|
### Account Management
|
||||||
|
|
||||||
|
|||||||
+111
@@ -6,6 +6,117 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.47.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **"What's new" on update.** The first launch after an update shows a
|
||||||
|
one-time card summarising what changed — like this one, right now.
|
||||||
|
Dismiss it and it never repeats; fresh installs never see it. (#180)
|
||||||
|
- **Situational tips.** Two one-time teaches that fire from play, not a
|
||||||
|
tour: staring at a stuck board for a while points you at Hint, and an
|
||||||
|
engaged game quietly mentions the long-press quick-action menu —
|
||||||
|
unless you've already found it yourself. (#181)
|
||||||
|
- **UI scale.** Settings → Accessibility now has a UI Scale control
|
||||||
|
(90% / 100% / 115% / 130%) that resizes every menu, button, and HUD
|
||||||
|
element live. The table always fits your screen regardless. (#182)
|
||||||
|
- **Hold `/` for shortcuts** (desktop). A reference card of every
|
||||||
|
keyboard binding appears while `/` is held. The onboarding tour and
|
||||||
|
this card now share one binding table, so they can never disagree —
|
||||||
|
the tour's copy had drifted from reality. (#183)
|
||||||
|
|
||||||
|
## [0.46.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Theme-store previews.** The store modal now shows each theme's
|
||||||
|
preview image next to its name — the server has advertised them since
|
||||||
|
the store launched; the client finally fetches them. Thumbnails load
|
||||||
|
in the background, pop in as they arrive, and are remembered for the
|
||||||
|
session. (#179)
|
||||||
|
- **Hint ghost preview.** Asking for a hint now also plays a translucent
|
||||||
|
copy of the suggested card gliding to its destination (twice, then it
|
||||||
|
fades) alongside the usual highlights — you see the move, not just the
|
||||||
|
pieces. Automatically disabled when reduce-motion is on. (#179)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **One toast style, one place.** Queued info banners and instant
|
||||||
|
celebration/warning/error toasts now share a single bottom-anchored
|
||||||
|
stack that clears the touch action bar; simultaneous toasts stack
|
||||||
|
upward instead of overlapping. (#178)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
|
||||||
|
players to left/right-click; it now describes drag, double-tap, and
|
||||||
|
the bottom-bar Hint button. (#178)
|
||||||
|
- **First launch shows one screen at a time.** On a fresh install the
|
||||||
|
Home screen no longer spawns underneath the welcome tour; the order is
|
||||||
|
now onboarding → Home → table. (#178)
|
||||||
|
|
||||||
|
## [0.45.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Thumb-reach action bar on touch (menu redesign Phase F).** The bottom
|
||||||
|
bar slims to five buttons: an enlarged **Undo · Draw · Hint** trio
|
||||||
|
(96×64 px targets) between compact Menu and Pause. **Draw is new** — it
|
||||||
|
draws from the stock exactly like tapping the deck, without the reach to
|
||||||
|
the top of a tall folded screen. Help, Modes, and New Game moved off the
|
||||||
|
touch bar (Menu → System, the Home grid, and Home's hero button cover
|
||||||
|
them). Desktop keeps its seven-button bar unchanged.
|
||||||
|
- **Hold-to-repeat Undo.** Press and hold Undo to step back repeatedly
|
||||||
|
(~5.5 undos/s after a short delay) instead of tap-tap-tap. Every step
|
||||||
|
goes through the normal undo path, so scoring penalties apply as usual.
|
||||||
|
|
||||||
|
## [0.44.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Home is now a real home (menu redesign Phase B).** The mode picker became
|
||||||
|
a hierarchy: a **Continue** card (mode · elapsed · score) appears while a
|
||||||
|
game is in progress and returns to the table; a hero **New Game** button
|
||||||
|
replays your last mode with the current deal options in one tap; deal
|
||||||
|
options (draw 1/3, a new **winnable-only** toggle, difficulty tiers) moved
|
||||||
|
into a disclosure under the hero; the six modes sit in a compact symmetric
|
||||||
|
2×3 grid with descriptions on wide screens; and the stats strip moved to
|
||||||
|
the bottom. Wide viewports (desktop, unfolded foldables) get a two-pane
|
||||||
|
layout — launch surfaces left, Continue/daily/stats right. Cancel is now
|
||||||
|
**Back to table** and only appears while a live game exists. (#175)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Save files are self-contained (schema v6).** Like replays in 0.43.3, a
|
||||||
|
saved game now stores the dealt board via the upstream session serializers
|
||||||
|
instead of re-dealing from the seed on load; v4/v5 saves still load through
|
||||||
|
the legacy path and rewrite as v6 on next save. (#171)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **CI is ~9× faster.** Root-caused why the actions cache never restored on
|
||||||
|
the host-executor runner (per-run workdir paths poisoned the cache version
|
||||||
|
hash); the test workflow now uses a persistent on-runner target dir, and a
|
||||||
|
seconds-long `fmt` gate fails formatting mistakes before the build. Warm
|
||||||
|
full-gate runs: 4m29s, down from ~40 min. (#176)
|
||||||
|
|
||||||
|
## [0.43.3] — 2026-07-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Replays are now self-contained (schema v4).** A replay stores the dealt
|
||||||
|
board itself via the upstream `card_game` session serializers instead of
|
||||||
|
re-dealing from the seed at playback time, so replays survive RNG and
|
||||||
|
upstream upgrades that change the seed→deal mapping — the failure that had
|
||||||
|
silently broken every stored replay. The web player and web game now
|
||||||
|
exchange the full payload through the wasm layer (the old JS path hardcoded
|
||||||
|
`schema_version: 2`, uploaded empty move lists, and corrupted u64 seeds via
|
||||||
|
`Math.round`), and the replay viewer reports unplayable old-format replays
|
||||||
|
in the caption instead of dying silently. Pre-v4 replays are rejected by a
|
||||||
|
version gate; local histories repopulate with new wins. (#170)
|
||||||
|
- **Difficulty-mode wins can upload.** The server's replay `mode` validation
|
||||||
|
now accepts data-carrying `GameMode` variants (previously a 400). (#170)
|
||||||
|
|
||||||
## [0.42.0] — 2026-07-06
|
## [0.42.0] — 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
@@ -7333,6 +7333,7 @@ dependencies = [
|
|||||||
"proptest",
|
"proptest",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -13,9 +13,10 @@
|
|||||||
# Run from the repo root:
|
# Run from the repo root:
|
||||||
# ./build_wasm.sh
|
# ./build_wasm.sh
|
||||||
#
|
#
|
||||||
# The generated pkg/ files are committed to git so self-hosters who don't
|
# The generated pkg/ files are NOT committed to git (issue #156). CI builds
|
||||||
# touch the WASM crates can skip this step. Regenerate after any change to
|
# them where needed: the Docker image's wasm-builder stage for deployment,
|
||||||
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
|
# and the web-e2e workflow for browser tests. Run this script locally before
|
||||||
|
# serving /web or /play from a source checkout.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Menu UX Redesign — July 2026
|
# Menu UX Redesign — July 2026
|
||||||
|
|
||||||
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
|
Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on
|
||||||
out of scope — this is about **structure and interaction**, not colors or type.
|
`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)
|
## Diagnosis (from code survey, 2026-07-07)
|
||||||
|
|
||||||
@@ -197,8 +199,13 @@ slimming), then **B**, then **F/G/H** as independent follow-ups.
|
|||||||
## Open decisions
|
## Open decisions
|
||||||
|
|
||||||
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
||||||
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
|
2. ~~Deal options: disclosure on Classic card vs. keep global row~~ —
|
||||||
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
|
**DECIDED 2026-07-09: disclosure on the Classic card / New Game hero.**
|
||||||
tucked under a "More" card?
|
3. ~~Time Attack + Seed: top-level cards vs. "More" card~~ —
|
||||||
4. Stitch mockups for Phase B, or iterate directly in-engine?
|
**DECIDED 2026-07-09: top-level cards, symmetric 2×3 grid.**
|
||||||
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
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.
|
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
||||||
#
|
#
|
||||||
# Polls recent workflow runs and prints a compact status block each cycle.
|
# Polls recent workflow runs and prints a compact status block each cycle.
|
||||||
# Stops when the newest docker-build (the deploy) has completed and no
|
# Stops when the newest docker-build (the deploy) has completed — the wasm
|
||||||
# web-wasm-rebuild is still pending — i.e. the full fix is live.
|
# is built inside that image build, so no other workflow gates the deploy.
|
||||||
#
|
#
|
||||||
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
||||||
# token is read from ~/.config/tea/config.yml (never printed).
|
# token is read from ~/.config/tea/config.yml (never printed).
|
||||||
@@ -49,16 +49,15 @@ for r in runs:
|
|||||||
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
||||||
r.get("status"), r.get("conclusion")))
|
r.get("status"), r.get("conclusion")))
|
||||||
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
||||||
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" in str(r.get("path")))
|
|
||||||
top = db[0] if db else None
|
top = db[0] if db else None
|
||||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
|
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success")
|
||||||
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
|
print("STATE=%s" % ("LIVE" if live else "DEPLOYING"))
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
echo "$out" | grep -v '^STATE='
|
echo "$out" | grep -v '^STATE='
|
||||||
if echo "$out" | grep -q '^STATE=LIVE'; then
|
if echo "$out" | grep -q '^STATE=LIVE'; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
|
echo "DEPLOY LIVE — newest docker-build succeeded."
|
||||||
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ default = []
|
|||||||
test-support = []
|
test-support = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
||||||
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||||
/// variants (e.g. `"Foundation1"` instead of `0`).
|
/// variants (e.g. `"Foundation1"` instead of `0`).
|
||||||
/// - v5 (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
|
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||||
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||||
/// still carry those keys load fine — the extra fields are ignored.
|
/// still carry those keys load fine — the extra fields are ignored.
|
||||||
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
|
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
||||||
/// loop in the engine.
|
/// loop in the engine.
|
||||||
@@ -95,8 +101,8 @@ pub enum GameMode {
|
|||||||
Difficulty(DifficultyLevel),
|
Difficulty(DifficultyLevel),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
|
/// Output struct for schema v6 serialisation. `recording` is the upstream
|
||||||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
/// session serialisation (config + dealt board + instruction list).
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PersistedGameState {
|
struct PersistedGameState {
|
||||||
pub draw_mode: DrawStockConfig,
|
pub draw_mode: DrawStockConfig,
|
||||||
@@ -105,15 +111,16 @@ struct PersistedGameState {
|
|||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub 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`
|
/// v6 files carry `recording` (the upstream session serialisation, deal
|
||||||
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
|
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from
|
||||||
/// intentionally absent: all three are rebuilt by replaying the instruction
|
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally
|
||||||
/// history through the upstream session stats. Older v4 save files still carry
|
/// 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.
|
/// those keys; serde ignores them.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct PersistedGameStateIn {
|
struct PersistedGameStateIn {
|
||||||
@@ -126,7 +133,12 @@ struct PersistedGameStateIn {
|
|||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
/// v4/v5 only. Replayed against a seed-dealt board.
|
||||||
|
#[serde(default)]
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
|
/// v6 only. Carries the dealt board explicitly.
|
||||||
|
#[serde(default)]
|
||||||
|
pub recording: Option<SessionRecording>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -222,7 +234,7 @@ impl Serialize for GameState {
|
|||||||
seed: self.seed,
|
seed: self.seed,
|
||||||
take_from_foundation: self.take_from_foundation,
|
take_from_foundation: self.take_from_foundation,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
saved_moves: self.saved_moves(),
|
recording: self.recording(),
|
||||||
}
|
}
|
||||||
.serialize(serializer)
|
.serialize(serializer)
|
||||||
}
|
}
|
||||||
@@ -232,34 +244,51 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Accept v4 (upstream named-variant serde) and v5 (current, derived
|
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt
|
||||||
// stats). v3 (legacy u8-index format) and all others are rejected.
|
// saves — loadable while the seed→deal mapping they were written
|
||||||
match persisted.schema_version {
|
// under still holds; rewritten as v6 on the next save). v3 (legacy
|
||||||
4 | 5 => {}
|
// 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 => {
|
v => {
|
||||||
return Err(serde::de::Error::custom(format!(
|
return Err(serde::de::Error::custom(format!(
|
||||||
"unsupported GameState schema version {v}"
|
"unsupported GameState schema version {v}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// Replay the saved instruction history with validation — a tampered
|
||||||
// score components and recycle_count as it processes each move, so the
|
// or corrupt file must surface an error, not a silently wrong board.
|
||||||
// derived stats are correct once replay completes. `undo_count()` resets
|
// The upstream session tracks score components and recycle_count as
|
||||||
// to 0 across save/load because undone moves are not part of the saved
|
// it processes each move, so the derived stats are correct once
|
||||||
// forward history.
|
// replay completes. `undo_count()` resets to 0 across save/load
|
||||||
|
// because undone moves are not part of the saved forward history.
|
||||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||||
for instruction in persisted.saved_moves {
|
for instruction in instructions {
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.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 {
|
impl GameState {
|
||||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||||
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
||||||
@@ -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.
|
/// Whether the player draws one or three cards from the stock per turn.
|
||||||
/// Derived from the underlying session config (set once at deal time).
|
/// Derived from the underlying session config (set once at deal time).
|
||||||
pub fn draw_mode(&self) -> DrawStockConfig {
|
pub fn draw_mode(&self) -> DrawStockConfig {
|
||||||
@@ -402,16 +553,6 @@ impl GameState {
|
|||||||
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects the session instruction history as upstream types for schema v4
|
|
||||||
/// serialisation.
|
|
||||||
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
|
|
||||||
self.session
|
|
||||||
.history()
|
|
||||||
.iter()
|
|
||||||
.map(|snapshot| *snapshot.instruction())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the deterministic instruction history for the current deal as
|
/// Returns the deterministic instruction history for the current deal as
|
||||||
/// upstream [`KlondikeInstruction`] values.
|
/// upstream [`KlondikeInstruction`] values.
|
||||||
///
|
///
|
||||||
@@ -1512,4 +1653,162 @@ mod tests {
|
|||||||
assert!(easy.is_err());
|
assert!(easy.is_err());
|
||||||
assert!(matches!(medium, Ok(Some(_))));
|
assert!(matches!(medium, Ok(Some(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play a few real moves on a fresh deal and return the game.
|
||||||
|
fn game_with_some_moves(seed: u64) -> GameState {
|
||||||
|
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||||
|
for _ in 0..40 {
|
||||||
|
let instructions = game.possible_instructions();
|
||||||
|
let applied = instructions
|
||||||
|
.into_iter()
|
||||||
|
.find(|i| game.clone().apply_instruction(*i).is_ok())
|
||||||
|
.and_then(|i| game.apply_instruction(i).ok());
|
||||||
|
if applied.is_none() && game.draw().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!game.instruction_history().is_empty(),
|
||||||
|
"test needs at least one recorded move"
|
||||||
|
);
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recording_round_trips_through_upstream_serde() {
|
||||||
|
let game = game_with_some_moves(51);
|
||||||
|
let recording = game.recording();
|
||||||
|
let json = serde_json::to_string(&recording).expect("serialize recording");
|
||||||
|
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
||||||
|
assert_eq!(recording, restored);
|
||||||
|
assert_eq!(recording.instructions(), game.instruction_history());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_recording_replays_to_identical_board_without_seed_dealing() {
|
||||||
|
let game = game_with_some_moves(145);
|
||||||
|
let recording = game.recording();
|
||||||
|
let json = serde_json::to_string(&recording).expect("serialize recording");
|
||||||
|
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
||||||
|
|
||||||
|
// Deliberately pass a DIFFERENT seed: the board must come from the
|
||||||
|
// recording, proving playback no longer depends on seed→deal mapping.
|
||||||
|
let (mut replayed, instructions) =
|
||||||
|
GameState::from_recording(&restored, 0xDEAD_BEEF, game.mode);
|
||||||
|
assert_eq!(replayed.draw_mode(), game.draw_mode());
|
||||||
|
for instruction in instructions {
|
||||||
|
replayed
|
||||||
|
.apply_instruction(instruction)
|
||||||
|
.expect("recorded instruction must replay cleanly");
|
||||||
|
}
|
||||||
|
for pile in [KlondikePile::Stock]
|
||||||
|
.into_iter()
|
||||||
|
.chain(crate::TABLEAUS.map(KlondikePile::Tableau))
|
||||||
|
.chain(crate::FOUNDATIONS.map(KlondikePile::Foundation))
|
||||||
|
{
|
||||||
|
assert_eq!(
|
||||||
|
replayed.pile(pile),
|
||||||
|
game.pile(pile),
|
||||||
|
"pile {pile:?} differs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(replayed.waste_cards(), game.waste_cards());
|
||||||
|
assert_eq!(replayed.move_count(), game.move_count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v6 save files carry the deal in the recording; the `seed` field is
|
||||||
|
/// presentation metadata. Corrupting it must not change the loaded board.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_load_ignores_seed_for_dealing() {
|
||||||
|
let game = game_with_some_moves(51);
|
||||||
|
let mut value = serde_json::to_value(&game).expect("serialise");
|
||||||
|
assert_eq!(value["schema_version"], 6);
|
||||||
|
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
|
||||||
|
let loaded: GameState =
|
||||||
|
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
|
||||||
|
assert_eq!(loaded.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(loaded.waste_cards(), game.waste_cards());
|
||||||
|
for index in 0..7 {
|
||||||
|
let t = GameState::tableau_from_index(index).expect("tableau index");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.pile(KlondikePile::Tableau(t)),
|
||||||
|
game.pile(KlondikePile::Tableau(t)),
|
||||||
|
"tableau {index} differs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(loaded.move_count(), game.move_count());
|
||||||
|
assert_eq!(loaded.score(), game.score());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy v5 files (seed + saved_moves, no recording) must still load
|
||||||
|
/// through the seed-dealt path until players resave as v6.
|
||||||
|
#[test]
|
||||||
|
fn v5_legacy_save_still_loads() {
|
||||||
|
let game = game_with_some_moves(145);
|
||||||
|
let v5 = serde_json::json!({
|
||||||
|
"draw_mode": game.draw_mode(),
|
||||||
|
"mode": game.mode,
|
||||||
|
"elapsed_seconds": 12,
|
||||||
|
"seed": game.seed,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 5,
|
||||||
|
"saved_moves": game.instruction_history(),
|
||||||
|
});
|
||||||
|
let loaded: GameState =
|
||||||
|
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
|
||||||
|
assert_eq!(loaded.move_count(), game.move_count());
|
||||||
|
assert_eq!(loaded.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(loaded.elapsed_seconds, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A v6 file without the recording payload is malformed and must be
|
||||||
|
/// rejected with an error rather than silently re-dealt from the seed.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_without_recording_is_rejected() {
|
||||||
|
let game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||||
|
let mut value = serde_json::to_value(&game).expect("serialise");
|
||||||
|
value.as_object_mut().expect("object").remove("recording");
|
||||||
|
let err = serde_json::from_value::<GameState>(value)
|
||||||
|
.expect_err("v6 save without recording must fail");
|
||||||
|
assert!(err.to_string().contains("recording"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recording whose instruction list is invalid for its own deal must
|
||||||
|
/// be rejected on load — tampered or corrupt files surface an error,
|
||||||
|
/// never a silently wrong board.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_with_invalid_history_is_rejected() {
|
||||||
|
use klondike::{DstFoundation, Foundation, KlondikePile};
|
||||||
|
// A foundation move from an empty tableau is never legal on a fresh
|
||||||
|
// deal's first instruction slot for this source shape.
|
||||||
|
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
|
src: KlondikePile::Foundation(Foundation::Foundation1),
|
||||||
|
foundation: Foundation::Foundation2,
|
||||||
|
});
|
||||||
|
let recording =
|
||||||
|
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
|
||||||
|
let v6 = serde_json::json!({
|
||||||
|
"draw_mode": DrawStockConfig::DrawOne,
|
||||||
|
"mode": GameMode::Classic,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"seed": 7,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 6,
|
||||||
|
"recording": recording,
|
||||||
|
});
|
||||||
|
let err =
|
||||||
|
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
|
||||||
|
assert!(err.to_string().contains("invalid"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
||||||
|
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||||
|
let recording = game.recording();
|
||||||
|
assert!(recording.is_empty());
|
||||||
|
let (replayed, instructions) = GameState::from_recording(&recording, 7, game.mode);
|
||||||
|
assert!(instructions.is_empty());
|
||||||
|
assert_eq!(replayed.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(replayed.draw_mode(), DrawStockConfig::DrawThree);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ pub use klondike::{
|
|||||||
|
|
||||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||||
// former `solitaire_data::solver` wrapper module.
|
// former `solitaire_data::solver` wrapper module.
|
||||||
pub use game_state::{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
|
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||||
// later phase — nothing outside solitaire_core consumes these yet).
|
// later phase — nothing outside solitaire_core consumes these yet).
|
||||||
|
|||||||
@@ -12,22 +12,24 @@
|
|||||||
//! carries any other version so older replays are silently dropped instead
|
//! carries any other version so older replays are silently dropped instead
|
||||||
//! of crashing the loader.
|
//! of crashing the loader.
|
||||||
//!
|
//!
|
||||||
//! The recording is intentionally minimal — only the
|
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
|
||||||
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
//! the upstream `card_game` session serialisation, which stores the dealt
|
||||||
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
//! board **explicitly** plus the ordered instruction list. `Undo` is not
|
||||||
//! represents the canonical path the player ultimately took to win, so
|
//! recorded: a replay represents the canonical path the player ultimately
|
||||||
//! backed-out missteps simply do not appear in the move list. The starting
|
//! took to win, so backed-out missteps simply do not appear (the session
|
||||||
//! deal is not stored either — the [`seed`](Replay::seed) +
|
//! pops them from its history).
|
||||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
//!
|
||||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
//! 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.
|
//! Each recorded move is the player's atomic *input*, not its outcome.
|
||||||
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||||
//! the engine resolves draw-vs-recycle deterministically from the current
|
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||||
//! stock state during playback, so the same input always produces the same
|
//! stock state during playback, so the same input always produces the same
|
||||||
//! effect on the same starting deal. Runtime-only pile-position types are
|
//! effect on the same starting deal.
|
||||||
//! never serialised — the instruction itself serialises via its compact
|
|
||||||
//! upstream serde representation.
|
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -35,7 +37,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
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 LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.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
|
/// variants which carried the *outcome* of a stock interaction rather
|
||||||
/// than the player's atomic input.
|
/// than the player's atomic input.
|
||||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||||
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||||
/// are now stored directly as upstream
|
/// were stored directly as upstream `KlondikeInstruction` (compact
|
||||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
/// int serde); `StockClick` became `RotateStock`. Pile-position types are
|
||||||
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
/// runtime-only and are never serialised. The starting deal was still
|
||||||
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
/// rebuilt from the seed at playback time.
|
||||||
/// and are discarded by the loader.
|
/// - v4 (current): the bare `moves` list was replaced by a
|
||||||
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
/// [`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
|
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||||
@@ -94,9 +102,10 @@ fn schema_v0() -> u32 {
|
|||||||
|
|
||||||
/// A complete recording of a single winning game.
|
/// A complete recording of a single winning game.
|
||||||
///
|
///
|
||||||
/// Replays are reconstructed by rebuilding a fresh
|
/// Replays are reconstructed via
|
||||||
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
|
||||||
/// [`moves`](Self::moves) in order. The presentation fields
|
/// 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),
|
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||||
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||||
/// "Replay (2:14 win on 2026-05-02)".
|
/// "Replay (2:14 win on 2026-05-02)".
|
||||||
@@ -105,8 +114,9 @@ pub struct Replay {
|
|||||||
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default = "schema_v0")]
|
#[serde(default = "schema_v0")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
/// Seed used for the deal — replay rasterises the deck via
|
/// Seed the recorded game was originally dealt from. Presentation /
|
||||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
/// indexing metadata only — playback rebuilds the board from
|
||||||
|
/// [`recording`](Self::recording), never by re-dealing this seed.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
/// Draw mode the recorded game was played in.
|
/// Draw mode the recorded game was played in.
|
||||||
pub draw_mode: DrawStockConfig,
|
pub draw_mode: DrawStockConfig,
|
||||||
@@ -119,11 +129,10 @@ pub struct Replay {
|
|||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
/// ISO-8601 date the win was recorded.
|
/// ISO-8601 date the win was recorded.
|
||||||
pub recorded_at: NaiveDate,
|
pub recorded_at: NaiveDate,
|
||||||
/// Ordered move list. Each entry is the atomic
|
/// The dealt board plus the ordered instruction list, serialised via the
|
||||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
/// upstream `card_game` session serializers. Self-contained: playback
|
||||||
/// issued, replayable against a fresh `GameState` constructed from the
|
/// needs nothing else to reproduce the game move-for-move.
|
||||||
/// seed via `GameState::apply_instruction`.
|
pub recording: SessionRecording,
|
||||||
pub moves: Vec<KlondikeInstruction>,
|
|
||||||
/// Public share URL for this replay on the active sync backend, set
|
/// Public share URL for this replay on the active sync backend, set
|
||||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||||
/// task resolves. `None` when the player won on a local-only
|
/// task resolves. `None` when the player won on a local-only
|
||||||
@@ -133,11 +142,12 @@ pub struct Replay {
|
|||||||
/// [`REPLAY_SCHEMA_VERSION`].
|
/// [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub share_url: Option<String>,
|
pub share_url: Option<String>,
|
||||||
/// Index into [`moves`](Self::moves) of the move that triggered
|
/// Index into the [`recording`](Self::recording)'s instruction list
|
||||||
/// the win condition (i.e. completed the last foundation pile).
|
/// 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
|
/// 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
|
/// the field is stored explicitly so the playback UI can read it
|
||||||
/// directly without re-deriving "the last move was the win" each
|
/// directly without re-deriving "the last move was the win" each
|
||||||
/// time, and to leave room for future recording semantics that
|
/// time, and to leave room for future recording semantics that
|
||||||
@@ -172,7 +182,7 @@ impl Replay {
|
|||||||
time_seconds: u64,
|
time_seconds: u64,
|
||||||
final_score: i32,
|
final_score: i32,
|
||||||
recorded_at: NaiveDate,
|
recorded_at: NaiveDate,
|
||||||
moves: Vec<KlondikeInstruction>,
|
recording: SessionRecording,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: REPLAY_SCHEMA_VERSION,
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
@@ -182,7 +192,7 @@ impl Replay {
|
|||||||
time_seconds,
|
time_seconds,
|
||||||
final_score,
|
final_score,
|
||||||
recorded_at,
|
recorded_at,
|
||||||
moves,
|
recording,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
win_move_index: None,
|
win_move_index: None,
|
||||||
}
|
}
|
||||||
@@ -193,7 +203,7 @@ impl Replay {
|
|||||||
/// [`Replay::new`]:
|
/// [`Replay::new`]:
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```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
|
/// `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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
|
||||||
|
KlondikePileStack, Tableau,
|
||||||
};
|
};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
@@ -447,18 +458,22 @@ mod tests {
|
|||||||
134,
|
134,
|
||||||
5_120,
|
5_120,
|
||||||
date,
|
date,
|
||||||
vec![
|
SessionRecording::from_instructions_unchecked(
|
||||||
KlondikeInstruction::RotateStock,
|
12345,
|
||||||
KlondikeInstruction::DstTableau(DstTableau {
|
DrawStockConfig::DrawThree,
|
||||||
src: KlondikePileStack::Stock,
|
[
|
||||||
tableau: Tableau::Tableau4,
|
KlondikeInstruction::RotateStock,
|
||||||
}),
|
KlondikeInstruction::DstTableau(DstTableau {
|
||||||
KlondikeInstruction::RotateStock,
|
src: KlondikePileStack::Stock,
|
||||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
tableau: Tableau::Tableau4,
|
||||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
}),
|
||||||
foundation: Foundation::Foundation1,
|
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.
|
/// rolling history wiped on the v0.19.0 update.
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_loads_when_share_url_field_is_absent() {
|
fn replay_loads_when_share_url_field_is_absent() {
|
||||||
let pre_v019_json = format!(
|
// Build a current-schema JSON object, then strip the optional
|
||||||
r#"{{
|
// fields to simulate a file written before they existed.
|
||||||
"schema_version": {schema},
|
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||||
"seed": 1,
|
let obj = value.as_object_mut().expect("replay serialises as object");
|
||||||
"draw_mode": "DrawOne",
|
obj.remove("share_url");
|
||||||
"mode": "Classic",
|
obj.remove("win_move_index");
|
||||||
"time_seconds": 60,
|
let parsed: Replay = serde_json::from_value(value)
|
||||||
"final_score": 100,
|
.expect("replay JSON without optional fields must still deserialise");
|
||||||
"recorded_at": "2025-01-01",
|
|
||||||
"moves": []
|
|
||||||
}}"#,
|
|
||||||
schema = REPLAY_SCHEMA_VERSION,
|
|
||||||
);
|
|
||||||
let parsed: Replay = serde_json::from_str(&pre_v019_json)
|
|
||||||
.expect("pre-v0.19.0 replay JSON must still deserialise");
|
|
||||||
assert!(
|
assert!(
|
||||||
parsed.share_url.is_none(),
|
parsed.share_url.is_none(),
|
||||||
"missing share_url field must default to None",
|
"missing share_url field must default to None",
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
parsed.win_move_index.is_none(),
|
||||||
|
"missing win_move_index field must default to None",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic-write contract — `.tmp` must not be left behind after
|
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||||
@@ -588,7 +600,11 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
id,
|
id,
|
||||||
date,
|
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 path = tmp_path("legacy_no_win_move_index");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
// Hand-rolled minimal current-schema replay JSON with no
|
// Current-schema replay JSON with the win_move_index field stripped —
|
||||||
// win_move_index field — the additive field must still default to None.
|
// the additive field must still default to None.
|
||||||
let no_field = format!(
|
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||||
r#"{{
|
value
|
||||||
"schema_version": {schema},
|
.as_object_mut()
|
||||||
"seed": 1,
|
.expect("replay serialises as object")
|
||||||
"draw_mode": "DrawOne",
|
.remove("win_move_index");
|
||||||
"mode": "Classic",
|
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
|
||||||
"time_seconds": 60,
|
|
||||||
"final_score": 100,
|
|
||||||
"recorded_at": "2026-05-02",
|
|
||||||
"moves": []
|
|
||||||
}}"#,
|
|
||||||
schema = REPLAY_SCHEMA_VERSION,
|
|
||||||
);
|
|
||||||
fs::write(&path, no_field).expect("write fixture");
|
|
||||||
|
|
||||||
let loaded = load_latest_replay_from(&path).expect("load");
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
assert_eq!(loaded.win_move_index, None);
|
assert_eq!(loaded.win_move_index, None);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
@@ -248,6 +251,38 @@ pub struct Settings {
|
|||||||
/// cleanly to `None` via `#[serde(default)]`.
|
/// cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_difficulty: Option<DifficultyLevel>,
|
pub last_difficulty: Option<DifficultyLevel>,
|
||||||
|
/// Mode of the last game the player launched from the home overlay.
|
||||||
|
/// The home hero "New Game" button replays this mode with one tap.
|
||||||
|
/// Older `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_mode: GameMode,
|
||||||
|
/// The release version whose "What's new" card the player has already
|
||||||
|
/// seen (e.g. `"0.46.0"`). Empty on installs that predate the card,
|
||||||
|
/// which correctly reads as "there is news to show" after an upgrade;
|
||||||
|
/// fresh installs stamp it silently when onboarding completes. Older
|
||||||
|
/// `settings.json` files deserialize cleanly to `""` via
|
||||||
|
/// `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_seen_whats_new: String,
|
||||||
|
/// `true` once the one-shot "Stuck? Try a hint" contextual tip has
|
||||||
|
/// fired (or been suppressed as unnecessary). Phase I teach: fired
|
||||||
|
/// by the situation, shown once, like `shown_achievement_onboarding`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_stall_hint_tip: bool,
|
||||||
|
/// `true` once the one-shot radial-menu contextual tip has fired —
|
||||||
|
/// or the player has already opened the radial menu on their own,
|
||||||
|
/// which marks the tip as unnecessary without showing it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_radial_menu_tip: bool,
|
||||||
|
/// Global UI scale multiplier applied to all UI chrome (HUD, modals,
|
||||||
|
/// action bar) — the table itself stays window-fit via
|
||||||
|
/// `compute_layout`. Cycles through 0.9 / 1.0 / 1.15 / 1.3 in
|
||||||
|
/// Settings → Accessibility; clamped to `[UI_SCALE_MIN,
|
||||||
|
/// UI_SCALE_MAX]` by [`Settings::sanitized`]. Older `settings.json`
|
||||||
|
/// files deserialize cleanly to `1.0` via the serde default.
|
||||||
|
#[serde(default = "default_ui_scale")]
|
||||||
|
pub ui_scale: f32,
|
||||||
/// Custom public name displayed on the leaderboard. When `None`, the
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
/// before submission. Older `settings.json` files written before this
|
/// before submission. Older `settings.json` files written before this
|
||||||
@@ -356,6 +391,16 @@ fn default_replay_move_interval_secs() -> f32 {
|
|||||||
0.45
|
0.45
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lowest / highest UI scale the settings row offers; anything outside
|
||||||
|
/// (hand-edited settings.json) clamps here on load.
|
||||||
|
pub const UI_SCALE_MIN: f32 = 0.9;
|
||||||
|
/// See [`UI_SCALE_MIN`].
|
||||||
|
pub const UI_SCALE_MAX: f32 = 1.3;
|
||||||
|
|
||||||
|
fn default_ui_scale() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_matomo_site_id() -> u32 {
|
fn default_matomo_site_id() -> u32 {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
@@ -415,6 +460,11 @@ impl Default for Settings {
|
|||||||
disable_smart_default_size: false,
|
disable_smart_default_size: false,
|
||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
|
last_mode: GameMode::Classic,
|
||||||
|
last_seen_whats_new: String::new(),
|
||||||
|
shown_stall_hint_tip: false,
|
||||||
|
shown_radial_menu_tip: false,
|
||||||
|
ui_scale: default_ui_scale(),
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
leaderboard_opted_in: false,
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
@@ -450,6 +500,7 @@ impl Settings {
|
|||||||
replay_move_interval_secs: self
|
replay_move_interval_secs: self
|
||||||
.replay_move_interval_secs
|
.replay_move_interval_secs
|
||||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||||
|
ui_scale: self.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX),
|
||||||
selected_theme_id,
|
selected_theme_id,
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ mod tests {
|
|||||||
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
||||||
/// to 0 on load because only the forward instruction history is persisted.
|
/// to 0 on load because only the forward instruction history is persisted.
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_v5_mid_game_round_trip() {
|
fn game_state_v6_mid_game_round_trip() {
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -518,11 +518,16 @@ mod tests {
|
|||||||
|
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
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 json = fs::read_to_string(&path).expect("read json");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse saved json");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["schema_version"], 6,
|
||||||
|
"saved file must use schema version 6",
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
json.contains("\"schema_version\"") && json.contains('5'),
|
parsed["recording"].is_object(),
|
||||||
"saved file must use schema version 5",
|
"saved file must embed the session recording",
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded =
|
let loaded =
|
||||||
@@ -530,7 +535,7 @@ mod tests {
|
|||||||
|
|
||||||
// The forward instruction history round-trips, so the reconstructed board
|
// The forward instruction history round-trips, so the reconstructed board
|
||||||
// re-serialises to byte-identical JSON.
|
// re-serialises to byte-identical JSON.
|
||||||
let path_reload = gs_path("v5_mid_game_reload");
|
let path_reload = gs_path("v6_mid_game_reload");
|
||||||
let _ = fs::remove_file(&path_reload);
|
let _ = fs::remove_file(&path_reload);
|
||||||
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ use thiserror::Error;
|
|||||||
/// so downloading it is pure waste.
|
/// so downloading it is pure waste.
|
||||||
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
|
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`].
|
/// Errors surfaced by [`ThemeStoreClient`].
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ThemeStoreError {
|
pub enum ThemeStoreError {
|
||||||
@@ -110,6 +114,42 @@ impl ThemeStoreClient {
|
|||||||
verify_archive(&bytes, entry)?;
|
verify_archive(&bytes, entry)?;
|
||||||
Ok(bytes.to_vec())
|
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
|
/// Checks downloaded `bytes` against the catalog `entry`'s declared
|
||||||
|
|||||||
@@ -1343,7 +1343,9 @@ mod tests {
|
|||||||
|
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
use solitaire_core::{
|
||||||
|
DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode,
|
||||||
|
};
|
||||||
use solitaire_data::Replay;
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||||
@@ -1364,7 +1366,11 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![KlondikeInstruction::RotateStock],
|
SessionRecording::from_instructions_unchecked(
|
||||||
|
1,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
[KlondikeInstruction::RotateStock],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1408,12 +1414,8 @@ mod tests {
|
|||||||
|
|
||||||
// Frame 1: enter Playing. The observer's first sample sees
|
// Frame 1: enter Playing. The observer's first sample sees
|
||||||
// `last_was_playing = false` and `now_playing = true`.
|
// `last_was_playing = false` and `now_playing = true`.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
assert!(
|
assert!(
|
||||||
!cinephile_unlocked(&app),
|
!cinephile_unlocked(&app),
|
||||||
@@ -1442,12 +1444,8 @@ mod tests {
|
|||||||
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Direct Playing → Inactive — the path the Stop button takes via
|
// Direct Playing → Inactive — the path the Stop button takes via
|
||||||
@@ -1473,12 +1471,8 @@ mod tests {
|
|||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
// First completion cycle to unlock.
|
// First completion cycle to unlock.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1496,12 +1490,8 @@ mod tests {
|
|||||||
// Second cycle: Inactive → Playing → Completed once more.
|
// Second cycle: Inactive → Playing → Completed once more.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1520,12 +1510,8 @@ mod tests {
|
|||||||
fn cinephile_fires_once_across_completed_linger() {
|
fn cinephile_fires_once_across_completed_linger() {
|
||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ use crate::events::{
|
|||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
|
use crate::safe_area::SafeAreaAnchoredBottom;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
@@ -160,6 +162,71 @@ pub struct ActiveToast {
|
|||||||
/// Duration of each queued info-toast in seconds.
|
/// Duration of each queued info-toast in seconds.
|
||||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||||
|
|
||||||
|
/// Marker on the persistent bottom-anchored flex column every toast
|
||||||
|
/// spawns into (Phase H). Stacking through one container gives queued
|
||||||
|
/// and immediate toasts a single shared anchor — simultaneous toasts
|
||||||
|
/// stack upward instead of relying on the old staggered-percentage
|
||||||
|
/// anchors to dodge each other.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastStackRoot;
|
||||||
|
|
||||||
|
/// Marker on every toast card node (both paths). Freshly spawned toasts
|
||||||
|
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
|
||||||
|
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
|
||||||
|
/// of latency, imperceptible at toast timescales, in exchange for the 14
|
||||||
|
/// toast handlers keeping their `Commands`-only signatures.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastNode;
|
||||||
|
|
||||||
|
/// Logical-pixel gap between the screen bottom and the toast stack,
|
||||||
|
/// before safe-area insets. Clears the floating glass tab bar on touch
|
||||||
|
/// (its margin + height + breathing room); desktop's docked bar needs less.
|
||||||
|
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT {
|
||||||
|
crate::hud_plugin::tab_bar::TAB_BAR_CLEARANCE_PX
|
||||||
|
} else {
|
||||||
|
72.0
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
||||||
|
fn spawn_toast_stack_root(mut commands: Commands) {
|
||||||
|
commands.spawn((
|
||||||
|
ToastStackRoot,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
// Newest toast sits nearest the bottom edge; older ones
|
||||||
|
// push upward.
|
||||||
|
flex_direction: FlexDirection::ColumnReverse,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
SafeAreaAnchoredBottom {
|
||||||
|
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
|
||||||
|
},
|
||||||
|
ZIndex(Z_TOAST),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
|
||||||
|
/// reveals them. No-op when every toast is already adopted.
|
||||||
|
fn adopt_toasts_into_stack(
|
||||||
|
mut commands: Commands,
|
||||||
|
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
|
||||||
|
root: Query<Entity, With<ToastStackRoot>>,
|
||||||
|
) {
|
||||||
|
let Ok(root) = root.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for toast in &orphans {
|
||||||
|
commands
|
||||||
|
.entity(toast)
|
||||||
|
.insert((ChildOf(root), Visibility::Inherited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||||
pub struct AnimationPlugin;
|
pub struct AnimationPlugin;
|
||||||
|
|
||||||
@@ -185,7 +252,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
.add_systems(Startup, init_slide_duration)
|
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -206,6 +273,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_warning_toast,
|
handle_warning_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
|
adopt_toasts_into_stack,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
@@ -637,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
|
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||||
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||||
/// Variants other than `Info` belong on the immediate-fire path
|
/// Variants other than `Info` belong on the immediate-fire path
|
||||||
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||||
spawn_toast_node(
|
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
|
||||||
commands,
|
|
||||||
ToastEntity,
|
|
||||||
message,
|
|
||||||
ToastVariant::Info,
|
|
||||||
// Slightly taller anchor than the immediate-fire path so a
|
|
||||||
// queued info banner doesn't collide with a celebration toast
|
|
||||||
// fired in the same frame.
|
|
||||||
Val::Percent(6.0),
|
|
||||||
Val::Percent(15.0),
|
|
||||||
Val::Percent(70.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||||
@@ -744,12 +800,6 @@ fn spawn_toast(
|
|||||||
(ToastOverlay, ToastTimer(duration_secs)),
|
(ToastOverlay, ToastTimer(duration_secs)),
|
||||||
message,
|
message,
|
||||||
variant,
|
variant,
|
||||||
// Sits above the queued banner so a celebration toast spawned
|
|
||||||
// alongside a queued info message remains readable.
|
|
||||||
Val::Percent(14.0),
|
|
||||||
Val::Percent(25.0),
|
|
||||||
Val::Percent(50.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,31 +816,25 @@ fn spawn_toast(
|
|||||||
/// rungs; 18 is the closest rung that preserves the scale invariants
|
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||||
/// tested in `ui_theme::tests`.
|
/// tested in `ui_theme::tests`.
|
||||||
/// - [`RADIUS_MD`] corners.
|
/// - [`RADIUS_MD`] corners.
|
||||||
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
///
|
||||||
/// queued and immediate paths so they layer instead of overlap.
|
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
|
||||||
// The 8-argument signature is intentional — these are the per-toast
|
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
|
||||||
// layout values that genuinely differ between the queued and fire-and-
|
/// into the shared bottom-anchored column — one anchor for every toast,
|
||||||
// forget call sites. A struct wrapper would just rename the same data.
|
/// simultaneous toasts stack instead of overlapping.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn spawn_toast_node<B: Bundle>(
|
fn spawn_toast_node<B: Bundle>(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
bundle: B,
|
bundle: B,
|
||||||
message: String,
|
message: String,
|
||||||
variant: ToastVariant,
|
variant: ToastVariant,
|
||||||
bottom_pct: Val,
|
|
||||||
left_pct: Val,
|
|
||||||
width_pct: Val,
|
|
||||||
padding: UiRect,
|
|
||||||
) -> Entity {
|
) -> Entity {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
bundle,
|
bundle,
|
||||||
|
ToastNode,
|
||||||
|
Visibility::Hidden,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
max_width: Val::Percent(70.0),
|
||||||
left: left_pct,
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||||
bottom: bottom_pct,
|
|
||||||
width: width_pct,
|
|
||||||
padding,
|
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
@@ -799,7 +843,6 @@ fn spawn_toast_node<B: Bundle>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
BorderColor::all(variant.border_color()),
|
BorderColor::all(variant.border_color()),
|
||||||
ZIndex(Z_TOAST),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -1315,4 +1358,85 @@ mod tests {
|
|||||||
fn cascade_duration_instant_is_zero() {
|
fn cascade_duration_instant_is_zero() {
|
||||||
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H: unified toast stack
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Both toast paths must end up as visible children of the single
|
||||||
|
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
|
||||||
|
#[test]
|
||||||
|
fn queued_and_immediate_toasts_stack_under_one_root() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update(); // Startup: spawns the stack root.
|
||||||
|
|
||||||
|
// One immediate (error) toast + one queued (info) toast in the
|
||||||
|
// same frame — the exact collision case the old staggered
|
||||||
|
// anchors existed to dodge.
|
||||||
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
|
app.world_mut().write_message(MoveRejectedEvent {
|
||||||
|
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(InfoToastEvent("stacked info".to_string()));
|
||||||
|
app.update(); // handlers spawn both toasts (hidden, unparented)
|
||||||
|
app.update(); // adopt_toasts_into_stack re-parents + reveals
|
||||||
|
|
||||||
|
let root = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<ToastStackRoot>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("exactly one ToastStackRoot must exist");
|
||||||
|
|
||||||
|
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
toasts.len(),
|
||||||
|
2,
|
||||||
|
"both the immediate and the queued toast must be adopted"
|
||||||
|
);
|
||||||
|
for (entity, child_of, visibility) in toasts {
|
||||||
|
assert_eq!(
|
||||||
|
child_of.parent(),
|
||||||
|
root,
|
||||||
|
"toast {entity} must be a child of the shared stack root"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*visibility,
|
||||||
|
Visibility::Inherited,
|
||||||
|
"adopted toast {entity} must be revealed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toast nodes must not carry their own absolute positioning — the
|
||||||
|
/// stack root owns layout (regression guard against reintroducing
|
||||||
|
/// per-path anchors).
|
||||||
|
#[test]
|
||||||
|
fn toast_nodes_have_no_absolute_position() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(WarningToastEvent("layout check".to_string()));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let node = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Node, With<ToastNode>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("warning toast must spawn a ToastNode");
|
||||||
|
assert_eq!(
|
||||||
|
node.position_type,
|
||||||
|
PositionType::Relative,
|
||||||
|
"toast nodes are flex children of the stack, not absolute overlays"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
//! custom `AssetSource` implementations used by the theme system.
|
||||||
|
|
||||||
pub mod card_face_svg;
|
pub mod card_face_svg;
|
||||||
|
pub mod hud_icon_svg;
|
||||||
pub mod icon_svg;
|
pub mod icon_svg;
|
||||||
pub mod sources;
|
pub mod sources;
|
||||||
pub mod svg_loader;
|
pub mod svg_loader;
|
||||||
|
|||||||
@@ -201,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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -304,21 +283,4 @@ mod tests {
|
|||||||
.with_z_lift(12.0);
|
.with_z_lift(12.0);
|
||||||
assert!((anim.z_lift - 12.0).abs() < 1e-6);
|
assert!((anim.z_lift - 12.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn win_scatter_has_eight_targets() {
|
|
||||||
let targets = win_scatter_targets(800.0);
|
|
||||||
assert_eq!(targets.len(), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn win_scatter_targets_are_off_center() {
|
|
||||||
for t in win_scatter_targets(400.0) {
|
|
||||||
let dist = t.length();
|
|
||||||
assert!(
|
|
||||||
dist > 100.0,
|
|
||||||
"scatter target should be well off-center: {t:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,16 +31,6 @@
|
|||||||
//! ));
|
//! ));
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! # Win cascade with `Expressive` curve
|
|
||||||
//!
|
|
||||||
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
|
||||||
//! (linear). To use the curve-based cascade instead, disable
|
|
||||||
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
|
|
||||||
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
|
|
||||||
//!
|
|
||||||
//! They **must not both be active** — both write to `Transform` on the same
|
|
||||||
//! 52 entities and will race.
|
|
||||||
//!
|
|
||||||
//! # Coexistence rules
|
//! # Coexistence rules
|
||||||
//!
|
//!
|
||||||
//! | Condition | Safe? |
|
//! | Condition | Safe? |
|
||||||
@@ -58,24 +48,21 @@ pub mod interaction;
|
|||||||
pub mod timing;
|
pub mod timing;
|
||||||
pub mod tuning;
|
pub mod tuning;
|
||||||
|
|
||||||
pub use animation::{CardAnimation, win_scatter_targets};
|
pub use animation::CardAnimation;
|
||||||
pub use chain::AnimationChain;
|
pub use chain::AnimationChain;
|
||||||
pub use curves::{MotionCurve, sample_curve};
|
pub use curves::{MotionCurve, sample_curve};
|
||||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||||
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||||
pub use timing::{
|
pub use timing::{
|
||||||
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, compute_duration, micro_vary,
|
||||||
cascade_delay, compute_duration, micro_vary,
|
|
||||||
};
|
};
|
||||||
pub use tuning::{AnimationTuning, InputPlatform};
|
pub use tuning::{AnimationTuning, InputPlatform};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
|
||||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
|
||||||
use crate::resources::DragState;
|
use crate::resources::DragState;
|
||||||
|
|
||||||
use animation::advance_card_animations;
|
use animation::advance_card_animations;
|
||||||
@@ -144,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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -433,19 +363,4 @@ mod tests {
|
|||||||
let state = HoverState::default();
|
let state = HoverState::default();
|
||||||
assert!(state.entity.is_none());
|
assert!(state.entity.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn win_scatter_produces_eight_distinct_points() {
|
|
||||||
let targets = win_scatter_targets(600.0);
|
|
||||||
assert_eq!(targets.len(), 8);
|
|
||||||
// All must be different.
|
|
||||||
for i in 0..8 {
|
|
||||||
for j in (i + 1)..8 {
|
|
||||||
assert_ne!(
|
|
||||||
targets[i], targets[j],
|
|
||||||
"scatter targets {i} and {j} must be distinct"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,17 +49,6 @@ pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
|
|||||||
duration * (1.0 + variation)
|
duration * (1.0 + variation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
|
|
||||||
///
|
|
||||||
/// `delay = index × interval_secs`.
|
|
||||||
#[inline]
|
|
||||||
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
|
|
||||||
index as f32 * interval_secs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recommended per-card interval for the win cascade (Normal speed).
|
|
||||||
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
|
|
||||||
|
|
||||||
/// Recommended per-card interval for deal animations (Normal speed).
|
/// Recommended per-card interval for deal animations (Normal speed).
|
||||||
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
|
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
|
||||||
|
|
||||||
@@ -137,22 +126,4 @@ mod tests {
|
|||||||
"micro_vary should differ for different indices"
|
"micro_vary should differ for different indices"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cascade_delay_zero_index_is_zero() {
|
|
||||||
assert_eq!(cascade_delay(0, 0.018), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cascade_delay_scales_linearly() {
|
|
||||||
let interval = 0.018;
|
|
||||||
for i in 0..52usize {
|
|
||||||
let expected = i as f32 * interval;
|
|
||||||
let actual = cascade_delay(i, interval);
|
|
||||||
assert!(
|
|
||||||
(actual - expected).abs() < 1e-6,
|
|
||||||
"cascade_delay({i}) = {actual}, expected {expected}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +14,14 @@ use crate::platform::{
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
|
||||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
|
||||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
|
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
|
||||||
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
|
||||||
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
|
||||||
WinSummaryPlugin,
|
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -115,6 +115,9 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
|
.add_plugins(WhatsNewPlugin)
|
||||||
|
.add_plugins(ContextualTipsPlugin)
|
||||||
|
.add_plugins(CheatSheetPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.add_plugins(UiFocusPlugin)
|
.add_plugins(UiFocusPlugin)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use solitaire_core::KlondikePile;
|
|||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_core::{Card, Suit};
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
use solitaire_sync::SyncResponse;
|
|
||||||
|
|
||||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||||
/// consumed by `GamePlugin`.
|
/// consumed by `GamePlugin`.
|
||||||
@@ -248,16 +247,6 @@ pub struct ToggleLeaderboardRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ToggleHomeRequestEvent;
|
pub struct ToggleHomeRequestEvent;
|
||||||
|
|
||||||
/// 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>);
|
|
||||||
|
|
||||||
/// Generic informational toast message. Any system can fire this to display
|
/// Generic informational toast message. Any system can fire this to display
|
||||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
|
|||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_animation::{MotionCurve, sample_curve};
|
||||||
|
use crate::card_plugin::{CardEntity, CardEntityIndex};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
|
||||||
NewGameRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
|
.add_message::<HintVisualEvent>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
.add_message::<RequestRedraw>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim.after(GameMutation),
|
||||||
start_foundation_flourish.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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,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)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -955,4 +1113,147 @@ mod tests {
|
|||||||
"FoundationFlourish must not be inserted under reduce-motion"
|
"FoundationFlourish must not be inserted under reduce-motion"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H — hint ghost-motion preview
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_pass_t_restarts_each_pass() {
|
||||||
|
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
|
||||||
|
// Just past one full pass, progress wraps back near zero.
|
||||||
|
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_alpha_fades_only_in_the_tail() {
|
||||||
|
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
|
||||||
|
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
|
||||||
|
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
|
||||||
|
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App with one indexed card entity and a real layout, ready to
|
||||||
|
/// receive `HintVisualEvent`s.
|
||||||
|
fn ghost_app(reduce_motion: bool) -> (App, Card) {
|
||||||
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
// Sibling systems in the plugin take GameStateResource
|
||||||
|
// non-optionally; give them a fresh deal.
|
||||||
|
app.insert_resource(GameStateResource(
|
||||||
|
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
|
||||||
|
));
|
||||||
|
app.insert_resource(SettingsResource(solitaire_data::Settings {
|
||||||
|
reduce_motion_mode: reduce_motion,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
CardEntity { card: card.clone() },
|
||||||
|
Sprite::default(),
|
||||||
|
Transform::from_xyz(-100.0, 40.0, 1.0),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let mut index = CardEntityIndex::default();
|
||||||
|
index.0.insert(card.clone(), entity);
|
||||||
|
app.insert_resource(index);
|
||||||
|
app.insert_resource(LayoutResource(crate::layout::compute_layout(
|
||||||
|
Vec2::new(1280.0, 720.0),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
app.update();
|
||||||
|
(app, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_hint(app: &mut App, card: Card) {
|
||||||
|
use solitaire_core::Tableau;
|
||||||
|
app.world_mut().write_message(HintVisualEvent {
|
||||||
|
source_card: card,
|
||||||
|
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ghost_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HintGhost>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_event_spawns_one_ghost_at_the_source_card() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
|
||||||
|
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
|
||||||
|
let transform = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Transform, With<HintGhost>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("ghost transform");
|
||||||
|
// The tick system may have advanced the ghost by one real-time
|
||||||
|
// frame already; assert it is still essentially at the source.
|
||||||
|
assert!(
|
||||||
|
transform
|
||||||
|
.translation
|
||||||
|
.truncate()
|
||||||
|
.distance(Vec2::new(-100.0, 40.0))
|
||||||
|
< 5.0,
|
||||||
|
"ghost must start at the hinted card, got {:?}",
|
||||||
|
transform.translation
|
||||||
|
);
|
||||||
|
assert_eq!(transform.translation.z, HINT_GHOST_Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_suppressed_under_reduce_motion() {
|
||||||
|
let (mut app, card) = ghost_app(true);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
0,
|
||||||
|
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_change_despawns_the_ghost() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
assert_eq!(ghost_count(&mut app), 1);
|
||||||
|
|
||||||
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
0,
|
||||||
|
"a board change makes the previewed move stale — the ghost must vanish"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_hint_replaces_the_first_ghost() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card.clone());
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
1,
|
||||||
|
"re-hinting must replace the in-flight ghost, not stack a second"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ pub struct NewGameRequestWriters;
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct UndoRequestWriters;
|
pub struct UndoRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `DrawRequestEvent` — same rationale as
|
||||||
|
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
|
||||||
|
/// meaningless (#143). Members: keyboard `D`/`Space`, stock click, touch
|
||||||
|
/// stock tap (input_plugin), and the touch action bar's Draw button
|
||||||
|
/// (hud_plugin, Phase F).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DrawRequestWriters;
|
||||||
|
|
||||||
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
||||||
/// arrival order and any same-frame order is fine (#143).
|
/// arrival order and any same-frame order is fine (#143).
|
||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
@@ -1062,11 +1070,16 @@ pub fn record_replay_on_win(
|
|||||||
if recording.moves.is_empty() {
|
if recording.moves.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The session itself is the authoritative recording: its history
|
||||||
|
// holds the dealt board plus the forward instruction list (undos
|
||||||
|
// already popped), and it serialises via the upstream card_game
|
||||||
|
// serializers so playback never re-deals from the seed.
|
||||||
|
let session_recording = game.0.recording();
|
||||||
// Recording freezes on win, so the move that triggered the
|
// Recording freezes on win, so the move that triggered the
|
||||||
// win condition is the last one in the list. Storing the
|
// win condition is the last one in the list. Storing the
|
||||||
// index explicitly lets the playback UI read the WIN MOVE
|
// index explicitly lets the playback UI read the WIN MOVE
|
||||||
// position directly instead of re-deriving it on every render.
|
// position directly instead of re-deriving it on every render.
|
||||||
let win_move_index = recording.moves.len().checked_sub(1);
|
let win_move_index = session_recording.len().checked_sub(1);
|
||||||
let replay = Replay::new(
|
let replay = Replay::new(
|
||||||
game.0.seed,
|
game.0.seed,
|
||||||
game.0.draw_mode(),
|
game.0.draw_mode(),
|
||||||
@@ -1074,7 +1087,7 @@ pub fn record_replay_on_win(
|
|||||||
ev.time_seconds,
|
ev.time_seconds,
|
||||||
ev.score,
|
ev.score,
|
||||||
Utc::now().date_naive(),
|
Utc::now().date_naive(),
|
||||||
recording.moves.clone(),
|
session_recording,
|
||||||
)
|
)
|
||||||
.with_win_move_index(win_move_index);
|
.with_win_move_index(win_move_index);
|
||||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||||
|
|||||||
@@ -853,17 +853,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
|||||||
let mut app = test_app(7654);
|
let mut app = test_app(7654);
|
||||||
app.insert_resource(ReplayPath(Some(path.clone())));
|
app.insert_resource(ReplayPath(Some(path.clone())));
|
||||||
|
|
||||||
// Push two recorded instructions manually so we can verify they
|
// Drive two real draws so the *session* history (the source the
|
||||||
// survive the freeze/save round-trip without having to drive a
|
// freeze now serialises from, via `GameState::recording()`) holds
|
||||||
// real win. Both are `RotateStock` — the only instruction
|
// two instructions. `RotateStock` is the only instruction the
|
||||||
// constructible without the runtime-only `klondike` pile-stack
|
// engine can drive without the runtime-only `klondike` pile-stack
|
||||||
// types (which the engine intentionally does not depend on); the
|
// types; the round-trip shape is identical for any variant.
|
||||||
// round-trip shape is identical for any instruction variant.
|
app.world_mut().write_message(DrawRequestEvent);
|
||||||
{
|
app.update();
|
||||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
app.world_mut().write_message(DrawRequestEvent);
|
||||||
recording.moves.push(KlondikeInstruction::RotateStock);
|
app.update();
|
||||||
recording.moves.push(KlondikeInstruction::RotateStock);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire the win event the engine emits when the last foundation
|
// Fire the win event the engine emits when the last foundation
|
||||||
// completes — `record_replay_on_win` listens for it.
|
// completes — `record_replay_on_win` listens for it.
|
||||||
@@ -895,9 +893,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
|||||||
loaded.time_seconds, 250,
|
loaded.time_seconds, 250,
|
||||||
"time_seconds must come from the win event"
|
"time_seconds must come from the win event"
|
||||||
);
|
);
|
||||||
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
|
let instructions = loaded.recording.instructions();
|
||||||
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
|
assert_eq!(instructions.len(), 2, "every recorded move must round-trip");
|
||||||
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
|
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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|||||||
+1136
-307
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,12 +109,19 @@ pub(super) fn apply_action_fade(
|
|||||||
/// Visual feedback for every action button — paints idle / hover / pressed
|
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||||
/// changes. One query covers all action buttons via the shared
|
/// changes. One query covers all action buttons via the shared
|
||||||
/// `ActionButton` marker.
|
/// `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)]
|
#[allow(clippy::type_complexity)]
|
||||||
pub(super) fn paint_action_buttons(
|
pub(super) fn paint_action_buttons(
|
||||||
mut buttons: Query<
|
mut buttons: Query<
|
||||||
(&Interaction, &mut BackgroundColor),
|
(&Interaction, &mut BackgroundColor),
|
||||||
(With<ActionButton>, Changed<Interaction>),
|
(
|
||||||
|
With<ActionButton>,
|
||||||
|
Without<GlassTabButton>,
|
||||||
|
Changed<Interaction>,
|
||||||
|
),
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
for (interaction, mut bg) in &mut buttons {
|
for (interaction, mut bg) in &mut buttons {
|
||||||
|
|||||||
@@ -30,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(
|
pub(super) fn handle_pause_button(
|
||||||
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
||||||
mut pause: MessageWriter<PauseRequestEvent>,
|
mut pause: MessageWriter<PauseRequestEvent>,
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ use bevy::window::WindowResized;
|
|||||||
mod fx;
|
mod fx;
|
||||||
mod interaction;
|
mod interaction;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
|
pub(crate) mod tab_bar;
|
||||||
mod updates;
|
mod updates;
|
||||||
|
|
||||||
pub use fx::*;
|
pub use fx::*;
|
||||||
use interaction::*;
|
use interaction::*;
|
||||||
use spawn::*;
|
use spawn::*;
|
||||||
|
use tab_bar::*;
|
||||||
use updates::*;
|
use updates::*;
|
||||||
|
|
||||||
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
||||||
@@ -28,7 +30,7 @@ struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
|||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
@@ -313,6 +315,18 @@ pub struct UndoButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct PauseButton;
|
pub struct PauseButton;
|
||||||
|
|
||||||
|
/// Accumulated hold time on the Undo button, driving hold-to-repeat undo
|
||||||
|
/// (Phase F). `handle_undo_button` owns the instant first undo on press;
|
||||||
|
/// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s
|
||||||
|
/// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every
|
||||||
|
/// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the
|
||||||
|
/// normal request path, so the existing scoring penalty applies per step.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct UndoHoldState {
|
||||||
|
/// Seconds the Undo button has been continuously held.
|
||||||
|
held_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
||||||
/// mirroring the `F1` keyboard accelerator.
|
/// mirroring the `F1` keyboard accelerator.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -323,31 +337,27 @@ pub struct HelpButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HintButton;
|
pub struct HintButton;
|
||||||
|
|
||||||
|
/// Marker on the touch action bar's "Draw" button (Phase F). Click fires
|
||||||
|
/// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the
|
||||||
|
/// draw action is within thumb reach without stretching to the top of a
|
||||||
|
/// tall folded screen. Touch layout only; desktop draws via stock
|
||||||
|
/// click / `D` / `Space`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct DrawButton;
|
||||||
|
|
||||||
/// Android HUD label for the Hint button — shared with the help screen's
|
/// Android HUD label for the Hint button — shared with the help screen's
|
||||||
/// controls reference so both always agree.
|
/// controls reference so both always agree.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
||||||
|
|
||||||
|
/// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`]
|
||||||
|
/// on Android so the bar and the help screen's controls reference can
|
||||||
|
/// never drift; the non-Android value only exists so the touch spawn
|
||||||
|
/// path stays host-compilable for tests.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL;
|
||||||
"Menu",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
ANDROID_HINT_LABEL,
|
|
||||||
"Mode",
|
|
||||||
"New",
|
|
||||||
];
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
const TOUCH_HINT_LABEL: &str = "Hint";
|
||||||
"Menu \u{2193}",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
"Hint",
|
|
||||||
"Modes \u{2193}",
|
|
||||||
"New Game",
|
|
||||||
];
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -455,6 +465,7 @@ impl Plugin for HudPlugin {
|
|||||||
// idempotent.
|
// idempotent.
|
||||||
app.add_message::<NewGameRequestEvent>()
|
app.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<PauseRequestEvent>()
|
.add_message::<PauseRequestEvent>()
|
||||||
.add_message::<HelpRequestEvent>()
|
.add_message::<HelpRequestEvent>()
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
@@ -471,6 +482,7 @@ impl Plugin for HudPlugin {
|
|||||||
.init_resource::<PreviousScore>()
|
.init_resource::<PreviousScore>()
|
||||||
.init_resource::<HudActionFade>()
|
.init_resource::<HudActionFade>()
|
||||||
.init_resource::<HudVisibility>()
|
.init_resource::<HudVisibility>()
|
||||||
|
.init_resource::<UndoHoldState>()
|
||||||
// Escape-close handlers for popovers read this; init defensively
|
// Escape-close handlers for popovers read this; init defensively
|
||||||
// so HudPlugin works under MinimalPlugins in tests.
|
// so HudPlugin works under MinimalPlugins in tests.
|
||||||
.init_resource::<ButtonInput<KeyCode>>()
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
@@ -560,6 +572,14 @@ impl Plugin for HudPlugin {
|
|||||||
.in_set(crate::game_plugin::UndoRequestWriters)
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
.before(GameMutation),
|
.before(GameMutation),
|
||||||
|
repeat_undo_on_hold
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
|
handle_draw_button
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_help_button,
|
handle_help_button,
|
||||||
handle_hint_button
|
handle_hint_button
|
||||||
@@ -577,6 +597,12 @@ impl Plugin for HudPlugin {
|
|||||||
handle_menu_backdrop_click,
|
handle_menu_backdrop_click,
|
||||||
close_menu_popover_on_escape,
|
close_menu_popover_on_escape,
|
||||||
paint_action_buttons,
|
paint_action_buttons,
|
||||||
|
paint_glass_tab_buttons,
|
||||||
|
drive_tab_expansion,
|
||||||
|
// Writes tab-label TextColor and reads TextFont — entity-
|
||||||
|
// disjoint from the UiTextFx readout writers (tab labels
|
||||||
|
// vs HUD readouts), which Bevy can't prove, so declare it.
|
||||||
|
animate_tab_expansion.ambiguous_with(UiTextFx),
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(HudButtons)
|
.in_set(HudButtons)
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ pub(super) fn handle_avatar_button(
|
|||||||
pub(super) fn spawn_action_buttons(
|
pub(super) fn spawn_action_buttons(
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
images: Option<ResMut<Assets<Image>>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let action_font_size =
|
let action_font_size =
|
||||||
@@ -370,9 +371,18 @@ pub(super) fn spawn_action_buttons(
|
|||||||
..default()
|
..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
|
// `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
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
Node {
|
Node {
|
||||||
@@ -400,83 +410,89 @@ pub(super) fn spawn_action_buttons(
|
|||||||
HudActionBar,
|
HudActionBar,
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
spawn_desktop_action_bar(row, &font);
|
||||||
// so Tab cycles the action bar in visual reading order.
|
|
||||||
// Undo and Pause are the primary gameplay actions — full brightness.
|
|
||||||
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
MenuButton,
|
|
||||||
ACTION_BAR_LABELS[0],
|
|
||||||
None,
|
|
||||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
|
||||||
&font,
|
|
||||||
0,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
UndoButton,
|
|
||||||
ACTION_BAR_LABELS[1],
|
|
||||||
Some("U"),
|
|
||||||
"Take back your last move. Costs points and blocks No Undo.",
|
|
||||||
&font,
|
|
||||||
1,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
PauseButton,
|
|
||||||
ACTION_BAR_LABELS[2],
|
|
||||||
Some("Esc"),
|
|
||||||
"Pause the game and freeze the timer.",
|
|
||||||
&font,
|
|
||||||
2,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
HelpButton,
|
|
||||||
ACTION_BAR_LABELS[3],
|
|
||||||
Some("F1"),
|
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
|
||||||
&font,
|
|
||||||
3,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
HintButton,
|
|
||||||
ACTION_BAR_LABELS[4],
|
|
||||||
Some("H"),
|
|
||||||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
|
||||||
&font,
|
|
||||||
4,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
ModesButton,
|
|
||||||
ACTION_BAR_LABELS[5],
|
|
||||||
None,
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
||||||
&font,
|
|
||||||
5,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
NewGameButton,
|
|
||||||
ACTION_BAR_LABELS[6],
|
|
||||||
Some("N"),
|
|
||||||
"Start a fresh deal. Confirms first if a game is in progress.",
|
|
||||||
&font,
|
|
||||||
6,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
|
||||||
|
/// is touch-only). All seven actions, uniform sizing.
|
||||||
|
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||||||
|
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||||
|
// so Tab cycles the action bar in visual reading order.
|
||||||
|
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||||
|
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
MenuButton,
|
||||||
|
"Menu \u{2193}",
|
||||||
|
None,
|
||||||
|
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
font,
|
||||||
|
0,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
UndoButton,
|
||||||
|
"Undo",
|
||||||
|
Some("U"),
|
||||||
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||||
|
font,
|
||||||
|
1,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
PauseButton,
|
||||||
|
"Pause",
|
||||||
|
Some("Esc"),
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
font,
|
||||||
|
2,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HelpButton,
|
||||||
|
"Help",
|
||||||
|
Some("F1"),
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
font,
|
||||||
|
3,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HintButton,
|
||||||
|
"Hint",
|
||||||
|
Some("H"),
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
font,
|
||||||
|
4,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
ModesButton,
|
||||||
|
"Modes \u{2193}",
|
||||||
|
None,
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
font,
|
||||||
|
5,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
NewGameButton,
|
||||||
|
"New Game",
|
||||||
|
Some("N"),
|
||||||
|
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||||
|
font,
|
||||||
|
6,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawns a single action button as a child of `row`. Each button shares
|
/// Spawns a single action button as a child of `row`. Each button shares
|
||||||
/// the same node geometry, idle colour, and `ActionButton` marker so
|
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||||
/// `paint_action_buttons` can recolour all of them with one query.
|
/// `paint_action_buttons` can recolour all of them with one query.
|
||||||
@@ -510,13 +526,34 @@ pub(super) fn spawn_action_button<M: Component>(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (pad, min_w, min_h) = action_button_metrics();
|
||||||
|
spawn_action_button_sized(
|
||||||
|
row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared body of [`spawn_action_button`] — one place owns the component
|
||||||
|
/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
|
||||||
|
/// bar button identically.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_action_button_sized<M: Component>(
|
||||||
|
row: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
hotkey: Option<&'static str>,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
text_color: Color,
|
||||||
|
pad: UiRect,
|
||||||
|
min_w: Val,
|
||||||
|
min_h: Val,
|
||||||
|
) {
|
||||||
let hotkey_font = TextFont {
|
let hotkey_font = TextFont {
|
||||||
font: font.font.clone(),
|
font: font.font.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let (pad, min_w, min_h) = action_button_metrics();
|
|
||||||
|
|
||||||
row.spawn((
|
row.spawn((
|
||||||
marker,
|
marker,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -670,7 +670,7 @@ fn hud_elements_carry_expected_tooltip_strings() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tooltip_for::<UndoButton>(&mut app),
|
tooltip_for::<UndoButton>(&mut app),
|
||||||
"Take back your last move. Costs points and blocks No Undo."
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo."
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tooltip_for::<PauseButton>(&mut app),
|
tooltip_for::<PauseButton>(&mut app),
|
||||||
@@ -899,3 +899,237 @@ fn hud_focus_only_engages_when_button_hovered() {
|
|||||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase F: touch action bar + hold-to-repeat undo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
|
||||||
|
/// buttons the tested spawn function creates — `headless_app` would
|
||||||
|
/// pre-spawn the platform-default bar and pollute the counts.
|
||||||
|
///
|
||||||
|
/// The touch branch spawns the glass tab bar with `icons: None`
|
||||||
|
/// (`MinimalPlugins` has no `Assets<Image>`), which exercises the
|
||||||
|
/// text-fallback path — same markers, labels pinned expanded.
|
||||||
|
fn bar_only_app(touch: bool) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.update();
|
||||||
|
let font = TextFont::default();
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
if touch {
|
||||||
|
spawn_glass_tab_bar(&mut commands, &font, None);
|
||||||
|
} else {
|
||||||
|
commands.spawn(Node::default()).with_children(|row| {
|
||||||
|
spawn_desktop_action_bar(row, &font);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_buttons<C: Component>(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query_filtered::<(), With<C>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn touch_tab_bar_has_thumb_trio_plus_menu_and_pause() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
|
||||||
|
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<DrawButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<HintButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<MenuButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<PauseButton>(&mut app), 1);
|
||||||
|
|
||||||
|
// Utility actions live elsewhere on touch: Help in Menu → System,
|
||||||
|
// modes on the Home screen, New Game on Home's hero button.
|
||||||
|
assert_eq!(count_buttons::<HelpButton>(&mut app), 0);
|
||||||
|
assert_eq!(count_buttons::<ModesButton>(&mut app), 0);
|
||||||
|
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The glass bar floats: its container must carry a bottom margin (not be
|
||||||
|
/// docked at 0) both before and after safe-area insets arrive.
|
||||||
|
#[test]
|
||||||
|
fn glass_tab_bar_floats_above_bottom_edge() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query_filtered::<(&Node, &SafeAreaAnchoredBottom), With<HudActionBar>>();
|
||||||
|
let (node, anchor) = q.single(world).expect("glass bar container exists");
|
||||||
|
assert!(anchor.base_bottom > 0.0, "bar must float, not dock");
|
||||||
|
assert_eq!(node.bottom, Val::Px(anchor.base_bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw is the persistently-expanded active pill (user decision
|
||||||
|
/// 2026-07-15); with `icons: None` every wrapper is pinned expanded via
|
||||||
|
/// text fallback, so instead assert on the *persistent* flag's effect:
|
||||||
|
/// all wrappers spawn at full reveal and target full reveal.
|
||||||
|
#[test]
|
||||||
|
fn glass_tab_bar_text_fallback_pins_labels_expanded() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query::<&TabLabelWrap>();
|
||||||
|
let wraps: Vec<_> = q.iter(world).collect();
|
||||||
|
assert_eq!(wraps.len(), 4, "Undo/Draw/Hint/Pause each carry a wrapper");
|
||||||
|
for wrap in wraps {
|
||||||
|
assert_eq!(wrap.progress, 1.0);
|
||||||
|
assert_eq!(wrap.target, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus order must match visual reading order: pill buttons left to
|
||||||
|
/// right, then the detached Menu circle.
|
||||||
|
#[test]
|
||||||
|
fn glass_tab_bar_focus_order_reads_left_to_right() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
|
||||||
|
for (f, expected) in [
|
||||||
|
(focusable_for::<UndoButton>(&mut app), 0),
|
||||||
|
(focusable_for::<DrawButton>(&mut app), 1),
|
||||||
|
(focusable_for::<HintButton>(&mut app), 2),
|
||||||
|
(focusable_for::<PauseButton>(&mut app), 3),
|
||||||
|
(focusable_for::<MenuButton>(&mut app), 4),
|
||||||
|
] {
|
||||||
|
assert_eq!(f.group, FocusGroup::Hud);
|
||||||
|
assert_eq!(f.order, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `expansion_width` is the single source of truth for the label reveal:
|
||||||
|
/// zero when collapsed, lead + monospace advance when expanded, linear
|
||||||
|
/// in between.
|
||||||
|
#[test]
|
||||||
|
fn expansion_width_is_linear_in_progress() {
|
||||||
|
assert_eq!(expansion_width(4, 16.0, 0.0), 0.0);
|
||||||
|
let full = expansion_width(4, 16.0, 1.0);
|
||||||
|
assert!(full > 4.0 * 16.0 * 0.62, "full width includes the lead gap");
|
||||||
|
let half = expansion_width(4, 16.0, 0.5);
|
||||||
|
assert!((half - full / 2.0).abs() < f32::EPSILON * 100.0);
|
||||||
|
// Out-of-range progress clamps rather than extrapolating.
|
||||||
|
assert_eq!(expansion_width(4, 16.0, 1.5), full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -554,11 +554,11 @@ pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
|
|||||||
|
|
||||||
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||||
if USE_TOUCH_UI_LAYOUT {
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
// Tight 3 px horizontal padding keeps the compact buttons narrow;
|
||||||
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
// Phase F trimmed the touch bar to 5 buttons (2 compact + 3
|
||||||
// shortest labels ("New", "Help") shrink to their text rather than
|
// primary), so width pressure is lower than the old 7-button row.
|
||||||
// padding the row out past the 900 logical-px viewport. min_height
|
// min_height stays at 44 px to preserve the comfortable touch
|
||||||
// stays at 44 px to preserve the comfortable touch target.
|
// target on the compact buttons.
|
||||||
(
|
(
|
||||||
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||||
Val::Px(44.0),
|
Val::Px(44.0),
|
||||||
|
|||||||
@@ -136,11 +136,22 @@ impl Plugin for InputPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
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_hint,
|
||||||
handle_keyboard_forfeit,
|
handle_keyboard_forfeit,
|
||||||
handle_stock_click,
|
handle_stock_click
|
||||||
handle_touch_stock_tap,
|
.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,
|
handle_double_click,
|
||||||
// Mouse drag pipeline.
|
// Mouse drag pipeline.
|
||||||
start_drag,
|
start_drag,
|
||||||
@@ -204,6 +215,7 @@ fn handle_keyboard_core(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
mut ev: CoreKeyboardMessages<'_>,
|
mut ev: CoreKeyboardMessages<'_>,
|
||||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||||
selection: Option<Res<SelectionState>>,
|
selection: Option<Res<SelectionState>>,
|
||||||
@@ -216,6 +228,19 @@ fn handle_keyboard_core(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mutual exclusion with pointer drags — mirrors `handle_selection_keys`.
|
||||||
|
// Undo / draw / new-game during an active mouse or touch drag would fire
|
||||||
|
// a StateChangedEvent whose card re-sync inserts a `CardAnim` on the
|
||||||
|
// dragged cards, fighting `follow_drag`'s per-frame Transform writes and
|
||||||
|
// leaving `DragState` origin indices stale against the mutated state.
|
||||||
|
// The keyboard-drag sentinel may proceed: `clear_selection_on_state_change`
|
||||||
|
// already drops that lift cleanly when the state moves.
|
||||||
|
if !drag.is_idle()
|
||||||
|
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// During replay playback (Playing or Completed) all game-input shortcuts
|
// During replay playback (Playing or Completed) all game-input shortcuts
|
||||||
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
||||||
// arrow keys (step). Letting game input through would mutate
|
// arrow keys (step). Letting game input through would mutate
|
||||||
@@ -462,11 +487,19 @@ pub fn emit_hint_visuals(
|
|||||||
fn handle_keyboard_forfeit(
|
fn handle_keyboard_forfeit(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
mut requests: MessageWriter<ForfeitRequestEvent>,
|
mut requests: MessageWriter<ForfeitRequestEvent>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Same pointer-drag exclusion as `handle_keyboard_core` — forfeiting
|
||||||
|
// mid-drag would mutate the game state under the dragged cards.
|
||||||
|
if !drag.is_idle()
|
||||||
|
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !keys.just_pressed(KeyCode::KeyG) {
|
if !keys.just_pressed(KeyCode::KeyG) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1370,17 +1403,37 @@ pub fn best_tableau_destination_for_stack(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decide the auto-move for the face-up run headed by the clicked/tapped card.
|
||||||
|
///
|
||||||
|
/// The move covers **exactly** `run_len` cards — the run from the clicked
|
||||||
|
/// card to the top of its pile. A lone top card goes to its best foundation
|
||||||
|
/// (or tableau) destination; a multi-card run goes whole to the best tableau
|
||||||
|
/// column. Runs larger or smaller than the clicked one are never considered.
|
||||||
|
///
|
||||||
|
/// Returns `(destination, count)`, or `None` when the clicked run has no
|
||||||
|
/// legal destination.
|
||||||
|
pub fn auto_move_for_run(
|
||||||
|
clicked_card: &Card,
|
||||||
|
pile: &KlondikePile,
|
||||||
|
game: &GameState,
|
||||||
|
run_len: usize,
|
||||||
|
) -> Option<(KlondikePile, usize)> {
|
||||||
|
if run_len == 1 {
|
||||||
|
best_destination(clicked_card, game).map(|dest| (dest, 1))
|
||||||
|
} else {
|
||||||
|
best_tableau_destination_for_stack(clicked_card, pile, game, run_len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
|
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
|
||||||
/// to the best legal destination.
|
/// to the best legal destination.
|
||||||
///
|
///
|
||||||
/// Move priority:
|
/// The move covers exactly the face-up run headed by the clicked card —
|
||||||
/// 1. Move the single **top** card to its best foundation (or tableau) destination.
|
/// see [`auto_move_for_run`].
|
||||||
/// 2. If no single-card move exists and the clicked card is the base of a
|
|
||||||
/// multi-card face-up stack, move the whole stack to the best tableau column.
|
|
||||||
///
|
///
|
||||||
/// When a multi-card stack double-click finds no legal destination (Priority 2
|
/// When the clicked run has no legal destination, fires `MoveRejectedEvent`
|
||||||
/// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
|
/// with `from == to == pile` so the invalid-move sound plays and the source
|
||||||
/// invalid-move sound plays and the source pile cards shake as feedback.
|
/// pile cards shake as feedback.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_double_click(
|
fn handle_double_click(
|
||||||
buttons: Res<ButtonInput<MouseButton>>,
|
buttons: Res<ButtonInput<MouseButton>>,
|
||||||
@@ -1411,7 +1464,11 @@ fn handle_double_click(
|
|||||||
return;
|
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 {
|
let Some(top_card) = card_ids.last() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1426,31 +1483,15 @@ fn handle_double_click(
|
|||||||
|
|
||||||
let now = time.elapsed_secs();
|
let now = time.elapsed_secs();
|
||||||
let prev = last_click
|
let prev = last_click
|
||||||
.get(top_card)
|
.get(clicked_card)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(f32::NEG_INFINITY);
|
.unwrap_or(f32::NEG_INFINITY);
|
||||||
|
|
||||||
if now - prev <= DOUBLE_CLICK_WINDOW {
|
if now - prev <= DOUBLE_CLICK_WINDOW {
|
||||||
// Double-click confirmed.
|
// Double-click confirmed.
|
||||||
last_click.remove(top_card);
|
last_click.remove(clicked_card);
|
||||||
|
|
||||||
// Priority 1: move the single top card (foundation preferred, then tableau).
|
if let Some((dest, count)) = auto_move_for_run(clicked_card, &pile, &game.0, card_ids.len())
|
||||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
|
||||||
moves.write(MoveRequestEvent {
|
|
||||||
from: pile,
|
|
||||||
to: dest,
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: if the player clicked the base of a multi-card face-up
|
|
||||||
// stack (card_ids.len() > 1), try moving the whole stack to another
|
|
||||||
// tableau column.
|
|
||||||
if card_ids.len() > 1
|
|
||||||
&& let Some((bottom_card, _)) = pile_cards.get(stack_index)
|
|
||||||
&& let Some((dest, count)) =
|
|
||||||
best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len())
|
|
||||||
{
|
{
|
||||||
moves.write(MoveRequestEvent {
|
moves.write(MoveRequestEvent {
|
||||||
from: pile,
|
from: pile,
|
||||||
@@ -1460,14 +1501,10 @@ fn handle_double_click(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both priorities failed — play the invalid-move sound and shake
|
// No legal destination for the clicked run — play the invalid-move
|
||||||
// the source pile as feedback. `MoveRejectedEvent` with
|
// sound and shake the source pile as feedback. `MoveRejectedEvent`
|
||||||
// `from == to` routes the shake to the source pile (which
|
// with `from == to` routes the shake to the source pile (which
|
||||||
// `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
|
// `start_shake_anim` reads from `ev.to`).
|
||||||
// only fired for multi-card stacks, so a double-click on a
|
|
||||||
// single card with no legal destination did nothing — no
|
|
||||||
// sound, no shake. Now both single-card and stack misses get
|
|
||||||
// the same feedback.
|
|
||||||
rejected.write(MoveRejectedEvent {
|
rejected.write(MoveRejectedEvent {
|
||||||
from: pile,
|
from: pile,
|
||||||
to: pile,
|
to: pile,
|
||||||
@@ -1475,7 +1512,7 @@ fn handle_double_click(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Single click — record the time.
|
// Single click — record the time.
|
||||||
last_click.insert(top_card.clone(), now);
|
last_click.insert(clicked_card.clone(), now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1491,10 +1528,9 @@ fn handle_double_click(
|
|||||||
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||||
/// are cleared and the tap/drag distinction is permanently lost.
|
/// are cleared and the tap/drag distinction is permanently lost.
|
||||||
///
|
///
|
||||||
/// Move priority:
|
/// The move covers exactly the face-up run headed by the tapped card —
|
||||||
/// 1. Single top card to its best foundation (or tableau).
|
/// see [`auto_move_for_run`]. Fires `MoveRejectedEvent` for audio + shake
|
||||||
/// 2. Whole face-up run to best tableau column when no single-card move exists.
|
/// feedback when the tapped run has no legal destination.
|
||||||
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_double_tap(
|
fn handle_double_tap(
|
||||||
mut touch_events: MessageReader<TouchInput>,
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
@@ -1554,8 +1590,7 @@ fn handle_double_tap(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
|
let Some((_, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card) else {
|
||||||
else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if !*found_face_up {
|
if !*found_face_up {
|
||||||
@@ -1591,53 +1626,27 @@ fn handle_double_tap(
|
|||||||
|
|
||||||
// --- One-tap auto-move (original behaviour) ---
|
// --- One-tap auto-move (original behaviour) ---
|
||||||
|
|
||||||
// Priority 1: move single top card.
|
// Move exactly the run headed by the tapped card.
|
||||||
if let Some(dest) = best_destination(found_card, &game.0) {
|
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() {
|
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;
|
sprite.color = STATE_SUCCESS;
|
||||||
commands.entity(entity).insert(HintHighlight {
|
commands.entity(entity).insert(HintHighlight {
|
||||||
remaining: DOUBLE_TAP_FLASH_SECS,
|
remaining: DOUBLE_TAP_FLASH_SECS,
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
moves.write(MoveRequestEvent {
|
moves.write(MoveRequestEvent {
|
||||||
from: *tapped_pile,
|
from: *tapped_pile,
|
||||||
to: dest,
|
to: dest,
|
||||||
count: 1,
|
count,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: move whole face-up stack to best tableau column.
|
|
||||||
if drag.cards.len() > 1 {
|
|
||||||
let stack_index = pile_cards.len() - drag.cards.len();
|
|
||||||
if let Some((bottom_card, _)) = pile_cards.get(stack_index)
|
|
||||||
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
|
||||||
bottom_card,
|
|
||||||
tapped_pile,
|
|
||||||
&game.0,
|
|
||||||
drag.cards.len(),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
|
||||||
if drag.cards.contains(&ce.card) {
|
|
||||||
sprite.color = STATE_SUCCESS;
|
|
||||||
commands.entity(entity).insert(HintHighlight {
|
|
||||||
remaining: DOUBLE_TAP_FLASH_SECS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
moves.write(MoveRequestEvent {
|
|
||||||
from: *tapped_pile,
|
|
||||||
to: dest,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rejected.write(MoveRejectedEvent {
|
rejected.write(MoveRejectedEvent {
|
||||||
from: *tapped_pile,
|
from: *tapped_pile,
|
||||||
to: *tapped_pile,
|
to: *tapped_pile,
|
||||||
|
|||||||
@@ -429,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
|
// Task #28 — find_hint pure-function tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -705,3 +817,123 @@ fn pressing_h_spawns_pending_hint_task() {
|
|||||||
"pressing H must spawn an async hint task",
|
"pressing H must spawn an async hint task",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Pointer-drag / keyboard mutual exclusion.
|
||||||
|
//
|
||||||
|
// A state mutation fired while `follow_drag` is writing the dragged cards'
|
||||||
|
// transforms every frame would make the card re-sync insert a `CardAnim`
|
||||||
|
// on the same entities (two systems fighting over `Transform`) and leave
|
||||||
|
// `DragState`'s origin indices stale. `handle_keyboard_core` and
|
||||||
|
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
|
||||||
|
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
|
||||||
|
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
|
||||||
|
// drops that lift cleanly when the state moves.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn keyboard_core_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<UndoRequestEvent>();
|
||||||
|
app.add_message::<NewGameRequestEvent>();
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<DrawRequestEvent>();
|
||||||
|
app.add_message::<StartZenRequestEvent>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.add_systems(Update, handle_keyboard_core);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_key(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_count<M: Message>(app: &App) -> usize {
|
||||||
|
let messages = app.world().resource::<Messages<M>>();
|
||||||
|
let mut cursor = messages.get_cursor();
|
||||||
|
cursor.read(messages).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
|
||||||
|
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||||
|
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
|
||||||
|
drag.committed = true;
|
||||||
|
drag.active_touch_id = active_touch_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_fires_undo_when_no_drag_active() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
1,
|
||||||
|
"U with an idle DragState must fire UndoRequestEvent",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_ignored_during_committed_mouse_drag() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(&mut app, None); // mouse drag
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"U during a mouse drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_key_ignored_during_touch_drag() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(&mut app, Some(7)); // real touch id
|
||||||
|
press_key(&mut app, KeyCode::KeyD);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<DrawRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"D during a touch drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_allowed_during_keyboard_lift() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(
|
||||||
|
&mut app,
|
||||||
|
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
|
||||||
|
);
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
1,
|
||||||
|
"the keyboard-lift sentinel must not block core shortcuts",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn g_key_forfeit_ignored_during_mouse_drag() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<ForfeitRequestEvent>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.add_systems(Update, handle_keyboard_forfeit);
|
||||||
|
|
||||||
|
set_drag(&mut app, None);
|
||||||
|
press_key(&mut app, KeyCode::KeyG);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<ForfeitRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"G during a mouse drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||||
spawn_modal_button, spawn_modal_header,
|
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY,
|
||||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||||
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
|
Z_PAUSE_DIALOG,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -536,11 +536,6 @@ fn spawn_leaderboard_screen(
|
|||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let font_status = TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
let font_row = TextFont {
|
let font_row = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
font_size: TYPE_BODY,
|
font_size: TYPE_BODY,
|
||||||
@@ -647,30 +642,31 @@ fn spawn_leaderboard_screen(
|
|||||||
.with_children(|body| {
|
.with_children(|body| {
|
||||||
match data {
|
match data {
|
||||||
LeaderboardResource::Idle => {
|
LeaderboardResource::Idle => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Fetching\u{2026}"),
|
body,
|
||||||
font_status.clone(),
|
"\u{21BB}",
|
||||||
TextColor(STATE_INFO),
|
"Fetching the leaderboard\u{2026}",
|
||||||
));
|
None,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
LeaderboardResource::Error(_) => {
|
LeaderboardResource::Error(_) => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
body,
|
||||||
font_status.clone(),
|
"!",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Couldn't reach the leaderboard.",
|
||||||
));
|
Some("Try again later."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Be the first on the leaderboard."),
|
body,
|
||||||
font_status.clone(),
|
"\u{2660}",
|
||||||
TextColor(TEXT_PRIMARY),
|
"Be the first on the leaderboard.",
|
||||||
));
|
Some("Win a game and opt in to appear here."),
|
||||||
body.spawn((
|
font_res,
|
||||||
Text::new("Win a game and opt in to appear here."),
|
);
|
||||||
font_row.clone(),
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
LeaderboardResource::Loaded(rows) => {
|
LeaderboardResource::Loaded(rows) => {
|
||||||
// Column headers
|
// Column headers
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub mod avatar_plugin;
|
|||||||
pub mod card_animation;
|
pub mod card_animation;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
|
pub mod cheat_sheet_plugin;
|
||||||
|
pub mod contextual_tips_plugin;
|
||||||
pub mod core_game_plugin;
|
pub mod core_game_plugin;
|
||||||
pub mod cursor_plugin;
|
pub mod cursor_plugin;
|
||||||
pub mod daily_challenge_plugin;
|
pub mod daily_challenge_plugin;
|
||||||
@@ -26,6 +28,7 @@ pub mod font_plugin;
|
|||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
pub mod help_plugin;
|
pub mod help_plugin;
|
||||||
pub mod home_plugin;
|
pub mod home_plugin;
|
||||||
|
pub mod hotkeys;
|
||||||
pub mod hud_plugin;
|
pub mod hud_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
@@ -60,10 +63,12 @@ pub mod theme_store_plugin;
|
|||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod touch_selection_plugin;
|
pub mod touch_selection_plugin;
|
||||||
pub mod ui_focus;
|
pub mod ui_focus;
|
||||||
|
pub mod ui_glass;
|
||||||
pub mod ui_modal;
|
pub mod ui_modal;
|
||||||
pub mod ui_theme;
|
pub mod ui_theme;
|
||||||
pub mod ui_tooltip;
|
pub mod ui_tooltip;
|
||||||
pub mod weekly_goals_plugin;
|
pub mod weekly_goals_plugin;
|
||||||
|
pub mod whats_new_plugin;
|
||||||
pub mod win_summary_plugin;
|
pub mod win_summary_plugin;
|
||||||
pub mod you_hub_plugin;
|
pub mod you_hub_plugin;
|
||||||
|
|
||||||
@@ -83,9 +88,8 @@ pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
|
|||||||
pub use card_animation::{
|
pub use card_animation::{
|
||||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||||
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
||||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, compute_duration, micro_vary,
|
||||||
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, sample_curve,
|
sample_curve,
|
||||||
win_scatter_targets,
|
|
||||||
};
|
};
|
||||||
pub use card_plugin::{
|
pub use card_plugin::{
|
||||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||||
@@ -94,6 +98,8 @@ pub use card_plugin::{
|
|||||||
pub use challenge_plugin::{
|
pub use challenge_plugin::{
|
||||||
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
||||||
};
|
};
|
||||||
|
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
|
||||||
|
pub use contextual_tips_plugin::ContextualTipsPlugin;
|
||||||
pub use core_game_plugin::CoreGamePlugin;
|
pub use core_game_plugin::CoreGamePlugin;
|
||||||
pub use cursor_plugin::CursorPlugin;
|
pub use cursor_plugin::CursorPlugin;
|
||||||
pub use daily_challenge_plugin::{
|
pub use daily_challenge_plugin::{
|
||||||
@@ -107,7 +113,7 @@ pub use events::{
|
|||||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
||||||
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||||
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
XpAwardedEvent,
|
XpAwardedEvent,
|
||||||
@@ -195,6 +201,7 @@ pub use ui_modal::{
|
|||||||
};
|
};
|
||||||
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
|
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
|
||||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||||
|
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
|
||||||
pub use win_summary_plugin::{
|
pub use win_summary_plugin::{
|
||||||
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
|
|||||||
pub struct OnboardingSlideIndex(pub u8);
|
pub struct OnboardingSlideIndex(pub u8);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
|
// Slide data — the hotkey slide renders the essential subset of the
|
||||||
// two screens stay in sync without a shared abstraction.
|
// 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
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -313,6 +258,10 @@ fn complete_onboarding(
|
|||||||
despawn_screen(commands, screens);
|
despawn_screen(commands, screens);
|
||||||
if let Some(s) = settings {
|
if let Some(s) = settings {
|
||||||
s.0.first_run_complete = true;
|
s.0.first_run_complete = true;
|
||||||
|
// A fresh install has nothing "new" to announce — stamp the
|
||||||
|
// running release so the What's-new card (Phase I) only ever
|
||||||
|
// fires after an actual upgrade.
|
||||||
|
s.0.last_seen_whats_new = crate::whats_new_plugin::current_release_version();
|
||||||
persist(path.map(|p| &p.0), &s.0);
|
persist(path.map(|p| &p.0), &s.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
/// Slide 2 — How to play.
|
||||||
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
spawn_modal_header(card, "Drag cards to play", font_res);
|
spawn_modal_header(card, "Drag cards to play", font_res);
|
||||||
spawn_modal_body_text(
|
spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
|
||||||
card,
|
|
||||||
"Left-click and drag any face-up card to move it between piles. \
|
|
||||||
You can drag a whole column at once by grabbing the topmost card \
|
|
||||||
you want to move. Double-click a face-up card to send it to a \
|
|
||||||
foundation pile automatically (when the move is legal). \
|
|
||||||
Right-click a card for a hint — valid destinations will highlight.",
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
spawn_modal_actions(card, |actions| {
|
spawn_modal_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
@@ -435,8 +391,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
||||||
|
|
||||||
// Vertical list of `key — description` rows, same chip style as HelpScreen.
|
// Vertical list of `key — description` rows, same chip style as
|
||||||
for row in HOTKEYS {
|
// 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 {
|
card.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
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]
|
#[test]
|
||||||
fn hotkey_list_is_non_empty() {
|
fn hotkey_slide_subset_is_non_empty() {
|
||||||
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
|
assert!(
|
||||||
}
|
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
|
||||||
|
"the onboarding slide needs at least one essential hotkey"
|
||||||
#[test]
|
);
|
||||||
fn all_hotkey_rows_have_non_empty_fields() {
|
|
||||||
for row in HOTKEYS {
|
|
||||||
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
|
|
||||||
assert!(
|
|
||||||
!row.description.is_empty(),
|
|
||||||
"hotkey description must not be empty"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,12 +122,8 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
|||||||
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
||||||
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||||
match state {
|
match state {
|
||||||
ReplayPlaybackState::Playing { replay, cursor, .. } => {
|
ReplayPlaybackState::Playing { moves, cursor, .. } => {
|
||||||
format!(
|
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len())
|
||||||
"\u{258C} MOVE LOG \u{00B7} {}/{}",
|
|
||||||
cursor,
|
|
||||||
replay.moves.len()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
||||||
ReplayPlaybackState::Inactive => String::new(),
|
ReplayPlaybackState::Inactive => String::new(),
|
||||||
@@ -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
|
/// 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
|
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
|
||||||
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
||||||
/// and so on.
|
/// and so on.
|
||||||
@@ -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).
|
/// for k=1 and k=2 only, k=3 returns empty).
|
||||||
/// - The move list is shorter than expected (defensive guard).
|
/// - The move list is shorter than expected (defensive guard).
|
||||||
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
if k == 0 || k > *cursor {
|
if k == 0 || k > *cursor {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
let zero_idx = *cursor - k;
|
let zero_idx = *cursor - k;
|
||||||
let Some(m) = replay.moves.get(zero_idx) else {
|
let Some(m) = moves.get(zero_idx) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let display_idx = *cursor - k + 1;
|
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`
|
/// 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,
|
/// displayed as `cursor + 1`); `k = 2` is the move after that,
|
||||||
/// and so on.
|
/// 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
|
/// replay — late in the move list, the trailing next rows
|
||||||
/// stay empty).
|
/// stay empty).
|
||||||
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
if k == 0 {
|
if k == 0 {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
let zero_idx = *cursor + k - 1;
|
let zero_idx = *cursor + k - 1;
|
||||||
let Some(m) = replay.moves.get(zero_idx) else {
|
let Some(m) = moves.get(zero_idx) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let display_idx = *cursor + k;
|
let display_idx = *cursor + k;
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow {
|
|||||||
/// Marker on a "next move" row below the active row. `offset`
|
/// Marker on a "next move" row below the active row. `offset`
|
||||||
/// is the 1-based distance forward from the active row:
|
/// is the 1-based distance forward from the active row:
|
||||||
/// `offset = 1` is the move that will apply next
|
/// `offset = 1` is the move that will apply next
|
||||||
/// (`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
|
/// `offset = 2` is the one after that, and so on. Up to
|
||||||
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
||||||
///
|
///
|
||||||
@@ -1258,11 +1258,11 @@ fn keybind_footer_hint_text() -> &'static str {
|
|||||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
||||||
/// position the marker outside the track.
|
/// position the marker outside the track.
|
||||||
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
||||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
let ReplayPlaybackState::Playing { replay, moves, .. } = state else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let idx = replay.win_move_index?;
|
let idx = replay.win_move_index?;
|
||||||
let total = replay.moves.len();
|
let total = moves.len();
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau};
|
||||||
use solitaire_core::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
use solitaire_data::Replay;
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
@@ -17,9 +17,11 @@ fn synthetic_replay(move_count: usize) -> Replay {
|
|||||||
120,
|
120,
|
||||||
1_000,
|
1_000,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||||
(0..move_count)
|
SessionRecording::from_instructions_unchecked(
|
||||||
.map(|_| KlondikeInstruction::RotateStock)
|
42,
|
||||||
.collect(),
|
DrawStockConfig::DrawOne,
|
||||||
|
(0..move_count).map(|_| KlondikeInstruction::RotateStock),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +95,7 @@ fn overlay_spawns_when_playback_starts() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -117,12 +114,7 @@ fn overlay_progress_text_reflects_cursor() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -138,12 +130,7 @@ fn overlay_stop_button_click_clears_playback() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(overlay_root_count(&mut app), 1);
|
assert_eq!(overlay_root_count(&mut app), 1);
|
||||||
@@ -195,12 +182,7 @@ fn floating_chip_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||||
replay: synthetic_replay(5),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -232,12 +214,7 @@ fn overlay_despawns_when_playback_returns_to_inactive() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false),
|
||||||
replay: synthetic_replay(3),
|
|
||||||
cursor: 1,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(overlay_root_count(&mut app), 1);
|
assert_eq!(overlay_root_count(&mut app), 1);
|
||||||
@@ -260,12 +237,7 @@ fn overlay_text_changes_on_completed() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false),
|
||||||
replay: synthetic_replay(7),
|
|
||||||
cursor: 7,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(banner_text(&mut app), "\u{258C} replay");
|
assert_eq!(banner_text(&mut app), "\u{258C} replay");
|
||||||
@@ -314,30 +286,30 @@ fn scrub_pct_covers_state_corners() {
|
|||||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
|
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
|
||||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
|
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::Playing {
|
scrub_pct(&ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10),
|
synthetic_replay(10),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
}),
|
)),
|
||||||
0.0,
|
0.0,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::Playing {
|
scrub_pct(&ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10),
|
synthetic_replay(10),
|
||||||
cursor: 5,
|
5,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
}),
|
)),
|
||||||
50.0,
|
50.0,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::Playing {
|
scrub_pct(&ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10),
|
synthetic_replay(10),
|
||||||
cursor: 10,
|
10,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
}),
|
)),
|
||||||
100.0,
|
100.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -367,12 +339,12 @@ fn format_game_caption_covers_state_corners() {
|
|||||||
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
|
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
|
||||||
// uses this date so the assertion is stable.
|
// uses this date so the assertion is stable.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_game_caption(&ReplayPlaybackState::Playing {
|
format_game_caption(&ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10),
|
synthetic_replay(10),
|
||||||
cursor: 5,
|
5,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
}),
|
)),
|
||||||
Some("GAME #2026-122".to_string()),
|
Some("GAME #2026-122".to_string()),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -381,12 +353,7 @@ fn format_game_caption_covers_state_corners() {
|
|||||||
let mut early_january = synthetic_replay(10);
|
let mut early_january = synthetic_replay(10);
|
||||||
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
|
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_game_caption(&ReplayPlaybackState::Playing {
|
format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)),
|
||||||
replay: early_january,
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
}),
|
|
||||||
Some("GAME #2026-005".to_string()),
|
Some("GAME #2026-005".to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -399,12 +366,7 @@ fn overlay_game_caption_shows_replay_date() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
|
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
|
||||||
@@ -431,12 +393,7 @@ fn overlay_scrub_fill_tracks_cursor() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 2,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -447,12 +404,7 @@ fn overlay_scrub_fill_tracks_cursor() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 6,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
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() {
|
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
|
||||||
// Synthetic replay constructor leaves win_move_index as None
|
// Synthetic replay constructor leaves win_move_index as None
|
||||||
// (legacy / pre-`ab857bb` path).
|
// (legacy / pre-`ab857bb` path).
|
||||||
let state = ReplayPlaybackState::Playing {
|
let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
assert_eq!(win_move_marker_pct(&state), None);
|
assert_eq!(win_move_marker_pct(&state), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
// 10 moves, win at index 9 → marker sits at 90 % of the track.
|
||||||
// Matches the recording semantic: cursor reaches the marker
|
// Matches the recording semantic: cursor reaches the marker
|
||||||
// exactly when the about-to-apply move IS the win move.
|
// exactly when the about-to-apply move IS the win move.
|
||||||
let state = ReplayPlaybackState::Playing {
|
let state = ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10).with_win_move_index(Some(9)),
|
synthetic_replay(10).with_win_move_index(Some(9)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
};
|
);
|
||||||
assert_eq!(win_move_marker_pct(&state), Some(90.0));
|
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() {
|
fn win_move_marker_pct_clamps_to_track_bounds() {
|
||||||
// Defensive: if a malformed replay carried `win_move_index >=
|
// Defensive: if a malformed replay carried `win_move_index >=
|
||||||
// total`, the marker must still sit on the track, not past it.
|
// total`, the marker must still sit on the track, not past it.
|
||||||
let state = ReplayPlaybackState::Playing {
|
let state = ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(5).with_win_move_index(Some(99)),
|
synthetic_replay(5).with_win_move_index(Some(99)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
};
|
);
|
||||||
assert_eq!(win_move_marker_pct(&state), Some(100.0));
|
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();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -559,12 +506,7 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() {
|
|||||||
// Default constructor → win_move_index: None (legacy replay).
|
// Default constructor → win_move_index: None (legacy replay).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -579,12 +521,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(win_marker_count(&mut app), 1);
|
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();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -669,12 +611,7 @@ fn scrub_notches_spawn_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -693,12 +630,7 @@ fn scrub_notches_carry_high_contrast_background_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -723,12 +655,7 @@ fn scrub_track_carries_high_contrast_background_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -759,12 +686,7 @@ fn scrub_notches_despawn_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(scrub_notch_count(&mut app), 5);
|
assert_eq!(scrub_notch_count(&mut app), 5);
|
||||||
@@ -821,12 +743,7 @@ fn scrub_notch_labels_spawn_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -844,12 +761,7 @@ fn scrub_notch_labels_carry_helper_strings() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -870,12 +782,7 @@ fn scrub_notch_labels_despawn_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(scrub_notch_label_count(&mut app), 5);
|
assert_eq!(scrub_notch_label_count(&mut app), 5);
|
||||||
@@ -947,12 +854,7 @@ fn keybind_footer_spawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -970,12 +872,7 @@ fn keybind_footer_paints_helper_strings() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -999,12 +896,7 @@ fn keybind_footer_carries_high_contrast_border_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1025,12 +917,7 @@ fn keybind_footer_despawns_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(keybind_footer_count(&mut app), 1);
|
assert_eq!(keybind_footer_count(&mut app), 1);
|
||||||
@@ -1054,12 +941,7 @@ fn scrub_notches_spawn_even_without_win_marker() {
|
|||||||
// Default constructor → win_move_index: None.
|
// Default constructor → win_move_index: None.
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1144,12 +1026,7 @@ fn format_move_body_handles_stock_cycle() {
|
|||||||
/// `Inactive` → empty.
|
/// `Inactive` → empty.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_move_log_header_covers_state_branches() {
|
fn format_move_log_header_covers_state_branches() {
|
||||||
let playing = ReplayPlaybackState::Playing {
|
let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_move_log_header(&playing),
|
format_move_log_header(&playing),
|
||||||
"\u{258C} MOVE LOG \u{00B7} 3/10"
|
"\u{258C} MOVE LOG \u{00B7} 3/10"
|
||||||
@@ -1167,24 +1044,14 @@ fn format_move_log_header_covers_state_branches() {
|
|||||||
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
|
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_active_move_row_handles_cursor_zero_and_positive() {
|
fn format_active_move_row_handles_cursor_zero_and_positive() {
|
||||||
let cursor_zero = ReplayPlaybackState::Playing {
|
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_active_move_row(&cursor_zero),
|
format_active_move_row(&cursor_zero),
|
||||||
"",
|
"",
|
||||||
"cursor=0 means no move applied yet; row stays empty",
|
"cursor=0 means no move applied yet; row stays empty",
|
||||||
);
|
);
|
||||||
|
|
||||||
let cursor_three = ReplayPlaybackState::Playing {
|
let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
// synthetic_replay produces all StockClicks, so the body
|
// synthetic_replay produces all StockClicks, so the body
|
||||||
// is "stock cycle". The displayed index is 3 (cursor),
|
// is "stock cycle". The displayed index is 3 (cursor),
|
||||||
// matching the most-recently-applied move at moves[2].
|
// matching the most-recently-applied move at moves[2].
|
||||||
@@ -1208,12 +1075,7 @@ fn move_log_panel_spawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1232,12 +1094,7 @@ fn move_log_panel_header_paints_helper_string() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 2,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1254,12 +1111,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
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]).
|
// Advance cursor to 2 (most-recently-applied move is moves[1]).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 2,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1292,12 +1139,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
|||||||
/// early in a replay don't paint stale text.
|
/// early in a replay don't paint stale text.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
|
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
|
||||||
let state_at_three = ReplayPlaybackState::Playing {
|
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
// k=1 → active (most recent applied). cursor=3 → display=3.
|
// k=1 → active (most recent applied). cursor=3 → display=3.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_kth_recent_row(&state_at_three, 1),
|
format_kth_recent_row(&state_at_three, 1),
|
||||||
@@ -1348,12 +1190,7 @@ fn move_log_prev_rows_spawn_with_panel() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1371,12 +1208,7 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
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).
|
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 2,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1422,12 +1249,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
|
|||||||
// Advance to cursor=5 — both offsets now have history.
|
// Advance to cursor=5 — both offsets now have history.
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1465,12 +1287,7 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
|
|||||||
/// alongside in-range correctness.
|
/// alongside in-range correctness.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_kth_next_row_handles_in_range_and_out_of_range() {
|
fn format_kth_next_row_handles_in_range_and_out_of_range() {
|
||||||
let state_at_three = ReplayPlaybackState::Playing {
|
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
// k=1 → moves[3], display=4
|
// k=1 → moves[3], display=4
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_kth_next_row(&state_at_three, 1),
|
format_kth_next_row(&state_at_three, 1),
|
||||||
@@ -1498,12 +1315,7 @@ fn move_log_next_rows_spawn_with_panel() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1520,12 +1332,7 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1549,12 +1356,7 @@ fn move_log_next_rows_underfill_at_replay_end() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 9,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1578,12 +1380,7 @@ fn active_row_wrapper_carries_accent_primary_background() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1621,12 +1418,7 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1647,24 +1439,14 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
|||||||
/// dropping it has to also update this test.
|
/// dropping it has to also update this test.
|
||||||
#[test]
|
#[test]
|
||||||
fn active_row_format_includes_focus_prefix() {
|
fn active_row_format_includes_focus_prefix() {
|
||||||
let state = ReplayPlaybackState::Playing {
|
let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
let row = format_active_move_row(&state);
|
let row = format_active_move_row(&state);
|
||||||
assert!(
|
assert!(
|
||||||
row.starts_with('\u{25B6}'),
|
row.starts_with('\u{25B6}'),
|
||||||
"active-row format must start with ▶ focus marker; got {row:?}",
|
"active-row format must start with ▶ focus marker; got {row:?}",
|
||||||
);
|
);
|
||||||
// Cursor=0 still returns empty, never just the prefix.
|
// Cursor=0 still returns empty, never just the prefix.
|
||||||
let cursor_zero = ReplayPlaybackState::Playing {
|
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_active_move_row(&cursor_zero),
|
format_active_move_row(&cursor_zero),
|
||||||
"",
|
"",
|
||||||
@@ -1679,12 +1461,7 @@ fn move_log_panel_despawns_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(move_log_panel_count(&mut app), 1);
|
assert_eq!(move_log_panel_count(&mut app), 1);
|
||||||
@@ -1728,21 +1505,11 @@ fn unique_button<M: Component>(app: &mut App) -> Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true)
|
||||||
replay: synthetic_replay(replay_len),
|
|
||||||
cursor,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false)
|
||||||
replay: synthetic_replay(replay_len),
|
|
||||||
cursor,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2191,12 +1958,7 @@ fn dim_layer_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||||
replay: synthetic_replay(5),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2255,12 +2017,7 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||||
replay: synthetic_replay(5),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ pub(crate) fn update_floating_progress_chip(
|
|||||||
// any). `cursor` is the index of the *next* move to apply, so
|
// any). `cursor` is the index of the *next* move to apply, so
|
||||||
// the most-recently-applied move sits at `cursor - 1`.
|
// the most-recently-applied move sits at `cursor - 1`.
|
||||||
let dest_pile = match state.as_ref() {
|
let dest_pile = match state.as_ref() {
|
||||||
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
ReplayPlaybackState::Playing { moves, cursor, .. } if *cursor > 0 => {
|
||||||
// The destination pile is recoverable directly from the
|
// The destination pile is recoverable directly from the
|
||||||
// instruction — no live state needed. `RotateStock` has no
|
// instruction — no live state needed. `RotateStock` has no
|
||||||
// destination (the chip hides over the stock pile).
|
// destination (the chip hides over the stock pile).
|
||||||
match &replay.moves[cursor - 1] {
|
match &moves[cursor - 1] {
|
||||||
KlondikeInstruction::DstFoundation(dst) => {
|
KlondikeInstruction::DstFoundation(dst) => {
|
||||||
Some(KlondikePile::Foundation(dst.foundation))
|
Some(KlondikePile::Foundation(dst.foundation))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
//! In-engine replay playback core.
|
//! In-engine replay playback core.
|
||||||
//!
|
//!
|
||||||
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
||||||
//! game state is reset to the deal seeded from the replay's `seed` /
|
//! game state is reset to the recorded deal — rebuilt directly from
|
||||||
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
|
//! `replay.recording` via `GameState::from_recording`, never by
|
||||||
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
|
//! re-dealing the seed — and the engine ticks through the recording's
|
||||||
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
|
//! instruction list at a steady cadence, firing the canonical
|
||||||
//! plays back identically to a live game.
|
//! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The
|
||||||
|
//! existing animation pipeline plays back identically to a live game.
|
||||||
//!
|
//!
|
||||||
//! ## Public surface
|
//! ## 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
|
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||||
/// for each [`KlondikeInstruction`].
|
/// for each [`KlondikeInstruction`].
|
||||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
/// 4. When `cursor == moves.len()`, the state transitions to
|
||||||
/// [`Completed`](Self::Completed). It lingers for
|
/// [`Completed`](Self::Completed). It lingers for
|
||||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||||
/// [`auto_clear_completed_replay`]) before returning to
|
/// [`auto_clear_completed_replay`]) before returning to
|
||||||
@@ -109,14 +110,20 @@ pub enum ReplayPlaybackState {
|
|||||||
#[default]
|
#[default]
|
||||||
Inactive,
|
Inactive,
|
||||||
/// A replay is currently being played back. The overlay reads
|
/// A replay is currently being played back. The overlay reads
|
||||||
/// `replay.moves.len()` for the denominator of the progress
|
/// `moves.len()` for the denominator of the progress
|
||||||
/// indicator and `cursor` for the numerator.
|
/// indicator and `cursor` for the numerator.
|
||||||
Playing {
|
Playing {
|
||||||
/// The replay being played back. Owned so the state is the
|
/// The replay being played back. Owned so the state is the
|
||||||
/// only place playback metadata lives — no separate resource
|
/// only place playback metadata lives — no separate resource
|
||||||
/// needed.
|
/// needed. Boxed: the v4 recording embeds the dealt board and
|
||||||
replay: Replay,
|
/// would otherwise dwarf the other variants
|
||||||
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
|
/// (`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,
|
cursor: usize,
|
||||||
/// Seconds remaining until the next move is dispatched.
|
/// Seconds remaining until the next move is dispatched.
|
||||||
secs_to_next: f32,
|
secs_to_next: f32,
|
||||||
@@ -138,6 +145,19 @@ pub enum ReplayPlaybackState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReplayPlaybackState {
|
impl ReplayPlaybackState {
|
||||||
|
/// Construct a [`Self::Playing`] state, extracting the instruction
|
||||||
|
/// list from `replay.recording` once up front.
|
||||||
|
pub fn playing(replay: Replay, cursor: usize, secs_to_next: f32, paused: bool) -> Self {
|
||||||
|
let moves = replay.recording.instructions();
|
||||||
|
Self::Playing {
|
||||||
|
replay: Box::new(replay),
|
||||||
|
moves,
|
||||||
|
cursor,
|
||||||
|
secs_to_next,
|
||||||
|
paused,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` when a replay is currently being played back.
|
/// Returns `true` when a replay is currently being played back.
|
||||||
pub fn is_playing(&self) -> bool {
|
pub fn is_playing(&self) -> bool {
|
||||||
matches!(self, Self::Playing { .. })
|
matches!(self, Self::Playing { .. })
|
||||||
@@ -156,7 +176,7 @@ impl ReplayPlaybackState {
|
|||||||
/// the total is no longer available in `Completed`.
|
/// the total is no longer available in `Completed`.
|
||||||
pub fn progress(&self) -> Option<(usize, usize)> {
|
pub fn progress(&self) -> Option<(usize, usize)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
|
Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())),
|
||||||
Self::Inactive | Self::Completed => None,
|
Self::Inactive | Self::Completed => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +211,11 @@ pub fn start_replay_playback(
|
|||||||
) {
|
) {
|
||||||
use solitaire_core::game_state::GameState;
|
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));
|
commands.insert_resource(GameStateResource(fresh));
|
||||||
|
|
||||||
// Initial `secs_to_next` uses the constant rather than reading
|
// Initial `secs_to_next` uses the constant rather than reading
|
||||||
@@ -201,7 +225,8 @@ pub fn start_replay_playback(
|
|||||||
// unusually short setting; subsequent ticks read the live setting
|
// unusually short setting; subsequent ticks read the live setting
|
||||||
// every frame via [`tick_replay_playback`].
|
// every frame via [`tick_replay_playback`].
|
||||||
**state = ReplayPlaybackState::Playing {
|
**state = ReplayPlaybackState::Playing {
|
||||||
replay,
|
replay: Box::new(replay),
|
||||||
|
moves,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -256,7 +281,7 @@ pub fn step_replay_playback(
|
|||||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let ReplayPlaybackState::Playing {
|
let ReplayPlaybackState::Playing {
|
||||||
replay,
|
moves,
|
||||||
cursor,
|
cursor,
|
||||||
paused: true,
|
paused: true,
|
||||||
..
|
..
|
||||||
@@ -264,10 +289,10 @@ pub fn step_replay_playback(
|
|||||||
else {
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= moves.len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let instruction = replay.moves[*cursor];
|
let instruction = moves[*cursor];
|
||||||
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
true
|
true
|
||||||
@@ -362,7 +387,7 @@ pub fn step_backwards_replay_playback(
|
|||||||
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
||||||
/// expires, fires the canonical event for the move at `cursor`,
|
/// expires, fires the canonical event for the move at `cursor`,
|
||||||
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
||||||
/// reaches `replay.moves.len()`, transitions to
|
/// reaches `moves.len()`, transitions to
|
||||||
/// [`ReplayPlaybackState::Completed`].
|
/// [`ReplayPlaybackState::Completed`].
|
||||||
///
|
///
|
||||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||||
@@ -384,10 +409,11 @@ fn tick_replay_playback(
|
|||||||
let mut transition_to_completed = false;
|
let mut transition_to_completed = false;
|
||||||
|
|
||||||
if let ReplayPlaybackState::Playing {
|
if let ReplayPlaybackState::Playing {
|
||||||
replay,
|
moves,
|
||||||
cursor,
|
cursor,
|
||||||
secs_to_next,
|
secs_to_next,
|
||||||
paused,
|
paused,
|
||||||
|
..
|
||||||
} = state.as_mut()
|
} = state.as_mut()
|
||||||
{
|
{
|
||||||
// While paused, the cursor and the timer freeze together —
|
// While paused, the cursor and the timer freeze together —
|
||||||
@@ -397,8 +423,8 @@ fn tick_replay_playback(
|
|||||||
// path.
|
// path.
|
||||||
if !*paused {
|
if !*paused {
|
||||||
*secs_to_next -= dt;
|
*secs_to_next -= dt;
|
||||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
while *secs_to_next <= 0.0 && *cursor < moves.len() {
|
||||||
let instruction = replay.moves[*cursor];
|
let instruction = moves[*cursor];
|
||||||
dispatch_instruction(
|
dispatch_instruction(
|
||||||
instruction,
|
instruction,
|
||||||
*cursor,
|
*cursor,
|
||||||
@@ -410,7 +436,7 @@ fn tick_replay_playback(
|
|||||||
*secs_to_next += interval;
|
*secs_to_next += interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= moves.len() {
|
||||||
transition_to_completed = true;
|
transition_to_completed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,8 +588,7 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use bevy::time::TimeUpdateStrategy;
|
use bevy::time::TimeUpdateStrategy;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||||
@@ -612,11 +637,11 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![
|
SessionRecording::from_instructions_unchecked(
|
||||||
KlondikeInstruction::RotateStock,
|
12345,
|
||||||
KlondikeInstruction::RotateStock,
|
DrawStockConfig::DrawOne,
|
||||||
KlondikeInstruction::RotateStock,
|
[KlondikeInstruction::RotateStock; 3],
|
||||||
],
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,11 +698,14 @@ mod tests {
|
|||||||
let state = app.world().resource::<ReplayPlaybackState>();
|
let state = app.world().resource::<ReplayPlaybackState>();
|
||||||
match state {
|
match state {
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::Playing {
|
||||||
cursor, replay: r, ..
|
cursor,
|
||||||
|
replay: r,
|
||||||
|
moves,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(*cursor, 0);
|
assert_eq!(*cursor, 0);
|
||||||
assert_eq!(r.seed, replay.seed);
|
assert_eq!(r.seed, replay.seed);
|
||||||
assert_eq!(r.moves.len(), 3);
|
assert_eq!(moves.len(), 3);
|
||||||
}
|
}
|
||||||
other => panic!("expected Playing, got {other:?}"),
|
other => panic!("expected Playing, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -778,7 +806,11 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![KlondikeInstruction::RotateStock],
|
SessionRecording::from_instructions_unchecked(
|
||||||
|
42,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
[KlondikeInstruction::RotateStock],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
start_playback(&mut app, one_move);
|
start_playback(&mut app, one_move);
|
||||||
app.update();
|
app.update();
|
||||||
@@ -887,7 +919,11 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![KlondikeInstruction::RotateStock; 10],
|
SessionRecording::from_instructions_unchecked(
|
||||||
|
7,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
[KlondikeInstruction::RotateStock; 10],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,9 +101,10 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
fn apply_safe_area_anchors(
|
fn apply_safe_area_anchors(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||||||
) {
|
) {
|
||||||
if !insets.is_changed() {
|
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
||||||
@@ -119,19 +120,31 @@ fn apply_safe_area_anchors(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let top_logical = raw_top.min(max_inset);
|
let top_logical = raw_top.min(max_inset);
|
||||||
|
// `bevy::ui::UiScale` (Phase K) multiplies every Val::Px at layout
|
||||||
|
// time. `base_top` is UI chrome and SHOULD scale with the rest of
|
||||||
|
// the interface, but the system-bar inset is physical reality —
|
||||||
|
// pre-divide it so the post-multiplication offset stays exact.
|
||||||
|
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||||
for (anchor, mut node) in &mut q {
|
for (anchor, mut node) in &mut q {
|
||||||
node.top = Val::Px(anchor.base_top + top_logical);
|
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
|
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
||||||
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||||||
fn apply_safe_area_bottom_anchors(
|
fn apply_safe_area_bottom_anchors(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
||||||
) {
|
) {
|
||||||
if !insets.is_changed() {
|
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
@@ -144,8 +157,12 @@ fn apply_safe_area_bottom_anchors(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let bottom_logical = raw_bottom.min(max_inset);
|
let bottom_logical = raw_bottom.min(max_inset);
|
||||||
|
// See `apply_safe_area_anchors`: the physical inset is pre-divided
|
||||||
|
// by the UI scale so a 90% setting can't sink the action bar into
|
||||||
|
// the gesture zone (and 130% doesn't over-inset it).
|
||||||
|
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||||
for (anchor, mut node) in &mut q {
|
for (anchor, mut node) in &mut q {
|
||||||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
|
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,19 +180,23 @@ fn apply_safe_area_bottom_anchors(
|
|||||||
fn apply_safe_area_to_modal_scrims(
|
fn apply_safe_area_to_modal_scrims(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
||||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||||
) {
|
) {
|
||||||
let has_new = !new_scrims.is_empty();
|
let has_new = !new_scrims.is_empty();
|
||||||
if !insets.is_changed() && !has_new {
|
if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
||||||
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
||||||
// value can't push the modal card off the visible area entirely.
|
// value can't push the modal card off the visible area entirely.
|
||||||
let top_logical = (insets.top / scale).min(window_height * 0.25);
|
// Physical insets are pre-divided by the UI scale — see
|
||||||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
|
// `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 {
|
for mut node in &mut scrims {
|
||||||
// Set both edges so the scrim's content box equals the usable area
|
// Set both edges so the scrim's content box equals the usable area
|
||||||
// between the status bar and the gesture/navigation bar. With
|
// between the status bar and the gesture/navigation bar. With
|
||||||
|
|||||||
@@ -363,6 +363,14 @@ pub(super) fn handle_settings_buttons(
|
|||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
// Text refreshed by `update_touch_input_mode_text` next frame.
|
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||||
}
|
}
|
||||||
|
SettingsButton::CycleUiScale => {
|
||||||
|
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_ui_scale_text`; the live
|
||||||
|
// `bevy::ui::UiScale` resource follows via
|
||||||
|
// `sync_ui_scale_resource` next frame.
|
||||||
|
}
|
||||||
SettingsButton::ToggleWinnableDealsOnly => {
|
SettingsButton::ToggleWinnableDealsOnly => {
|
||||||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ struct ReduceMotionText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TouchInputModeText;
|
struct TouchInputModeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current UI scale percentage.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct UiScaleText;
|
||||||
|
|
||||||
/// Marks the `Text` node showing the live tooltip-delay value.
|
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TooltipDelayText;
|
struct TooltipDelayText;
|
||||||
@@ -277,6 +281,10 @@ enum SettingsButton {
|
|||||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||||
/// a card/stack, second tap on a target pile moves it).
|
/// a card/stack, second tap on a target pile moves it).
|
||||||
ToggleTouchInputMode,
|
ToggleTouchInputMode,
|
||||||
|
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
|
||||||
|
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
|
||||||
|
/// stays window-fit (Phase K).
|
||||||
|
CycleUiScale,
|
||||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||||
/// random Classic-mode deals are filtered through
|
/// random Classic-mode deals are filtered through
|
||||||
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||||
@@ -354,6 +362,7 @@ impl SettingsButton {
|
|||||||
SettingsButton::ToggleHighContrast => 61,
|
SettingsButton::ToggleHighContrast => 61,
|
||||||
SettingsButton::ToggleReduceMotion => 62,
|
SettingsButton::ToggleReduceMotion => 62,
|
||||||
SettingsButton::ToggleTouchInputMode => 63,
|
SettingsButton::ToggleTouchInputMode => 63,
|
||||||
|
SettingsButton::CycleUiScale => 64,
|
||||||
// Picker rows — every swatch in a row shares the row's
|
// Picker rows — every swatch in a row shares the row's
|
||||||
// priority so entity-index tiebreaking yields left → right.
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
SettingsButton::SelectCardBack(_) => 70,
|
SettingsButton::SelectCardBack(_) => 70,
|
||||||
@@ -449,6 +458,11 @@ impl Plugin for SettingsPlugin {
|
|||||||
handle_volume_keys,
|
handle_volume_keys,
|
||||||
record_window_geometry_changes,
|
record_window_geometry_changes,
|
||||||
persist_window_geometry_after_debounce,
|
persist_window_geometry_after_debounce,
|
||||||
|
// State sync, not UI — runs even under `headless()`
|
||||||
|
// so the live `bevy::ui::UiScale` always mirrors the
|
||||||
|
// setting; rides the mutator spine so it is ordered
|
||||||
|
// after every settings writer this frame.
|
||||||
|
sync_ui_scale_resource,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(SettingsMutation),
|
.in_set(SettingsMutation),
|
||||||
@@ -492,6 +506,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
||||||
update_reduce_motion_text,
|
update_reduce_motion_text,
|
||||||
update_touch_input_mode_text,
|
update_touch_input_mode_text,
|
||||||
|
update_ui_scale_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
update_replay_move_interval_text,
|
update_replay_move_interval_text,
|
||||||
|
|||||||
@@ -736,3 +736,60 @@ fn scroll_clamps_offset_to_zero_at_top() {
|
|||||||
"scrolling past top must clamp to 0, got {offset}"
|
"scrolling past top must clamp to 0, got {offset}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase K: UI scale
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_scale_steps_cycle_and_wrap() {
|
||||||
|
assert_eq!(next_ui_scale(0.9), 1.0);
|
||||||
|
assert_eq!(next_ui_scale(1.0), 1.15);
|
||||||
|
assert_eq!(next_ui_scale(1.15), 1.3);
|
||||||
|
assert_eq!(next_ui_scale(1.3), 0.9, "the cycle must wrap");
|
||||||
|
// A hand-edited in-between value advances to the next larger step.
|
||||||
|
assert_eq!(next_ui_scale(1.05), 1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_scale_label_formats_as_percent() {
|
||||||
|
assert_eq!(ui_scale_label(0.9), "90%");
|
||||||
|
assert_eq!(ui_scale_label(1.0), "100%");
|
||||||
|
assert_eq!(ui_scale_label(1.15), "115%");
|
||||||
|
assert_eq!(ui_scale_label(1.3), "130%");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_scale_setting_syncs_the_bevy_resource() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(UiScale(1.0));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.ui_scale = 1.3;
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(app.world().resource::<UiScale>().0 - 1.3).abs() < f32::EPSILON,
|
||||||
|
"Settings::ui_scale must drive bevy::ui::UiScale"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_scale_out_of_range_sanitizes_on_load() {
|
||||||
|
use solitaire_data::settings::{UI_SCALE_MAX, UI_SCALE_MIN};
|
||||||
|
|
||||||
|
let wild = Settings {
|
||||||
|
ui_scale: 5.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(wild.ui_scale, UI_SCALE_MAX);
|
||||||
|
|
||||||
|
let tiny = Settings {
|
||||||
|
ui_scale: 0.1,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
|
||||||
|
}
|
||||||
|
|||||||
@@ -326,6 +326,15 @@ fn spawn_accessibility_tab(
|
|||||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
"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,
|
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);
|
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -515,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
|
/// Display string for the "Smart window size" toggle. The argument
|
||||||
/// is the *enabled* state (i.e. the inverse of the underlying
|
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||||
/// `disable_smart_default_size` field) so reading the label gives
|
/// `disable_smart_default_size` field) so reading the label gives
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
|
|||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||||
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bevy resource wrapping the current stats.
|
/// Bevy resource wrapping the current stats.
|
||||||
@@ -719,25 +719,17 @@ pub(crate) fn spawn_stats_body(
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
.with_children(|body| {
|
.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
|
// the wall of em-dashes reads as "nothing to track yet" rather
|
||||||
// than as broken state.
|
// than as broken state.
|
||||||
if is_first_launch {
|
if is_first_launch {
|
||||||
body.spawn((
|
crate::ui_modal::spawn_empty_state(
|
||||||
Text::new("Play a game to start tracking stats."),
|
body,
|
||||||
TextFont {
|
"\u{2663}",
|
||||||
font_size: TYPE_CAPTION,
|
"Play a game to start tracking stats.",
|
||||||
..default()
|
None,
|
||||||
},
|
font_res,
|
||||||
TextColor(TEXT_SECONDARY),
|
);
|
||||||
Node {
|
|
||||||
margin: UiRect {
|
|
||||||
bottom: VAL_SPACE_2,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- primary stat cells grid ---
|
// --- primary stat cells grid ---
|
||||||
@@ -893,6 +885,20 @@ pub(crate) fn spawn_replays_body(
|
|||||||
selected_index: usize,
|
selected_index: usize,
|
||||||
font_res: Option<&FontResource>,
|
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_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_row = TextFont {
|
let font_row = TextFont {
|
||||||
font: font_handle,
|
font: font_handle,
|
||||||
@@ -1939,7 +1945,11 @@ mod tests {
|
|||||||
time_seconds,
|
time_seconds,
|
||||||
0,
|
0,
|
||||||
date,
|
date,
|
||||||
vec![],
|
solitaire_core::SessionRecording::from_instructions_unchecked(
|
||||||
|
1,
|
||||||
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
|
[],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
r.share_url = share_url;
|
r.share_url = share_url;
|
||||||
r
|
r
|
||||||
|
|||||||
@@ -22,12 +22,11 @@ use solitaire_data::{
|
|||||||
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
|
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
|
||||||
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
|
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::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent,
|
||||||
WarningToastEvent,
|
|
||||||
};
|
};
|
||||||
use crate::game_plugin::RecordingReplay;
|
use crate::game_plugin::RecordingReplay;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
@@ -108,7 +107,6 @@ impl Plugin for SyncPlugin {
|
|||||||
.init_resource::<PullTask>()
|
.init_resource::<PullTask>()
|
||||||
.init_resource::<PendingReplayUpload>()
|
.init_resource::<PendingReplayUpload>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<SyncCompleteEvent>()
|
|
||||||
.add_message::<SyncConfigureRequestEvent>()
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>();
|
.add_message::<WarningToastEvent>();
|
||||||
|
|
||||||
@@ -198,7 +196,6 @@ fn poll_pull_result(
|
|||||||
achievements_path: Res<AchievementsStoragePath>,
|
achievements_path: Res<AchievementsStoragePath>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
progress_path: Res<ProgressStoragePath>,
|
progress_path: Res<ProgressStoragePath>,
|
||||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
|
||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -213,7 +210,7 @@ fn poll_pull_result(
|
|||||||
match result {
|
match result {
|
||||||
Ok(remote) => {
|
Ok(remote) => {
|
||||||
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
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.
|
// Persist merged state atomically.
|
||||||
if let Some(p) = &stats_path.0
|
if let Some(p) = &stats_path.0
|
||||||
@@ -233,17 +230,10 @@ fn poll_pull_result(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update in-world resources.
|
// Update in-world resources.
|
||||||
let now = Utc::now();
|
stats.0 = merged.stats;
|
||||||
stats.0 = merged.stats.clone();
|
achievements.0 = merged.achievements;
|
||||||
achievements.0 = merged.achievements.clone();
|
progress.0 = merged.progress;
|
||||||
progress.0 = merged.progress.clone();
|
status.0 = SyncStatus::LastSynced(Utc::now());
|
||||||
status.0 = SyncStatus::LastSynced(now);
|
|
||||||
|
|
||||||
complete_writer.write(SyncCompleteEvent(Ok(SyncResponse {
|
|
||||||
merged,
|
|
||||||
server_time: now,
|
|
||||||
conflicts,
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
Err(SyncError::UnsupportedPlatform) => {
|
Err(SyncError::UnsupportedPlatform) => {
|
||||||
// No backend configured — not an error, just leave status as Idle.
|
// No backend configured — not an error, just leave status as Idle.
|
||||||
@@ -266,8 +256,7 @@ fn poll_pull_result(
|
|||||||
if matches!(e, SyncError::Auth(_)) {
|
if matches!(e, SyncError::Auth(_)) {
|
||||||
configure_sync.write(SyncConfigureRequestEvent);
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
}
|
}
|
||||||
status.0 = SyncStatus::Error(msg.clone());
|
status.0 = SyncStatus::Error(msg);
|
||||||
complete_writer.write(SyncCompleteEvent(Err(msg)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,7 +335,11 @@ fn push_replay_on_win(
|
|||||||
ev.time_seconds,
|
ev.time_seconds,
|
||||||
ev.score,
|
ev.score,
|
||||||
Utc::now().date_naive(),
|
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 provider = provider.0.clone();
|
||||||
let rt = rt.0.clone();
|
let rt = rt.0.clone();
|
||||||
@@ -632,7 +625,11 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
|
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
|
||||||
vec![],
|
solitaire_core::SessionRecording::from_instructions_unchecked(
|
||||||
|
7,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
[],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
let history = ReplayHistory {
|
let history = ReplayHistory {
|
||||||
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
|
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
||||||
//! `SyncPlugin`.
|
//! `SyncPlugin`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bevy::asset::RenderAssetUsages;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -29,8 +32,8 @@ use crate::resources::TokioRuntimeResource;
|
|||||||
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
||||||
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||||
spawn_modal_button, spawn_modal_header,
|
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
@@ -105,6 +108,21 @@ struct InstallTask(Option<(String, Task<InstallResult>)>);
|
|||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct StoreBaseUrl(Option<String>);
|
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
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -118,6 +136,8 @@ impl Plugin for ThemeStorePlugin {
|
|||||||
.init_resource::<CatalogTask>()
|
.init_resource::<CatalogTask>()
|
||||||
.init_resource::<InstallTask>()
|
.init_resource::<InstallTask>()
|
||||||
.init_resource::<StoreBaseUrl>()
|
.init_resource::<StoreBaseUrl>()
|
||||||
|
.init_resource::<PreviewCache>()
|
||||||
|
.init_resource::<PreviewTasks>()
|
||||||
// Esc-close reads keyboard input; register defensively so
|
// Esc-close reads keyboard input; register defensively so
|
||||||
// the plugin works under MinimalPlugins in tests.
|
// the plugin works under MinimalPlugins in tests.
|
||||||
.init_resource::<ButtonInput<KeyCode>>()
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
@@ -129,6 +149,7 @@ impl Plugin for ThemeStorePlugin {
|
|||||||
(
|
(
|
||||||
handle_open_request,
|
handle_open_request,
|
||||||
poll_catalog_task,
|
poll_catalog_task,
|
||||||
|
poll_preview_tasks,
|
||||||
handle_install_buttons,
|
handle_install_buttons,
|
||||||
poll_install_task,
|
poll_install_task,
|
||||||
handle_close_button,
|
handle_close_button,
|
||||||
@@ -176,6 +197,7 @@ fn handle_open_request(
|
|||||||
mut catalog_task: ResMut<CatalogTask>,
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
mut store_base: ResMut<StoreBaseUrl>,
|
mut store_base: ResMut<StoreBaseUrl>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -207,16 +229,28 @@ fn handle_open_request(
|
|||||||
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
|
spawn_store_modal(
|
||||||
|
&mut commands,
|
||||||
|
&catalog_state,
|
||||||
|
None,
|
||||||
|
&previews,
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
|
/// 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.
|
/// rebuilds the modal if it is still open.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn poll_catalog_task(
|
fn poll_catalog_task(
|
||||||
mut catalog_task: ResMut<CatalogTask>,
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
mut catalog_state: ResMut<CatalogState>,
|
mut catalog_state: ResMut<CatalogState>,
|
||||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
registry: Option<Res<ThemeRegistry>>,
|
registry: Option<Res<ThemeRegistry>>,
|
||||||
|
rt: Option<Res<TokioRuntimeResource>>,
|
||||||
|
store_base: Res<StoreBaseUrl>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
|
mut preview_tasks: ResMut<PreviewTasks>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -229,7 +263,29 @@ fn poll_catalog_task(
|
|||||||
catalog_task.0 = None;
|
catalog_task.0 = None;
|
||||||
|
|
||||||
*catalog_state = match result {
|
*catalog_state = match result {
|
||||||
Ok(entries) => CatalogState::Loaded(entries),
|
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) => {
|
Err(e) => {
|
||||||
warn!("theme store: catalog fetch failed: {e}");
|
warn!("theme store: catalog fetch failed: {e}");
|
||||||
CatalogState::Error(e.to_string())
|
CatalogState::Error(e.to_string())
|
||||||
@@ -241,10 +297,69 @@ fn poll_catalog_task(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
&catalog_state,
|
&catalog_state,
|
||||||
registry.as_deref(),
|
registry.as_deref(),
|
||||||
|
&previews,
|
||||||
font_res.as_deref(),
|
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.
|
/// Starts a download + import task when an Install button is pressed.
|
||||||
/// One install at a time; repeat clicks while busy are ignored.
|
/// One install at a time; repeat clicks while busy are ignored.
|
||||||
fn handle_install_buttons(
|
fn handle_install_buttons(
|
||||||
@@ -308,6 +423,7 @@ fn poll_install_task(
|
|||||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -343,6 +459,7 @@ fn poll_install_task(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
&catalog_state,
|
&catalog_state,
|
||||||
registry.as_deref(),
|
registry.as_deref(),
|
||||||
|
&previews,
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -378,11 +495,12 @@ fn rebuild_open_modal(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
catalog_state: &CatalogState,
|
catalog_state: &CatalogState,
|
||||||
registry: Option<&ThemeRegistry>,
|
registry: Option<&ThemeRegistry>,
|
||||||
|
previews: &PreviewCache,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
for entity in screens {
|
for entity in screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
spawn_store_modal(commands, catalog_state, registry, font_res);
|
spawn_store_modal(commands, catalog_state, registry, previews, font_res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +509,7 @@ fn spawn_store_modal(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
catalog_state: &CatalogState,
|
catalog_state: &CatalogState,
|
||||||
registry: Option<&ThemeRegistry>,
|
registry: Option<&ThemeRegistry>,
|
||||||
|
previews: &PreviewCache,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
let body_font = TextFont {
|
let body_font = TextFont {
|
||||||
@@ -409,31 +528,45 @@ fn spawn_store_modal(
|
|||||||
|
|
||||||
match catalog_state {
|
match catalog_state {
|
||||||
CatalogState::Idle | CatalogState::Loading => {
|
CatalogState::Idle | CatalogState::Loading => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Loading catalog…"),
|
card,
|
||||||
body_font.clone(),
|
"\u{21BB}",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Loading the catalog\u{2026}",
|
||||||
));
|
None,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Error(msg) => {
|
CatalogState::Error(msg) => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new(format!("Could not load the catalog: {msg}")),
|
card,
|
||||||
body_font.clone(),
|
"!",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Couldn't load the catalog.",
|
||||||
));
|
Some(msg.as_str()),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Loaded(entries) if entries.is_empty() => {
|
CatalogState::Loaded(entries) if entries.is_empty() => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new("The server has no themes yet."),
|
card,
|
||||||
body_font.clone(),
|
"#",
|
||||||
TextColor(TEXT_SECONDARY),
|
"The server has no themes yet.",
|
||||||
));
|
Some("Themes dropped into the server's theme_store folder appear here."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Loaded(entries) => {
|
CatalogState::Loaded(entries) => {
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let installed =
|
let installed =
|
||||||
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
||||||
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
|
spawn_store_row(
|
||||||
|
card,
|
||||||
|
entry,
|
||||||
|
installed,
|
||||||
|
previews.0.get(&entry.id),
|
||||||
|
&body_font,
|
||||||
|
&caption_font,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,12 +585,20 @@ fn spawn_store_modal(
|
|||||||
commands.entity(scrim).insert(ScrimDismissible);
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One catalog row: name + author/size caption on the left, Install
|
/// Height of a row's preview thumbnail in logical pixels; the width
|
||||||
/// button (or "Installed" caption) on the right.
|
/// 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(
|
fn spawn_store_row(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
entry: &ThemeCatalogEntry,
|
entry: &ThemeCatalogEntry,
|
||||||
installed: bool,
|
installed: bool,
|
||||||
|
preview: Option<&Handle<Image>>,
|
||||||
body_font: &TextFont,
|
body_font: &TextFont,
|
||||||
caption_font: &TextFont,
|
caption_font: &TextFont,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
@@ -477,25 +618,45 @@ fn spawn_store_row(
|
|||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn(Node {
|
row.spawn(Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Row,
|
||||||
row_gap: VAL_SPACE_2,
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|col| {
|
.with_children(|left| {
|
||||||
col.spawn((
|
if let Some(handle) = preview {
|
||||||
Text::new(entry.name.clone()),
|
let (aw, ah) = entry.card_aspect;
|
||||||
body_font.clone(),
|
let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32;
|
||||||
TextColor(TEXT_PRIMARY),
|
left.spawn((
|
||||||
));
|
ImageNode::new(handle.clone()),
|
||||||
col.spawn((
|
Node {
|
||||||
Text::new(format!(
|
width: Val::Px(width),
|
||||||
"by {} — {} KB",
|
height: Val::Px(PREVIEW_THUMB_HEIGHT_PX),
|
||||||
entry.author,
|
..default()
|
||||||
entry.size_bytes.div_ceil(1024)
|
},
|
||||||
)),
|
));
|
||||||
caption_font.clone(),
|
}
|
||||||
TextColor(TEXT_SECONDARY),
|
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 {
|
if installed {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
@@ -661,4 +822,85 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert_eq!(screen_count(&mut app), 1);
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
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,
|
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
||||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
|
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED,
|
||||||
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY,
|
||||||
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
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,
|
z_panel: i32,
|
||||||
build_card: F,
|
build_card: F,
|
||||||
) -> Entity
|
) -> 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
|
where
|
||||||
F: FnOnce(&mut ChildSpawnerCommands),
|
F: FnOnce(&mut ChildSpawnerCommands),
|
||||||
{
|
{
|
||||||
@@ -235,7 +261,7 @@ where
|
|||||||
padding: UiRect::all(VAL_SPACE_5),
|
padding: UiRect::all(VAL_SPACE_5),
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
||||||
max_width: Val::Px(720.0),
|
max_width: Val::Px(max_width),
|
||||||
align_items: AlignItems::Stretch,
|
align_items: AlignItems::Stretch,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
@@ -302,6 +328,80 @@ pub fn spawn_modal_body_text(
|
|||||||
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
|
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.
|
/// Spawns the bottom actions row — flex-row with primary right-aligned.
|
||||||
/// The closure populates the row's buttons via `spawn_modal_button`.
|
/// The closure populates the row's buttons via `spawn_modal_button`.
|
||||||
///
|
///
|
||||||
@@ -1158,4 +1258,62 @@ mod tests {
|
|||||||
"exactly one of the two stacked dismissible modals should remain"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
//! One-shot "What's new" card on the first launch after an update
|
||||||
|
//! (Phase I of the 2026-07 UI redesign).
|
||||||
|
//!
|
||||||
|
//! ObtainX updates install silently, so shipped features go unnoticed —
|
||||||
|
//! nobody found the theme store on their own. On the first launch where
|
||||||
|
//! the running release differs from `Settings::last_seen_whats_new`,
|
||||||
|
//! this plugin shows a single dismissible card summarising the latest
|
||||||
|
//! changelog section, then records the version so the card never
|
||||||
|
//! repeats. Fresh installs never see it: onboarding completion stamps
|
||||||
|
//! the current version silently, so the card only ever describes an
|
||||||
|
//! *upgrade*.
|
||||||
|
//!
|
||||||
|
//! # Launch beat
|
||||||
|
//!
|
||||||
|
//! Splash → (first run only: onboarding) → **what's new** → Home →
|
||||||
|
//! table. `spawn_home_on_launch` waits on [`WhatsNewPending`] the same
|
||||||
|
//! way it waits for the onboarding modal.
|
||||||
|
//!
|
||||||
|
//! # Version source
|
||||||
|
//!
|
||||||
|
//! The embedded `CHANGELOG.md`'s topmost release section provides both
|
||||||
|
//! the card's content and the "current version" for change detection —
|
||||||
|
//! one source of truth, no build-time version plumbing. (The APK
|
||||||
|
//! `versionName` comes from the release tag at package time and never
|
||||||
|
//! reaches Rust; the workspace `Cargo.toml` version is static.)
|
||||||
|
|
||||||
|
use bevy::input::ButtonInput;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use solitaire_data::save_settings_to;
|
||||||
|
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::onboarding_plugin::OnboardingScreen;
|
||||||
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
|
use crate::ui_modal::{
|
||||||
|
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
|
||||||
|
spawn_modal_header,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The changelog ships inside the binary — small, changes only at
|
||||||
|
/// release cadence, and the card must work offline (§4.2).
|
||||||
|
const CHANGELOG: &str = include_str!("../../CHANGELOG.md");
|
||||||
|
|
||||||
|
/// Most bullets shown on the card; a giant release stays skimmable.
|
||||||
|
const MAX_CARD_BULLETS: usize = 8;
|
||||||
|
|
||||||
|
/// Marker on the What's-new modal's scrim root.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct WhatsNewScreen;
|
||||||
|
|
||||||
|
/// Marker on the card's "Got it" button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct WhatsNewCloseButton;
|
||||||
|
|
||||||
|
/// `true` until this plugin has made its launch-beat decision — either
|
||||||
|
/// the card was shown and dismissed, or there was nothing to show.
|
||||||
|
/// `spawn_home_on_launch` waits on this so the card gets the beat
|
||||||
|
/// before Home.
|
||||||
|
#[derive(Resource, Debug)]
|
||||||
|
pub struct WhatsNewPending(pub bool);
|
||||||
|
|
||||||
|
impl Default for WhatsNewPending {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One display line of the card.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum NoteLine {
|
||||||
|
/// A `###` section heading ("Added", "Fixed", …).
|
||||||
|
Heading(String),
|
||||||
|
/// The lead sentence of one changelog bullet.
|
||||||
|
Bullet(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The changelog's topmost release section, reduced to card content.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ReleaseNotes {
|
||||||
|
/// Version string without the `v` prefix, e.g. `"0.46.0"`.
|
||||||
|
version: String,
|
||||||
|
lines: Vec<NoteLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version of the topmost changelog release — the engine's notion
|
||||||
|
/// of "the running release". Empty only if the changelog is malformed.
|
||||||
|
pub fn current_release_version() -> String {
|
||||||
|
parse_latest_release(CHANGELOG).map_or_else(String::new, |notes| notes.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the first `## [x.y.z]` section of `changelog` into card
|
||||||
|
/// content. `## [Unreleased]` is skipped; `### Internal` subsections
|
||||||
|
/// are dropped (players don't care about CI); each bullet is reduced
|
||||||
|
/// to its lead sentence with markdown emphasis stripped.
|
||||||
|
fn parse_latest_release(changelog: &str) -> Option<ReleaseNotes> {
|
||||||
|
let mut lines_iter = changelog.lines();
|
||||||
|
let mut version = None;
|
||||||
|
for line in lines_iter.by_ref() {
|
||||||
|
if let Some(rest) = line.strip_prefix("## [") {
|
||||||
|
let (v, _) = rest.split_once(']')?;
|
||||||
|
if v.eq_ignore_ascii_case("Unreleased") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
version = Some(v.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let version = version?;
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut skipping_section = false;
|
||||||
|
let mut current_bullet: Option<String> = None;
|
||||||
|
|
||||||
|
let flush = |bullet: &mut Option<String>, lines: &mut Vec<NoteLine>| {
|
||||||
|
if let Some(text) = bullet.take() {
|
||||||
|
lines.push(NoteLine::Bullet(lead_sentence(&text)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in lines_iter {
|
||||||
|
if line.starts_with("## [") {
|
||||||
|
break; // next (older) release section
|
||||||
|
}
|
||||||
|
if let Some(heading) = line.strip_prefix("### ") {
|
||||||
|
flush(&mut current_bullet, &mut lines);
|
||||||
|
skipping_section = heading.trim().eq_ignore_ascii_case("internal");
|
||||||
|
if !skipping_section {
|
||||||
|
lines.push(NoteLine::Heading(heading.trim().to_string()));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if skipping_section {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(rest) = line.strip_prefix("- ") {
|
||||||
|
flush(&mut current_bullet, &mut lines);
|
||||||
|
current_bullet = Some(rest.trim().to_string());
|
||||||
|
} else if current_bullet.is_some() && !line.trim().is_empty() {
|
||||||
|
// Wrapped continuation of the current bullet.
|
||||||
|
if let Some(bullet) = current_bullet.as_mut() {
|
||||||
|
bullet.push(' ');
|
||||||
|
bullet.push_str(line.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut current_bullet, &mut lines);
|
||||||
|
|
||||||
|
Some(ReleaseNotes { version, lines })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips markdown emphasis / code ticks and truncates to the first
|
||||||
|
/// sentence — changelog bullets lead with a bold summary sentence, and
|
||||||
|
/// that is exactly the card-sized version.
|
||||||
|
fn lead_sentence(bullet: &str) -> String {
|
||||||
|
let stripped: String = bullet.replace("**", "").replace('`', "");
|
||||||
|
// Trailing "(#123)" references never survive the sentence cut, but
|
||||||
|
// guard against a bullet that is only a reference.
|
||||||
|
let end = stripped.find(". ").map_or(stripped.len(), |i| i + 1);
|
||||||
|
stripped[..end].trim().trim_end_matches('.').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the launch-gate spawn system and the dismiss handlers.
|
||||||
|
pub struct WhatsNewPlugin;
|
||||||
|
|
||||||
|
impl Plugin for WhatsNewPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<WhatsNewPending>()
|
||||||
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(maybe_spawn_whats_new, handle_whats_new_close).chain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the card once the launch surface is clear, or stands down when
|
||||||
|
/// there is nothing to show. See the module docs for the exact beat.
|
||||||
|
///
|
||||||
|
/// The seen-version stamp persists on *spawn*, not dismissal — if the
|
||||||
|
/// app dies with the card open the player has still seen it once, and
|
||||||
|
/// the card must never nag.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn maybe_spawn_whats_new(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut pending: ResMut<WhatsNewPending>,
|
||||||
|
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
||||||
|
onboarding: Query<(), With<OnboardingScreen>>,
|
||||||
|
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
|
||||||
|
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
|
||||||
|
other_scrims: Query<(), With<ModalScrim>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
screens: Query<(), With<WhatsNewScreen>>,
|
||||||
|
) {
|
||||||
|
if !pending.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !screens.is_empty() {
|
||||||
|
// Card already open — the seen-version stamp landed on spawn,
|
||||||
|
// so without this guard the next frame would read "already
|
||||||
|
// seen" and release the beat under the open card.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !splash.is_empty()
|
||||||
|
|| !onboarding.is_empty()
|
||||||
|
|| !restore_prompts.is_empty()
|
||||||
|
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(settings) = settings.as_mut() else {
|
||||||
|
// Headless / no settings wired: nothing to compare against.
|
||||||
|
pending.0 = false;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !settings.0.first_run_complete {
|
||||||
|
// Fresh install mid-onboarding — completion stamps the version
|
||||||
|
// itself (see `complete_onboarding`), which resolves this gate
|
||||||
|
// on a later frame without ever showing the card.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(notes) = parse_latest_release(CHANGELOG) else {
|
||||||
|
pending.0 = false;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if settings.0.last_seen_whats_new == notes.version {
|
||||||
|
pending.0 = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !other_scrims.is_empty() {
|
||||||
|
// Another modal owns the beat right now; try again next frame.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_whats_new_card(&mut commands, ¬es, font_res.as_deref());
|
||||||
|
|
||||||
|
settings.0.last_seen_whats_new = notes.version.clone();
|
||||||
|
if let Some(p) = storage_path
|
||||||
|
&& let Some(path) = p.0.as_deref()
|
||||||
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("whats-new: failed to persist seen version: {e}");
|
||||||
|
}
|
||||||
|
// `pending` stays true until dismissal so Home keeps waiting.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses the card on "Got it" or Esc and releases the launch beat.
|
||||||
|
fn handle_whats_new_close(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
|
buttons: Query<&Interaction, (With<WhatsNewCloseButton>, Changed<Interaction>)>,
|
||||||
|
screens: Query<Entity, With<WhatsNewScreen>>,
|
||||||
|
other_scrims: Query<(), (With<ModalScrim>, Without<WhatsNewScreen>)>,
|
||||||
|
mut pending: ResMut<WhatsNewPending>,
|
||||||
|
) {
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)) && other_scrims.is_empty();
|
||||||
|
if !click && !esc {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
pending.0 = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the card: header with the version, the parsed changelog
|
||||||
|
/// lines, and a "Got it" action.
|
||||||
|
fn spawn_whats_new_card(
|
||||||
|
commands: &mut Commands,
|
||||||
|
notes: &ReleaseNotes,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_heading = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_bullet = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately NOT ScrimDismissible: scrim-tap despawns without
|
||||||
|
// running the close handler, which would leave the launch beat
|
||||||
|
// held and Home never spawning. Esc and "Got it" both release it.
|
||||||
|
let title = format!("What's new in v{}", notes.version);
|
||||||
|
spawn_modal(commands, WhatsNewScreen, Z_MODAL_PANEL, |card| {
|
||||||
|
spawn_modal_header(card, &title, font_res);
|
||||||
|
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
max_height: Val::Vh(60.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|body| {
|
||||||
|
let mut bullets_shown = 0usize;
|
||||||
|
for line in ¬es.lines {
|
||||||
|
match line {
|
||||||
|
NoteLine::Heading(heading) => {
|
||||||
|
body.spawn((
|
||||||
|
Text::new(heading.clone()),
|
||||||
|
font_heading.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
Node {
|
||||||
|
margin: UiRect::top(VAL_SPACE_1),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
NoteLine::Bullet(text) => {
|
||||||
|
if bullets_shown >= MAX_CARD_BULLETS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bullets_shown += 1;
|
||||||
|
body.spawn((
|
||||||
|
Text::new(format!("\u{2022} {text}")),
|
||||||
|
font_bullet.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
WhatsNewCloseButton,
|
||||||
|
"Got it",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
const SAMPLE: &str = "# Changelog\n\n## [Unreleased]\n\n## [0.46.0] — 2026-07-13\n\n\
|
||||||
|
### Added\n\n- **Theme-store previews.** The store modal now shows each theme's\n \
|
||||||
|
preview image next to its name. (#179)\n- **Hint ghost preview.** You see the move. (#179)\n\n\
|
||||||
|
### Internal\n\n- **CI is faster.** Nobody cares in-game. (#176)\n\n\
|
||||||
|
### Fixed\n\n- **Touch onboarding copy.** No more left-click. (#178)\n\n\
|
||||||
|
## [0.45.0] — 2026-07-13\n\n### Added\n\n- Old stuff.\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_top_section_version_and_skips_unreleased() {
|
||||||
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
||||||
|
assert_eq!(notes.version, "0.46.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drops_internal_sections_and_older_releases() {
|
||||||
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
||||||
|
let headings: Vec<&str> = notes
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter_map(|l| match l {
|
||||||
|
NoteLine::Heading(h) => Some(h.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(headings, vec!["Added", "Fixed"]);
|
||||||
|
assert!(
|
||||||
|
!notes
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("CI is faster"))),
|
||||||
|
"Internal bullets must not reach the card"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!notes
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("Old stuff"))),
|
||||||
|
"older release sections must not bleed in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bullets_reduce_to_their_lead_sentence() {
|
||||||
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
||||||
|
assert!(
|
||||||
|
notes
|
||||||
|
.lines
|
||||||
|
.contains(&NoteLine::Bullet("Theme-store previews".to_string()))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
notes
|
||||||
|
.lines
|
||||||
|
.contains(&NoteLine::Bullet("Hint ghost preview".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_changelog_parses_to_a_nonempty_version() {
|
||||||
|
let version = current_release_version();
|
||||||
|
assert!(
|
||||||
|
!version.is_empty(),
|
||||||
|
"the real CHANGELOG.md must yield a version"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
version.chars().next().is_some_and(|c| c.is_ascii_digit()),
|
||||||
|
"version must be bare (no v prefix): {version}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_with(settings: Settings) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(WhatsNewPlugin);
|
||||||
|
app.insert_resource(SettingsResource(settings));
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&WhatsNewScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upgrade_shows_card_and_stamps_version() {
|
||||||
|
let mut app = app_with(Settings {
|
||||||
|
first_run_complete: true,
|
||||||
|
last_seen_whats_new: "0.1.0".into(),
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(card_count(&mut app), 1, "an upgrade must show the card");
|
||||||
|
assert_eq!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.last_seen_whats_new,
|
||||||
|
current_release_version(),
|
||||||
|
"the seen version must stamp on spawn"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<WhatsNewPending>().0,
|
||||||
|
"the launch beat stays held until dismissal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seen_version_shows_nothing_and_releases_the_beat() {
|
||||||
|
let mut app = app_with(Settings {
|
||||||
|
first_run_complete: true,
|
||||||
|
last_seen_whats_new: current_release_version(),
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(card_count(&mut app), 0);
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<WhatsNewPending>().0,
|
||||||
|
"nothing to show must release the launch beat"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_install_waits_for_onboarding_and_never_shows() {
|
||||||
|
let mut app = app_with(Settings {
|
||||||
|
first_run_complete: false,
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
assert_eq!(card_count(&mut app), 0);
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<WhatsNewPending>().0,
|
||||||
|
"mid-onboarding the beat stays held"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Onboarding completion stamps the version (mirrored from
|
||||||
|
// complete_onboarding) — afterwards the card must stand down.
|
||||||
|
{
|
||||||
|
let mut settings = app.world_mut().resource_mut::<SettingsResource>();
|
||||||
|
settings.0.first_run_complete = true;
|
||||||
|
settings.0.last_seen_whats_new = current_release_version();
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
assert_eq!(card_count(&mut app), 0, "fresh installs never see the card");
|
||||||
|
assert!(!app.world().resource::<WhatsNewPending>().0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn got_it_dismisses_and_releases_the_beat() {
|
||||||
|
let mut app = app_with(Settings {
|
||||||
|
first_run_complete: true,
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
assert_eq!(card_count(&mut app), 1);
|
||||||
|
|
||||||
|
let button = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<WhatsNewCloseButton>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("Got it button must exist");
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(card_count(&mut app), 0, "Got it must dismiss the card");
|
||||||
|
assert!(!app.world().resource::<WhatsNewPending>().0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,68 @@
|
|||||||
|
# --- WASM build stage ---
|
||||||
|
# Builds solitaire_server/web/pkg/ (replay viewer + Bevy canvas app) from
|
||||||
|
# source. The artifacts are not committed to the repo (issue #156); this
|
||||||
|
# stage is their single source of truth for deployment.
|
||||||
|
FROM rust:1.95-slim AS wasm-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
|
# Pinned wasm toolchain — keep versions in sync with
|
||||||
|
# .gitea/workflows/web-e2e.yml and build_wasm.sh prerequisites.
|
||||||
|
RUN curl -sSL https://github.com/rustwasm/wasm-bindgen/releases/download/0.2.120/wasm-bindgen-0.2.120-x86_64-unknown-linux-musl.tar.gz \
|
||||||
|
| tar xz -C /opt \
|
||||||
|
&& ln -s /opt/wasm-bindgen-0.2.120-x86_64-unknown-linux-musl/wasm-bindgen /usr/local/bin/wasm-bindgen \
|
||||||
|
&& curl -sSL https://github.com/rustwasm/wasm-pack/releases/download/v0.14.0/wasm-pack-v0.14.0-x86_64-unknown-linux-musl.tar.gz \
|
||||||
|
| tar xz -C /opt \
|
||||||
|
&& ln -s /opt/wasm-pack-v0.14.0-x86_64-unknown-linux-musl/wasm-pack /usr/local/bin/wasm-pack \
|
||||||
|
&& curl -sSL https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||||
|
| tar xz -C /opt \
|
||||||
|
&& ln -s /opt/binaryen-version_130/bin/wasm-opt /usr/local/bin/wasm-opt
|
||||||
|
|
||||||
|
# Manifests first so the dependency-fetch layer caches across source changes
|
||||||
|
# (same pattern as the server build stage below).
|
||||||
|
COPY .cargo/config.toml ./.cargo/config.toml
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
|
||||||
|
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
|
||||||
|
COPY solitaire_data/Cargo.toml ./solitaire_data/Cargo.toml
|
||||||
|
COPY solitaire_engine/Cargo.toml ./solitaire_engine/Cargo.toml
|
||||||
|
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
|
||||||
|
COPY solitaire_app/Cargo.toml ./solitaire_app/Cargo.toml
|
||||||
|
COPY solitaire_assetgen/Cargo.toml ./solitaire_assetgen/Cargo.toml
|
||||||
|
COPY solitaire_wasm/Cargo.toml ./solitaire_wasm/Cargo.toml
|
||||||
|
COPY solitaire_web/Cargo.toml ./solitaire_web/Cargo.toml
|
||||||
|
|
||||||
|
RUN for crate in solitaire_core solitaire_sync solitaire_data solitaire_engine \
|
||||||
|
solitaire_server solitaire_app solitaire_assetgen solitaire_wasm solitaire_web; do \
|
||||||
|
mkdir -p $crate/src && echo "pub fn _stub() {}" > $crate/src/lib.rs; \
|
||||||
|
done && \
|
||||||
|
echo "fn main() {}" > solitaire_server/src/main.rs && \
|
||||||
|
echo "fn main() {}" > solitaire_app/src/main.rs && \
|
||||||
|
echo "fn main() {}" > solitaire_assetgen/src/main.rs
|
||||||
|
|
||||||
|
RUN cargo fetch --locked
|
||||||
|
|
||||||
|
# Real source for the wasm-feeding crates. Whole crate directories (not just
|
||||||
|
# src/) because solitaire_engine embeds theme/audio/font assets at compile
|
||||||
|
# time from its own assets/ and the workspace assets/.
|
||||||
|
COPY build_wasm.sh ./
|
||||||
|
COPY solitaire_core ./solitaire_core
|
||||||
|
COPY solitaire_sync ./solitaire_sync
|
||||||
|
COPY solitaire_data ./solitaire_data
|
||||||
|
COPY solitaire_engine ./solitaire_engine
|
||||||
|
COPY solitaire_wasm ./solitaire_wasm
|
||||||
|
COPY solitaire_web ./solitaire_web
|
||||||
|
COPY assets ./assets
|
||||||
|
|
||||||
|
RUN ./build_wasm.sh
|
||||||
|
|
||||||
# --- Build stage ---
|
# --- Build stage ---
|
||||||
FROM rust:1.95-slim AS builder
|
FROM rust:1.95-slim AS builder
|
||||||
|
|
||||||
@@ -67,6 +132,8 @@ COPY --from=builder /build/target/release/solitaire_server ./server
|
|||||||
# /app/assets → /assets route
|
# /app/assets → /assets route
|
||||||
# Card themes (dark + classic) are embedded in the binary; no theme files needed here.
|
# Card themes (dark + classic) are embedded in the binary; no theme files needed here.
|
||||||
COPY solitaire_server/web ./solitaire_server/web
|
COPY solitaire_server/web ./solitaire_server/web
|
||||||
|
# The wasm bundles are never in the repo — they come from the wasm-builder stage.
|
||||||
|
COPY --from=wasm-builder /build/solitaire_server/web/pkg ./solitaire_server/web/pkg
|
||||||
COPY assets ./assets
|
COPY assets ./assets
|
||||||
|
|
||||||
ENV SERVER_PORT=8080
|
ENV SERVER_PORT=8080
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ async function main() {
|
|||||||
invariant_ok: !!snap?.invariants?.state_ok,
|
invariant_ok: !!snap?.invariants?.state_ok,
|
||||||
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
|
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
|
||||||
replay_payload_present: payload !== null,
|
replay_payload_present: payload !== null,
|
||||||
replay_moves_len: Array.isArray(payload?.moves) ? payload.moves.length : 0,
|
replay_moves_len: Array.isArray(payload?.recording?.instructions)
|
||||||
|
? payload.recording.instructions.length
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
|
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ test("draw-mode toggle affects replay payload draw_mode", async ({ page }) => {
|
|||||||
|
|
||||||
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
||||||
expect(payload.draw_mode).toBe("DrawThree");
|
expect(payload.draw_mode).toBe("DrawThree");
|
||||||
expect(payload.schema_version).toBe(2);
|
expect(payload.schema_version).toBe(4);
|
||||||
expect(Array.isArray(payload.moves)).toBeTruthy();
|
expect(Array.isArray(payload.recording?.instructions)).toBeTruthy();
|
||||||
expect(payload.moves.length).toBeGreaterThan(0);
|
expect(payload.recording.instructions.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
|
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test("debug failure report contains replay diagnostics", async ({ page }) => {
|
|||||||
expect(report.invariants).toBeTruthy();
|
expect(report.invariants).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replay payload builder exports schema-v2 moves", async ({ page }) => {
|
test("replay payload builder exports a schema-v4 recording", async ({ page }) => {
|
||||||
await page.goto("/play-classic?seed=42");
|
await page.goto("/play-classic?seed=42");
|
||||||
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
||||||
|
|
||||||
@@ -57,10 +57,14 @@ test("replay payload builder exports schema-v2 moves", async ({ page }) => {
|
|||||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
|
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
||||||
expect(payload.schema_version).toBe(2);
|
// Schema v4: the wasm layer assembles the whole payload; the deal is
|
||||||
|
// embedded in `recording` and moves live at recording.instructions.
|
||||||
|
expect(payload.schema_version).toBe(4);
|
||||||
expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
|
expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
|
||||||
expect(payload.mode).toBe("Classic");
|
expect(payload.mode).toBe("Classic");
|
||||||
expect(Array.isArray(payload.moves)).toBeTruthy();
|
expect(payload.recording).toBeTruthy();
|
||||||
expect(payload.moves.length).toBeGreaterThan(0);
|
expect(payload.recording.initial_state).toBeTruthy();
|
||||||
expect(payload.win_move_index).toBe(payload.moves.length - 1);
|
expect(Array.isArray(payload.recording.instructions)).toBeTruthy();
|
||||||
|
expect(payload.recording.instructions.length).toBeGreaterThan(0);
|
||||||
|
expect(payload.win_move_index).toBe(payload.recording.instructions.length - 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,17 +31,37 @@ use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
|
|||||||
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
|
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
|
||||||
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
|
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
|
||||||
|
|
||||||
fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
|
/// Extract the mode label from the client's serde representation.
|
||||||
|
///
|
||||||
|
/// Unit `GameMode` variants serialise as plain strings (`"Classic"`),
|
||||||
|
/// but data-carrying variants serialise as single-key objects
|
||||||
|
/// (`{"Difficulty": "Easy"}`) — a plain-`String` field rejected those
|
||||||
|
/// uploads with a 400 even though `"Difficulty"` sits in `KNOWN_MODES`.
|
||||||
|
fn mode_label(mode: &serde_json::Value) -> Result<String, AppError> {
|
||||||
|
if let Some(s) = mode.as_str() {
|
||||||
|
return Ok(s.to_string());
|
||||||
|
}
|
||||||
|
if let Some(obj) = mode.as_object()
|
||||||
|
&& obj.len() == 1
|
||||||
|
&& let Some(key) = obj.keys().next()
|
||||||
|
{
|
||||||
|
return Ok(key.clone());
|
||||||
|
}
|
||||||
|
Err(AppError::BadRequest(format!(
|
||||||
|
"invalid mode '{mode}'; expected a mode string or single-variant object"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_header(h: &ReplayHeader, mode: &str) -> Result<(), AppError> {
|
||||||
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
|
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"invalid draw_mode '{}'; expected one of {:?}",
|
"invalid draw_mode '{}'; expected one of {:?}",
|
||||||
h.draw_mode, KNOWN_DRAW_MODES
|
h.draw_mode, KNOWN_DRAW_MODES
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if !KNOWN_MODES.contains(&h.mode.as_str()) {
|
if !KNOWN_MODES.contains(&mode) {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"invalid mode '{}'; expected one of {:?}",
|
"invalid mode '{mode}'; expected one of {KNOWN_MODES:?}"
|
||||||
h.mode, KNOWN_MODES
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
|
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
|
||||||
@@ -72,7 +92,11 @@ fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
|
|||||||
struct ReplayHeader {
|
struct ReplayHeader {
|
||||||
seed: u64,
|
seed: u64,
|
||||||
draw_mode: String,
|
draw_mode: String,
|
||||||
mode: String,
|
/// Kept as raw JSON: unit variants arrive as strings, data-carrying
|
||||||
|
/// variants (`GameMode::Difficulty(level)`) as single-key objects.
|
||||||
|
/// [`mode_label`] projects both onto the label stored in the `mode`
|
||||||
|
/// column.
|
||||||
|
mode: serde_json::Value,
|
||||||
time_seconds: i64,
|
time_seconds: i64,
|
||||||
final_score: i64,
|
final_score: i64,
|
||||||
recorded_at: String,
|
recorded_at: String,
|
||||||
@@ -126,7 +150,8 @@ pub async fn upload(
|
|||||||
let header: ReplayHeader = serde_json::from_value(payload.clone())
|
let header: ReplayHeader = serde_json::from_value(payload.clone())
|
||||||
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
|
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
|
||||||
|
|
||||||
validate_header(&header)?;
|
let mode = mode_label(&header.mode)?;
|
||||||
|
validate_header(&header, &mode)?;
|
||||||
|
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let received_at = Utc::now().to_rfc3339();
|
let received_at = Utc::now().to_rfc3339();
|
||||||
@@ -144,7 +169,7 @@ pub async fn upload(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
seed_i64,
|
seed_i64,
|
||||||
header.draw_mode,
|
header.draw_mode,
|
||||||
header.mode,
|
mode,
|
||||||
header.time_seconds,
|
header.time_seconds,
|
||||||
header.final_score,
|
header.final_score,
|
||||||
header.recorded_at,
|
header.recorded_at,
|
||||||
@@ -158,7 +183,7 @@ pub async fn upload(
|
|||||||
// beats their existing best. Only classic mode counts for the leaderboard.
|
// beats their existing best. Only classic mode counts for the leaderboard.
|
||||||
// Use `received_at` (server-computed) rather than `header.recorded_at`
|
// Use `received_at` (server-computed) rather than `header.recorded_at`
|
||||||
// (client-supplied) so clients cannot spoof the timestamp.
|
// (client-supplied) so clients cannot spoof the timestamp.
|
||||||
if header.mode == "Classic" {
|
if mode == "Classic" {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"UPDATE leaderboard
|
r#"UPDATE leaderboard
|
||||||
SET best_score = ?,
|
SET best_score = ?,
|
||||||
|
|||||||
@@ -445,33 +445,30 @@ function showWin(s) {
|
|||||||
submitReplay(s);
|
submitReplay(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReplayPayload(s) {
|
function buildReplayPayload() {
|
||||||
if (!game || !s) return null;
|
if (!game) return null;
|
||||||
let moves;
|
// The wasm side assembles the entire schema-v4 payload (including the
|
||||||
|
// session recording and the u64 seed, which JS numbers can't hold);
|
||||||
|
// JS only supplies the wall-clock time and today's date.
|
||||||
try {
|
try {
|
||||||
moves = game.replay_moves();
|
const json = game.replay_export(
|
||||||
if (!Array.isArray(moves) || moves.length === 0) return null;
|
Math.max(1, elapsedSecs),
|
||||||
|
new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const payload = JSON.parse(json);
|
||||||
|
if (!Array.isArray(payload?.recording?.instructions)
|
||||||
|
|| payload.recording.instructions.length === 0) return null;
|
||||||
|
return payload;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("fs: replay export failed", e);
|
console.warn("fs: replay export failed", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
schema_version: 2,
|
|
||||||
seed: Math.round(game.seed()),
|
|
||||||
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
|
||||||
mode: "Classic",
|
|
||||||
time_seconds: Math.max(1, elapsedSecs),
|
|
||||||
final_score: s.score,
|
|
||||||
recorded_at: new Date().toISOString().slice(0, 10),
|
|
||||||
moves,
|
|
||||||
win_move_index: moves.length - 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReplay(s) {
|
async function submitReplay(s) {
|
||||||
const token = localStorage.getItem('fs_token');
|
const token = localStorage.getItem('fs_token');
|
||||||
if (!token || !game) return;
|
if (!token || !game) return;
|
||||||
const payload = buildReplayPayload(s);
|
const payload = buildReplayPayload();
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/replays', {
|
await fetch('/api/replays', {
|
||||||
@@ -1029,7 +1026,7 @@ window.__FERROUS_DEBUG__ = {
|
|||||||
},
|
},
|
||||||
replayPayload() {
|
replayPayload() {
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
return buildReplayPayload(snap ?? game.state());
|
return buildReplayPayload();
|
||||||
},
|
},
|
||||||
runAutoplay(options) {
|
runAutoplay(options) {
|
||||||
return runDebugAutoplay(options);
|
return runDebugAutoplay(options);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,595 +0,0 @@
|
|||||||
/**
|
|
||||||
* Browser-side replay state machine. Owns a live `GameState` and the
|
|
||||||
* replay's move list; each `step()` applies the next move.
|
|
||||||
*/
|
|
||||||
export class ReplayPlayer {
|
|
||||||
__destroy_into_raw() {
|
|
||||||
const ptr = this.__wbg_ptr;
|
|
||||||
this.__wbg_ptr = 0;
|
|
||||||
ReplayPlayerFinalization.unregister(this);
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
free() {
|
|
||||||
const ptr = this.__destroy_into_raw();
|
|
||||||
wasm.__wbg_replayplayer_free(ptr, 0);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns `true` once every move has been applied.
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
is_finished() {
|
|
||||||
const ret = wasm.replayplayer_is_finished(this.__wbg_ptr);
|
|
||||||
return ret !== 0;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Construct from a raw replay JSON string.
|
|
||||||
* @param {string} replay_json
|
|
||||||
*/
|
|
||||||
constructor(replay_json) {
|
|
||||||
const ptr0 = passStringToWasm0(replay_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.replayplayer_new(ptr0, len0);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
this.__wbg_ptr = ret[0];
|
|
||||||
ReplayPlayerFinalization.register(this, this.__wbg_ptr, this);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
|
||||||
*
|
|
||||||
* Throws a JS string exception on serialisation failure (should never
|
|
||||||
* occur in practice — `StateSnapshot` contains only primitive types).
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
state() {
|
|
||||||
const ret = wasm.replayplayer_state(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Apply the next move; returns the post-step snapshot, or `null`
|
|
||||||
* once the move list is exhausted.
|
|
||||||
*
|
|
||||||
* Returns `null` (not an exception) when the replay is finished.
|
|
||||||
* Throws `"replay_desync"` when the next recorded move is illegal for
|
|
||||||
* the current state, and logs the underlying core error to the JS console.
|
|
||||||
* Throws a JS string exception on serialisation failure.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
step() {
|
|
||||||
const ret = wasm.replayplayer_step(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 0-indexed position of the next move to apply.
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
step_idx() {
|
|
||||||
const ret = wasm.replayplayer_step_idx(this.__wbg_ptr);
|
|
||||||
return ret >>> 0;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Total number of moves the replay contains.
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
total_steps() {
|
|
||||||
const ret = wasm.replayplayer_total_steps(this.__wbg_ptr);
|
|
||||||
return ret >>> 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interactive Klondike game backed by the real `solitaire_core` rules engine.
|
|
||||||
*
|
|
||||||
* Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
|
||||||
* `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
|
|
||||||
* full pile snapshot at any time without mutating state.
|
|
||||||
*/
|
|
||||||
export class SolitaireGame {
|
|
||||||
static __wrap(ptr) {
|
|
||||||
const obj = Object.create(SolitaireGame.prototype);
|
|
||||||
obj.__wbg_ptr = ptr;
|
|
||||||
SolitaireGameFinalization.register(obj, obj.__wbg_ptr, obj);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
__destroy_into_raw() {
|
|
||||||
const ptr = this.__wbg_ptr;
|
|
||||||
this.__wbg_ptr = 0;
|
|
||||||
SolitaireGameFinalization.unregister(this);
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
free() {
|
|
||||||
const ptr = this.__destroy_into_raw();
|
|
||||||
wasm.__wbg_solitairegame_free(ptr, 0);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Apply one auto-complete move (only valid when `is_auto_completable`).
|
|
||||||
*
|
|
||||||
* If no card can go directly to a foundation this step, advances the
|
|
||||||
* waste by calling `draw()` so the next step can try again. Returns the
|
|
||||||
* post-move snapshot, or `null` when no progress is possible.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
auto_complete_step() {
|
|
||||||
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Applies the legal move currently at `index` from `debug_legal_moves()`.
|
|
||||||
* @param {number} index
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_apply_legal_move(index) {
|
|
||||||
const ret = wasm.solitairegame_debug_apply_legal_move(this.__wbg_ptr, index);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Applies one debug move encoded as JSON.
|
|
||||||
*
|
|
||||||
* JSON must match [`DebugMove`], for example:
|
|
||||||
* `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
|
|
||||||
* `{"kind":"stock_click"}`.
|
|
||||||
* @param {string} move_json
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_apply_move_json(move_json) {
|
|
||||||
const ptr0 = passStringToWasm0(move_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.solitairegame_debug_apply_move_json(this.__wbg_ptr, ptr0, len0);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns all currently-legal debug moves as a JS array.
|
|
||||||
*
|
|
||||||
* Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_legal_moves() {
|
|
||||||
const ret = wasm.solitairegame_debug_legal_moves(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns deterministic instruction history for the current game.
|
|
||||||
*
|
|
||||||
* Together with `seed()` and `draw_mode`, this history is replayable.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_move_history() {
|
|
||||||
const ret = wasm.solitairegame_debug_move_history(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns a comprehensive debug snapshot for automated verification.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_snapshot() {
|
|
||||||
const ret = wasm.solitairegame_debug_snapshot(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Draw from stock to waste (or recycle waste → stock when stock is empty).
|
|
||||||
* Returns `{ok, error?, snapshot?}`.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
draw() {
|
|
||||||
const ret = wasm.solitairegame_draw(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
|
|
||||||
*
|
|
||||||
* Returns an error string if the JSON is malformed or describes a state
|
|
||||||
* that can't be deserialised (e.g. from a future schema version).
|
|
||||||
* @param {string} json
|
|
||||||
* @returns {SolitaireGame}
|
|
||||||
*/
|
|
||||||
static from_saved(json) {
|
|
||||||
const ptr0 = passStringToWasm0(json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.solitairegame_from_saved(ptr0, len0);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return SolitaireGame.__wrap(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Move `count` cards from pile `from` to pile `to`.
|
|
||||||
*
|
|
||||||
* Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
|
|
||||||
* `"tableau-0"` .. `"tableau-6"`.
|
|
||||||
*
|
|
||||||
* Returns `{ok, error?, snapshot?}`.
|
|
||||||
* @param {string} from
|
|
||||||
* @param {string} to
|
|
||||||
* @param {number} count
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
move_cards(from, to, count) {
|
|
||||||
const ptr0 = passStringToWasm0(from, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ptr1 = passStringToWasm0(to, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len1 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.solitairegame_move_cards(this.__wbg_ptr, ptr0, len0, ptr1, len1, count);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Create a new DrawOne or DrawThree Classic game from the given seed.
|
|
||||||
*
|
|
||||||
* `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
|
|
||||||
* Pass `Date.now()` or a random integer from JS for variety.
|
|
||||||
* @param {number} seed
|
|
||||||
* @param {boolean} draw_three
|
|
||||||
*/
|
|
||||||
constructor(seed, draw_three) {
|
|
||||||
const ret = wasm.solitairegame_new(seed, draw_three);
|
|
||||||
this.__wbg_ptr = ret;
|
|
||||||
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns replay moves encoded in the `solitaire_data::Replay` wire format
|
|
||||||
* — a list of upstream [`KlondikeInstruction`]s.
|
|
||||||
*
|
|
||||||
* This is the deterministic instruction history; together with `seed()`
|
|
||||||
* and the draw mode it replays cleanly via `apply_instruction`.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
replay_moves() {
|
|
||||||
const ret = wasm.solitairegame_replay_moves(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* The seed used to deal this game.
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
seed() {
|
|
||||||
const ret = wasm.solitairegame_seed(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Serialise the full game state as a JSON string for `localStorage`.
|
|
||||||
*
|
|
||||||
* Use [`SolitaireGame::from_saved`] to restore it. The returned string is
|
|
||||||
* opaque — callers should treat it as a blob and store/restore it verbatim.
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
serialize() {
|
|
||||||
let deferred2_0;
|
|
||||||
let deferred2_1;
|
|
||||||
try {
|
|
||||||
const ret = wasm.solitairegame_serialize(this.__wbg_ptr);
|
|
||||||
var ptr1 = ret[0];
|
|
||||||
var len1 = ret[1];
|
|
||||||
if (ret[3]) {
|
|
||||||
ptr1 = 0; len1 = 0;
|
|
||||||
throw takeFromExternrefTable0(ret[2]);
|
|
||||||
}
|
|
||||||
deferred2_0 = ptr1;
|
|
||||||
deferred2_1 = len1;
|
|
||||||
return getStringFromWasm0(ptr1, len1);
|
|
||||||
} finally {
|
|
||||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Full pile snapshot as a JS object.
|
|
||||||
*
|
|
||||||
* Throws a JS string exception on serialisation failure.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
state() {
|
|
||||||
const ret = wasm.solitairegame_state(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
undo() {
|
|
||||||
const ret = wasm.solitairegame_undo(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Symbol.dispose) SolitaireGame.prototype[Symbol.dispose] = SolitaireGame.prototype.free;
|
|
||||||
function __wbg_get_imports() {
|
|
||||||
const import0 = {
|
|
||||||
__proto__: null,
|
|
||||||
__wbg_Error_3639a60ed15f87e7: function(arg0, arg1) {
|
|
||||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_String_8564e559799eccda: function(arg0, arg1) {
|
|
||||||
const ret = String(arg1);
|
|
||||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len1 = WASM_VECTOR_LEN;
|
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
|
||||||
},
|
|
||||||
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
|
||||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
|
||||||
},
|
|
||||||
__wbg_error_48655ee7e4756f8b: function(arg0) {
|
|
||||||
console.error(arg0);
|
|
||||||
},
|
|
||||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
|
||||||
let deferred0_0;
|
|
||||||
let deferred0_1;
|
|
||||||
try {
|
|
||||||
deferred0_0 = arg0;
|
|
||||||
deferred0_1 = arg1;
|
|
||||||
console.error(getStringFromWasm0(arg0, arg1));
|
|
||||||
} finally {
|
|
||||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
__wbg_new_227d7c05414eb861: function() {
|
|
||||||
const ret = new Error();
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_new_2fad8ca02fd00684: function() {
|
|
||||||
const ret = new Object();
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_new_3baa8d9866155c79: function() {
|
|
||||||
const ret = new Array();
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
|
|
||||||
arg0[arg1] = arg2;
|
|
||||||
},
|
|
||||||
__wbg_set_f614f6a0608d1d1d: function(arg0, arg1, arg2) {
|
|
||||||
arg0[arg1 >>> 0] = arg2;
|
|
||||||
},
|
|
||||||
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
|
|
||||||
const ret = arg1.stack;
|
|
||||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len1 = WASM_VECTOR_LEN;
|
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
|
||||||
},
|
|
||||||
__wbindgen_cast_0000000000000001: function(arg0) {
|
|
||||||
// Cast intrinsic for `F64 -> Externref`.
|
|
||||||
const ret = arg0;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
|
||||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
|
||||||
const ret = getStringFromWasm0(arg0, arg1);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_cast_0000000000000003: function(arg0) {
|
|
||||||
// Cast intrinsic for `U64 -> Externref`.
|
|
||||||
const ret = BigInt.asUintN(64, arg0);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_init_externref_table: function() {
|
|
||||||
const table = wasm.__wbindgen_externrefs;
|
|
||||||
const offset = table.grow(4);
|
|
||||||
table.set(0, undefined);
|
|
||||||
table.set(offset + 0, undefined);
|
|
||||||
table.set(offset + 1, null);
|
|
||||||
table.set(offset + 2, true);
|
|
||||||
table.set(offset + 3, false);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
__proto__: null,
|
|
||||||
"./solitaire_wasm_bg.js": import0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
|
|
||||||
? { register: () => {}, unregister: () => {} }
|
|
||||||
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
|
|
||||||
const SolitaireGameFinalization = (typeof FinalizationRegistry === 'undefined')
|
|
||||||
? { register: () => {}, unregister: () => {} }
|
|
||||||
: new FinalizationRegistry(ptr => wasm.__wbg_solitairegame_free(ptr, 1));
|
|
||||||
|
|
||||||
let cachedDataViewMemory0 = null;
|
|
||||||
function getDataViewMemory0() {
|
|
||||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
|
||||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
|
||||||
}
|
|
||||||
return cachedDataViewMemory0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStringFromWasm0(ptr, len) {
|
|
||||||
return decodeText(ptr >>> 0, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedUint8ArrayMemory0 = null;
|
|
||||||
function getUint8ArrayMemory0() {
|
|
||||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
|
||||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
|
||||||
}
|
|
||||||
return cachedUint8ArrayMemory0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function passStringToWasm0(arg, malloc, realloc) {
|
|
||||||
if (realloc === undefined) {
|
|
||||||
const buf = cachedTextEncoder.encode(arg);
|
|
||||||
const ptr = malloc(buf.length, 1) >>> 0;
|
|
||||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
|
||||||
WASM_VECTOR_LEN = buf.length;
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
let len = arg.length;
|
|
||||||
let ptr = malloc(len, 1) >>> 0;
|
|
||||||
|
|
||||||
const mem = getUint8ArrayMemory0();
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (; offset < len; offset++) {
|
|
||||||
const code = arg.charCodeAt(offset);
|
|
||||||
if (code > 0x7F) break;
|
|
||||||
mem[ptr + offset] = code;
|
|
||||||
}
|
|
||||||
if (offset !== len) {
|
|
||||||
if (offset !== 0) {
|
|
||||||
arg = arg.slice(offset);
|
|
||||||
}
|
|
||||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
|
||||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
|
||||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
|
||||||
|
|
||||||
offset += ret.written;
|
|
||||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
WASM_VECTOR_LEN = offset;
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeFromExternrefTable0(idx) {
|
|
||||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
|
||||||
wasm.__externref_table_dealloc(idx);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
||||||
cachedTextDecoder.decode();
|
|
||||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
|
||||||
let numBytesDecoded = 0;
|
|
||||||
function decodeText(ptr, len) {
|
|
||||||
numBytesDecoded += len;
|
|
||||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
|
||||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
||||||
cachedTextDecoder.decode();
|
|
||||||
numBytesDecoded = len;
|
|
||||||
}
|
|
||||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedTextEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
if (!('encodeInto' in cachedTextEncoder)) {
|
|
||||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
|
||||||
const buf = cachedTextEncoder.encode(arg);
|
|
||||||
view.set(buf);
|
|
||||||
return {
|
|
||||||
read: arg.length,
|
|
||||||
written: buf.length
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let WASM_VECTOR_LEN = 0;
|
|
||||||
|
|
||||||
let wasmModule, wasmInstance, wasm;
|
|
||||||
function __wbg_finalize_init(instance, module) {
|
|
||||||
wasmInstance = instance;
|
|
||||||
wasm = instance.exports;
|
|
||||||
wasmModule = module;
|
|
||||||
cachedDataViewMemory0 = null;
|
|
||||||
cachedUint8ArrayMemory0 = null;
|
|
||||||
wasm.__wbindgen_start();
|
|
||||||
return wasm;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function __wbg_load(module, imports) {
|
|
||||||
if (typeof Response === 'function' && module instanceof Response) {
|
|
||||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
|
||||||
try {
|
|
||||||
return await WebAssembly.instantiateStreaming(module, imports);
|
|
||||||
} catch (e) {
|
|
||||||
const validResponse = module.ok && expectedResponseType(module.type);
|
|
||||||
|
|
||||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
|
||||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
|
||||||
|
|
||||||
} else { throw e; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = await module.arrayBuffer();
|
|
||||||
return await WebAssembly.instantiate(bytes, imports);
|
|
||||||
} else {
|
|
||||||
const instance = await WebAssembly.instantiate(module, imports);
|
|
||||||
|
|
||||||
if (instance instanceof WebAssembly.Instance) {
|
|
||||||
return { instance, module };
|
|
||||||
} else {
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectedResponseType(type) {
|
|
||||||
switch (type) {
|
|
||||||
case 'basic': case 'cors': case 'default': return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSync(module) {
|
|
||||||
if (wasm !== undefined) return wasm;
|
|
||||||
|
|
||||||
|
|
||||||
if (module !== undefined) {
|
|
||||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
|
||||||
({module} = module)
|
|
||||||
} else {
|
|
||||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imports = __wbg_get_imports();
|
|
||||||
if (!(module instanceof WebAssembly.Module)) {
|
|
||||||
module = new WebAssembly.Module(module);
|
|
||||||
}
|
|
||||||
const instance = new WebAssembly.Instance(module, imports);
|
|
||||||
return __wbg_finalize_init(instance, module);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function __wbg_init(module_or_path) {
|
|
||||||
if (wasm !== undefined) return wasm;
|
|
||||||
|
|
||||||
|
|
||||||
if (module_or_path !== undefined) {
|
|
||||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
|
||||||
({module_or_path} = module_or_path)
|
|
||||||
} else {
|
|
||||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (module_or_path === undefined) {
|
|
||||||
module_or_path = new URL('solitaire_wasm_bg.wasm', import.meta.url);
|
|
||||||
}
|
|
||||||
const imports = __wbg_get_imports();
|
|
||||||
|
|
||||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
|
||||||
module_or_path = fetch(module_or_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
|
||||||
|
|
||||||
return __wbg_finalize_init(instance, module);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { initSync, __wbg_init as default };
|
|
||||||
Binary file not shown.
@@ -159,16 +159,17 @@
|
|||||||
|
|
||||||
function buildReplayPayload() {
|
function buildReplayPayload() {
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
|
// Schema v4: the wasm side assembles the full payload,
|
||||||
|
// including the session recording and the u64 seed.
|
||||||
try {
|
try {
|
||||||
const moves = game.replay_moves();
|
const json = game.replay_export(
|
||||||
if (!Array.isArray(moves) || moves.length === 0) return null;
|
1,
|
||||||
return {
|
new Date().toISOString().slice(0, 10),
|
||||||
schema_version: 2,
|
);
|
||||||
seed: Math.round(game.seed()),
|
const payload = JSON.parse(json);
|
||||||
draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne",
|
if (!Array.isArray(payload?.recording?.instructions)
|
||||||
mode: "Classic",
|
|| payload.recording.instructions.length === 0) return null;
|
||||||
moves,
|
return payload;
|
||||||
};
|
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,18 @@ function resetPlayer() {
|
|||||||
playInterval = null;
|
playInterval = null;
|
||||||
btnPlay.textContent = "▶ Play";
|
btnPlay.textContent = "▶ Play";
|
||||||
}
|
}
|
||||||
player = new ReplayPlayer(replayJson);
|
// Old replays (schema < 4) are rejected by the wasm player with a
|
||||||
|
// descriptive error — surface it instead of leaving a dead board.
|
||||||
|
try {
|
||||||
|
player = new ReplayPlayer(replayJson);
|
||||||
|
} catch (e) {
|
||||||
|
captionEl.textContent = `Cannot play this replay: ${e}`;
|
||||||
|
btnStep.disabled = true;
|
||||||
|
btnPlay.disabled = true;
|
||||||
|
btnPrev.disabled = true;
|
||||||
|
btnRestart.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
btnPrev.disabled = true;
|
btnPrev.disabled = true;
|
||||||
btnRestart.disabled = true;
|
btnRestart.disabled = true;
|
||||||
btnStep.disabled = false;
|
btnStep.disabled = false;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub mod stats;
|
|||||||
pub mod theme_store;
|
pub mod theme_store;
|
||||||
|
|
||||||
pub use achievements::AchievementRecord;
|
pub use achievements::AchievementRecord;
|
||||||
pub use merge::{merge, merge_at};
|
pub use merge::merge;
|
||||||
pub use progress::{PlayerProgress, level_for_xp};
|
pub use progress::{PlayerProgress, level_for_xp};
|
||||||
pub use stats::StatsSnapshot;
|
pub use stats::StatsSnapshot;
|
||||||
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||||
@@ -98,30 +98,3 @@ pub struct LeaderboardEntry {
|
|||||||
/// When this entry was last recorded.
|
/// When this entry was last recorded.
|
||||||
pub recorded_at: DateTime<Utc>,
|
pub recorded_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Error types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Errors returned by the sync server in `application/json` error bodies.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
|
||||||
pub enum ApiError {
|
|
||||||
/// The request could not be authenticated (missing or invalid JWT).
|
|
||||||
#[error("unauthorized")]
|
|
||||||
Unauthorized,
|
|
||||||
/// The supplied credentials were incorrect.
|
|
||||||
#[error("invalid credentials")]
|
|
||||||
InvalidCredentials,
|
|
||||||
/// A username that was requested for registration is already taken.
|
|
||||||
#[error("username already taken")]
|
|
||||||
UsernameTaken,
|
|
||||||
/// The request payload was too large (> 1 MB).
|
|
||||||
#[error("payload too large")]
|
|
||||||
PayloadTooLarge,
|
|
||||||
/// The request body could not be parsed.
|
|
||||||
#[error("bad request: {0}")]
|
|
||||||
BadRequest(String),
|
|
||||||
/// An unexpected server-side error occurred.
|
|
||||||
#[error("internal server error")]
|
|
||||||
Internal,
|
|
||||||
}
|
|
||||||
|
|||||||
+146
-46
@@ -26,16 +26,21 @@ use solitaire_core::{
|
|||||||
DrawStockConfig,
|
DrawStockConfig,
|
||||||
game_state::{GameMode, GameState},
|
game_state::{GameMode, GameState},
|
||||||
};
|
};
|
||||||
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
use solitaire_core::{KlondikeInstruction, KlondikePile, SessionRecording};
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// Mirrors `solitaire_data::Replay` v3.
|
/// Replay schema version this player understands. Mirrors
|
||||||
|
/// `solitaire_data::REPLAY_SCHEMA_VERSION`; the loader rejects any
|
||||||
|
/// other version with a descriptive error instead of desyncing.
|
||||||
|
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
|
||||||
|
|
||||||
|
/// Mirrors `solitaire_data::Replay` v4.
|
||||||
///
|
///
|
||||||
/// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
|
/// `recording` is the upstream `card_game` session serialisation
|
||||||
/// move-currency `solitaire_core` persists. A stock click is
|
/// (`{config, initial_state, instructions}`): the dealt board is stored
|
||||||
/// `KlondikeInstruction::RotateStock`; a card move is a
|
/// explicitly, so playback rebuilds the exact deal instead of re-dealing
|
||||||
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are
|
/// from `seed` — schemas ≤ v3 did the latter and silently broke whenever
|
||||||
/// runtime-only and intentionally not part of the wire format.
|
/// an RNG or upstream upgrade changed the seed→deal mapping.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Replay {
|
pub struct Replay {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -46,7 +51,9 @@ pub struct Replay {
|
|||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
pub recorded_at: NaiveDate,
|
pub recorded_at: NaiveDate,
|
||||||
pub moves: Vec<KlondikeInstruction>,
|
pub recording: SessionRecording,
|
||||||
|
#[serde(default)]
|
||||||
|
pub win_move_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
||||||
@@ -125,10 +132,20 @@ impl ReplayPlayer {
|
|||||||
pub fn from_json(replay_json: &str) -> Result<Self, String> {
|
pub fn from_json(replay_json: &str) -> Result<Self, String> {
|
||||||
let replay: Replay =
|
let replay: Replay =
|
||||||
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
||||||
let game = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
if replay.schema_version != REPLAY_SCHEMA_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"unsupported replay schema_version {} (this player requires {}); \
|
||||||
|
replays recorded by older clients cannot be replayed",
|
||||||
|
replay.schema_version, REPLAY_SCHEMA_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// The recording carries the dealt board and session config, so the
|
||||||
|
// rebuilt game is bit-identical to the recorded deal no matter how
|
||||||
|
// the current build maps seeds to deals.
|
||||||
|
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
game,
|
game,
|
||||||
moves: replay.moves,
|
moves,
|
||||||
step_idx: 0,
|
step_idx: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -510,6 +527,36 @@ impl SolitaireGame {
|
|||||||
self.game.instruction_history()
|
self.game.instruction_history()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the complete schema-v4 replay upload payload for the live
|
||||||
|
/// game as a JSON string, ready to `POST /api/replays` verbatim.
|
||||||
|
///
|
||||||
|
/// The JS layer must not assemble this payload itself: the recording
|
||||||
|
/// serialises through the upstream `card_game` serializers, and the
|
||||||
|
/// `u64` seed exceeds JS number precision (`Math.round(game.seed())`
|
||||||
|
/// silently corrupts it).
|
||||||
|
///
|
||||||
|
/// `recorded_at` is an ISO-8601 date (`YYYY-MM-DD`); the browser
|
||||||
|
/// supplies it because the wasm build has no reliable local clock.
|
||||||
|
fn replay_export_native(&self, time_seconds: u64, recorded_at: &str) -> Result<String, String> {
|
||||||
|
let recorded_at: NaiveDate = recorded_at
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("invalid recorded_at date '{recorded_at}': {e}"))?;
|
||||||
|
let recording = self.game.recording();
|
||||||
|
let win_move_index = recording.len().checked_sub(1);
|
||||||
|
let replay = Replay {
|
||||||
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
|
seed: self.game.seed,
|
||||||
|
draw_mode: self.game.draw_mode(),
|
||||||
|
mode: self.game.mode,
|
||||||
|
time_seconds,
|
||||||
|
final_score: self.game.score(),
|
||||||
|
recorded_at,
|
||||||
|
recording,
|
||||||
|
win_move_index,
|
||||||
|
};
|
||||||
|
serde_json::to_string(&replay).map_err(|e| format!("replay serialisation failed: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn debug_snapshot_native(&self) -> DebugSnapshot {
|
fn debug_snapshot_native(&self) -> DebugSnapshot {
|
||||||
let legal_moves = self.legal_moves_native();
|
let legal_moves = self.legal_moves_native();
|
||||||
let invariants = invariant_report_for_game(&self.game, &legal_moves);
|
let invariants = invariant_report_for_game(&self.game, &legal_moves);
|
||||||
@@ -694,6 +741,17 @@ impl SolitaireGame {
|
|||||||
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Complete schema-v4 replay payload for the live game, as a JSON
|
||||||
|
/// string ready to `POST /api/replays` verbatim. See
|
||||||
|
/// [`Self::replay_export_native`] for why JS must not assemble the
|
||||||
|
/// payload itself. `recorded_at` is an ISO-8601 `YYYY-MM-DD` date.
|
||||||
|
/// `time_seconds` is `u32` so JS can pass a plain number (a `u64`
|
||||||
|
/// would demand a `BigInt`).
|
||||||
|
pub fn replay_export(&self, time_seconds: u32, recorded_at: String) -> Result<String, JsValue> {
|
||||||
|
self.replay_export_native(u64::from(time_seconds), &recorded_at)
|
||||||
|
.map_err(|e| JsValue::from_str(&e))
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns all currently-legal debug moves as a JS array.
|
/// Returns all currently-legal debug moves as a JS array.
|
||||||
///
|
///
|
||||||
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
||||||
@@ -897,44 +955,25 @@ mod tests {
|
|||||||
"progressed game must export a non-empty replay move list"
|
"progressed game must export a non-empty replay move list"
|
||||||
);
|
);
|
||||||
|
|
||||||
let moves_json = match serde_json::to_value(&exported_moves) {
|
let replay_json = match game.replay_export_native(120, "2026-06-01") {
|
||||||
Ok(value) => value,
|
|
||||||
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
moves_json.is_array(),
|
|
||||||
"exported replay moves must serialise as a JSON array"
|
|
||||||
);
|
|
||||||
|
|
||||||
let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
|
|
||||||
Ok(parsed) => parsed,
|
|
||||||
Err(err) => {
|
|
||||||
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
parsed_back, exported_moves,
|
|
||||||
"replay move JSON must round-trip through KlondikeInstruction"
|
|
||||||
);
|
|
||||||
|
|
||||||
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
|
|
||||||
Some(date) => date,
|
|
||||||
None => panic!("invalid recorded_at date in test"),
|
|
||||||
};
|
|
||||||
let replay = Replay {
|
|
||||||
schema_version: 3,
|
|
||||||
seed,
|
|
||||||
draw_mode,
|
|
||||||
mode: GameMode::Classic,
|
|
||||||
time_seconds: 120,
|
|
||||||
final_score: game.game.score(),
|
|
||||||
recorded_at,
|
|
||||||
moves: exported_moves,
|
|
||||||
};
|
|
||||||
let replay_json = match serde_json::to_string(&replay) {
|
|
||||||
Ok(json) => json,
|
Ok(json) => json,
|
||||||
Err(err) => panic!("failed to serialise replay JSON: {err}"),
|
Err(err) => panic!("failed to export replay JSON: {err}"),
|
||||||
};
|
};
|
||||||
|
let parsed: Replay = match serde_json::from_str(&replay_json) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(err) => panic!("exported replay JSON must parse back as Replay: {err}"),
|
||||||
|
};
|
||||||
|
assert_eq!(parsed.schema_version, REPLAY_SCHEMA_VERSION);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.recording.instructions(),
|
||||||
|
exported_moves,
|
||||||
|
"exported recording must carry the exact instruction history"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.win_move_index,
|
||||||
|
Some(exported_moves.len() - 1),
|
||||||
|
"win_move_index must point at the last instruction"
|
||||||
|
);
|
||||||
|
|
||||||
let mut player = match ReplayPlayer::from_json(&replay_json) {
|
let mut player = match ReplayPlayer::from_json(&replay_json) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
@@ -962,6 +1001,67 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-v4 replays re-dealt from the seed at playback time — the exact
|
||||||
|
/// mechanism that broke when the seed→deal mapping changed. The player
|
||||||
|
/// must refuse them with a version error, never desync silently.
|
||||||
|
#[test]
|
||||||
|
fn replay_player_rejects_pre_v4_schema_versions() {
|
||||||
|
let v3_json = r#"{
|
||||||
|
"schema_version": 3,
|
||||||
|
"seed": 7,
|
||||||
|
"draw_mode": "DrawOne",
|
||||||
|
"mode": "Classic",
|
||||||
|
"time_seconds": 60,
|
||||||
|
"final_score": 100,
|
||||||
|
"recorded_at": "2026-05-01",
|
||||||
|
"recording": null,
|
||||||
|
"moves": []
|
||||||
|
}"#;
|
||||||
|
// v3 files carry `moves`, not `recording`; either way the version
|
||||||
|
// gate (or the missing field) must produce an error, not a player.
|
||||||
|
let err = match ReplayPlayer::from_json(v3_json) {
|
||||||
|
Err(err) => err,
|
||||||
|
Ok(_) => panic!("v3 replay must be rejected"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
err.contains("schema_version") || err.contains("invalid replay JSON"),
|
||||||
|
"error must name the version/format problem, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point of v4: playback rebuilds the deal from the
|
||||||
|
/// recording, so a replay stays correct even when the top-level
|
||||||
|
/// `seed` no longer maps to the same deal (RNG upgrades, or a
|
||||||
|
/// corrupted seed from the old JS `Math.round` path).
|
||||||
|
#[test]
|
||||||
|
fn replay_playback_ignores_seed_for_dealing() {
|
||||||
|
let game = SolitaireGame {
|
||||||
|
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||||
|
};
|
||||||
|
let replay_json = game
|
||||||
|
.replay_export_native(60, "2026-06-01")
|
||||||
|
.expect("export must succeed");
|
||||||
|
// Corrupt the seed field only — playback must be unaffected.
|
||||||
|
let mut value: serde_json::Value =
|
||||||
|
serde_json::from_str(&replay_json).expect("parse exported JSON");
|
||||||
|
value["seed"] = serde_json::Value::from(0_u64);
|
||||||
|
let corrupted = serde_json::to_string(&value).expect("reserialise");
|
||||||
|
|
||||||
|
let player = ReplayPlayer::from_json(&corrupted).expect("player must construct");
|
||||||
|
let original_deal = serde_json::to_string(&game.snap()).expect("serialise original deal");
|
||||||
|
let replayed_deal =
|
||||||
|
serde_json::to_string(&player.snapshot()).expect("serialise replayed deal");
|
||||||
|
// Compare the board projections (piles), not the GameState wrapper
|
||||||
|
// (whose serde includes the now-different seed metadata).
|
||||||
|
let orig: serde_json::Value = serde_json::from_str(&original_deal).expect("parse");
|
||||||
|
let repl: serde_json::Value = serde_json::from_str(&replayed_deal).expect("parse");
|
||||||
|
assert_eq!(
|
||||||
|
orig["tableaus"], repl["tableaus"],
|
||||||
|
"tableau deal must come from the recording, not the seed"
|
||||||
|
);
|
||||||
|
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_api_autonomous_seed_batch_smoke() {
|
fn debug_api_autonomous_seed_batch_smoke() {
|
||||||
for seed in 0_u64..128_u64 {
|
for seed in 0_u64..128_u64 {
|
||||||
|
|||||||
Reference in New Issue
Block a user