Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5c3188686 | |||
| 6a289b7b50 | |||
| bee712c5ab | |||
| 0db5e9dac4 | |||
| 681a54d9bb | |||
| 7894559ca7 | |||
| ab803c07af | |||
| e43b329fc1 | |||
| 7c07f71f02 | |||
| c1329bbb21 | |||
| 4303ef3f5b | |||
| 4df962ee07 | |||
| f281425b45 | |||
| 2c822ba2d7 | |||
| 7ddf2733c9 | |||
| 585570559c | |||
| 45436d0eda | |||
| 2062bd06f3 | |||
| 0cb15872b1 | |||
| 395a322adc | |||
| 5199a5e499 | |||
| 16242e6d77 | |||
| 202a64db45 | |||
| c0415eb0ee | |||
| a449f60bc5 | |||
| ad5f613277 | |||
| c50eaf81f7 | |||
| b44d2777ec | |||
| 52407e7256 | |||
| da3e5423dc | |||
| a1864271de | |||
| f63db769ae | |||
| 4437a1aaf9 | |||
| e7345aed6c | |||
| 140251beae | |||
| d6f32d3154 | |||
| 8fdc41f36f | |||
| 2e25476d0a | |||
| d3cb1a51d4 | |||
| c8358f4275 | |||
| a2432dfe7a | |||
| 511550232c | |||
| e5c4f51a6e | |||
| 23902cdc44 | |||
| 3cc8eacafa | |||
| 90e24d9711 | |||
| decbe0bbd9 | |||
| 1873b3f9be | |||
| d11d97e677 | |||
| d322abf67b | |||
| c9e4c0b4cd | |||
| fe68861e10 | |||
| c33b39cf11 | |||
| 23ff62c397 | |||
| 0b2ffca016 | |||
| fbe48acef6 | |||
| cd79877933 | |||
| 52befa6199 | |||
| e63046700c | |||
| ab857bbb6e | |||
| 886e0cf8a1 | |||
| 3d92a91e3b | |||
| 9113cdb483 | |||
| c153363626 | |||
| 93b67f1d0b | |||
| 279e23d0af | |||
| 12fba2157a |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
libxkbcommon-dev
|
libxkbcommon-dev
|
||||||
|
|
||||||
- name: Cache cargo registry and build artifacts
|
- name: Cache cargo registry and build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
needs: test
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
libxkbcommon-dev
|
libxkbcommon-dev
|
||||||
|
|
||||||
- name: Cache cargo registry and build artifacts
|
- name: Cache cargo registry and build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Triggered by pushing a version tag, e.g. `git tag v0.22.0 && git push origin v0.22.0`.
|
||||||
|
# Builds a Linux x86_64 tarball and a signed Android APK, then publishes
|
||||||
|
# both as assets on a GitHub Release. Obtainium can track this repo's
|
||||||
|
# releases and download the APK automatically.
|
||||||
|
#
|
||||||
|
# Required repository secrets (Settings → Secrets and variables → Actions):
|
||||||
|
# ANDROID_KEYSTORE_BASE64 base64-encoded .jks file (see README for gen command)
|
||||||
|
# ANDROID_KEYSTORE_PASSWORD password used with -storepass when creating the keystore
|
||||||
|
# ANDROID_KEY_ALIAS alias used with -alias when creating the keystore
|
||||||
|
# ANDROID_KEY_PASSWORD password used with -keypass when creating the keystore
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # gh release create needs write access
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-D warnings"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job 1: Linux x86_64 binary + assets tarball
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
jobs:
|
||||||
|
build-linux:
|
||||||
|
name: Build · Linux x86_64
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install system deps
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
|
- name: Cache cargo registry + build artifacts
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: linux-release-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: linux-release-
|
||||||
|
|
||||||
|
- name: Build release binary
|
||||||
|
run: cargo build --release -p solitaire_app
|
||||||
|
|
||||||
|
- name: Package tarball
|
||||||
|
run: |
|
||||||
|
mkdir solitaire-quest
|
||||||
|
cp target/release/solitaire_app solitaire-quest/
|
||||||
|
cp -r assets solitaire-quest/
|
||||||
|
tar -czf solitaire-quest-linux-x86_64.tar.gz solitaire-quest
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v5
|
||||||
|
with:
|
||||||
|
name: linux
|
||||||
|
path: solitaire-quest-linux-x86_64.tar.gz
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job 2: Android APK (multi-arch) — release-built and signed via cargo-apk
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build-android:
|
||||||
|
name: Build · Android APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust stable + Android targets
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Expose NDK root to cargo-apk
|
||||||
|
# ANDROID_NDK_LATEST_HOME is set by the GitHub-hosted runner.
|
||||||
|
# cargo-apk reads ANDROID_NDK_ROOT; write it to GITHUB_ENV so
|
||||||
|
# all subsequent steps in this job inherit it.
|
||||||
|
run: echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache cargo registry + cargo-apk binary + build artifacts
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
~/.cargo/bin
|
||||||
|
target
|
||||||
|
key: android-release-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: android-release-
|
||||||
|
|
||||||
|
- name: Install cargo-apk
|
||||||
|
# --locked: use the dependency versions cargo-apk was tested with.
|
||||||
|
# cargo install is a no-op when the cached binary is already current.
|
||||||
|
run: cargo install --locked cargo-apk
|
||||||
|
|
||||||
|
- name: Inject release signing config
|
||||||
|
# cargo-apk --release requires [package.metadata.android.signing.release]
|
||||||
|
# in solitaire_app/Cargo.toml. Appended at CI time so secrets never
|
||||||
|
# live in the repo. printf keeps every line inside the YAML run block,
|
||||||
|
# avoiding the YAML parse error a heredoc with column-0 content causes.
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > release.keystore
|
||||||
|
{
|
||||||
|
printf '\n[package.metadata.android.signing.release]\n'
|
||||||
|
printf 'path = "%s"\n' "${GITHUB_WORKSPACE}/release.keystore"
|
||||||
|
printf 'keystore_password = "%s"\n' "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
printf 'key_alias = "%s"\n' "$ANDROID_KEY_ALIAS"
|
||||||
|
printf 'key_password = "%s"\n' "$ANDROID_KEY_PASSWORD"
|
||||||
|
} >> solitaire_app/Cargo.toml
|
||||||
|
|
||||||
|
- name: Build and sign APK (release profile)
|
||||||
|
# `--lib` scopes cargo-apk to the cdylib target only.
|
||||||
|
# Without it, cargo-apk panics post-sign with
|
||||||
|
# "Bin is not compatible with Cdylib" (cargo-subcommand
|
||||||
|
# artifact iteration walks the bin target after the
|
||||||
|
# cdylib APK is already produced). See SESSION_HANDOFF.md
|
||||||
|
# "Cosmetic cargo apk build --lib workaround."
|
||||||
|
run: cargo apk build -p solitaire_app --lib --release
|
||||||
|
|
||||||
|
- name: Stage APK for upload
|
||||||
|
run: |
|
||||||
|
cp target/release/apk/solitaire-quest.apk \
|
||||||
|
"solitaire-quest-${{ github.ref_name }}.apk"
|
||||||
|
rm release.keystore
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v5
|
||||||
|
with:
|
||||||
|
name: android
|
||||||
|
path: solitaire-quest-${{ github.ref_name }}.apk
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job 3: Create the GitHub Release once both builds succeed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
release:
|
||||||
|
name: Publish GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-linux, build-android]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: linux
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: android
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release create "${{ github.ref_name }}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "Solitaire Quest ${{ github.ref_name }}" \
|
||||||
|
--generate-notes \
|
||||||
|
"solitaire-quest-linux-x86_64.tar.gz" \
|
||||||
|
"solitaire-quest-${{ github.ref_name }}.apk"
|
||||||
@@ -7,3 +7,6 @@
|
|||||||
*.tmp
|
*.tmp
|
||||||
data/
|
data/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# IDE project files
|
||||||
|
.idea/
|
||||||
|
|||||||
+682
-2
@@ -6,8 +6,688 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
No threads in flight. v0.21.2 cut on 2026-05-08; CHANGELOG accumulates
|
## [0.22.0] — 2026-05-08
|
||||||
the next cycle here.
|
|
||||||
|
Adds difficulty-tier game selection, Android JNI bridges for keystore and
|
||||||
|
clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens.
|
||||||
|
Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Difficulty-tier game mode** (this release).
|
||||||
|
`DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster /
|
||||||
|
Random`) added to `solitaire_core::game_state` alongside a new
|
||||||
|
`GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed
|
||||||
|
catalogs (40 seeds each, 200 total) are generated by the new
|
||||||
|
`gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog
|
||||||
|
contains seeds proven winnable at progressively larger solver budgets
|
||||||
|
(1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent`
|
||||||
|
→ catalog seed → `NewGameRequestEvent`; the `Random` tier uses a
|
||||||
|
system-time seed and intentionally bypasses the winnable-only filter.
|
||||||
|
The home overlay gains an expandable `▶ Difficulty` section between the
|
||||||
|
Draw Mode row and the mode-card grid; the last-played tier is persisted
|
||||||
|
in `Settings::last_difficulty` and pre-expands/highlights on re-open.
|
||||||
|
Difficulty wins pool into Classic stats (no separate buckets).
|
||||||
|
- **Prev/Next replay selector in the Stats overlay** (`a449f60`).
|
||||||
|
`ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and
|
||||||
|
`ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen`
|
||||||
|
as a flex row of two bordered chips flanking a `"Replay N / M"`
|
||||||
|
caption, with a detail line below showing the selected replay's
|
||||||
|
duration + date and an optional `"· Shareable"` badge. Both chips
|
||||||
|
carry `ModalButton(Secondary)` so the existing `repaint_modal_buttons`
|
||||||
|
paint loop gives them hover/press feedback at zero extra cost.
|
||||||
|
`repaint_replay_selector_detail` is wired into the existing
|
||||||
|
`.chain()` alongside `handle_replay_selector_buttons` and
|
||||||
|
`repaint_replay_selector_caption`. The click handler and repaint
|
||||||
|
systems have been registered (and dormant) since v0.19.0; this
|
||||||
|
commit is purely the missing spawn site.
|
||||||
|
- **6 new selector unit tests** (`a449f60`). Covers: spawn-site
|
||||||
|
presence (Prev, Next, Caption, Detail all spawn with the screen),
|
||||||
|
caption initial text ("Replay 1 / 1"), detail initial text
|
||||||
|
("{dur} win on {date}"), Shareable badge when `share_url` is set,
|
||||||
|
empty-history "No replays" caption, and ordinal wrapping.
|
||||||
|
`make_test_replay(time_seconds, share_url)` helper encapsulates
|
||||||
|
`Replay::new(...)` + `chrono::NaiveDate`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`const { assert!() }` for dim-layer z-order test** (`a449f60`).
|
||||||
|
Converted `assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)` in
|
||||||
|
`replay_overlay` tests to `const { assert!(…) }` to satisfy
|
||||||
|
`clippy::assertions_on_constants` (constant-fold at compile time
|
||||||
|
rather than a runtime no-op).
|
||||||
|
|
||||||
|
### Added (post-cut, same pending release)
|
||||||
|
|
||||||
|
- **Double-tap auto-move on touch screens** (`395a322`).
|
||||||
|
`handle_double_tap` fires `MoveRequestEvent` (single card to
|
||||||
|
foundation/tableau, or a whole face-up stack via
|
||||||
|
`best_tableau_destination_for_stack`) when two `TouchPhase::Ended`
|
||||||
|
events on the same card arrive within `DOUBLE_TAP_WINDOW` (0.5 s,
|
||||||
|
slightly wider than the mouse `DOUBLE_CLICK_WINDOW` to account for
|
||||||
|
touch latency). If no legal destination exists, fires
|
||||||
|
`MoveRejectedEvent` (audio + visual rejection feedback). The system
|
||||||
|
is inserted into the touch drag chain immediately before
|
||||||
|
`touch_end_drag` so `DragState.active_touch_id` and `committed` are
|
||||||
|
still readable; the tap timestamp is tracked in a `Local<HashMap<u32,
|
||||||
|
f32>>` keyed by card ID.
|
||||||
|
- **Play-by-Seed dialog** (`0cb1587`).
|
||||||
|
`PlayBySeedPlugin` adds a numeric-input modal that accepts a decimal
|
||||||
|
seed, runs a solver preview in the background (debounced 500 ms via
|
||||||
|
`AsyncComputeTaskPool`), and shows a win/no-win verdict before
|
||||||
|
dealing. A new `HomeMode::PlayBySeed` card in the home overlay fires
|
||||||
|
`StartPlayBySeedRequestEvent`; the handler in `PlayBySeedPlugin`
|
||||||
|
spawns the dialog. Digit, Backspace, Enter (confirm), and Escape
|
||||||
|
(cancel) are handled via `ButtonInput<KeyCode>`. Five unit tests
|
||||||
|
cover spawn, digit append, buffer read, confirm, and cancel paths.
|
||||||
|
- **75 new challenge seeds** (`2062bd0`).
|
||||||
|
New `gen_seeds` binary in `solitaire_assetgen` brute-searches seeds
|
||||||
|
in the `0xCAFEBABE…` namespace and filters for hands solvable in
|
||||||
|
≤250 moves via the core solver. The 75 confirmed-win seeds are
|
||||||
|
appended to `CHALLENGE_SEEDS` in `solitaire_data::challenge`.
|
||||||
|
|
||||||
|
### Fixed (post-cut, same pending release)
|
||||||
|
|
||||||
|
- **Gate `handle_fullscreen` to non-Android** (`45436d0`).
|
||||||
|
F11 fullscreen toggle makes no sense on Android (the OS owns window
|
||||||
|
sizing); the fn and its `MonitorSelection`/`WindowMode` imports are
|
||||||
|
now `#[cfg(not(target_os = "android"))]`-gated. The `add_systems`
|
||||||
|
call is extracted as a separate statement so `#[cfg]` can annotate it
|
||||||
|
(attributes cannot appear mid-chain in Rust).
|
||||||
|
- **Android APK launch: export `android_main`** (`202a64d`).
|
||||||
|
`NativeActivity` dlopen-s `libsolitaire_app.so` and calls
|
||||||
|
`android_main` as its entry point. Without the symbol the app
|
||||||
|
crashed immediately with `UnsatisfiedLinkError`. The new function
|
||||||
|
sets `bevy::android::ANDROID_APP` (required by `WinitPlugin`) then
|
||||||
|
delegates to `run()` — equivalent to what `#[bevy_main]` would
|
||||||
|
generate, but usable on an arbitrary entry point name.
|
||||||
|
- **Android APK launch: gate `resize_constraints` to non-Android**
|
||||||
|
(`202a64d`). On Android `max_width/max_height` default to `0.0`;
|
||||||
|
Bevy's clamp panicked with `min=800 > max=0`.
|
||||||
|
- **Android APK launch: gate `apply_smart_default_window_size` to
|
||||||
|
non-Android** (`202a64d`). The system calls `.clamp(800.0,
|
||||||
|
logical_w)` which panics when the emulator reports zero window
|
||||||
|
dimensions during early Android lifecycle events. The OS controls
|
||||||
|
window size on Android; the system is irrelevant there.
|
||||||
|
- **Ignore `.idea/` IDE project files** (`16242e6`). Android Studio
|
||||||
|
created `.idea/` when the project was opened during APK
|
||||||
|
verification; added to `.gitignore` and removed the accidentally-
|
||||||
|
committed files.
|
||||||
|
|
||||||
|
### Android verification result
|
||||||
|
|
||||||
|
APK boots on `x86_64-linux-android` in a Pixel_7 AVD (Android 14 /
|
||||||
|
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
|
||||||
|
Bevy renderer initialises, splash screen loads. This is the first
|
||||||
|
confirmed end-to-end device run.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- Tests: **1300+ passing** / 0 failing
|
||||||
|
- Clippy: clean
|
||||||
|
- Crates touched: `solitaire_core` (game_state), `solitaire_data`
|
||||||
|
(settings, stats, difficulty_seeds, challenge), `solitaire_engine`
|
||||||
|
(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin,
|
||||||
|
input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs),
|
||||||
|
`solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries)
|
||||||
|
|
||||||
|
## [0.21.8] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for replay-overlay polish. Through-line:
|
||||||
|
**notch-label centering + WIN MOVE HC legibility + HC system extension**.
|
||||||
|
All three items were "optional polish" flagged in the v0.21.7 handoff;
|
||||||
|
all three ship in two commits.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`STATE_SUCCESS_HC` constant** (`c50eaf8`). Brighter lime
|
||||||
|
(`#c8e862`, L≈0.73) in `ui_theme` for use wherever the
|
||||||
|
standard `STATE_SUCCESS` (`#acc267`, L≈0.51) needs extra
|
||||||
|
luminance under HC mode. Sits above the bumped notch ticks
|
||||||
|
(`BORDER_SUBTLE_HC` gray, L≈0.60) so a WIN MOVE marker at
|
||||||
|
this colour is unambiguous.
|
||||||
|
- **`HighContrastBackground::with_hc(default, hc)` constructor**
|
||||||
|
(`c50eaf8`). Extends `HighContrastBackground` with an
|
||||||
|
`hc_color: Color` field (default = `BORDER_SUBTLE_HC` via
|
||||||
|
`with_default()`). `update_high_contrast_backgrounds` now
|
||||||
|
reads `marker.hc_color` instead of the hardcoded constant —
|
||||||
|
backwards-compatible; all existing `with_default()` usages
|
||||||
|
continue to bump to gray.
|
||||||
|
- **WIN MOVE scrub-bar marker HC bump** (`c50eaf8`). Marker
|
||||||
|
now carries `HighContrastBackground::with_hc(STATE_SUCCESS,
|
||||||
|
STATE_SUCCESS_HC)` so the lime stays lime under HC (brighter
|
||||||
|
lime rather than gray). Pin test locks both the default and
|
||||||
|
HC colour fields on the spawned entity.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Scrub-bar notch-label centering** (`b44d277`). Middle
|
||||||
|
three labels ("25%", "50%", "75%") previously had their
|
||||||
|
left edge at the notch; now their text centre coincides
|
||||||
|
with the notch tick. Implemented using the CSS
|
||||||
|
`translateX(-50%)` pattern for Bevy 0.18 UI: a fixed
|
||||||
|
`SCRUB_LABEL_CENTER_WIDTH = 36 px` container with
|
||||||
|
`margin.left = -18 px` is placed at `left: Percent(pct)`,
|
||||||
|
and `Justify::Center` centres the text within it. Endpoint
|
||||||
|
labels ("0%", "100%") keep their flush-left / flush-right
|
||||||
|
anchoring. `with_default()` remains one-argument.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- Tests: 1276 passing / 0 failing (engine: 831)
|
||||||
|
- Clippy: clean
|
||||||
|
- Crates touched: `solitaire_engine` (replay_overlay.rs,
|
||||||
|
ui_theme.rs, settings_plugin.rs)
|
||||||
|
|
||||||
|
## [0.21.7] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release closing the last major B-2 sub-piece. Through-line:
|
||||||
|
**mini-tableau preview dim layer**. The mockup's "Game Peek Band at
|
||||||
|
50 % opacity" is now implemented as a full-screen UI scrim that darkens
|
||||||
|
the card world during replay so the chrome (banner + move-log panel)
|
||||||
|
reads clearly against the scene.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Full-screen tableau dim layer** (`da3e542`). Spawns a
|
||||||
|
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
|
||||||
|
black) at `Z_REPLAY_DIM = Z_REPLAY_OVERLAY − 1 = 54` whenever
|
||||||
|
a replay starts; despawned alongside the banner and move-log
|
||||||
|
panel when the replay ends. Bevy's UI/world compositor means
|
||||||
|
no changes to `card_plugin` are needed — UI nodes always
|
||||||
|
render above world-space sprites regardless of `Transform.z`.
|
||||||
|
The dim layer carries no `Interaction` component (purely
|
||||||
|
visual; pointer events pass through). Adds `Z_REPLAY_DIM`
|
||||||
|
and `TABLEAU_DIM_ALPHA` constants plus two new tests:
|
||||||
|
lifecycle (spawn/despawn mirrors the floating-chip pattern)
|
||||||
|
and z-ordering invariant (`Z_REPLAY_DIM < Z_REPLAY_OVERLAY`
|
||||||
|
pinned). 1275 tests pass / 0 failing.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- Tests: 1275 passing / 0 failing
|
||||||
|
- Clippy: clean
|
||||||
|
- Crates touched: `solitaire_engine` (replay_overlay.rs)
|
||||||
|
|
||||||
|
## [0.21.6] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.5 work. Through-line:
|
||||||
|
**Move Log panel + scrub-UX polish**. v0.21.5 closed out the
|
||||||
|
keyboard-accelerator surface (Space / Esc / ← / →) and the
|
||||||
|
keybind footer; v0.21.6 builds on that with two parallel
|
||||||
|
threads — accessibility + scrub-on-hold polish for the v0.21.5
|
||||||
|
surfaces, plus a brand-new Move Log panel anchored to the
|
||||||
|
viewport's bottom edge that gives players a 5-row recent-and-
|
||||||
|
upcoming move history alongside the existing top-edge banner.
|
||||||
|
|
||||||
|
The Move Log panel is the first replay-overlay surface that
|
||||||
|
*isn't* attached to the banner — it lives at a separate screen
|
||||||
|
anchor (bottom: 0) with its own spawn/despawn lifecycle.
|
||||||
|
Establishes the pattern for "multi-anchor replay UI" that the
|
||||||
|
remaining B-2 sub-piece (mini-tableau preview) will inherit.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **HC-mode coverage for the scrub track + quarter-mark notch
|
||||||
|
ticks** (`d3cb1a5`). Adds parallel primitive
|
||||||
|
`HighContrastBackground` to `ui_theme` and a paint system
|
||||||
|
`update_high_contrast_backgrounds` in `settings_plugin` that
|
||||||
|
mirrors the existing border-marker pattern but targets
|
||||||
|
`BackgroundColor` instead of `BorderColor`. Tags the 1 px
|
||||||
|
scrub track Node and all five quarter-mark notch ticks so
|
||||||
|
they bump from `BORDER_SUBTLE` (`#505050`) →
|
||||||
|
`BORDER_SUBTLE_HC` (`#a0a0a0`) under HC mode. Scrub fill
|
||||||
|
(`ACCENT_PRIMARY`) and WIN MOVE marker (`STATE_SUCCESS`)
|
||||||
|
don't get the marker — accent and state colours are already
|
||||||
|
saturated and don't need an HC luminance variant.
|
||||||
|
- **Continuous scrub on key-held arrow keys** (`2e25476`).
|
||||||
|
Holding ← or → triggers continuous step at 100 ms cadence
|
||||||
|
(10 steps/sec) — matches the mockup's `[← →] scrub`
|
||||||
|
terminology while keeping single-press = single-step
|
||||||
|
semantics. Per-key accumulators in a new
|
||||||
|
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
|
||||||
|
the accumulator and fire immediately. Release resets to 0
|
||||||
|
so the next fresh press fires immediately rather than at
|
||||||
|
half-interval.
|
||||||
|
- **Move Log panel** (`d6f32d3` + `140251b` + `e7345ae` +
|
||||||
|
`4437a1a`). New bottom-edge UI panel showing a 5-row window
|
||||||
|
onto recent + upcoming moves: 2 prev rows above the active
|
||||||
|
row + active row highlighted in `ACCENT_PRIMARY` + 2 next
|
||||||
|
rows below. Header reads `▌ MOVE LOG · N/M` (or
|
||||||
|
`▌ MOVE LOG · COMPLETE` when finished). Active row carries
|
||||||
|
a `▶` focus prefix and `TEXT_PRIMARY_HC` text colour for
|
||||||
|
legible contrast against the brick-red highlight. Prev /
|
||||||
|
next rows render in `TEXT_SECONDARY` so the active row
|
||||||
|
stays the focal point.
|
||||||
|
- Sibling-of-banner pattern (separate root entity anchored
|
||||||
|
at viewport bottom, not a banner child) — same
|
||||||
|
spawn/despawn lifecycle as `ReplayFloatingProgressChip`,
|
||||||
|
different screen anchor.
|
||||||
|
- Five pure helpers handle the formatting:
|
||||||
|
`format_pile`, `format_move_body`,
|
||||||
|
`format_move_log_header`, `format_kth_recent_row` (active
|
||||||
|
+ prev), `format_kth_next_row` (next). 1-indexed display
|
||||||
|
numbers throughout (`Foundation(2)` reads as "foundation
|
||||||
|
3" rather than the enum's 0-index).
|
||||||
|
- Panel grows from 56 → 84 → 112 px across the four
|
||||||
|
move-log commits. `MOVE_LOG_PREV_ROWS` and
|
||||||
|
`MOVE_LOG_NEXT_ROWS` constants (both = 2) parameterise
|
||||||
|
the row count; `format_kth_recent_row` and
|
||||||
|
`format_kth_next_row` return empty for out-of-range k so
|
||||||
|
panels gracefully under-fill at the start (cursor=1) and
|
||||||
|
end (cursor=N-1) of a replay.
|
||||||
|
- HC marker on the panel's top border so the 1 px edge
|
||||||
|
bumps under HC mode (same pattern as the keybind footer).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`react_to_state_change` despawns the Move Log panel** on
|
||||||
|
`Playing → Inactive` alongside the banner root and floating
|
||||||
|
progress chip. Third query in the same defer-and-despawn
|
||||||
|
cycle.
|
||||||
|
- **Move Log panel height grew 56 → 84 → 112 px** across the
|
||||||
|
prev-rows and next-rows commits. The panel is sized to fit
|
||||||
|
the chosen row count + header + padding; tunable via the
|
||||||
|
`MOVE_LOG_PANEL_HEIGHT` const.
|
||||||
|
- **`format_active_move_row` now prefixes the `▶` focus
|
||||||
|
marker** (`e7345ae`). Wraps `format_kth_recent_row(state, 1)`
|
||||||
|
and prepends the prefix when the body is non-empty. Empty
|
||||||
|
case still returns empty — cursor=0 doesn't paint a stray
|
||||||
|
`▶` on an otherwise-empty row.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed twice this cycle — once
|
||||||
|
recording the HC paint + continuous-scrub polish, then
|
||||||
|
again as the Move Log arc shipped commit-by-commit. The
|
||||||
|
Resume menu's B option now traces the full arc:
|
||||||
|
notches → labels → footer → ESC → HC → arrow keys →
|
||||||
|
HC paint → continuous scrub → move log.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1273 passing tests / 0 failing** across the workspace
|
||||||
|
(net +23 from v0.21.5's 1250 baseline):
|
||||||
|
- 2 from `d3cb1a5` (HC marker on track + notches).
|
||||||
|
- 2 from `2e25476` (continuous-scrub repeat-while-held +
|
||||||
|
release-resets-accumulator).
|
||||||
|
- 8 from `d6f32d3` (move-log panel init + 5 helpers + 3
|
||||||
|
spawn / lifecycle scenarios).
|
||||||
|
- 4 from `140251b` (prev rows: helper k coverage + spawn
|
||||||
|
cardinality + spawn texts + repaint on cursor advance).
|
||||||
|
- 3 from `e7345ae` (active row highlight: wrapper bg +
|
||||||
|
text colour + focus prefix + cursor=0 stays empty).
|
||||||
|
- 4 from `4437a1a` (next rows: helper k coverage + spawn
|
||||||
|
cardinality + spawn texts + under-fill at replay end).
|
||||||
|
- Clippy clean across the workspace.
|
||||||
|
|
||||||
|
## [0.21.5] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.4 work. One through-line:
|
||||||
|
**replay-overlay scrubbing affordances + accessibility**. v0.21.4
|
||||||
|
shipped pause / resume / step + the WIN MOVE marker as the first
|
||||||
|
*scrubbing-shaped* additions to the replay overlay; v0.21.5
|
||||||
|
fills out the rest of the scrubbing UX so the player has both
|
||||||
|
visual anchor points (notches + labels) and a complete keyboard
|
||||||
|
control surface (Space / Esc / ← / →) for navigating a paused
|
||||||
|
replay.
|
||||||
|
|
||||||
|
Two of the six commits in this cycle are layout-changing — they
|
||||||
|
grow the banner height from 60 px → 76 px → 92 px to make room
|
||||||
|
for the notch labels and keybind footer. Banner geometry was
|
||||||
|
fixed for every prior B-2 commit; this release establishes the
|
||||||
|
"grow the container, add a flex-column child" pattern that the
|
||||||
|
remaining B-2 sub-pieces (move-log scroller, mini-tableau
|
||||||
|
preview) will inherit when they land.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Quarter-mark scrub-bar notches** (`fe68861`). Five 1 px
|
||||||
|
vertical ticks at 0 / 25 / 50 / 75 / 100 % give the player
|
||||||
|
visual anchor points without needing to mentally bisect the
|
||||||
|
bar. Pure helper `scrub_notch_positions()` returns the fixed
|
||||||
|
array; spawn loop sits next to the WIN MOVE marker spawn so
|
||||||
|
the lifecycles match. Notches paint in `BORDER_SUBTLE` (same
|
||||||
|
as the unfilled track) and rely on extending past the 1 px
|
||||||
|
track (5 px tall, anchored 2 px above the track top) for
|
||||||
|
visibility — same trick the WIN MOVE marker uses. Spawned
|
||||||
|
*after* the WIN MOVE marker so a notch and the marker
|
||||||
|
landing on the same percentage paint the marker on top.
|
||||||
|
- **Percentage labels under each notch** (`d322abf`). Five
|
||||||
|
`0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px
|
||||||
|
row beneath the 1 px scrub track give the player explicit
|
||||||
|
quarter-mark readouts. Banner grew from 60 → 76 px to
|
||||||
|
accommodate the row — first **layout-changing** commit in
|
||||||
|
the B-2 arc. Pure helper `scrub_notch_labels()` returns the
|
||||||
|
fixed array, paired index-for-index with
|
||||||
|
`scrub_notch_positions()`. Spawn loop applies an "endpoints
|
||||||
|
flush, middle three percent-anchored" positioning pattern:
|
||||||
|
leftmost label gets `left: 0`, rightmost gets `right: 0`,
|
||||||
|
middle three anchor at `left: Val::Percent(p)` since Bevy
|
||||||
|
0.18 UI lacks a clean CSS-style `translate-x: -50%`
|
||||||
|
centering primitive. Label colour is `TEXT_SECONDARY`
|
||||||
|
rather than the mockup's `BORDER_SUBTLE` (the latter would
|
||||||
|
match the notches but is too low-contrast against
|
||||||
|
`BG_ELEVATED_HI` to read at 12 px).
|
||||||
|
- **Keybind-hint footer** (`1873b3f`). Vim-style mode line on
|
||||||
|
the left (`▌ NORMAL │ replay`) plus a keybind hint on the
|
||||||
|
right at the bottom edge of the banner. Banner grew from
|
||||||
|
76 → 92 px to fit the 16 px footer row. Surfaces every
|
||||||
|
wired keyboard accelerator visually so CLAUDE.md §3.3's
|
||||||
|
UI-first contract holds for keyboard accelerators too. The
|
||||||
|
footer lists *only* keybinds that are actually wired —
|
||||||
|
the only-wired-keybinds discipline means each release
|
||||||
|
cycle's hint string is a precise honest contract with the
|
||||||
|
player. Two pure helpers (`keybind_footer_mode_text`,
|
||||||
|
`keybind_footer_hint_text`) keep the static text testable.
|
||||||
|
1 px top border in `BORDER_SUBTLE` separates the footer
|
||||||
|
from the labels row.
|
||||||
|
- **ESC keyboard accelerator for replay-stop** (`90e24d9`).
|
||||||
|
New `handle_stop_keyboard` system parallels
|
||||||
|
`handle_pause_keyboard` in shape — fires only when state
|
||||||
|
is `Playing`, calls `stop_replay_playback`. Cross-plugin
|
||||||
|
coordination via `pause_plugin::toggle_pause`: added a
|
||||||
|
fourth defer-if check
|
||||||
|
(`replay_state.is_some_and(|s| s.is_playing())`) right
|
||||||
|
after the existing `other_modal_scrims` check so ESC
|
||||||
|
during active replay belongs to the replay overlay, not
|
||||||
|
the pause modal.
|
||||||
|
- **HC-mode coverage for the keybind-footer top border**
|
||||||
|
(`23902cd`).
|
||||||
|
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
|
||||||
|
on the footer's border-carrying Node so the existing
|
||||||
|
`apply_high_contrast_borders` system bumps the 1 px top
|
||||||
|
border from `#505050` → `#a0a0a0` when
|
||||||
|
`Settings::high_contrast_mode` is on. Without the marker
|
||||||
|
the footer reads as floating loose under HC because the
|
||||||
|
border that anchors it to the labels row is
|
||||||
|
near-invisible.
|
||||||
|
- **← / → keyboard accelerators for paused stepping**
|
||||||
|
(`e5c4f51`). New `step_backwards_replay_playback` in
|
||||||
|
`replay_playback.rs` decrements the cursor and dispatches
|
||||||
|
`UndoRequestEvent`; the game's `handle_undo` reads it
|
||||||
|
next frame to reverse its most-recent move. Hooks the
|
||||||
|
existing undo system rather than replaying-forward-from-
|
||||||
|
zero — every replay-applied move pushes to the undo stack
|
||||||
|
the same way a player move would, so undo is the right
|
||||||
|
reversal primitive. Both arrow keys are paused-only via
|
||||||
|
the same destructure-gate pattern the forward step uses.
|
||||||
|
The mockup labels these `[← →] scrub`; single-move step
|
||||||
|
is the closest behaviour shippable today, so the footer
|
||||||
|
hint reads `[← →] step` — only-wired-keybinds discipline.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Banner height grew 60 → 76 → 92 px** across two
|
||||||
|
layout-changing commits (`d322abf` then `1873b3f`). Top
|
||||||
|
row's `flex_grow: 1.0` still consumes 59 px so the
|
||||||
|
existing content (label / progress chip / buttons) has
|
||||||
|
the same vertical space; the new rows (16 px labels +
|
||||||
|
16 px footer) extend the banner downward into the
|
||||||
|
gameplay area. Banner geometry is now mutable — every
|
||||||
|
prior B-2 commit fit inside fixed 60 px space.
|
||||||
|
- **Keybind-footer hint text grew alongside the wirings**:
|
||||||
|
`[SPACE] pause/resume` →
|
||||||
|
`[SPACE] pause/resume · [ESC] stop` →
|
||||||
|
`[SPACE] pause/resume · [ESC] stop · [← →] step`.
|
||||||
|
- **`pause_plugin::toggle_pause` now defers when a replay
|
||||||
|
is active** (`90e24d9`). Adds a fourth defer-if check to
|
||||||
|
the existing modal-stack pattern.
|
||||||
|
- **`ReplayOverlayPlugin` registers
|
||||||
|
`add_message::<UndoRequestEvent>()`** (`e5c4f51`).
|
||||||
|
Defensive registration so the plugin runs cleanly under
|
||||||
|
`MinimalPlugins` without `GamePlugin` attached.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed five times this cycle.
|
||||||
|
The B option in the Resume menu now traces the full arc:
|
||||||
|
notches → labels → footer → ESC → HC → arrow keys.
|
||||||
|
- The pre-existing `daily_challenge` warning test that
|
||||||
|
fails when wall-clock UTC is within 30 minutes of
|
||||||
|
midnight is documented in this cycle's handoff. Same
|
||||||
|
shape as the earlier `winnable_seed_search` flake —
|
||||||
|
time-dependent, deterministically passes outside the
|
||||||
|
trigger window.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1250 total tests / 1249 passing / 1 pre-existing
|
||||||
|
time-dependent flake** across the workspace (net +22 from
|
||||||
|
v0.21.4's 1228 baseline):
|
||||||
|
- 4 from `fe68861` (scrub-notch coverage)
|
||||||
|
- 4 from `d322abf` (notch-label coverage)
|
||||||
|
- 4 from `1873b3f` (keybind-footer coverage)
|
||||||
|
- 3 from `90e24d9` (ESC-accelerator coverage)
|
||||||
|
- 1 from `23902cd` (HC-marker coverage)
|
||||||
|
- 6 from `e5c4f51` (arrow-keyboard coverage)
|
||||||
|
- **Pre-existing flake**:
|
||||||
|
`daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_day`
|
||||||
|
fails when wall-clock UTC is within 30 minutes of
|
||||||
|
midnight. Verified pre-existing by stash-and-retest
|
||||||
|
before each commit. Will pass deterministically outside
|
||||||
|
the trigger window. Not introduced by this release.
|
||||||
|
- Clippy clean across the workspace.
|
||||||
|
|
||||||
|
## [0.21.4] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.3 work. One through-line:
|
||||||
|
**replay-scrubbing accessibility**. The replay overlay used to be
|
||||||
|
pure-passive — the player started a replay, watched it execute,
|
||||||
|
and waited for it to end. v0.21.4 adds the scaffolding for
|
||||||
|
*navigating within* a replay: a WIN MOVE marker on the scrub bar
|
||||||
|
so the player can see at a glance where the winning move sits,
|
||||||
|
and pause / resume / step controls so they can stop on any move
|
||||||
|
and inspect the board.
|
||||||
|
|
||||||
|
The work is also the first three commits on the B-2 replay
|
||||||
|
screen-takeover redesign arc. The remaining pieces (screen-
|
||||||
|
takeover layout, move-log scroller, mini-tableau preview) are
|
||||||
|
deferred to a future cycle because they need a layout reflow
|
||||||
|
that the existing banner-only overlay can't carry.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`Replay::win_move_index: Option<usize>` data field**
|
||||||
|
(`ab857bb`). Additive optional field on the persisted
|
||||||
|
`Replay` shape. `#[serde(default)]` keeps older
|
||||||
|
`latest_replay.json` / `replays.json` files loadable without
|
||||||
|
bumping `REPLAY_SCHEMA_VERSION` — this is purely additive.
|
||||||
|
Populated at the live recording site
|
||||||
|
(`game_plugin::handle_game_won`) via a new builder-style
|
||||||
|
setter `Replay::with_win_move_index`. For fresh recordings
|
||||||
|
the value is always `Some(moves.len() - 1)` because recording
|
||||||
|
freezes on win, but storing it explicitly lets the playback
|
||||||
|
UI read the WIN MOVE position directly without re-deriving
|
||||||
|
on every render.
|
||||||
|
- **WIN MOVE scrub-bar marker** (`52befa6`). New
|
||||||
|
`ReplayOverlayWinMoveMarker` component spawned as a sibling
|
||||||
|
to `ReplayOverlayScrubFill` under the 1px scrub track,
|
||||||
|
absolute-positioned at `replay.win_move_index / total %` of
|
||||||
|
the bar. Painted in `STATE_SUCCESS` (green) so the marker
|
||||||
|
reads as "this is where the win lives." Pure helper
|
||||||
|
`win_move_marker_pct` returns `None` for any state where the
|
||||||
|
marker shouldn't draw (Inactive, Completed, replay missing
|
||||||
|
the field, empty move list); percentage clamps to `[0, 100]`
|
||||||
|
defensively. Spawn-time only — the position never changes
|
||||||
|
during a single playback because the underlying `Replay` is
|
||||||
|
immutable while `Playing`.
|
||||||
|
- **Pause / Resume / Step playback controls** (`fbe48ac`). New
|
||||||
|
`paused: bool` field on `ReplayPlaybackState::Playing`.
|
||||||
|
`tick_replay_playback` skips the `secs_to_next` decrement
|
||||||
|
entirely while paused so cursor and timer freeze together;
|
||||||
|
resuming starts the next move from a full interval. New
|
||||||
|
public API: `toggle_pause_replay_playback` and
|
||||||
|
`step_replay_playback` (the latter hard-gated to `Playing {
|
||||||
|
paused: true }` via the destructure pattern itself, so
|
||||||
|
manual stepping can't race the tick loop). On-screen Pause
|
||||||
|
and Step buttons sit alongside the existing Stop button;
|
||||||
|
`Space` keyboard accelerator toggles pause / resume.
|
||||||
|
- **`Replay::with_win_move_index` builder** (`ab857bb`).
|
||||||
|
Chainable setter so the recording site can write
|
||||||
|
`Replay::new(...).with_win_move_index(idx)`. Keeps
|
||||||
|
`Replay::new`'s signature stable across the 13+ existing
|
||||||
|
test-fixture call sites that don't care about the field.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`Replay::new` writes `win_move_index: None`** (`ab857bb`).
|
||||||
|
Existing canonical constructor stays signature-compatible
|
||||||
|
with all existing callers. The field is opt-in via the
|
||||||
|
builder.
|
||||||
|
- **`game_plugin::handle_game_won` populates the new field**
|
||||||
|
(`ab857bb`). The recording site computes
|
||||||
|
`recording.moves.len().checked_sub(1)` as the win-move
|
||||||
|
index. `checked_sub` rather than direct subtraction guards
|
||||||
|
the unreachable empty-recording branch (which is also
|
||||||
|
guarded earlier in the function).
|
||||||
|
- **`tick_replay_playback` honors the new `paused` flag**
|
||||||
|
(`fbe48ac`). Skipping the timer decrement is the only
|
||||||
|
behavior change; the loop body and Completed-detection are
|
||||||
|
unchanged. Stepping fires moves directly via
|
||||||
|
`step_replay_playback`, bypassing the tick path entirely.
|
||||||
|
- **Pause / Resume button label is reactive** (`fbe48ac`).
|
||||||
|
`update_pause_button_label` walks `Children` from the
|
||||||
|
marked button to its inner `Text` and repaints the label
|
||||||
|
whenever `ReplayPlaybackState` changes. Pure helper
|
||||||
|
`pause_button_label` covers all four state arms (running,
|
||||||
|
paused, inactive, completed).
|
||||||
|
- **25 existing `Playing { ... }` construction sites gained
|
||||||
|
`paused: false`** (`fbe48ac`). Mechanical edit across
|
||||||
|
`replay_overlay`, `achievement_plugin`, and
|
||||||
|
`replay_playback` tests to satisfy the new field
|
||||||
|
requirement. No behavioral change.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed three times this cycle —
|
||||||
|
once after each post-cut feature commit. The B-2 entry in
|
||||||
|
the Visual-identity follow-ups list now points at the
|
||||||
|
remaining sub-pieces (screen-takeover layout, move-log
|
||||||
|
scroller, mini-tableau preview) as a single multi-session
|
||||||
|
arc rather than three independent ones, since they share a
|
||||||
|
layout-reflow prerequisite.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1228 passing tests / 0 failing** across the workspace
|
||||||
|
(net +21 from v0.21.3's 1207 baseline):
|
||||||
|
- 5 from `ab857bb`'s `win_move_index` coverage: default
|
||||||
|
constructor, builder set / set-None, on-disk round-trip,
|
||||||
|
legacy-JSON-loads-with-None backward-compat. The last
|
||||||
|
test pins the no-schema-bump claim — if a future refactor
|
||||||
|
drops the `#[serde(default)]`, that test catches it.
|
||||||
|
- 8 from `52befa6`'s WIN MOVE marker: pure-helper truth
|
||||||
|
table (Inactive / Completed / no-field / correct-position
|
||||||
|
/ clamp) + spawn-presence-with-field /
|
||||||
|
spawn-absence-without / despawn-with-overlay observables.
|
||||||
|
- 8 from `fbe48ac`'s playback controls: label truth table,
|
||||||
|
label repaint on state change, click-toggles-paused,
|
||||||
|
step advances cursor by exactly one with paused
|
||||||
|
preserved, step-while-running no-op, Space toggles
|
||||||
|
paused.
|
||||||
|
- Zero clippy warnings under `cargo clippy --workspace
|
||||||
|
--all-targets -- -D warnings`.
|
||||||
|
- `cargo test --workspace` clean.
|
||||||
|
|
||||||
|
## [0.21.3] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.2 work. One through-line:
|
||||||
|
**accessibility arc closure**. v0.21.2 explicitly carved out
|
||||||
|
"dynamic-paint sites" (HUD action buttons, modal buttons, radial
|
||||||
|
menu rim) on the assumption that their existing paint cycles would
|
||||||
|
race the central `update_high_contrast_borders` system. v0.21.3
|
||||||
|
walks the actual code, finds the carve-out was over-cautious, and
|
||||||
|
closes it. Bonus: the first real consumer of `ToastVariant::Warning`
|
||||||
|
also lands here, making the `ToastVariant` enum fully load-bearing
|
||||||
|
(every variant has at least one driver).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`WarningToastEvent(String)` — first `ToastVariant::Warning`
|
||||||
|
consumer** (`279e23d`). Generic carrier message that any system
|
||||||
|
can fire to spawn a 4 s amber-bordered fire-and-forget toast.
|
||||||
|
Mirrors the v0.21.2 `MoveRejectedEvent` → `Error` toast wiring:
|
||||||
|
domain message crosses the plugin boundary, the animation
|
||||||
|
plugin's `handle_warning_toast` system reads it and spawns. Not
|
||||||
|
queued (Warning is alert-shaped, not info-shaped — should never
|
||||||
|
block on a queue).
|
||||||
|
- **Daily-challenge-expiry warning** (`279e23d`). First in-engine
|
||||||
|
driver of `WarningToastEvent`. New
|
||||||
|
`daily_challenge_plugin::check_daily_expiry_warning` system
|
||||||
|
fires at most once per `DailyChallengeResource::date` when the
|
||||||
|
player is within 30 min of UTC midnight reset and today's
|
||||||
|
challenge isn't yet complete. Suppression decided by a pure
|
||||||
|
helper (`compute_expiry_warning_minutes`) covering: already-
|
||||||
|
completed-today, already-shown-for-this-date, outside the
|
||||||
|
threshold window, post-midnight rollover. Pure-helper-plus-
|
||||||
|
thin-system shape because `Utc::now()` can't be pinned without
|
||||||
|
injecting a clock resource — overkill for one consumer.
|
||||||
|
- **`radial_rim_outline` pure helper** (`c153363`). Decision
|
||||||
|
logic for the radial-menu rim outline colour. Resting outlines
|
||||||
|
always carry `BORDER_SUBTLE`; focused outlines carry
|
||||||
|
`BORDER_STRONG` normally and `BORDER_SUBTLE_HC` under HC. Naive
|
||||||
|
marker substitution would invert the focused-vs-resting
|
||||||
|
hierarchy because `BORDER_SUBTLE_HC` (`#a0a0a0`) is *lighter*
|
||||||
|
than `BORDER_STRONG` (`#505050`); folding the choice in here
|
||||||
|
keeps the focused rim more visible under HC, not less.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **HC marker pattern extended to HUD action buttons + modal
|
||||||
|
buttons** (`c153363`). Re-reading the code revealed both sites'
|
||||||
|
paint systems (`paint_action_buttons`, `paint_modal_buttons`)
|
||||||
|
only mutate `BackgroundColor` — `BorderColor` is set once at
|
||||||
|
spawn and never touched. So the existing
|
||||||
|
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
|
||||||
|
pattern works cleanly for both, no race. v0.21.2's carve-out
|
||||||
|
comment was based on assumed-but-not-actual race risk; this
|
||||||
|
cycle treats it as the doc-vs-implementation drift pattern in
|
||||||
|
the wild and verifies before trusting.
|
||||||
|
- **Radial menu rim folds HC into per-frame respawn**
|
||||||
|
(`c153363`). The rim is the only true dynamic-painter of the
|
||||||
|
three carved-out sites — `radial_redraw_overlay` despawns and
|
||||||
|
respawns all rim sprites every frame the radial is `Active`.
|
||||||
|
The `HighContrastBorder` marker can't apply (entities don't
|
||||||
|
persist across frames) so HC is read directly in the system
|
||||||
|
via `Option<Res<SettingsResource>>` and routed through
|
||||||
|
`radial_rim_outline`. The `Option<Res<...>>` shape preserves
|
||||||
|
test compatibility under `MinimalPlugins`.
|
||||||
|
- **Animation plugin registers `WarningToastEvent`** (`279e23d`).
|
||||||
|
Joins `InfoToastEvent`, `MoveRejectedEvent` etc. in
|
||||||
|
`AnimationPlugin::build`. Daily-challenge plugin also
|
||||||
|
registers it (idempotent) so the message exists when running
|
||||||
|
the daily plugin under `MinimalPlugins` without the animation
|
||||||
|
plugin attached.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed twice this cycle — once after
|
||||||
|
the Toast Warning wiring (menu trimmed 5 → 4 options), and
|
||||||
|
again after the HC dynamic-paint rollout (menu trimmed 4 → 3,
|
||||||
|
with all remaining options now flagged as multi-session). The
|
||||||
|
`High-contrast accessibility mode` entry in the Visual-identity
|
||||||
|
follow-ups list is updated to reflect that no "un-tagged
|
||||||
|
because race-risk" surfaces remain.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1207 passing tests / 0 failing** across the workspace
|
||||||
|
(net +12 from v0.21.2's 1195 baseline):
|
||||||
|
- 7 tests for `compute_expiry_warning_minutes` (`279e23d`)
|
||||||
|
covering each suppression rule + the inclusive boundary at
|
||||||
|
exactly 30 min remaining.
|
||||||
|
- 1 in-Bevy test (`check_system_fires_warning_event_only_once_per_day`)
|
||||||
|
pinning `DailyExpiryWarningShown`'s once-per-date
|
||||||
|
suppression and the symmetric "already-completed-today"
|
||||||
|
suppression.
|
||||||
|
- 4 truth-table tests for `radial_rim_outline` (`c153363`):
|
||||||
|
focused × HC. The "resting stays subtle under HC" test
|
||||||
|
explicitly documents *why* — it's the hierarchy-preservation
|
||||||
|
invariant a future refactor might be tempted to break.
|
||||||
|
- Zero clippy warnings under `cargo clippy --workspace
|
||||||
|
--all-targets -- -D warnings`.
|
||||||
|
- `cargo test --workspace` clean.
|
||||||
|
|
||||||
## [0.21.2] — 2026-05-08
|
## [0.21.2] — 2026-05-08
|
||||||
|
|
||||||
|
|||||||
Generated
+4
@@ -6967,6 +6967,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"png 0.17.16",
|
"png 0.17.16",
|
||||||
|
"solitaire_core",
|
||||||
|
"solitaire_data",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6986,6 +6988,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jni 0.21.1",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -7009,6 +7012,7 @@ dependencies = [
|
|||||||
"bevy",
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jni 0.21.1",
|
||||||
"kira",
|
"kira",
|
||||||
"resvg",
|
"resvg",
|
||||||
"ron",
|
"ron",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ keyring = "4"
|
|||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
arboard = { version = "3", default-features = false }
|
arboard = { version = "3", default-features = false }
|
||||||
|
jni = { version = "0.21", default-features = false }
|
||||||
|
|
||||||
solitaire_core = { path = "solitaire_core" }
|
solitaire_core = { path = "solitaire_core" }
|
||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
|
|||||||
+165
-112
@@ -1,65 +1,98 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-08 — **v0.21.1 cut and tagged at `daa655a`**,
|
**Last updated:** 2026-05-08 — **v0.21.8 tagged at `c50eaf8`**;
|
||||||
working tree clean, all post-tag work pushed to origin.
|
nine post-cut commits on master. Push pending.
|
||||||
|
|
||||||
v0.21.1 is a patch release for the post-v0.21.0 work: closes
|
v0.21.8 closes the last optional polish items in the B-2
|
||||||
Resume-prompt Options A (app icon — runtime `Window::icon` plus
|
replay screen-takeover arc: **notch-label centering** (middle
|
||||||
the 9-size PNG hierarchy) and F (high-contrast + reduce-motion
|
three scrub-bar labels now centred on their notch ticks via the
|
||||||
accessibility modes — Settings flags wired through engine and
|
CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
|
||||||
UI). Plus a card-visual iteration cycle that moved through three
|
MOVE HC legibility** (lime stays lime under HC mode via the
|
||||||
states (v0.21.0 Terminal pink/gray → brief 4-colour-deck
|
extended `HighContrastBackground::with_hc` constructor and a
|
||||||
experiment → traditional 2-colour Microsoft-Solitaire-on-dark-mode
|
new `STATE_SUCCESS_HC` brighter-lime constant). The replay
|
||||||
red/near-white) and two visible-bug fixes (suit-coloured border
|
overlay arc is now fully closed with no known open items.
|
||||||
anti-aliasing artifact at rounded corners, pile-marker
|
|
||||||
bleed-through producing "gray L" shapes at occupied piles —
|
|
||||||
the latter implemented the previously-documented-but-not-enforced
|
|
||||||
"markers visible only at empty piles" invariant).
|
|
||||||
|
|
||||||
Full v0.21.1 detail lives in `CHANGELOG.md` § [0.21.1]. This
|
Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
|
||||||
file from here on focuses on what's *open* post-cut and how to
|
file from here on focuses on what's *open* post-cut and how to
|
||||||
resume.
|
resume.
|
||||||
|
|
||||||
## Status at pause
|
## Status at pause
|
||||||
|
|
||||||
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
|
- **HEAD locally:** `f281425` (Android Keystore JNI).
|
||||||
`daa655a`; any post-cut docs edits ride on top of that.
|
Docs ride on top; push pending.
|
||||||
- **HEAD on origin:** matches local. v0.21.1 is fully on origin.
|
- **HEAD on origin:** `395a322` (double-tap commit — last pushed).
|
||||||
- **Working tree:** clean. No WIP outstanding.
|
- **Working tree:** clean (docs uncommitted). No WIP outstanding.
|
||||||
- **`artwork/` directory:** still untracked. Intentional.
|
- **`artwork/` directory:** still untracked. Intentional.
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
clean.
|
clean.
|
||||||
- **Tests:** **1192 passing / 0 failing** across the workspace
|
- **Tests:** **1292 passing / 0 failing** across the workspace.
|
||||||
(net +8 from v0.21.0's 1184 baseline). Detail in
|
- **Tags on origin:** `v0.9.0` through `v0.21.8`.
|
||||||
`CHANGELOG.md` § [0.21.1] § Stats.
|
- **Android:** APK verified booting on Pixel_7 AVD (Android 14,
|
||||||
- **Tags on origin:** `v0.9.0` through `v0.21.1`. v0.21.1 is on
|
x86_64). All desktop-only systems (handle_fullscreen) now gated.
|
||||||
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
|
See Phase Android punch list for remaining work.
|
||||||
`41a009a`.
|
|
||||||
|
|
||||||
## Since the v0.21.1 cut
|
## Since the v0.21.8 cut
|
||||||
|
|
||||||
No threads in flight. Working tree clean as of 2026-05-08. New
|
Seven commits since the v0.21.8 tag:
|
||||||
work since the cut would land here as commit narratives; for
|
- `a449f60` — Stats Prev/Next selector spawn site
|
||||||
the v0.21.1 contents themselves, see `CHANGELOG.md` § [0.21.1].
|
- `202a64d` — Android launch fixes (android_main, resize_constraints,
|
||||||
|
apply_smart_default_window_size) — **closes APK launch verification**
|
||||||
|
- `16242e6` — Ignore .idea/ IDE files
|
||||||
|
- `395a322` — double-tap auto-move for touch input
|
||||||
|
- `0cb1587` — Play-by-Seed dialog + HomeMode card
|
||||||
|
- `2062bd0` — 75 new challenge seeds + gen_seeds binary
|
||||||
|
- `45436d0` — gate handle_fullscreen to non-Android
|
||||||
|
- `2c822ba` — JNI clipboard bridge for Android Stats share-link
|
||||||
|
- `f281425` — Android Keystore AES-GCM token storage via JNI
|
||||||
|
|
||||||
|
CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
|
||||||
|
|
||||||
|
Open next-step menu:
|
||||||
|
1. **Phase 8 (sync)** — the biggest open arc. Local storage
|
||||||
|
scaffolding, self-hosted Axum server, GPGS stub.
|
||||||
|
2. **Android follow-ups** — JNI ClipboardManager, Android Keystore,
|
||||||
|
GPGS. Launch verification and double-tap both closed; these
|
||||||
|
are the remaining Phase Android items.
|
||||||
|
3. **Move Log auto-scroll** — only relevant if the panel
|
||||||
|
row count grows beyond the current 5-row fixed window.
|
||||||
|
|
||||||
## Open punch list
|
## Open punch list
|
||||||
|
|
||||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
### Phase Android (build + persistence shipped; runtime gaps remain)
|
||||||
|
|
||||||
- **APK launch verification on AVD / device.** `adb install` then
|
- *APK launch verification — closed 2026-05-08 by `202a64d`.*
|
||||||
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
|
Three fixes shipped: `android_main` export (missing NativeActivity
|
||||||
The build works and persistence is wired, but no end-to-end
|
entry point), `resize_constraints` gated to non-Android (max=0
|
||||||
device run has been logged. Shakes out runtime bugs the build +
|
panic), `apply_smart_default_window_size` gated to non-Android
|
||||||
unit tests can't catch.
|
(clamp panic on zero-dimension window event). Verified booting on
|
||||||
- **JNI ClipboardManager bridge.** Replaces the Android stub for
|
Pixel_7 AVD (Android 14, x86_64, SwiftShader Vulkan), 2+ min
|
||||||
the Stats "Copy share link" toast. `arboard` doesn't ship an
|
runtime without crash. B0004 ECS hierarchy warnings remain
|
||||||
Android backend; small custom JNI call.
|
(non-fatal; entity parent/child component mismatch); investigate
|
||||||
- **Android Keystore for credentials.** `keyring` is target-gated
|
if they surface gameplay bugs.
|
||||||
to a stub returning `KeychainUnavailable`; replace with Android
|
- *Double-tap auto-move — closed 2026-05-08 by `395a322`.*
|
||||||
Keystore via JNI when sync auth ships on mobile.
|
`handle_double_tap` fires `MoveRequestEvent` on two rapid
|
||||||
- **Google Play Games (gpgs) integration.** Listed as a
|
`TouchPhase::Ended` events within 0.5 s. Prefers foundation;
|
||||||
Phase-Android target since Phase 1; now unblocked by the build
|
falls back to tableau stack move. Fires `MoveRejectedEvent` when
|
||||||
target.
|
no legal destination exists. System runs before `touch_end_drag`
|
||||||
|
in the chain so drag state is readable.
|
||||||
|
- *F11 fullscreen gate — closed 2026-05-08 by `45436d0`.*
|
||||||
|
`handle_fullscreen` and its `MonitorSelection`/`WindowMode`
|
||||||
|
imports are `#[cfg(not(target_os = "android"))]`-gated. The
|
||||||
|
`add_systems` call is a separate statement (not mid-chain).
|
||||||
|
- *JNI ClipboardManager bridge — closed 2026-05-08 by `2c822ba`.*
|
||||||
|
`android_clipboard::set_text(url)` calls `ClipboardManager` via
|
||||||
|
JNI. Stats share-link button now writes to the clipboard with a
|
||||||
|
"Copied: {url}" toast; falls back to "Share link: {url}" on JNI
|
||||||
|
error. Requires AVD functional test (see verification steps in
|
||||||
|
the approved plan).
|
||||||
|
- *Android Keystore for credentials — closed 2026-05-08 by `f281425`.*
|
||||||
|
`android_keystore` module: AES-256/GCM/NoPadding device-bound key,
|
||||||
|
tokens serialised to JSON and stored atomically at
|
||||||
|
`{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+tag]`.
|
||||||
|
`auth_tokens.rs` Android stubs now delegate to it. Key
|
||||||
|
invalidation (biometric reset) → `TokenError::KeychainUnavailable`.
|
||||||
|
Requires AVD functional test before Phase 8 sync goes live on
|
||||||
|
Android.
|
||||||
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
||||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
panic doesn't affect the APK on disk but produces noisy stderr.
|
||||||
Either upstream a cargo-apk fix or document `--lib` as
|
Either upstream a cargo-apk fix or document `--lib` as
|
||||||
@@ -72,34 +105,57 @@ chrome migration, splash boot screen, replay-overlay banner,
|
|||||||
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
|
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
|
||||||
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
||||||
|
|
||||||
- **Replay-overlay screen-takeover redesign.** The full mockup
|
- *Replay-overlay screen-takeover redesign — closed 2026-05-08
|
||||||
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a
|
across 13 commits (v0.21.4–v0.21.7).* The full mockup
|
||||||
mini-tableau preview, playback controls, move-log scroll, and
|
(`docs/ui-mockups/replay-overlay-mobile.html`) has shipped:
|
||||||
a WIN MOVE marker on the scrub bar. Banner-local pieces all
|
banner chrome (v0.21.0), floating MOVE chip (v0.21.2), WIN
|
||||||
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
|
MOVE scrub-bar marker (post-v0.21.3), playback controls /
|
||||||
`e080b49`); the screen-takeover is a multi-session redesign
|
Space accelerator (post-v0.21.3), scrub notches + labels +
|
||||||
with data-layer impact (move-log scroller; WIN MOVE needs a
|
keybind footer + ESC / ← / → accelerators + HC border
|
||||||
`win_move_index` field on `Replay` that doesn't yet exist).
|
(v0.21.5), Move Log panel + HC scrub track + continuous
|
||||||
- **Floating `MOVE N/M` chip above the focused card during
|
scrub (v0.21.6), and full-screen 50 % opacity dim layer
|
||||||
playback.** Cross-plugin work — `update_progress_text` writes
|
(v0.21.7). Every major B-2 sub-piece is now closed. The
|
||||||
the banner chip but the card-position lookup belongs in
|
only remaining items are minor polish: notch-label centering
|
||||||
`card_plugin`. Smaller scope than the screen-takeover.
|
and WIN MOVE HC contrast bump (see Open next-step menu).*
|
||||||
- **Toast Warning / Error variants.** `ToastVariant` has slots
|
- *Floating `MOVE N/M` chip above the focused card during
|
||||||
for `Warning` (gold) and `Error` (pink) but no in-engine
|
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
||||||
event uses them yet. Wire when a warning- or error-flavoured
|
`Text2d` entity sibling to the banner overlay; uses the same
|
||||||
toast event materialises.
|
`LayoutResource` pile coordinates so it survives window
|
||||||
|
resizes without UI/camera math.
|
||||||
|
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
|
||||||
|
Daily-challenge-expiry toast fires once per `daily.date` when
|
||||||
|
within 30 min of UTC midnight reset and today is incomplete.
|
||||||
|
`ToastVariant` is now fully load-bearing (every variant has at
|
||||||
|
least one real driver). Future Warning drivers can either reuse
|
||||||
|
the generic `WarningToastEvent(String)` carrier or add their
|
||||||
|
own domain message + `animation_plugin` handler.
|
||||||
|
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
|
||||||
|
`MoveRejectedEvent` now fires a 2-second pink-bordered
|
||||||
|
"Invalid move" toast as the third leg of the
|
||||||
|
audio + visual + text rejection-feedback stool.
|
||||||
- *High-contrast accessibility mode — closed 2026-05-08 by
|
- *High-contrast accessibility mode — closed 2026-05-08 by
|
||||||
`c5787c6` + `07e0357`.* Card text rendering picks up
|
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
|
||||||
`TEXT_PRIMARY_HC` (`#f5f5f5`) and `RED_SUIT_COLOUR_HC`
|
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
|
||||||
(`#ff8aa0`); Settings panel has a toggle. Future scope:
|
dynamic-paint rollout (`c153363`).* Card text rendering plus
|
||||||
extend HC through chrome borders (`BORDER_SUBTLE_HC` already
|
8 static-border chrome surfaces (modal scaffold, tooltip,
|
||||||
defined, not yet consumed), buttons, popover edges.
|
onboarding key chips, help panel key chips, stats panel
|
||||||
- *Reduced-motion mode — closed 2026-05-08 by the same pair.*
|
cells, home Level/XP/Score row, home mode buttons, home
|
||||||
`effective_slide_secs` forces 0 when on, regardless of the
|
mode-hotkey chips, 4 settings panel surfaces) all boost
|
||||||
`AnimSpeed` setting. Future scope: gate splash scanline
|
borders to `BORDER_SUBTLE_HC` under HC via the
|
||||||
overlay + cursor pulse animation on the same flag, gate
|
`HighContrastBorder` marker. The previously-carved-out
|
||||||
warning-chip pulse, gate any future card-lift z-bump
|
dynamic-paint sites are now also covered: HUD action buttons
|
||||||
animation.
|
and modal buttons take the same marker (their paint cycles
|
||||||
|
only mutate `BackgroundColor`, so no race); the radial menu
|
||||||
|
rim folds HC into its per-frame spawn via
|
||||||
|
`radial_rim_outline` so the focused rim boosts to
|
||||||
|
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
|
||||||
|
hierarchy that naive marker substitution would invert).
|
||||||
|
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
|
||||||
|
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
|
||||||
|
card animations; `pulse_splash_cursor` skips the per-frame
|
||||||
|
pulse multiplier; `spawn_splash` skips the scanline overlay
|
||||||
|
entirely. Future scope: gate any future card-lift z-bump
|
||||||
|
animation, warning-chip pulse (when one materialises).
|
||||||
|
|
||||||
### Carried forward from v0.19.0
|
### Carried forward from v0.19.0
|
||||||
|
|
||||||
@@ -115,11 +171,19 @@ palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
|||||||
|
|
||||||
### Other small candidates
|
### Other small candidates
|
||||||
|
|
||||||
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
|
- *Play-by-Seed dialog — closed 2026-05-08 by `0cb1587`.*
|
||||||
noted Prev/Next markers exist in `stats_plugin` but no spawn
|
`PlayBySeedPlugin` adds a numeric-input modal with async solver
|
||||||
site renders them today — the Shareable badge therefore lands
|
preview (debounced 500 ms). `HomeMode::PlayBySeed` card fires
|
||||||
on the single-replay caption. If/when Prev/Next is plumbed,
|
`StartPlayBySeedRequestEvent`. 5 unit tests. 75 new verified-win
|
||||||
the badge will need to follow.
|
seeds (`2062bd0`) expand `CHALLENGE_SEEDS` via the new
|
||||||
|
`solitaire_assetgen::gen_seeds` binary.
|
||||||
|
- *Prev/Next selector chips spawn site — closed 2026-05-08 by
|
||||||
|
`a449f60`.* `ReplayPrevButton` / `ReplayNextButton` /
|
||||||
|
`ReplaySelectorCaption` / `ReplaySelectorDetail` now spawn in
|
||||||
|
`spawn_stats_screen` as a compact chip row above the Watch
|
||||||
|
Replay action. The Shareable badge is in the detail line.
|
||||||
|
The click handler and repaint systems were already live since
|
||||||
|
v0.19.0; this was purely the missing spawn site.
|
||||||
- **Toast queue / immediate unification.** The two toast paths
|
- **Toast queue / immediate unification.** The two toast paths
|
||||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
||||||
for fire-and-forget) now share visual treatment but remain
|
for fire-and-forget) now share visual treatment but remain
|
||||||
@@ -203,19 +267,21 @@ into a v0.21.1 / v0.22.0 cut.
|
|||||||
```
|
```
|
||||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||||
Branch: master. v0.21.1 is tagged at daa655a (cut 2026-05-08, a
|
Branch: master. v0.21.8 is tagged at c50eaf8 (cut 2026-05-08,
|
||||||
patch release rolling up app-icon, accessibility modes, and the
|
replay-overlay polish). Seven post-cut commits are on master (see
|
||||||
card-visual iteration cycle that closed Resume-prompt Options A
|
"Since the v0.21.8 cut" above); push of the last four pending.
|
||||||
and F). v0.21.0 stays at 04f9bf9. Working tree clean. See
|
v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
|
||||||
CHANGELOG.md § [0.21.1] for full detail of what shipped in the
|
v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
|
||||||
patch release.
|
v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
|
||||||
|
Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
|
||||||
|
pending. See CHANGELOG.md § [0.21.9] for full detail.
|
||||||
|
|
||||||
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
|
State: HEAD locally — see `git rev-parse HEAD`. Workspace
|
||||||
pass (1192+; check with `cargo test --workspace`), clippy clean.
|
tests: 1292 passing / 0 failing. Clippy clean.
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
READ FIRST (in order, before doing anything):
|
||||||
1. SESSION_HANDOFF.md — this file
|
1. SESSION_HANDOFF.md — this file
|
||||||
2. CHANGELOG.md — [0.21.1] section is the most recent cut
|
2. CHANGELOG.md — [0.21.9] section has the pending-cut items
|
||||||
3. CLAUDE.md — unified-3.0 rule set
|
3. CLAUDE.md — unified-3.0 rule set
|
||||||
4. CLAUDE_SPEC.md — formal architecture spec
|
4. CLAUDE_SPEC.md — formal architecture spec
|
||||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||||
@@ -230,34 +296,17 @@ READ FIRST (in order, before doing anything):
|
|||||||
fresh machine)
|
fresh machine)
|
||||||
|
|
||||||
DECISION TO ASK THE PLAYER FIRST:
|
DECISION TO ASK THE PLAYER FIRST:
|
||||||
A. APK launch verification on AVD / device — `adb install` +
|
A. Android follow-ups — JNI ClipboardManager bridge (arboard
|
||||||
`adb logcat` to shake out runtime bugs the build / unit
|
has no Android backend), Android Keystore (blocked on Phase 8).
|
||||||
tests can't catch. Likely surfaces JNI ClipboardManager
|
Launch verification + double-tap are closed.
|
||||||
and Android Keystore stubs that need real bridges. Larger
|
B. Phase 8 (sync) — local storage scaffolding, self-hosted
|
||||||
scope; needs an Android device or emulator running.
|
Axum server, `SolitaireServerClient` impl. The biggest open
|
||||||
(Was Resume-prompt B before the post-v0.21.1 menu trim.)
|
arc by scope; rolls up Android dependencies (Keystore,
|
||||||
B. Replay-overlay extensions — either the floating `MOVE N/M`
|
|
||||||
chip above the focused card (smaller, cross-plugin; needs
|
|
||||||
cursor → card-position plumbing in `card_plugin`) or the
|
|
||||||
full screen-takeover redesign (multi-session: move-log
|
|
||||||
scroll, mini tableau preview, WIN MOVE marker, data-layer
|
|
||||||
impact for `Replay::win_move_index`).
|
|
||||||
C. Toast Warning / Error variant wiring. UI infrastructure
|
|
||||||
exists in `ToastVariant`; no in-engine event uses Warning
|
|
||||||
(gold) or Error (pink) yet. Wire when a real warning- or
|
|
||||||
error-flavoured event materialises.
|
|
||||||
D. Phase 8 (sync) — local storage scaffolding, self-hosted
|
|
||||||
Axum server, `SolitaireServerClient` impl, GPGS stub
|
|
||||||
wired into Settings. The biggest open arc by scope; rolls
|
|
||||||
up several Phase Android dependencies (Keystore,
|
|
||||||
ClipboardManager).
|
ClipboardManager).
|
||||||
E. Extend high-contrast through chrome — `BORDER_SUBTLE_HC`
|
C. Play-by-Seed polish — the dialog is functional but has no
|
||||||
was defined in v0.21.1 but isn't yet consumed; popover
|
visual preview of the solver verdict in the UI yet; the
|
||||||
edges, button borders, focus rings still use the default
|
HomeMode card is wired but the dialog spawn site and verdict
|
||||||
non-HC tokens. Plus reduce-motion still doesn't gate
|
display could use a second pass.
|
||||||
splash scanline / cursor pulse / warning-chip pulse —
|
|
||||||
v0.21.1 only gated card slide_secs. Both are small,
|
|
||||||
finite, half-day scope.
|
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
- Use the system git config (already correct).
|
- Use the system git config (already correct).
|
||||||
@@ -281,5 +330,9 @@ WORKFLOW NOTES:
|
|||||||
a "this does X" doc comment, verify the code actually does
|
a "this does X" doc comment, verify the code actually does
|
||||||
X and add a test if not. Two layers, two checks.
|
X and add a test if not. Two layers, two checks.
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A–E. Don't pick unilaterally.
|
OPEN AT THE START: ask which of A–C. Don't pick unilaterally.
|
||||||
|
Note: every remaining option is multi-session by nature (A is
|
||||||
|
gated on Android tooling; B and C are explicitly multi-session
|
||||||
|
arcs). A fresh session is a better fit for any of them than the
|
||||||
|
tail of a long working stretch.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -18,21 +18,22 @@ use std::io::Write;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{
|
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
#[cfg(not(target_os = "android"))]
|
||||||
};
|
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::winit::WinitWindows;
|
use bevy::winit::WinitWindows;
|
||||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
|
||||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
|
WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// App entry point — builds and runs the Bevy app.
|
/// App entry point — builds and runs the Bevy app.
|
||||||
@@ -76,6 +77,7 @@ pub fn run() {
|
|||||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||||
// sessions don't end up with a comparatively tiny window.
|
// sessions don't end up with a comparatively tiny window.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
let had_saved_geometry = settings.window_geometry.is_some();
|
let had_saved_geometry = settings.window_geometry.is_some();
|
||||||
let (window_resolution, window_position) = match settings.window_geometry {
|
let (window_resolution, window_position) = match settings.window_geometry {
|
||||||
Some(geom) => (
|
Some(geom) => (
|
||||||
@@ -116,6 +118,9 @@ pub fn run() {
|
|||||||
// small enough that a few stray dropped frames from
|
// small enough that a few stray dropped frames from
|
||||||
// disabling vsync are imperceptible.
|
// disabling vsync are imperceptible.
|
||||||
present_mode: PresentMode::AutoNoVsync,
|
present_mode: PresentMode::AutoNoVsync,
|
||||||
|
// Android windows always fill the screen; max_width/max_height
|
||||||
|
// default to 0.0, which panics Bevy's clamp when min > max.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||||
min_width: 800.0,
|
min_width: 800.0,
|
||||||
min_height: 600.0,
|
min_height: 600.0,
|
||||||
@@ -142,6 +147,13 @@ pub fn run() {
|
|||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
|
// Cursor-icon feedback is desktop-only; Android has no pointer cursor.
|
||||||
|
// The drop-target highlight systems (update_drop_highlights,
|
||||||
|
// update_drop_target_overlays) live in CursorPlugin but ARE useful
|
||||||
|
// on Android — they've been left running because their Bevy system
|
||||||
|
// params compile and function on Android; only the CursorIcon insert
|
||||||
|
// is inert. Gate the whole plugin if the cursor APIs ever cause
|
||||||
|
// Android linker issues; for now it's harmless to leave it registered.
|
||||||
.add_plugins(CursorPlugin)
|
.add_plugins(CursorPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
.add_plugins(RadialMenuPlugin)
|
.add_plugins(RadialMenuPlugin)
|
||||||
@@ -158,6 +170,8 @@ pub fn run() {
|
|||||||
.add_plugins(DailyChallengePlugin)
|
.add_plugins(DailyChallengePlugin)
|
||||||
.add_plugins(WeeklyGoalsPlugin)
|
.add_plugins(WeeklyGoalsPlugin)
|
||||||
.add_plugins(ChallengePlugin)
|
.add_plugins(ChallengePlugin)
|
||||||
|
.add_plugins(PlayBySeedPlugin)
|
||||||
|
.add_plugins(DifficultyPlugin)
|
||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
@@ -195,6 +209,8 @@ pub fn run() {
|
|||||||
// every fresh launch can flip `disable_smart_default_size` in
|
// every fresh launch can flip `disable_smart_default_size` in
|
||||||
// Settings to opt out. The flag is checked once at startup; a
|
// Settings to opt out. The flag is checked once at startup; a
|
||||||
// mid-session change applies on the next launch.
|
// mid-session change applies on the next launch.
|
||||||
|
// Android windows are always full-screen; the OS controls sizing.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
if !had_saved_geometry && !settings.disable_smart_default_size {
|
if !had_saved_geometry && !settings.disable_smart_default_size {
|
||||||
app.add_systems(Update, apply_smart_default_window_size);
|
app.add_systems(Update, apply_smart_default_window_size);
|
||||||
}
|
}
|
||||||
@@ -215,6 +231,7 @@ pub fn run() {
|
|||||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||||
/// populates the `Monitor` entities asynchronously after winit's
|
/// populates the `Monitor` entities asynchronously after winit's
|
||||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
fn apply_smart_default_window_size(
|
fn apply_smart_default_window_size(
|
||||||
mut applied: Local<bool>,
|
mut applied: Local<bool>,
|
||||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||||
@@ -335,6 +352,20 @@ fn set_window_icon(
|
|||||||
*applied = true;
|
*applied = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android entry point called by NativeActivity after dlopen-ing the `.so`.
|
||||||
|
/// Sets the `AndroidApp` handle that Bevy's winit backend reads before
|
||||||
|
/// constructing the event loop, then delegates to [`run`].
|
||||||
|
///
|
||||||
|
/// The `#[bevy_main]` proc-macro would generate the same code but only
|
||||||
|
/// works on a function named `main`; our shared entry point is `run`, so
|
||||||
|
/// we emit the equivalent expansion manually.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
||||||
|
let _ = bevy::android::ANDROID_APP.set(android_app);
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
/// Wraps the default panic hook with one that also appends a crash log
|
/// Wraps the default panic hook with one that also appends a crash log
|
||||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||||
/// still runs afterwards, so stderr output and debugger integration are
|
/// still runs afterwards, so stderr output and debugger integration are
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ publish = false
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
png = "0.17"
|
png = "0.17"
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
solitaire_core = { path = "../solitaire_core" }
|
||||||
|
solitaire_data = { path = "../solitaire_data" }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen_sfx"
|
name = "gen_sfx"
|
||||||
@@ -20,3 +22,11 @@ path = "src/bin/gen_sfx.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen_art"
|
name = "gen_art"
|
||||||
path = "src/bin/gen_art.rs"
|
path = "src/bin/gen_art.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gen_seeds"
|
||||||
|
path = "src/bin/gen_seeds.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gen_difficulty_seeds"
|
||||||
|
path = "src/bin/gen_difficulty_seeds.rs"
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! Generate difficulty-stratified seed catalogs for `EASY_SEEDS`, `MEDIUM_SEEDS`,
|
||||||
|
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
||||||
|
//! `solitaire_data/src/difficulty_seeds.rs`.
|
||||||
|
//!
|
||||||
|
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
|
||||||
|
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
|
||||||
|
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
|
||||||
|
//! provably-winnable seeds).
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p solitaire_assetgen --bin gen_difficulty_seeds --release -- \
|
||||||
|
//! --start 0xD1FF0000_00000000 --per-tier 40
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Flags:
|
||||||
|
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xD1FF000000000000)
|
||||||
|
//! --per-tier Seeds to emit per tier (default 40)
|
||||||
|
//! --help Print this message
|
||||||
|
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||||
|
|
||||||
|
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||||
|
// whose budget proves it Winnable.
|
||||||
|
const BUDGETS: &[(&str, u64, usize)] = &[
|
||||||
|
("Easy", 1_000, 1_000),
|
||||||
|
("Medium", 5_000, 5_000),
|
||||||
|
("Hard", 25_000, 25_000),
|
||||||
|
("Expert", 100_000, 100_000),
|
||||||
|
("Grandmaster", 200_000, 200_000),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
|
let mut start: u64 = 0xD1FF_0000_0000_0000;
|
||||||
|
let mut per_tier: usize = 40;
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--start" => {
|
||||||
|
let val = args.next().unwrap_or_else(|| {
|
||||||
|
eprintln!("error: --start requires a value");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
start = parse_u64(&val);
|
||||||
|
}
|
||||||
|
"--per-tier" => {
|
||||||
|
let val = args.next().unwrap_or_else(|| {
|
||||||
|
eprintln!("error: --per-tier requires a value");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
per_tier = val.parse().unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: --per-tier must be a positive integer");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
eprintln!("gen_difficulty_seeds: generate tiered seed catalogs");
|
||||||
|
eprintln!(" --start <seed> starting seed (hex or decimal)");
|
||||||
|
eprintln!(" --per-tier <n> seeds per tier (default 40)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("error: unknown argument: {other}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if per_tier == 0 {
|
||||||
|
eprintln!("error: --per-tier must be > 0");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let draw_mode = DrawMode::DrawOne;
|
||||||
|
let num_tiers = BUDGETS.len();
|
||||||
|
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||||
|
let mut tried: u64 = 0;
|
||||||
|
let mut seed = start;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"gen_difficulty_seeds: finding {} seeds per tier from 0x{start:016X} (DrawOne) …",
|
||||||
|
per_tier
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
" Tiers: {}",
|
||||||
|
BUDGETS.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
while buckets.iter().any(|b| b.len() < per_tier) {
|
||||||
|
tried += 1;
|
||||||
|
'tier: for (i, &(name, move_budget, state_budget)) in BUDGETS.iter().enumerate() {
|
||||||
|
if buckets[i].len() >= per_tier {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cfg = SolverConfig { move_budget, state_budget };
|
||||||
|
match try_solve(seed, draw_mode.clone(), &cfg) {
|
||||||
|
SolverResult::Winnable => {
|
||||||
|
buckets[i].push(seed);
|
||||||
|
eprintln!(
|
||||||
|
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
||||||
|
buckets[i].len(),
|
||||||
|
per_tier
|
||||||
|
);
|
||||||
|
break 'tier; // assign to the cheapest tier that proves it winnable
|
||||||
|
}
|
||||||
|
SolverResult::Unwinnable => {
|
||||||
|
// Definitely unsolvable — skip all remaining tiers.
|
||||||
|
break 'tier;
|
||||||
|
}
|
||||||
|
SolverResult::Inconclusive => {
|
||||||
|
// Budget exhausted without proof — try the next larger tier.
|
||||||
|
// If this is the last tier, the seed is discarded (Inconclusive
|
||||||
|
// at max budget means "probably but not provably winnable").
|
||||||
|
if i == num_tiers - 1 {
|
||||||
|
break 'tier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seed = seed.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n");
|
||||||
|
|
||||||
|
let date = current_date();
|
||||||
|
for (i, (tier_name, _, _)) in BUDGETS.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
" // Generated by solitaire_assetgen::gen_difficulty_seeds \
|
||||||
|
(tier={tier_name}, date={date})"
|
||||||
|
);
|
||||||
|
for chunk in buckets[i].chunks(5) {
|
||||||
|
for s in chunk {
|
||||||
|
println!(
|
||||||
|
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
|
||||||
|
(s >> 48) & 0xFFFF,
|
||||||
|
(s >> 32) & 0xFFFF,
|
||||||
|
(s >> 16) & 0xFFFF,
|
||||||
|
s & 0xFFFF,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u64(s: &str) -> u64 {
|
||||||
|
let cleaned = s.replace('_', "");
|
||||||
|
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
|
||||||
|
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: could not parse '{s}' as a hex u64");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cleaned.parse().unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: could not parse '{s}' as a decimal u64");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_date() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let days = secs / 86400;
|
||||||
|
let mut y = 1970u64;
|
||||||
|
let mut d = days;
|
||||||
|
loop {
|
||||||
|
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||||
|
let days_in_year = if leap { 366 } else { 365 };
|
||||||
|
if d < days_in_year {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d -= days_in_year;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||||
|
let month_days: [u64; 12] = [
|
||||||
|
31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
||||||
|
];
|
||||||
|
let mut m = 0usize;
|
||||||
|
for &md in &month_days {
|
||||||
|
if d < md {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d -= md;
|
||||||
|
m += 1;
|
||||||
|
}
|
||||||
|
format!("{y}-{:02}-{:02}", m + 1, d + 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
||||||
|
//!
|
||||||
|
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
||||||
|
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
|
||||||
|
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
||||||
|
//! pasting into `solitaire_data/src/challenge.rs`.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p solitaire_assetgen --bin gen_seeds --release -- \
|
||||||
|
//! --start 0xCAFE_BABE_0000_0000 --count 75
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Flags:
|
||||||
|
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xCAFEBABE00000000)
|
||||||
|
//! --count Number of Winnable seeds to emit (default 75)
|
||||||
|
//! --help Print this message
|
||||||
|
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
|
let mut start: u64 = 0xCAFE_BABE_0000_0000;
|
||||||
|
let mut count: usize = 75;
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--start" => {
|
||||||
|
let val = args.next().unwrap_or_else(|| {
|
||||||
|
eprintln!("error: --start requires a value");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
start = parse_u64(&val);
|
||||||
|
}
|
||||||
|
"--count" => {
|
||||||
|
let val = args.next().unwrap_or_else(|| {
|
||||||
|
eprintln!("error: --count requires a value");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
count = val.parse().unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: --count must be a positive integer");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
eprintln!("{}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/gen_seeds.rs")).lines().take(20).collect::<Vec<_>>().join("\n"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("error: unknown argument: {other}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
eprintln!("error: --count must be > 0");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let draw_mode = DrawMode::DrawOne;
|
||||||
|
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||||
|
let mut tried: u64 = 0;
|
||||||
|
let mut seed = start;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"gen_seeds: finding {count} Winnable seeds from 0x{start:016X} (DrawOne) …"
|
||||||
|
);
|
||||||
|
|
||||||
|
while found.len() < count {
|
||||||
|
tried += 1;
|
||||||
|
if matches!(
|
||||||
|
try_solve(seed, draw_mode.clone(), &cfg),
|
||||||
|
SolverResult::Winnable
|
||||||
|
) {
|
||||||
|
found.push(seed);
|
||||||
|
eprintln!(
|
||||||
|
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
||||||
|
found.len(),
|
||||||
|
count,
|
||||||
|
seed,
|
||||||
|
tried
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seed = seed.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("\nDone. Paste the block below into CHALLENGE_SEEDS in solitaire_data/src/challenge.rs:\n");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" // Generated by solitaire_assetgen::gen_seeds \
|
||||||
|
(start=0x{start:016X}, count={count}, date={date})",
|
||||||
|
date = current_date()
|
||||||
|
);
|
||||||
|
for chunk in found.chunks(5) {
|
||||||
|
for s in chunk {
|
||||||
|
println!(
|
||||||
|
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
|
||||||
|
(s >> 48) & 0xFFFF,
|
||||||
|
(s >> 32) & 0xFFFF,
|
||||||
|
(s >> 16) & 0xFFFF,
|
||||||
|
s & 0xFFFF,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u64(s: &str) -> u64 {
|
||||||
|
let cleaned = s.replace('_', "");
|
||||||
|
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
|
||||||
|
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: could not parse '{s}' as a hex u64");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cleaned.parse().unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: could not parse '{s}' as a decimal u64");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_date() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let days = secs / 86400;
|
||||||
|
// Gregorian calendar computation (Tomohiko Sakamoto's algorithm variant)
|
||||||
|
let mut y = 1970u64;
|
||||||
|
let mut d = days;
|
||||||
|
loop {
|
||||||
|
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||||
|
let days_in_year = if leap { 366 } else { 365 };
|
||||||
|
if d < days_in_year {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d -= days_in_year;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||||
|
let month_days: [u64; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
let mut m = 0usize;
|
||||||
|
for &md in &month_days {
|
||||||
|
if d < md {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d -= md;
|
||||||
|
m += 1;
|
||||||
|
}
|
||||||
|
format!("{y}-{:02}-{:02}", m + 1, d + 1)
|
||||||
|
}
|
||||||
@@ -50,6 +50,35 @@ pub enum DrawMode {
|
|||||||
DrawThree,
|
DrawThree,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
|
||||||
|
/// catalog is drawn from. `Random` skips verification entirely and uses a
|
||||||
|
/// system-time seed — deals may or may not be winnable.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||||
|
pub enum DifficultyLevel {
|
||||||
|
#[default]
|
||||||
|
Easy,
|
||||||
|
Medium,
|
||||||
|
Hard,
|
||||||
|
Expert,
|
||||||
|
Grandmaster,
|
||||||
|
/// Unverified system-time seed — may or may not be winnable.
|
||||||
|
Random,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DifficultyLevel {
|
||||||
|
/// Short human-readable label shown in the HUD and win summary.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Easy => "Easy",
|
||||||
|
Self::Medium => "Medium",
|
||||||
|
Self::Hard => "Hard",
|
||||||
|
Self::Expert => "Expert",
|
||||||
|
Self::Grandmaster => "Grandmaster",
|
||||||
|
Self::Random => "Random",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
|
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
|
||||||
///
|
///
|
||||||
/// - `Classic`: standard Klondike scoring, undo allowed.
|
/// - `Classic`: standard Klondike scoring, undo allowed.
|
||||||
@@ -59,6 +88,8 @@ pub enum DrawMode {
|
|||||||
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
||||||
/// countdown around the session and auto-deals a fresh game on every win
|
/// countdown around the session and auto-deals a fresh game on every win
|
||||||
/// (see `solitaire_engine::TimeAttackPlugin`).
|
/// (see `solitaire_engine::TimeAttackPlugin`).
|
||||||
|
/// - `Difficulty(DifficultyLevel)`: seed drawn from a pre-verified per-tier catalog
|
||||||
|
/// (or system-time for `Random`). Rules identical to Classic.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub enum GameMode {
|
pub enum GameMode {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -70,6 +101,8 @@ pub enum GameMode {
|
|||||||
Challenge,
|
Challenge,
|
||||||
/// Play as many games as possible within 10 minutes.
|
/// Play as many games as possible within 10 minutes.
|
||||||
TimeAttack,
|
TimeAttack,
|
||||||
|
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
|
||||||
|
Difficulty(DifficultyLevel),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of game state used for undo.
|
/// Snapshot of game state used for undo.
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ tokio = { workspace = true }
|
|||||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
keyring-core = { workspace = true }
|
keyring-core = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = { workspace = true }
|
||||||
|
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
|
||||||
|
# process-wide JavaVM handle for JNI. Must be listed here so the
|
||||||
|
# symbol resolves when cross-compiling for Android targets.
|
||||||
|
bevy = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
solitaire_server = { path = "../solitaire_server" }
|
solitaire_server = { path = "../solitaire_server" }
|
||||||
solitaire_sync = { workspace = true }
|
solitaire_sync = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
/// Android Keystore token storage via JNI.
|
||||||
|
///
|
||||||
|
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
|
||||||
|
/// device-bound key from the Android Keystore, and written atomically to
|
||||||
|
/// `{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`.
|
||||||
|
///
|
||||||
|
/// The Keystore key survives app restarts but is destroyed on uninstall (or if
|
||||||
|
/// the user changes biometric/lock credentials, in which case decryption fails
|
||||||
|
/// and we surface `TokenError::KeychainUnavailable` so the caller knows to
|
||||||
|
/// prompt re-login — identical semantics to a Linux box without Secret Service).
|
||||||
|
///
|
||||||
|
/// Only compiled and linked on `target_os = "android"`.
|
||||||
|
use jni::{
|
||||||
|
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::auth_tokens::TokenError;
|
||||||
|
|
||||||
|
const KEY_ALIAS: &str = "solitaire_quest_token_key";
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct TokenBlob {
|
||||||
|
username: String,
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JVM helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
||||||
|
where
|
||||||
|
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
||||||
|
{
|
||||||
|
let app = bevy::android::ANDROID_APP
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?;
|
||||||
|
|
||||||
|
// SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime.
|
||||||
|
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
|
||||||
|
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread_permanently()
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
|
||||||
|
|
||||||
|
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keystore key management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Load the existing AES key from the Android Keystore, or generate one if it
|
||||||
|
/// doesn't exist yet. Returns a local reference valid for the current JNI frame.
|
||||||
|
fn load_or_create_key<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result<JObject<'local>> {
|
||||||
|
// KeyStore ks = KeyStore.getInstance("AndroidKeyStore"); ks.load(null);
|
||||||
|
let ks_class = env.find_class("java/security/KeyStore")?;
|
||||||
|
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||||
|
let ks = env
|
||||||
|
.call_static_method(
|
||||||
|
&ks_class,
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||||
|
&[ks_type.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
let null = JObject::null();
|
||||||
|
env.call_method(
|
||||||
|
&ks,
|
||||||
|
"load",
|
||||||
|
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||||
|
&[JValue::Object(&null)],
|
||||||
|
)?
|
||||||
|
.v()?;
|
||||||
|
|
||||||
|
// Key key = ks.getKey(ALIAS, null) — char[] password is null for hardware keys
|
||||||
|
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||||
|
let null2 = JObject::null();
|
||||||
|
let key = env
|
||||||
|
.call_method(
|
||||||
|
&ks,
|
||||||
|
"getKey",
|
||||||
|
"(Ljava/lang/String;[C)Ljava/security/Key;",
|
||||||
|
&[alias.borrow(), JValue::Object(&null2)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
if !env.is_same_object(&key, JObject::null())? {
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No key yet — generate AES-256 with GCM block mode.
|
||||||
|
let builder_class =
|
||||||
|
env.find_class("android/security/keystore/KeyGenParameterSpec$Builder")?;
|
||||||
|
let alias2 = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||||
|
// PURPOSE_ENCRYPT | PURPOSE_DECRYPT = 1 | 2 = 3
|
||||||
|
let purpose = JValueOwned::Int(3);
|
||||||
|
let builder = env.new_object(
|
||||||
|
&builder_class,
|
||||||
|
"(Ljava/lang/String;I)V",
|
||||||
|
&[alias2.borrow(), purpose.borrow()],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let str_class = env.find_class("java/lang/String")?;
|
||||||
|
|
||||||
|
// builder.setBlockModes(["GCM"])
|
||||||
|
let gcm_str = env.new_string("GCM")?;
|
||||||
|
let block_modes: JObjectArray = env.new_object_array(1, &str_class, &gcm_str)?;
|
||||||
|
let block_modes_val = JValueOwned::Object(block_modes.into());
|
||||||
|
let builder = env
|
||||||
|
.call_method(
|
||||||
|
&builder,
|
||||||
|
"setBlockModes",
|
||||||
|
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[block_modes_val.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
// builder.setEncryptionPaddings(["NoPadding"])
|
||||||
|
let nopad_str = env.new_string("NoPadding")?;
|
||||||
|
let enc_pads: JObjectArray = env.new_object_array(1, &str_class, &nopad_str)?;
|
||||||
|
let enc_pads_val = JValueOwned::Object(enc_pads.into());
|
||||||
|
let builder = env
|
||||||
|
.call_method(
|
||||||
|
&builder,
|
||||||
|
"setEncryptionPaddings",
|
||||||
|
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[enc_pads_val.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
// KeyGenParameterSpec spec = builder.build()
|
||||||
|
let spec = env
|
||||||
|
.call_method(
|
||||||
|
&builder,
|
||||||
|
"build",
|
||||||
|
"()Landroid/security/keystore/KeyGenParameterSpec;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
// KeyGenerator kg = KeyGenerator.getInstance("AES", "AndroidKeyStore")
|
||||||
|
let kg_class = env.find_class("javax/crypto/KeyGenerator")?;
|
||||||
|
let aes = JValueOwned::from(env.new_string("AES")?);
|
||||||
|
let ks_name = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||||
|
let kg = env
|
||||||
|
.call_static_method(
|
||||||
|
&kg_class,
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)Ljavax/crypto/KeyGenerator;",
|
||||||
|
&[aes.borrow(), ks_name.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
// kg.init(spec); return kg.generateKey()
|
||||||
|
let spec_val = JValueOwned::Object(spec);
|
||||||
|
env.call_method(
|
||||||
|
&kg,
|
||||||
|
"init",
|
||||||
|
"(Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||||
|
&[spec_val.borrow()],
|
||||||
|
)?
|
||||||
|
.v()?;
|
||||||
|
|
||||||
|
env.call_method(&kg, "generateKey", "()Ljavax/crypto/SecretKey;", &[])?
|
||||||
|
.l()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AES-GCM encrypt / decrypt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns `[12-byte IV][ciphertext+GCM-tag]`.
|
||||||
|
fn encrypt_gcm(
|
||||||
|
env: &mut JNIEnv<'_>,
|
||||||
|
key: &JObject<'_>,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> jni::errors::Result<Vec<u8>> {
|
||||||
|
let cipher_class = env.find_class("javax/crypto/Cipher")?;
|
||||||
|
let transform = JValueOwned::from(env.new_string("AES/GCM/NoPadding")?);
|
||||||
|
let cipher = env
|
||||||
|
.call_static_method(
|
||||||
|
&cipher_class,
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
|
||||||
|
&[transform.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
// cipher.init(Cipher.ENCRYPT_MODE=1, key)
|
||||||
|
let mode = JValueOwned::Int(1);
|
||||||
|
env.call_method(
|
||||||
|
&cipher,
|
||||||
|
"init",
|
||||||
|
"(ILjava/security/Key;)V",
|
||||||
|
&[mode.borrow(), JValue::Object(key)],
|
||||||
|
)?
|
||||||
|
.v()?;
|
||||||
|
|
||||||
|
// IV is generated by Android's provider; read it back after init.
|
||||||
|
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
|
||||||
|
// SAFETY: the method signature guarantees a byte array return.
|
||||||
|
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
|
||||||
|
let iv = env.convert_byte_array(&iv_arr)?;
|
||||||
|
|
||||||
|
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
||||||
|
let pt_val = JValueOwned::Object(pt_arr.into());
|
||||||
|
let ct_jobj = env
|
||||||
|
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
||||||
|
.l()?;
|
||||||
|
// SAFETY: doFinal([B) returns [B.
|
||||||
|
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
|
||||||
|
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
||||||
|
out.extend_from_slice(&iv);
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expects `data` as `[12-byte IV][ciphertext+GCM-tag]`.
|
||||||
|
fn decrypt_gcm(
|
||||||
|
env: &mut JNIEnv<'_>,
|
||||||
|
key: &JObject<'_>,
|
||||||
|
data: &[u8],
|
||||||
|
) -> jni::errors::Result<Vec<u8>> {
|
||||||
|
let (iv, ciphertext) = data.split_at(12);
|
||||||
|
|
||||||
|
let cipher_class = env.find_class("javax/crypto/Cipher")?;
|
||||||
|
let transform = JValueOwned::from(env.new_string("AES/GCM/NoPadding")?);
|
||||||
|
let cipher = env
|
||||||
|
.call_static_method(
|
||||||
|
&cipher_class,
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
|
||||||
|
&[transform.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
// GCMParameterSpec spec = new GCMParameterSpec(128, iv)
|
||||||
|
let spec_class = env.find_class("javax/crypto/spec/GCMParameterSpec")?;
|
||||||
|
let tag_len = JValueOwned::Int(128);
|
||||||
|
let iv_arr = env.byte_array_from_slice(iv)?;
|
||||||
|
let iv_val = JValueOwned::Object(iv_arr.into());
|
||||||
|
let spec = env.new_object(
|
||||||
|
&spec_class,
|
||||||
|
"(I[B)V",
|
||||||
|
&[tag_len.borrow(), iv_val.borrow()],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// cipher.init(Cipher.DECRYPT_MODE=2, key, spec)
|
||||||
|
let mode = JValueOwned::Int(2);
|
||||||
|
let spec_val = JValueOwned::Object(spec);
|
||||||
|
env.call_method(
|
||||||
|
&cipher,
|
||||||
|
"init",
|
||||||
|
"(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||||
|
&[mode.borrow(), JValue::Object(key), spec_val.borrow()],
|
||||||
|
)?
|
||||||
|
.v()?;
|
||||||
|
|
||||||
|
let ct_arr = env.byte_array_from_slice(ciphertext)?;
|
||||||
|
let ct_val = JValueOwned::Object(ct_arr.into());
|
||||||
|
let pt_jobj = env
|
||||||
|
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
||||||
|
.l()?;
|
||||||
|
// SAFETY: doFinal([B) returns [B.
|
||||||
|
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
|
||||||
|
env.convert_byte_array(&pt_arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn token_file_path() -> Option<PathBuf> {
|
||||||
|
crate::platform::data_dir().map(|d| d.join("auth_tokens.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
|
||||||
|
let path = token_file_path()
|
||||||
|
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(TokenError::NotFound(String::new()));
|
||||||
|
}
|
||||||
|
std::fs::read(&path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
||||||
|
let path = token_file_path()
|
||||||
|
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
std::fs::write(&tmp, data)
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?;
|
||||||
|
std::fs::rename(&tmp, &path)
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
|
||||||
|
let data = read_file_bytes().map_err(|e| match e {
|
||||||
|
TokenError::NotFound(_) => TokenError::NotFound(username.to_string()),
|
||||||
|
other => other,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if data.len() < 12 {
|
||||||
|
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let plaintext = with_jvm(|env| {
|
||||||
|
let key = load_or_create_key(env)?;
|
||||||
|
decrypt_gcm(env, &key, &data)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let blob: TokenBlob = serde_json::from_slice(&plaintext)
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("JSON decode: {e}")))?;
|
||||||
|
|
||||||
|
if blob.username != username {
|
||||||
|
return Err(TokenError::NotFound(username.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API — mirrors auth_tokens desktop surface exactly.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Encrypt and store `access_token` and `refresh_token` for `username`.
|
||||||
|
///
|
||||||
|
/// Overwrites any previously stored tokens.
|
||||||
|
pub fn store_tokens(
|
||||||
|
username: &str,
|
||||||
|
access_token: &str,
|
||||||
|
refresh_token: &str,
|
||||||
|
) -> Result<(), TokenError> {
|
||||||
|
let blob = TokenBlob {
|
||||||
|
username: username.to_string(),
|
||||||
|
access_token: access_token.to_string(),
|
||||||
|
refresh_token: refresh_token.to_string(),
|
||||||
|
};
|
||||||
|
let plaintext = serde_json::to_vec(&blob)
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
|
||||||
|
|
||||||
|
let encrypted = with_jvm(|env| {
|
||||||
|
let key = load_or_create_key(env)?;
|
||||||
|
encrypt_gcm(env, &key, &plaintext)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
write_file_bytes(&encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the stored access token for `username`.
|
||||||
|
///
|
||||||
|
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||||
|
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||||
|
load_blob(username).map(|b| b.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the stored refresh token for `username`.
|
||||||
|
///
|
||||||
|
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||||
|
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||||
|
load_blob(username).map(|b| b.refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete stored tokens and remove the Keystore key for `username`.
|
||||||
|
///
|
||||||
|
/// Missing file or missing Keystore entry are silently ignored.
|
||||||
|
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
||||||
|
if let Some(path) = token_file_path() {
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||||
|
with_jvm(|env| {
|
||||||
|
let ks_class = env.find_class("java/security/KeyStore")?;
|
||||||
|
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||||
|
let ks = env
|
||||||
|
.call_static_method(
|
||||||
|
&ks_class,
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||||
|
&[ks_type.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
let null = JObject::null();
|
||||||
|
env.call_method(
|
||||||
|
&ks,
|
||||||
|
"load",
|
||||||
|
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||||
|
&[JValue::Object(&null)],
|
||||||
|
)?
|
||||||
|
.v()?;
|
||||||
|
|
||||||
|
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||||
|
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
|
||||||
|
.v()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -131,35 +131,29 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Android stub — same public API, always returns KeychainUnavailable.
|
// Android — delegate to the JNI Keystore bridge in android_keystore.
|
||||||
// Lets `sync_client::*` compile unchanged on Android; the runtime
|
|
||||||
// effect is "session login required every launch", same as a Linux
|
|
||||||
// box without Secret Service.
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn store_tokens(
|
pub fn store_tokens(
|
||||||
_username: &str,
|
username: &str,
|
||||||
_access_token: &str,
|
access_token: &str,
|
||||||
_refresh_token: &str,
|
refresh_token: &str,
|
||||||
) -> Result<(), TokenError> {
|
) -> Result<(), TokenError> {
|
||||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
crate::android_keystore::store_tokens(username, access_token, refresh_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
|
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
crate::android_keystore::load_access_token(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
|
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
crate::android_keystore::load_refresh_token(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
crate::android_keystore::delete_tokens(username)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,82 @@ pub const CHALLENGE_SEEDS: &[u64] = &[
|
|||||||
0xDDDD_EEEE_FFFF_0000,
|
0xDDDD_EEEE_FFFF_0000,
|
||||||
0x0101_0101_0101_0101,
|
0x0101_0101_0101_0101,
|
||||||
0xA1B2_C3D4_E5F6_0718,
|
0xA1B2_C3D4_E5F6_0718,
|
||||||
|
// Generated by solitaire_assetgen::gen_seeds (start=0xCAFEBABE00000000, count=75, date=2026-05-09)
|
||||||
|
0xCAFE_BABE_0000_0000,
|
||||||
|
0xCAFE_BABE_0000_0002,
|
||||||
|
0xCAFE_BABE_0000_0004,
|
||||||
|
0xCAFE_BABE_0000_0008,
|
||||||
|
0xCAFE_BABE_0000_000B,
|
||||||
|
0xCAFE_BABE_0000_000D,
|
||||||
|
0xCAFE_BABE_0000_000E,
|
||||||
|
0xCAFE_BABE_0000_0010,
|
||||||
|
0xCAFE_BABE_0000_0011,
|
||||||
|
0xCAFE_BABE_0000_0014,
|
||||||
|
0xCAFE_BABE_0000_0016,
|
||||||
|
0xCAFE_BABE_0000_0019,
|
||||||
|
0xCAFE_BABE_0000_001A,
|
||||||
|
0xCAFE_BABE_0000_001F,
|
||||||
|
0xCAFE_BABE_0000_0020,
|
||||||
|
0xCAFE_BABE_0000_0021,
|
||||||
|
0xCAFE_BABE_0000_0024,
|
||||||
|
0xCAFE_BABE_0000_0025,
|
||||||
|
0xCAFE_BABE_0000_0027,
|
||||||
|
0xCAFE_BABE_0000_002B,
|
||||||
|
0xCAFE_BABE_0000_002D,
|
||||||
|
0xCAFE_BABE_0000_0030,
|
||||||
|
0xCAFE_BABE_0000_0034,
|
||||||
|
0xCAFE_BABE_0000_0036,
|
||||||
|
0xCAFE_BABE_0000_003A,
|
||||||
|
0xCAFE_BABE_0000_003B,
|
||||||
|
0xCAFE_BABE_0000_003D,
|
||||||
|
0xCAFE_BABE_0000_0042,
|
||||||
|
0xCAFE_BABE_0000_0043,
|
||||||
|
0xCAFE_BABE_0000_0044,
|
||||||
|
0xCAFE_BABE_0000_004C,
|
||||||
|
0xCAFE_BABE_0000_004D,
|
||||||
|
0xCAFE_BABE_0000_004F,
|
||||||
|
0xCAFE_BABE_0000_0050,
|
||||||
|
0xCAFE_BABE_0000_0051,
|
||||||
|
0xCAFE_BABE_0000_0054,
|
||||||
|
0xCAFE_BABE_0000_0055,
|
||||||
|
0xCAFE_BABE_0000_0056,
|
||||||
|
0xCAFE_BABE_0000_0059,
|
||||||
|
0xCAFE_BABE_0000_005B,
|
||||||
|
0xCAFE_BABE_0000_005C,
|
||||||
|
0xCAFE_BABE_0000_005E,
|
||||||
|
0xCAFE_BABE_0000_0060,
|
||||||
|
0xCAFE_BABE_0000_0062,
|
||||||
|
0xCAFE_BABE_0000_0064,
|
||||||
|
0xCAFE_BABE_0000_0067,
|
||||||
|
0xCAFE_BABE_0000_0069,
|
||||||
|
0xCAFE_BABE_0000_006A,
|
||||||
|
0xCAFE_BABE_0000_006B,
|
||||||
|
0xCAFE_BABE_0000_006C,
|
||||||
|
0xCAFE_BABE_0000_006D,
|
||||||
|
0xCAFE_BABE_0000_006E,
|
||||||
|
0xCAFE_BABE_0000_006F,
|
||||||
|
0xCAFE_BABE_0000_0072,
|
||||||
|
0xCAFE_BABE_0000_0073,
|
||||||
|
0xCAFE_BABE_0000_0074,
|
||||||
|
0xCAFE_BABE_0000_0079,
|
||||||
|
0xCAFE_BABE_0000_007A,
|
||||||
|
0xCAFE_BABE_0000_007D,
|
||||||
|
0xCAFE_BABE_0000_007E,
|
||||||
|
0xCAFE_BABE_0000_007F,
|
||||||
|
0xCAFE_BABE_0000_0082,
|
||||||
|
0xCAFE_BABE_0000_0083,
|
||||||
|
0xCAFE_BABE_0000_0084,
|
||||||
|
0xCAFE_BABE_0000_0085,
|
||||||
|
0xCAFE_BABE_0000_0089,
|
||||||
|
0xCAFE_BABE_0000_008A,
|
||||||
|
0xCAFE_BABE_0000_008D,
|
||||||
|
0xCAFE_BABE_0000_008E,
|
||||||
|
0xCAFE_BABE_0000_0090,
|
||||||
|
0xCAFE_BABE_0000_0094,
|
||||||
|
0xCAFE_BABE_0000_0095,
|
||||||
|
0xCAFE_BABE_0000_0098,
|
||||||
|
0xCAFE_BABE_0000_0099,
|
||||||
|
0xCAFE_BABE_0000_009F,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
|
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
//! Pre-verified seed catalogs for each [`DifficultyLevel`] tier.
|
||||||
|
//!
|
||||||
|
//! Each slice contains seeds that are provably winnable in Draw-One mode and
|
||||||
|
//! that required a specific solver-budget range to solve — the **smallest**
|
||||||
|
//! budget that returns `Winnable` determines the tier. See
|
||||||
|
//! `solitaire_assetgen/src/bin/gen_difficulty_seeds.rs` for the generator.
|
||||||
|
//!
|
||||||
|
//! # Tiers and budget boundaries
|
||||||
|
//!
|
||||||
|
//! | Tier | move_budget | state_budget |
|
||||||
|
//! |-------------|-------------|--------------|
|
||||||
|
//! | Easy | 1 000 | 1 000 |
|
||||||
|
//! | Medium | 5 000 | 5 000 |
|
||||||
|
//! | Hard | 25 000 | 25 000 |
|
||||||
|
//! | Expert | 100 000 | 100 000 |
|
||||||
|
//! | Grandmaster | 200 000 | 200 000 |
|
||||||
|
//!
|
||||||
|
//! [`DifficultyLevel::Random`] has no catalog — the engine picks a system-time
|
||||||
|
//! seed and skips verification.
|
||||||
|
|
||||||
|
use solitaire_core::game_state::DifficultyLevel;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalogs (populated by gen_difficulty_seeds)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
|
||||||
|
pub const EASY_SEEDS: &[u64] = &[
|
||||||
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
|
||||||
|
0xD1FF_0000_0000_0001,
|
||||||
|
0xD1FF_0000_0000_0002,
|
||||||
|
0xD1FF_0000_0000_0007,
|
||||||
|
0xD1FF_0000_0000_0008,
|
||||||
|
0xD1FF_0000_0000_0009,
|
||||||
|
0xD1FF_0000_0000_000E,
|
||||||
|
0xD1FF_0000_0000_0013,
|
||||||
|
0xD1FF_0000_0000_0015,
|
||||||
|
0xD1FF_0000_0000_0018,
|
||||||
|
0xD1FF_0000_0000_001D,
|
||||||
|
0xD1FF_0000_0000_0021,
|
||||||
|
0xD1FF_0000_0000_0022,
|
||||||
|
0xD1FF_0000_0000_0026,
|
||||||
|
0xD1FF_0000_0000_002C,
|
||||||
|
0xD1FF_0000_0000_002E,
|
||||||
|
0xD1FF_0000_0000_002F,
|
||||||
|
0xD1FF_0000_0000_0035,
|
||||||
|
0xD1FF_0000_0000_0036,
|
||||||
|
0xD1FF_0000_0000_003C,
|
||||||
|
0xD1FF_0000_0000_0045,
|
||||||
|
0xD1FF_0000_0000_0046,
|
||||||
|
0xD1FF_0000_0000_0048,
|
||||||
|
0xD1FF_0000_0000_0049,
|
||||||
|
0xD1FF_0000_0000_004D,
|
||||||
|
0xD1FF_0000_0000_004F,
|
||||||
|
0xD1FF_0000_0000_0050,
|
||||||
|
0xD1FF_0000_0000_0051,
|
||||||
|
0xD1FF_0000_0000_0053,
|
||||||
|
0xD1FF_0000_0000_0054,
|
||||||
|
0xD1FF_0000_0000_0057,
|
||||||
|
0xD1FF_0000_0000_0058,
|
||||||
|
0xD1FF_0000_0000_005A,
|
||||||
|
0xD1FF_0000_0000_005B,
|
||||||
|
0xD1FF_0000_0000_005C,
|
||||||
|
0xD1FF_0000_0000_005D,
|
||||||
|
0xD1FF_0000_0000_005F,
|
||||||
|
0xD1FF_0000_0000_0061,
|
||||||
|
0xD1FF_0000_0000_0062,
|
||||||
|
0xD1FF_0000_0000_0063,
|
||||||
|
0xD1FF_0000_0000_0069,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
|
||||||
|
pub const MEDIUM_SEEDS: &[u64] = &[
|
||||||
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
|
||||||
|
0xD1FF_0000_0000_0000,
|
||||||
|
0xD1FF_0000_0000_0012,
|
||||||
|
0xD1FF_0000_0000_0016,
|
||||||
|
0xD1FF_0000_0000_001B,
|
||||||
|
0xD1FF_0000_0000_001C,
|
||||||
|
0xD1FF_0000_0000_0020,
|
||||||
|
0xD1FF_0000_0000_002A,
|
||||||
|
0xD1FF_0000_0000_0034,
|
||||||
|
0xD1FF_0000_0000_003A,
|
||||||
|
0xD1FF_0000_0000_0041,
|
||||||
|
0xD1FF_0000_0000_0043,
|
||||||
|
0xD1FF_0000_0000_0060,
|
||||||
|
0xD1FF_0000_0000_006A,
|
||||||
|
0xD1FF_0000_0000_006C,
|
||||||
|
0xD1FF_0000_0000_006E,
|
||||||
|
0xD1FF_0000_0000_006F,
|
||||||
|
0xD1FF_0000_0000_0071,
|
||||||
|
0xD1FF_0000_0000_0072,
|
||||||
|
0xD1FF_0000_0000_0075,
|
||||||
|
0xD1FF_0000_0000_0076,
|
||||||
|
0xD1FF_0000_0000_007B,
|
||||||
|
0xD1FF_0000_0000_007E,
|
||||||
|
0xD1FF_0000_0000_0081,
|
||||||
|
0xD1FF_0000_0000_0083,
|
||||||
|
0xD1FF_0000_0000_0084,
|
||||||
|
0xD1FF_0000_0000_0087,
|
||||||
|
0xD1FF_0000_0000_0090,
|
||||||
|
0xD1FF_0000_0000_0092,
|
||||||
|
0xD1FF_0000_0000_0093,
|
||||||
|
0xD1FF_0000_0000_0098,
|
||||||
|
0xD1FF_0000_0000_0099,
|
||||||
|
0xD1FF_0000_0000_009A,
|
||||||
|
0xD1FF_0000_0000_009E,
|
||||||
|
0xD1FF_0000_0000_00A5,
|
||||||
|
0xD1FF_0000_0000_00A8,
|
||||||
|
0xD1FF_0000_0000_00AA,
|
||||||
|
0xD1FF_0000_0000_00AB,
|
||||||
|
0xD1FF_0000_0000_00AE,
|
||||||
|
0xD1FF_0000_0000_00AF,
|
||||||
|
0xD1FF_0000_0000_00B0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
|
||||||
|
pub const HARD_SEEDS: &[u64] = &[
|
||||||
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
|
||||||
|
0xD1FF_0000_0000_001F,
|
||||||
|
0xD1FF_0000_0000_0024,
|
||||||
|
0xD1FF_0000_0000_0025,
|
||||||
|
0xD1FF_0000_0000_0031,
|
||||||
|
0xD1FF_0000_0000_0032,
|
||||||
|
0xD1FF_0000_0000_003E,
|
||||||
|
0xD1FF_0000_0000_004A,
|
||||||
|
0xD1FF_0000_0000_006D,
|
||||||
|
0xD1FF_0000_0000_0079,
|
||||||
|
0xD1FF_0000_0000_007C,
|
||||||
|
0xD1FF_0000_0000_0080,
|
||||||
|
0xD1FF_0000_0000_008A,
|
||||||
|
0xD1FF_0000_0000_0097,
|
||||||
|
0xD1FF_0000_0000_00B1,
|
||||||
|
0xD1FF_0000_0000_00B2,
|
||||||
|
0xD1FF_0000_0000_00B3,
|
||||||
|
0xD1FF_0000_0000_00B5,
|
||||||
|
0xD1FF_0000_0000_00B7,
|
||||||
|
0xD1FF_0000_0000_00B8,
|
||||||
|
0xD1FF_0000_0000_00B9,
|
||||||
|
0xD1FF_0000_0000_00BA,
|
||||||
|
0xD1FF_0000_0000_00BB,
|
||||||
|
0xD1FF_0000_0000_00BC,
|
||||||
|
0xD1FF_0000_0000_00BD,
|
||||||
|
0xD1FF_0000_0000_00C2,
|
||||||
|
0xD1FF_0000_0000_00C3,
|
||||||
|
0xD1FF_0000_0000_00C5,
|
||||||
|
0xD1FF_0000_0000_00CC,
|
||||||
|
0xD1FF_0000_0000_00CE,
|
||||||
|
0xD1FF_0000_0000_00D1,
|
||||||
|
0xD1FF_0000_0000_00D2,
|
||||||
|
0xD1FF_0000_0000_00D6,
|
||||||
|
0xD1FF_0000_0000_00D7,
|
||||||
|
0xD1FF_0000_0000_00DC,
|
||||||
|
0xD1FF_0000_0000_00DF,
|
||||||
|
0xD1FF_0000_0000_00E0,
|
||||||
|
0xD1FF_0000_0000_00E1,
|
||||||
|
0xD1FF_0000_0000_00E4,
|
||||||
|
0xD1FF_0000_0000_00E6,
|
||||||
|
0xD1FF_0000_0000_00E7,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
|
||||||
|
pub const EXPERT_SEEDS: &[u64] = &[
|
||||||
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
|
||||||
|
0xD1FF_0000_0000_0006,
|
||||||
|
0xD1FF_0000_0000_000B,
|
||||||
|
0xD1FF_0000_0000_0019,
|
||||||
|
0xD1FF_0000_0000_0082,
|
||||||
|
0xD1FF_0000_0000_00CB,
|
||||||
|
0xD1FF_0000_0000_00D5,
|
||||||
|
0xD1FF_0000_0000_00D8,
|
||||||
|
0xD1FF_0000_0000_00E8,
|
||||||
|
0xD1FF_0000_0000_00EA,
|
||||||
|
0xD1FF_0000_0000_00EB,
|
||||||
|
0xD1FF_0000_0000_00EC,
|
||||||
|
0xD1FF_0000_0000_00ED,
|
||||||
|
0xD1FF_0000_0000_00F2,
|
||||||
|
0xD1FF_0000_0000_00F3,
|
||||||
|
0xD1FF_0000_0000_00F4,
|
||||||
|
0xD1FF_0000_0000_00FE,
|
||||||
|
0xD1FF_0000_0000_00FF,
|
||||||
|
0xD1FF_0000_0000_0102,
|
||||||
|
0xD1FF_0000_0000_0103,
|
||||||
|
0xD1FF_0000_0000_0104,
|
||||||
|
0xD1FF_0000_0000_0105,
|
||||||
|
0xD1FF_0000_0000_0106,
|
||||||
|
0xD1FF_0000_0000_0109,
|
||||||
|
0xD1FF_0000_0000_010B,
|
||||||
|
0xD1FF_0000_0000_010C,
|
||||||
|
0xD1FF_0000_0000_0110,
|
||||||
|
0xD1FF_0000_0000_0113,
|
||||||
|
0xD1FF_0000_0000_0114,
|
||||||
|
0xD1FF_0000_0000_011B,
|
||||||
|
0xD1FF_0000_0000_011C,
|
||||||
|
0xD1FF_0000_0000_011E,
|
||||||
|
0xD1FF_0000_0000_0120,
|
||||||
|
0xD1FF_0000_0000_0121,
|
||||||
|
0xD1FF_0000_0000_0122,
|
||||||
|
0xD1FF_0000_0000_0123,
|
||||||
|
0xD1FF_0000_0000_0124,
|
||||||
|
0xD1FF_0000_0000_0126,
|
||||||
|
0xD1FF_0000_0000_012B,
|
||||||
|
0xD1FF_0000_0000_012C,
|
||||||
|
0xD1FF_0000_0000_012E,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
|
||||||
|
pub const GRANDMASTER_SEEDS: &[u64] = &[
|
||||||
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
|
||||||
|
0xD1FF_0000_0000_0027,
|
||||||
|
0xD1FF_0000_0000_00A0,
|
||||||
|
0xD1FF_0000_0000_00C4,
|
||||||
|
0xD1FF_0000_0000_00D4,
|
||||||
|
0xD1FF_0000_0000_00DE,
|
||||||
|
0xD1FF_0000_0000_00F9,
|
||||||
|
0xD1FF_0000_0000_0107,
|
||||||
|
0xD1FF_0000_0000_0108,
|
||||||
|
0xD1FF_0000_0000_0130,
|
||||||
|
0xD1FF_0000_0000_0132,
|
||||||
|
0xD1FF_0000_0000_0133,
|
||||||
|
0xD1FF_0000_0000_0134,
|
||||||
|
0xD1FF_0000_0000_0135,
|
||||||
|
0xD1FF_0000_0000_0137,
|
||||||
|
0xD1FF_0000_0000_0139,
|
||||||
|
0xD1FF_0000_0000_013A,
|
||||||
|
0xD1FF_0000_0000_013D,
|
||||||
|
0xD1FF_0000_0000_013F,
|
||||||
|
0xD1FF_0000_0000_0140,
|
||||||
|
0xD1FF_0000_0000_0141,
|
||||||
|
0xD1FF_0000_0000_0142,
|
||||||
|
0xD1FF_0000_0000_0143,
|
||||||
|
0xD1FF_0000_0000_0145,
|
||||||
|
0xD1FF_0000_0000_0146,
|
||||||
|
0xD1FF_0000_0000_014A,
|
||||||
|
0xD1FF_0000_0000_014B,
|
||||||
|
0xD1FF_0000_0000_014C,
|
||||||
|
0xD1FF_0000_0000_014D,
|
||||||
|
0xD1FF_0000_0000_014F,
|
||||||
|
0xD1FF_0000_0000_0150,
|
||||||
|
0xD1FF_0000_0000_0151,
|
||||||
|
0xD1FF_0000_0000_0152,
|
||||||
|
0xD1FF_0000_0000_0153,
|
||||||
|
0xD1FF_0000_0000_0157,
|
||||||
|
0xD1FF_0000_0000_0158,
|
||||||
|
0xD1FF_0000_0000_015B,
|
||||||
|
0xD1FF_0000_0000_015C,
|
||||||
|
0xD1FF_0000_0000_015E,
|
||||||
|
0xD1FF_0000_0000_0162,
|
||||||
|
0xD1FF_0000_0000_0164,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Type alias for the catalog lookup return: a static slice or `None` for `Random`.
|
||||||
|
pub type DifficultySeeds = Option<&'static [u64]>;
|
||||||
|
|
||||||
|
/// Return the seed catalog for `level`, or `None` for `Random` (caller must
|
||||||
|
/// use a system-time seed instead).
|
||||||
|
pub fn seeds_for(level: DifficultyLevel) -> DifficultySeeds {
|
||||||
|
match level {
|
||||||
|
DifficultyLevel::Easy => Some(EASY_SEEDS),
|
||||||
|
DifficultyLevel::Medium => Some(MEDIUM_SEEDS),
|
||||||
|
DifficultyLevel::Hard => Some(HARD_SEEDS),
|
||||||
|
DifficultyLevel::Expert => Some(EXPERT_SEEDS),
|
||||||
|
DifficultyLevel::Grandmaster => Some(GRANDMASTER_SEEDS),
|
||||||
|
DifficultyLevel::Random => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_difficulty_seeds_are_unique() {
|
||||||
|
let all: Vec<u64> = [
|
||||||
|
EASY_SEEDS,
|
||||||
|
MEDIUM_SEEDS,
|
||||||
|
HARD_SEEDS,
|
||||||
|
EXPERT_SEEDS,
|
||||||
|
GRANDMASTER_SEEDS,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|s| s.iter().copied())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut sorted = all.clone();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
let before = sorted.len();
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sorted.len(), before, "duplicate seeds found across difficulty tiers");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seeds_for_random_returns_none() {
|
||||||
|
assert!(seeds_for(DifficultyLevel::Random).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seeds_for_non_random_returns_some() {
|
||||||
|
for level in [
|
||||||
|
DifficultyLevel::Easy,
|
||||||
|
DifficultyLevel::Medium,
|
||||||
|
DifficultyLevel::Hard,
|
||||||
|
DifficultyLevel::Expert,
|
||||||
|
DifficultyLevel::Grandmaster,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
seeds_for(level).is_some(),
|
||||||
|
"{level:?} should return Some catalog"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,9 @@ pub use weekly::{
|
|||||||
pub mod challenge;
|
pub mod challenge;
|
||||||
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||||
|
|
||||||
|
pub mod difficulty_seeds;
|
||||||
|
pub use difficulty_seeds::{seeds_for, DifficultySeeds};
|
||||||
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
@@ -147,6 +150,9 @@ pub use settings::{
|
|||||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android_keystore;
|
||||||
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
pub use auth_tokens::{
|
pub use auth_tokens::{
|
||||||
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
||||||
|
|||||||
@@ -147,12 +147,38 @@ pub struct Replay {
|
|||||||
/// [`REPLAY_SCHEMA_VERSION`].
|
/// [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub share_url: Option<String>,
|
pub share_url: Option<String>,
|
||||||
|
/// Index into [`moves`](Self::moves) of the move that triggered
|
||||||
|
/// the win condition (i.e. completed the last foundation pile).
|
||||||
|
///
|
||||||
|
/// For replays recorded by the live engine this is always
|
||||||
|
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
||||||
|
/// the field is stored explicitly so the playback UI can read it
|
||||||
|
/// directly without re-deriving "the last move was the win" each
|
||||||
|
/// time, and to leave room for future recording semantics that
|
||||||
|
/// might capture post-win state.
|
||||||
|
///
|
||||||
|
/// `None` for replays loaded from disk that pre-date this field.
|
||||||
|
/// `#[serde(default)]` keeps older `latest_replay.json` /
|
||||||
|
/// `replays.json` files loadable without bumping
|
||||||
|
/// [`REPLAY_SCHEMA_VERSION`] — this is an additive optional
|
||||||
|
/// field, not a schema-breaking change.
|
||||||
|
///
|
||||||
|
/// Surfaced by the replay-overlay scrub bar's WIN MOVE marker
|
||||||
|
/// (B-2 screen-takeover redesign) when present.
|
||||||
|
#[serde(default)]
|
||||||
|
pub win_move_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Replay {
|
impl Replay {
|
||||||
/// Construct a fresh replay with the current schema version. The
|
/// Construct a fresh replay with the current schema version. The
|
||||||
/// caller fills in the recorded fields; this is the canonical
|
/// caller fills in the recorded fields; this is the canonical
|
||||||
/// constructor used by the engine on win.
|
/// constructor used by the engine on win.
|
||||||
|
///
|
||||||
|
/// [`win_move_index`](Self::win_move_index) and
|
||||||
|
/// [`share_url`](Self::share_url) default to `None` — the engine
|
||||||
|
/// uses [`with_win_move_index`](Self::with_win_move_index) at the
|
||||||
|
/// recording site to set the former, and `sync_plugin` writes the
|
||||||
|
/// latter directly when the upload task resolves.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
seed: u64,
|
seed: u64,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawMode,
|
||||||
@@ -172,8 +198,24 @@ impl Replay {
|
|||||||
recorded_at,
|
recorded_at,
|
||||||
moves,
|
moves,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
|
win_move_index: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builder-style setter for [`win_move_index`](Self::win_move_index).
|
||||||
|
/// Returns `self` so the recording site can chain it onto
|
||||||
|
/// [`Replay::new`]:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `None` is a valid input — useful for tests that don't care about
|
||||||
|
/// the WIN MOVE marker's scrub-bar position.
|
||||||
|
pub fn with_win_move_index(mut self, idx: Option<usize>) -> Self {
|
||||||
|
self.win_move_index = idx;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rolling history of the player's most recent winning replays.
|
/// Rolling history of the player's most recent winning replays.
|
||||||
@@ -737,4 +779,71 @@ mod tests {
|
|||||||
|
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// win_move_index — additive optional field for the WIN MOVE marker
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_new_defaults_win_move_index_to_none() {
|
||||||
|
let r = sample_replay();
|
||||||
|
assert_eq!(r.win_move_index, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_win_move_index_sets_value() {
|
||||||
|
let r = sample_replay().with_win_move_index(Some(3));
|
||||||
|
assert_eq!(r.win_move_index, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_win_move_index_accepts_none() {
|
||||||
|
// Passing None through the builder is a valid no-op — useful for
|
||||||
|
// tests / synthetic replays that don't care about the marker.
|
||||||
|
let r = sample_replay().with_win_move_index(None);
|
||||||
|
assert_eq!(r.win_move_index, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_with_win_move_index_round_trips_on_disk() {
|
||||||
|
let path = tmp_path("win_move_index_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let original = sample_replay().with_win_move_index(Some(3));
|
||||||
|
save_latest_replay_to(&path, &original).expect("save");
|
||||||
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
|
assert_eq!(loaded.win_move_index, Some(3));
|
||||||
|
assert_eq!(loaded, original);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Older replay files written before this field was added must still
|
||||||
|
/// load — `#[serde(default)]` keeps `win_move_index` optional and
|
||||||
|
/// defaults missing fields to `None`. This is the contract that lets
|
||||||
|
/// us add the field without bumping `REPLAY_SCHEMA_VERSION`.
|
||||||
|
#[test]
|
||||||
|
fn replay_without_win_move_index_loads_with_none() {
|
||||||
|
let path = tmp_path("legacy_no_win_move_index");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
||||||
|
let v2_no_field = r#"{
|
||||||
|
"schema_version": 2,
|
||||||
|
"seed": 1,
|
||||||
|
"draw_mode": "DrawOne",
|
||||||
|
"mode": "Classic",
|
||||||
|
"time_seconds": 60,
|
||||||
|
"final_score": 100,
|
||||||
|
"recorded_at": "2026-05-02",
|
||||||
|
"moves": []
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, v2_no_field).expect("write fixture");
|
||||||
|
|
||||||
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
|
assert_eq!(loaded.win_move_index, None);
|
||||||
|
assert_eq!(loaded.schema_version, REPLAY_SCHEMA_VERSION);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
@@ -224,6 +224,13 @@ pub struct Settings {
|
|||||||
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||||
#[serde(default = "default_replay_move_interval_secs")]
|
#[serde(default = "default_replay_move_interval_secs")]
|
||||||
pub replay_move_interval_secs: f32,
|
pub replay_move_interval_secs: f32,
|
||||||
|
/// Last difficulty tier the player selected. `None` means the player has
|
||||||
|
/// never used the difficulty picker. When `Some`, the difficulty section in
|
||||||
|
/// the home overlay opens pre-expanded and highlights this tier. Older
|
||||||
|
/// `settings.json` files written before this field existed deserialize
|
||||||
|
/// cleanly to `None` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_difficulty: Option<DifficultyLevel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -342,6 +349,7 @@ impl Default for Settings {
|
|||||||
winnable_deals_only: false,
|
winnable_deals_only: false,
|
||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ impl StatsExt for StatsSnapshot {
|
|||||||
// Time Attack uses its own session-level scoring; a per-game best
|
// Time Attack uses its own session-level scoring; a per-game best
|
||||||
// wouldn't compose with the other modes' single-game numbers.
|
// wouldn't compose with the other modes' single-game numbers.
|
||||||
GameMode::TimeAttack => {}
|
GameMode::TimeAttack => {}
|
||||||
|
// Difficulty games pool into the Classic best-score/time buckets per
|
||||||
|
// the user's stats preference.
|
||||||
|
GameMode::Difficulty(_) => {
|
||||||
|
self.classic_best_score = self.classic_best_score.max(score_u32);
|
||||||
|
self.classic_fastest_win_seconds =
|
||||||
|
min_ignore_zero(self.classic_fastest_win_seconds, time_seconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.last_modified = Utc::now();
|
self.last_modified = Utc::now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ zip = { workspace = true }
|
|||||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
arboard = { workspace = true }
|
arboard = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1445,6 +1445,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1480,6 +1481,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1512,6 +1514,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
@@ -1534,6 +1537,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
@@ -1559,6 +1563,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/// Android clipboard bridge via JNI.
|
||||||
|
///
|
||||||
|
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
||||||
|
/// through the JNI. Only compiled and linked on `target_os = "android"`.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn set_text(text: &str) -> Result<(), String> {
|
||||||
|
use bevy::android::ANDROID_APP;
|
||||||
|
use jni::{
|
||||||
|
objects::{JObject, JValueOwned},
|
||||||
|
JavaVM,
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = ANDROID_APP
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||||
|
|
||||||
|
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
|
||||||
|
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||||
|
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||||
|
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread_permanently()
|
||||||
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||||
|
|
||||||
|
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
|
||||||
|
// valid for the lifetime of the process.
|
||||||
|
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
||||||
|
|
||||||
|
(|| -> jni::errors::Result<()> {
|
||||||
|
// ClipboardManager cm = activity.getSystemService("clipboard")
|
||||||
|
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
||||||
|
let cm = env
|
||||||
|
.call_method(
|
||||||
|
&activity,
|
||||||
|
"getSystemService",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
|
&[svc_name.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
// ClipData clip = ClipData.newPlainText("link", text)
|
||||||
|
let label = JValueOwned::from(env.new_string("link")?);
|
||||||
|
let java_text = JValueOwned::from(env.new_string(text)?);
|
||||||
|
let clip_class = env.find_class("android/content/ClipData")?;
|
||||||
|
let clip = env
|
||||||
|
.call_static_method(
|
||||||
|
&clip_class,
|
||||||
|
"newPlainText",
|
||||||
|
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
|
||||||
|
&[label.borrow(), java_text.borrow()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
|
||||||
|
// cm.setPrimaryClip(clip)
|
||||||
|
let clip_val = JValueOwned::Object(clip);
|
||||||
|
env.call_method(
|
||||||
|
&cm,
|
||||||
|
"setPrimaryClip",
|
||||||
|
"(Landroid/content/ClipData;)V",
|
||||||
|
&[clip_val.borrow()],
|
||||||
|
)?
|
||||||
|
.v()
|
||||||
|
})()
|
||||||
|
.map_err(|e| format!("clipboard JNI: {e}"))
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ use crate::card_plugin::CardEntity;
|
|||||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||||
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, XpAwardedEvent,
|
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, WarningToastEvent,
|
||||||
|
XpAwardedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -164,6 +165,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
@@ -186,6 +188,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_auto_complete_toast,
|
handle_auto_complete_toast,
|
||||||
handle_xp_awarded_toast,
|
handle_xp_awarded_toast,
|
||||||
handle_move_rejected_toast,
|
handle_move_rejected_toast,
|
||||||
|
handle_warning_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
)
|
)
|
||||||
@@ -651,6 +654,23 @@ fn handle_move_rejected_toast(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns a 4-second amber-bordered Warning toast for every incoming
|
||||||
|
/// [`WarningToastEvent`]. First in-engine consumer of
|
||||||
|
/// [`ToastVariant::Warning`] — exercises the variant's amber accent and
|
||||||
|
/// the design-system "act soon" semantic.
|
||||||
|
///
|
||||||
|
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
|
||||||
|
/// event (not a domain-specific one) because Warning has multiple
|
||||||
|
/// candidate drivers and the call-site knows the message wording.
|
||||||
|
fn handle_warning_toast(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut events: MessageReader<WarningToastEvent>,
|
||||||
|
) {
|
||||||
|
for ev in events.read() {
|
||||||
|
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
|
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
|
||||||
///
|
///
|
||||||
/// Skipped while the game is paused so toast countdowns freeze along with the
|
/// Skipped while the game is paused so toast countdowns freeze along with the
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
use chrono::{Local, NaiveDate};
|
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
||||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||||
use solitaire_sync::ChallengeGoal;
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
|
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
|
||||||
XpAwardedEvent,
|
WarningToastEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
@@ -30,6 +30,11 @@ use crate::sync_plugin::SyncProviderResource;
|
|||||||
/// Bonus XP awarded for completing today's daily challenge.
|
/// Bonus XP awarded for completing today's daily challenge.
|
||||||
pub const DAILY_BONUS_XP: u64 = 100;
|
pub const DAILY_BONUS_XP: u64 = 100;
|
||||||
|
|
||||||
|
/// Minutes before UTC midnight at which the daily-challenge expiry warning
|
||||||
|
/// fires. The reset is global (UTC), so the warning is global too — local
|
||||||
|
/// midnight may be hours away or already past.
|
||||||
|
pub const DAILY_EXPIRY_WARNING_MINUTES: i64 = 30;
|
||||||
|
|
||||||
/// The active daily challenge — date + RNG seed for that date's deal,
|
/// The active daily challenge — date + RNG seed for that date's deal,
|
||||||
/// plus optional goal metadata fetched from the server.
|
/// plus optional goal metadata fetched from the server.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
@@ -74,6 +79,16 @@ pub struct DailyChallengeCompletedEvent {
|
|||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||||
|
|
||||||
|
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
|
||||||
|
/// already fired for, so the toast spawns at most once per day.
|
||||||
|
///
|
||||||
|
/// `None` until the first warning fires; thereafter holds the date the
|
||||||
|
/// warning was shown for. When `daily.date` advances (a new local day rolls
|
||||||
|
/// over while the app stays open), this becomes stale and the next warning
|
||||||
|
/// can fire.
|
||||||
|
#[derive(Resource, Default, Debug)]
|
||||||
|
struct DailyExpiryWarningShown(Option<NaiveDate>);
|
||||||
|
|
||||||
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
|
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
|
||||||
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
|
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
|
||||||
pub struct DailyChallengePlugin;
|
pub struct DailyChallengePlugin;
|
||||||
@@ -82,18 +97,21 @@ impl Plugin for DailyChallengePlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(DailyChallengeResource::for_today())
|
app.insert_resource(DailyChallengeResource::for_today())
|
||||||
.init_resource::<DailyChallengeTask>()
|
.init_resource::<DailyChallengeTask>()
|
||||||
|
.init_resource::<DailyExpiryWarningShown>()
|
||||||
.add_message::<DailyChallengeCompletedEvent>()
|
.add_message::<DailyChallengeCompletedEvent>()
|
||||||
.add_message::<DailyGoalAnnouncementEvent>()
|
.add_message::<DailyGoalAnnouncementEvent>()
|
||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_systems(Startup, fetch_server_challenge)
|
.add_systems(Startup, fetch_server_challenge)
|
||||||
.add_systems(Update, poll_server_challenge)
|
.add_systems(Update, poll_server_challenge)
|
||||||
// record/award after the base ProgressUpdate so we don't fight
|
// record/award after the base ProgressUpdate so we don't fight
|
||||||
// ProgressPlugin's add_xp on the same frame.
|
// ProgressPlugin's add_xp on the same frame.
|
||||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||||
.add_systems(Update, handle_start_daily_request.before(GameMutation));
|
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
||||||
|
.add_systems(Update, check_daily_expiry_warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +233,71 @@ fn handle_start_daily_request(
|
|||||||
announce.write(DailyGoalAnnouncementEvent(desc));
|
announce.write(DailyGoalAnnouncementEvent(desc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure decision logic for the daily-challenge expiry warning. Returns the
|
||||||
|
/// integer minutes-until-UTC-midnight if a warning toast should fire on this
|
||||||
|
/// frame, or `None` if any suppression condition holds.
|
||||||
|
///
|
||||||
|
/// Suppression rules (in order):
|
||||||
|
/// 1. Player has already completed today's daily challenge.
|
||||||
|
/// 2. The warning has already fired for `daily_date`.
|
||||||
|
/// 3. UTC midnight is more than [`DAILY_EXPIRY_WARNING_MINUTES`] away.
|
||||||
|
/// 4. UTC midnight has already passed for the current calendar day (the
|
||||||
|
/// minutes-remaining is negative — happens for at most one frame at the
|
||||||
|
/// rollover boundary).
|
||||||
|
///
|
||||||
|
/// Factored out so the threshold/clock behavior is unit-testable without an
|
||||||
|
/// `App`.
|
||||||
|
fn compute_expiry_warning_minutes(
|
||||||
|
daily_date: NaiveDate,
|
||||||
|
last_completed: Option<NaiveDate>,
|
||||||
|
last_shown: Option<NaiveDate>,
|
||||||
|
now_utc: DateTime<Utc>,
|
||||||
|
threshold_mins: i64,
|
||||||
|
) -> Option<i64> {
|
||||||
|
if last_completed == Some(daily_date) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if last_shown == Some(daily_date) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let next_midnight = (now_utc.date_naive() + Duration::days(1))
|
||||||
|
.and_hms_opt(0, 0, 0)?
|
||||||
|
.and_utc();
|
||||||
|
let mins_remaining = (next_midnight - now_utc).num_minutes();
|
||||||
|
if !(0..=threshold_mins).contains(&mins_remaining) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(mins_remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each-frame check for the daily-challenge expiry warning. Fires a single
|
||||||
|
/// [`WarningToastEvent`] when the player is within
|
||||||
|
/// [`DAILY_EXPIRY_WARNING_MINUTES`] of UTC midnight reset and hasn't yet
|
||||||
|
/// completed today's challenge.
|
||||||
|
///
|
||||||
|
/// Idempotent — `DailyExpiryWarningShown` ensures the toast spawns at most
|
||||||
|
/// once per `daily.date`.
|
||||||
|
fn check_daily_expiry_warning(
|
||||||
|
daily: Res<DailyChallengeResource>,
|
||||||
|
progress: Res<ProgressResource>,
|
||||||
|
mut shown: ResMut<DailyExpiryWarningShown>,
|
||||||
|
mut warning: MessageWriter<WarningToastEvent>,
|
||||||
|
) {
|
||||||
|
let Some(mins) = compute_expiry_warning_minutes(
|
||||||
|
daily.date,
|
||||||
|
progress.0.daily_challenge_last_completed,
|
||||||
|
shown.0,
|
||||||
|
Utc::now(),
|
||||||
|
DAILY_EXPIRY_WARNING_MINUTES,
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
shown.0 = Some(daily.date);
|
||||||
|
warning.write(WarningToastEvent(format!(
|
||||||
|
"Daily challenge expires in {mins} min"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -385,4 +468,141 @@ mod tests {
|
|||||||
assert_eq!(r.target_score, Some(1_000));
|
assert_eq!(r.target_score, Some(1_000));
|
||||||
assert_eq!(r.max_time_secs, Some(300));
|
assert_eq!(r.max_time_secs, Some(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Daily-expiry warning toast (compute_expiry_warning_minutes + system)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a UTC `DateTime` at the given calendar position. Used to
|
||||||
|
/// drive the pure helper through every threshold edge.
|
||||||
|
fn utc_at(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
|
||||||
|
ymd(y, m, d).and_hms_opt(h, min, 0).unwrap().and_utc()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_fires_inside_threshold_when_incomplete_and_unseen() {
|
||||||
|
// 23:50 UTC, 10 min until reset, < 30 min threshold.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
|
||||||
|
assert_eq!(mins, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_fires_at_exact_threshold_boundary() {
|
||||||
|
// 23:30 UTC, exactly 30 min remaining — the inclusive boundary.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 30);
|
||||||
|
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
|
||||||
|
assert_eq!(mins, Some(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_suppressed_outside_threshold() {
|
||||||
|
// 23:00 UTC, 60 min remaining — outside the 30 min window.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 0);
|
||||||
|
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
|
||||||
|
assert_eq!(mins, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_suppressed_when_already_completed_today() {
|
||||||
|
// 23:50 UTC inside threshold, but today is already done.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(
|
||||||
|
ymd(2026, 5, 8),
|
||||||
|
Some(ymd(2026, 5, 8)),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(mins, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_suppressed_when_yesterdays_completion_is_stale() {
|
||||||
|
// Yesterday's completion is irrelevant — we want to warn about today.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(
|
||||||
|
ymd(2026, 5, 8),
|
||||||
|
Some(ymd(2026, 5, 7)),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(mins, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_suppressed_when_already_shown_for_this_date() {
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(
|
||||||
|
ymd(2026, 5, 8),
|
||||||
|
None,
|
||||||
|
Some(ymd(2026, 5, 8)),
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(mins, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_fires_when_last_shown_was_yesterday() {
|
||||||
|
// Player kept the app open across a midnight rollover. Stale
|
||||||
|
// "shown" date doesn't suppress today's warning.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(
|
||||||
|
ymd(2026, 5, 8),
|
||||||
|
None,
|
||||||
|
Some(ymd(2026, 5, 7)),
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(mins, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_system_fires_warning_event_only_once_per_day() {
|
||||||
|
// The pure helper is exhaustively tested above. This test verifies
|
||||||
|
// the system that consumes it correctly stores the "shown" date so
|
||||||
|
// the WarningToastEvent fires at most once per `daily.date`, even
|
||||||
|
// when the system runs many frames in a row inside the threshold.
|
||||||
|
//
|
||||||
|
// The system reads `Utc::now()` directly, so we can't pin the clock.
|
||||||
|
// Instead, we simulate the post-warning state by pre-populating
|
||||||
|
// `DailyExpiryWarningShown` with `daily.date` and asserting nothing
|
||||||
|
// fires; then we verify the symmetric "completed today" suppression.
|
||||||
|
let mut app = headless_app();
|
||||||
|
let today = app.world().resource::<DailyChallengeResource>().date;
|
||||||
|
|
||||||
|
// Pre-mark warning as already shown for today.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<DailyExpiryWarningShown>()
|
||||||
|
.0 = Some(today);
|
||||||
|
app.update();
|
||||||
|
let events = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(events).next().is_none(),
|
||||||
|
"no warning fires when DailyExpiryWarningShown already covers today"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset shown, mark today as completed.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<DailyExpiryWarningShown>()
|
||||||
|
.0 = None;
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.daily_challenge_last_completed = Some(today);
|
||||||
|
app.update();
|
||||||
|
let events = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(events).next().is_none(),
|
||||||
|
"no warning fires when today is already completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
//! Difficulty-tier game-start plugin.
|
||||||
|
//!
|
||||||
|
//! Handles [`StartDifficultyRequestEvent`] by picking the next seed from the
|
||||||
|
//! appropriate pre-verified catalog in `solitaire_data::difficulty_seeds` and
|
||||||
|
//! writing a [`NewGameRequestEvent`]. For [`DifficultyLevel::Random`] a
|
||||||
|
//! system-time seed is used instead — the deal may or may not be winnable.
|
||||||
|
//!
|
||||||
|
//! # Catalog cycling
|
||||||
|
//!
|
||||||
|
//! Each tier maintains an independent cursor in [`DifficultyIndexResource`]
|
||||||
|
//! that advances one step each time a game is started at that tier. The cursor
|
||||||
|
//! wraps modulo the catalog length so players never run out of variety. The
|
||||||
|
//! resource is *not* persisted — it resets to 0 on every launch, which is fine
|
||||||
|
//! because the starting position is effectively random (player-chosen timing
|
||||||
|
//! determines which seed in the 40-entry catalog they start at).
|
||||||
|
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
||||||
|
use solitaire_data::difficulty_seeds::seeds_for;
|
||||||
|
|
||||||
|
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Per-tier catalog cursors. Each value is the index of the **next** seed to
|
||||||
|
/// deal from that tier's catalog. Wraps modulo the catalog length.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct DifficultyIndexResource {
|
||||||
|
easy: usize,
|
||||||
|
medium: usize,
|
||||||
|
hard: usize,
|
||||||
|
expert: usize,
|
||||||
|
grandmaster: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DifficultyIndexResource {
|
||||||
|
/// Advance the cursor for `level` and return the seed at the old position.
|
||||||
|
/// Falls back to a system-time seed if the catalog is unexpectedly empty.
|
||||||
|
pub fn next_seed(&mut self, level: DifficultyLevel) -> u64 {
|
||||||
|
let Some(catalog) = seeds_for(level) else {
|
||||||
|
return seed_from_system_time();
|
||||||
|
};
|
||||||
|
if catalog.is_empty() {
|
||||||
|
return seed_from_system_time();
|
||||||
|
}
|
||||||
|
let cursor = match level {
|
||||||
|
DifficultyLevel::Easy => &mut self.easy,
|
||||||
|
DifficultyLevel::Medium => &mut self.medium,
|
||||||
|
DifficultyLevel::Hard => &mut self.hard,
|
||||||
|
DifficultyLevel::Expert => &mut self.expert,
|
||||||
|
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
||||||
|
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
||||||
|
};
|
||||||
|
let seed = catalog[*cursor % catalog.len()];
|
||||||
|
*cursor = cursor.wrapping_add(1);
|
||||||
|
seed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Registers all difficulty-mode systems and resources.
|
||||||
|
pub struct DifficultyPlugin;
|
||||||
|
|
||||||
|
impl Plugin for DifficultyPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<DifficultyIndexResource>()
|
||||||
|
.add_message::<StartDifficultyRequestEvent>()
|
||||||
|
.add_message::<NewGameRequestEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
handle_difficulty_request.before(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolves `StartDifficultyRequestEvent` → catalog seed → `NewGameRequestEvent`.
|
||||||
|
fn handle_difficulty_request(
|
||||||
|
mut requests: MessageReader<StartDifficultyRequestEvent>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
mut index: ResMut<DifficultyIndexResource>,
|
||||||
|
) {
|
||||||
|
for ev in requests.read() {
|
||||||
|
let seed = if ev.level == DifficultyLevel::Random {
|
||||||
|
seed_from_system_time()
|
||||||
|
} else {
|
||||||
|
index.next_seed(ev.level)
|
||||||
|
};
|
||||||
|
|
||||||
|
new_game.write(NewGameRequestEvent {
|
||||||
|
seed: Some(seed),
|
||||||
|
mode: Some(GameMode::Difficulty(ev.level)),
|
||||||
|
confirmed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_from_system_time() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use solitaire_data::difficulty_seeds::{EASY_SEEDS, MEDIUM_SEEDS};
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(DifficultyPlugin);
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_request(app: &mut App, level: DifficultyLevel) {
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(StartDifficultyRequestEvent { level });
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_new_game_events(app: &mut App) -> Vec<NewGameRequestEvent> {
|
||||||
|
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
cursor.read(msgs).copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn easy_request_dispatches_seed_from_easy_catalog() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
fire_request(&mut app, DifficultyLevel::Easy);
|
||||||
|
|
||||||
|
let events = drain_new_game_events(&mut app);
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
let ev = &events[0];
|
||||||
|
assert!(ev.seed.is_some());
|
||||||
|
assert_eq!(ev.mode, Some(GameMode::Difficulty(DifficultyLevel::Easy)));
|
||||||
|
assert!(!ev.confirmed);
|
||||||
|
// Seed must come from the Easy catalog (non-empty catalog is the test
|
||||||
|
// precondition — the catalog uniqueness test in difficulty_seeds.rs
|
||||||
|
// guards integrity).
|
||||||
|
if !EASY_SEEDS.is_empty() {
|
||||||
|
assert!(
|
||||||
|
EASY_SEEDS.contains(&ev.seed.unwrap()),
|
||||||
|
"seed {:?} not in EASY_SEEDS",
|
||||||
|
ev.seed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successive_easy_requests_cycle_through_catalog() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
fire_request(&mut app, DifficultyLevel::Easy);
|
||||||
|
fire_request(&mut app, DifficultyLevel::Easy);
|
||||||
|
|
||||||
|
let events = drain_new_game_events(&mut app);
|
||||||
|
assert_eq!(events.len(), 2);
|
||||||
|
// Two successive requests should return different seeds (assuming the
|
||||||
|
// catalog has at least 2 entries — it has 40).
|
||||||
|
if EASY_SEEDS.len() >= 2 {
|
||||||
|
assert_ne!(
|
||||||
|
events[0].seed, events[1].seed,
|
||||||
|
"successive Easy requests should produce different seeds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn medium_request_dispatches_seed_from_medium_catalog() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
fire_request(&mut app, DifficultyLevel::Medium);
|
||||||
|
|
||||||
|
let events = drain_new_game_events(&mut app);
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
events[0].mode,
|
||||||
|
Some(GameMode::Difficulty(DifficultyLevel::Medium))
|
||||||
|
);
|
||||||
|
if !MEDIUM_SEEDS.is_empty() {
|
||||||
|
assert!(MEDIUM_SEEDS.contains(&events[0].seed.unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_request_dispatches_some_seed_with_random_mode() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
fire_request(&mut app, DifficultyLevel::Random);
|
||||||
|
|
||||||
|
let events = drain_new_game_events(&mut app);
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert!(events[0].seed.is_some(), "Random should always produce Some(seed)");
|
||||||
|
assert_eq!(
|
||||||
|
events[0].mode,
|
||||||
|
Some(GameMode::Difficulty(DifficultyLevel::Random))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_tier_cursors_are_independent() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
fire_request(&mut app, DifficultyLevel::Easy);
|
||||||
|
fire_request(&mut app, DifficultyLevel::Medium);
|
||||||
|
|
||||||
|
let events = drain_new_game_events(&mut app);
|
||||||
|
assert_eq!(events.len(), 2);
|
||||||
|
// Seeds from different catalogs should differ (they come from different
|
||||||
|
// address ranges by construction of gen_difficulty_seeds).
|
||||||
|
assert_ne!(
|
||||||
|
events[0].seed, events[1].seed,
|
||||||
|
"Easy and Medium should draw from independent catalogs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,6 +172,23 @@ pub struct StartTimeAttackRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct StartDailyChallengeRequestEvent;
|
pub struct StartDailyChallengeRequestEvent;
|
||||||
|
|
||||||
|
/// Request to open the Play-by-Seed dialog. Fired by the Home overlay
|
||||||
|
/// "Play by Seed" mode card. The handler in `play_by_seed_plugin` spawns
|
||||||
|
/// a numeric-input modal where the player types a decimal seed and
|
||||||
|
/// optionally sees a solver-verified verdict before dealing.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct StartPlayBySeedRequestEvent;
|
||||||
|
|
||||||
|
/// Request to start a game at a specific difficulty tier. Fired by the
|
||||||
|
/// difficulty section in the home overlay. The handler in `difficulty_plugin`
|
||||||
|
/// picks a seed from the corresponding pre-verified catalog (or generates a
|
||||||
|
/// random system-time seed for `DifficultyLevel::Random`) and writes a
|
||||||
|
/// `NewGameRequestEvent`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
|
pub struct StartDifficultyRequestEvent {
|
||||||
|
pub level: solitaire_core::game_state::DifficultyLevel,
|
||||||
|
}
|
||||||
|
|
||||||
/// Request to toggle the Stats overlay. Fired by the HUD Menu-popover
|
/// Request to toggle the Stats overlay. Fired by the HUD Menu-popover
|
||||||
/// "Stats" row alongside the existing `S` accelerator.
|
/// "Stats" row alongside the existing `S` accelerator.
|
||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
@@ -212,6 +229,21 @@ pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
|||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct InfoToastEvent(pub String);
|
pub struct InfoToastEvent(pub String);
|
||||||
|
|
||||||
|
/// Generic warning toast message. Spawns a fire-and-forget
|
||||||
|
/// [`ToastVariant::Warning`](crate::animation_plugin::ToastVariant) toast.
|
||||||
|
///
|
||||||
|
/// Distinct from [`InfoToastEvent`] in two ways:
|
||||||
|
/// 1. **Variant.** Warning carries the design-system warning border accent,
|
||||||
|
/// not the neutral info accent — so the player can distinguish "you might
|
||||||
|
/// want to act" from "here's some neutral information".
|
||||||
|
/// 2. **No queue.** Warnings are alerts, not a stream. Each event spawns its
|
||||||
|
/// own toast immediately rather than waiting for the info queue to drain.
|
||||||
|
///
|
||||||
|
/// First in-engine driver: daily-challenge expiry warning fired by
|
||||||
|
/// `daily_challenge_plugin` when < 30 min from UTC midnight reset.
|
||||||
|
#[derive(Message, Debug, Clone)]
|
||||||
|
pub struct WarningToastEvent(pub String);
|
||||||
|
|
||||||
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
|
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
|
||||||
/// animation layer can display a "+N XP" toast alongside the win cascade.
|
/// animation layer can display a "+N XP" toast alongside the win cascade.
|
||||||
#[derive(Message, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
|
|||||||
@@ -936,6 +936,11 @@ pub fn record_replay_on_win(
|
|||||||
if recording.moves.is_empty() {
|
if recording.moves.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Recording freezes on win, so the move that triggered the
|
||||||
|
// win condition is the last one in the list. Storing the
|
||||||
|
// index explicitly lets the playback UI read the WIN MOVE
|
||||||
|
// position directly instead of re-deriving it on every render.
|
||||||
|
let win_move_index = recording.moves.len().checked_sub(1);
|
||||||
let replay = Replay::new(
|
let replay = Replay::new(
|
||||||
game.0.seed,
|
game.0.seed,
|
||||||
game.0.draw_mode.clone(),
|
game.0.draw_mode.clone(),
|
||||||
@@ -944,7 +949,8 @@ pub fn record_replay_on_win(
|
|||||||
ev.score,
|
ev.score,
|
||||||
Utc::now().date_naive(),
|
Utc::now().date_naive(),
|
||||||
recording.moves.clone(),
|
recording.moves.clone(),
|
||||||
);
|
)
|
||||||
|
.with_win_move_index(win_move_index);
|
||||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||||
// No persistence path configured (e.g. tests / minimal Linux
|
// No persistence path configured (e.g. tests / minimal Linux
|
||||||
// containers without dirs::data_dir). The in-memory replay
|
// containers without dirs::data_dir). The in-memory replay
|
||||||
|
|||||||
@@ -16,15 +16,15 @@
|
|||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||||
use solitaire_data::save_settings_to;
|
use solitaire_data::save_settings_to;
|
||||||
|
|
||||||
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::{
|
||||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||||
ToggleProfileRequestEvent,
|
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -81,6 +81,27 @@ struct HomeDrawThreeButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct HomeScrollable;
|
struct HomeScrollable;
|
||||||
|
|
||||||
|
/// Marker on the "▶ Difficulty" / "▼ Difficulty" toggle button that
|
||||||
|
/// expands / collapses the difficulty tier chip row.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HomeDifficultyToggle;
|
||||||
|
|
||||||
|
/// Marker on each difficulty tier chip inside the expanded difficulty
|
||||||
|
/// section. The wrapped `DifficultyLevel` identifies which tier was
|
||||||
|
/// clicked so the handler can fire `StartDifficultyRequestEvent`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HomeDifficultyChip(DifficultyLevel);
|
||||||
|
|
||||||
|
/// Whether the difficulty section is currently expanded. Toggled by
|
||||||
|
/// `handle_home_difficulty_toggle` and checked by `spawn_home_screen`
|
||||||
|
/// to determine initial render state.
|
||||||
|
///
|
||||||
|
/// Initialised at plugin startup; `spawn_home_on_launch` upgrades it
|
||||||
|
/// to `true` when `settings.last_difficulty` is already set so
|
||||||
|
/// returning players see their tier pre-expanded.
|
||||||
|
#[derive(Resource, Default, Debug)]
|
||||||
|
pub struct DifficultyExpanded(pub bool);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Private mode-card data shape
|
// Private mode-card data shape
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -96,6 +117,7 @@ enum HomeMode {
|
|||||||
Zen,
|
Zen,
|
||||||
Challenge,
|
Challenge,
|
||||||
TimeAttack,
|
TimeAttack,
|
||||||
|
PlayBySeed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HomeMode {
|
impl HomeMode {
|
||||||
@@ -107,6 +129,7 @@ impl HomeMode {
|
|||||||
HomeMode::Zen => "Zen Mode",
|
HomeMode::Zen => "Zen Mode",
|
||||||
HomeMode::Challenge => "Challenge",
|
HomeMode::Challenge => "Challenge",
|
||||||
HomeMode::TimeAttack => "Time Attack",
|
HomeMode::TimeAttack => "Time Attack",
|
||||||
|
HomeMode::PlayBySeed => "Play by Seed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +141,7 @@ impl HomeMode {
|
|||||||
HomeMode::Zen => "No timer, no score. Just the cards.",
|
HomeMode::Zen => "No timer, no score. Just the cards.",
|
||||||
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
|
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
|
||||||
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
|
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
|
||||||
|
HomeMode::PlayBySeed => "Enter any number to play a specific deal.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +174,9 @@ impl HomeMode {
|
|||||||
// ships ▲ (up triangle) but evidently not the sideways
|
// ships ▲ (up triangle) but evidently not the sideways
|
||||||
// siblings.
|
// siblings.
|
||||||
HomeMode::TimeAttack => "\u{2192}",
|
HomeMode::TimeAttack => "\u{2192}",
|
||||||
|
// Number sign — ASCII, universally available. Reads as
|
||||||
|
// "a specific number / seed ID".
|
||||||
|
HomeMode::PlayBySeed => "#",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +189,7 @@ impl HomeMode {
|
|||||||
HomeMode::Zen => "Z",
|
HomeMode::Zen => "Z",
|
||||||
HomeMode::Challenge => "X",
|
HomeMode::Challenge => "X",
|
||||||
HomeMode::TimeAttack => "T",
|
HomeMode::TimeAttack => "T",
|
||||||
|
HomeMode::PlayBySeed => "6",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,11 +261,14 @@ impl Plugin for HomePlugin {
|
|||||||
// Pre-mark the auto-show as already done in headless mode so the
|
// Pre-mark the auto-show as already done in headless mode so the
|
||||||
// gating system is a permanent no-op for tests.
|
// gating system is a permanent no-op for tests.
|
||||||
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
|
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
|
||||||
|
.init_resource::<DifficultyExpanded>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
.add_message::<StartChallengeRequestEvent>()
|
.add_message::<StartChallengeRequestEvent>()
|
||||||
.add_message::<StartTimeAttackRequestEvent>()
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
|
.add_message::<StartPlayBySeedRequestEvent>()
|
||||||
|
.add_message::<StartDifficultyRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleProfileRequestEvent>()
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
@@ -245,13 +276,10 @@ impl Plugin for HomePlugin {
|
|||||||
// runs cleanly under MinimalPlugins headless tests too.
|
// runs cleanly under MinimalPlugins headless tests too.
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
// `.chain()` because several systems (M-toggle, card click,
|
// `.chain()` because several systems (M-toggle, card click,
|
||||||
// cancel button, digit-key shortcut) all read the
|
// cancel button, digit-key shortcut, difficulty handlers)
|
||||||
// `HomeScreen` entity and may queue a despawn on it in the
|
// all read the `HomeScreen` entity and may queue a despawn
|
||||||
// same tick. Bevy's parallel scheduler would otherwise let
|
// on it in the same tick. Chaining serialises these systems
|
||||||
// two of them run simultaneously and double-despawn the
|
// and keeps the despawn deterministic.
|
||||||
// entity, panicking when the second command buffer is
|
|
||||||
// applied. Chaining serialises these systems and keeps the
|
|
||||||
// despawn deterministic.
|
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -262,6 +290,8 @@ impl Plugin for HomePlugin {
|
|||||||
handle_home_cancel_button,
|
handle_home_cancel_button,
|
||||||
handle_home_profile_chip,
|
handle_home_profile_chip,
|
||||||
handle_home_draw_mode_buttons,
|
handle_home_draw_mode_buttons,
|
||||||
|
handle_home_difficulty_toggle,
|
||||||
|
handle_home_difficulty_chip_click,
|
||||||
handle_home_digit_keys,
|
handle_home_digit_keys,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
@@ -306,6 +336,7 @@ fn spawn_home_on_launch(
|
|||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
daily: Option<Res<DailyChallengeResource>>,
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||||
) {
|
) {
|
||||||
if shown.0
|
if shown.0
|
||||||
|| !splash.is_empty()
|
|| !splash.is_empty()
|
||||||
@@ -316,6 +347,11 @@ fn spawn_home_on_launch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-expand the difficulty section when the player has a saved preference.
|
||||||
|
if settings.as_ref().is_some_and(|s| s.0.last_difficulty.is_some()) {
|
||||||
|
diff_expanded.0 = true;
|
||||||
|
}
|
||||||
|
|
||||||
spawn_home_screen(
|
spawn_home_screen(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
build_home_context(
|
build_home_context(
|
||||||
@@ -324,6 +360,7 @@ fn spawn_home_on_launch(
|
|||||||
settings.as_deref(),
|
settings.as_deref(),
|
||||||
daily.as_deref(),
|
daily.as_deref(),
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
|
diff_expanded.0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
shown.0 = true;
|
shown.0 = true;
|
||||||
@@ -343,6 +380,7 @@ fn toggle_home_screen(
|
|||||||
daily: Option<Res<DailyChallengeResource>>,
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
diff_expanded: Res<DifficultyExpanded>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::KeyM) {
|
if !keys.just_pressed(KeyCode::KeyM) {
|
||||||
return;
|
return;
|
||||||
@@ -358,6 +396,7 @@ fn toggle_home_screen(
|
|||||||
settings.as_deref(),
|
settings.as_deref(),
|
||||||
daily.as_deref(),
|
daily.as_deref(),
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
|
diff_expanded.0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -373,6 +412,7 @@ fn build_home_context<'a>(
|
|||||||
settings: Option<&SettingsResource>,
|
settings: Option<&SettingsResource>,
|
||||||
daily: Option<&DailyChallengeResource>,
|
daily: Option<&DailyChallengeResource>,
|
||||||
font_res: Option<&'a FontResource>,
|
font_res: Option<&'a FontResource>,
|
||||||
|
difficulty_expanded: bool,
|
||||||
) -> HomeContext<'a> {
|
) -> HomeContext<'a> {
|
||||||
let daily_today = daily.map(|d| {
|
let daily_today = daily.map(|d| {
|
||||||
let completed_today = progress
|
let completed_today = progress
|
||||||
@@ -398,6 +438,8 @@ fn build_home_context<'a>(
|
|||||||
.map(|s| s.0.draw_mode.clone())
|
.map(|s| s.0.draw_mode.clone())
|
||||||
.unwrap_or(DrawMode::DrawOne),
|
.unwrap_or(DrawMode::DrawOne),
|
||||||
font_res,
|
font_res,
|
||||||
|
difficulty_expanded,
|
||||||
|
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +465,7 @@ fn handle_home_card_click(
|
|||||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||||
@@ -457,6 +500,9 @@ fn handle_home_card_click(
|
|||||||
HomeMode::TimeAttack => {
|
HomeMode::TimeAttack => {
|
||||||
time_attack.write(StartTimeAttackRequestEvent);
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
}
|
}
|
||||||
|
HomeMode::PlayBySeed => {
|
||||||
|
play_by_seed.write(StartPlayBySeedRequestEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the modal after dispatching the launch event.
|
// Close the modal after dispatching the launch event.
|
||||||
@@ -557,6 +603,7 @@ fn handle_home_draw_mode_buttons(
|
|||||||
stats: Option<Res<StatsResource>>,
|
stats: Option<Res<StatsResource>>,
|
||||||
daily: Option<Res<DailyChallengeResource>>,
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
|
diff_expanded: Res<DifficultyExpanded>,
|
||||||
) {
|
) {
|
||||||
if screens.is_empty() {
|
if screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -600,10 +647,92 @@ fn handle_home_draw_mode_buttons(
|
|||||||
Some(settings),
|
Some(settings),
|
||||||
daily.as_deref(),
|
daily.as_deref(),
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
|
diff_expanded.0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Difficulty section handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Click on the "▶/▼ Difficulty" header — toggle `DifficultyExpanded` and
|
||||||
|
/// repaint the Home modal so the chevron and chip row update. Mirrors
|
||||||
|
/// `handle_home_draw_mode_buttons`: despawn + respawn keeps all styling in
|
||||||
|
/// `spawn_difficulty_section` rather than scattered across mutation helpers.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_home_difficulty_toggle(
|
||||||
|
mut commands: Commands,
|
||||||
|
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
|
||||||
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
stats: Option<Res<StatsResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
diff_expanded.0 = !diff_expanded.0;
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
spawn_home_screen(
|
||||||
|
&mut commands,
|
||||||
|
build_home_context(
|
||||||
|
progress.as_deref(),
|
||||||
|
stats.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
diff_expanded.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click on a difficulty tier chip — persist `last_difficulty`, fire
|
||||||
|
/// `StartDifficultyRequestEvent`, and close the Home modal.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_home_difficulty_chip_click(
|
||||||
|
mut commands: Commands,
|
||||||
|
chips: Query<(&Interaction, &HomeDifficultyChip), Changed<Interaction>>,
|
||||||
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
mut difficulty_ev: MessageWriter<StartDifficultyRequestEvent>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
) {
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((_, chip)) = chips.iter().find(|(i, _)| **i == Interaction::Pressed) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let level = chip.0;
|
||||||
|
|
||||||
|
if let Some(s) = settings.as_mut() {
|
||||||
|
s.0.last_difficulty = Some(level);
|
||||||
|
if let Some(p) = storage_path
|
||||||
|
&& let Some(path) = p.0.as_deref()
|
||||||
|
&& let Err(e) = save_settings_to(path, &s.0)
|
||||||
|
{
|
||||||
|
warn!("home: failed to persist last_difficulty: {e}");
|
||||||
|
}
|
||||||
|
changed.write(SettingsChangedEvent(s.0.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
difficulty_ev.write(StartDifficultyRequestEvent { level });
|
||||||
|
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Digit-key shortcuts (1-5) — modal-scoped
|
// Digit-key shortcuts (1-5) — modal-scoped
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -619,6 +748,7 @@ fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
|
|||||||
KeyCode::Digit3 => Some(HomeMode::Zen),
|
KeyCode::Digit3 => Some(HomeMode::Zen),
|
||||||
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
||||||
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
||||||
|
KeyCode::Digit6 => Some(HomeMode::PlayBySeed),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,6 +776,7 @@ fn handle_home_digit_keys(
|
|||||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
||||||
) {
|
) {
|
||||||
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
||||||
if screens.is_empty() {
|
if screens.is_empty() {
|
||||||
@@ -658,6 +789,7 @@ fn handle_home_digit_keys(
|
|||||||
KeyCode::Digit3,
|
KeyCode::Digit3,
|
||||||
KeyCode::Digit4,
|
KeyCode::Digit4,
|
||||||
KeyCode::Digit5,
|
KeyCode::Digit5,
|
||||||
|
KeyCode::Digit6,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|k| keys.just_pressed(*k))
|
.find(|k| keys.just_pressed(*k))
|
||||||
@@ -687,6 +819,9 @@ fn handle_home_digit_keys(
|
|||||||
HomeMode::TimeAttack => {
|
HomeMode::TimeAttack => {
|
||||||
time_attack.write(StartTimeAttackRequestEvent);
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
}
|
}
|
||||||
|
HomeMode::PlayBySeed => {
|
||||||
|
play_by_seed.write(StartPlayBySeedRequestEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the modal after dispatching the launch event — same shape as
|
// Close the modal after dispatching the launch event — same shape as
|
||||||
@@ -717,6 +852,11 @@ struct HomeContext<'a> {
|
|||||||
daily_today: Option<DailyToday>,
|
daily_today: Option<DailyToday>,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawMode,
|
||||||
font_res: Option<&'a FontResource>,
|
font_res: Option<&'a FontResource>,
|
||||||
|
/// Whether the difficulty section header is currently expanded.
|
||||||
|
difficulty_expanded: bool,
|
||||||
|
/// The last difficulty tier the player selected (persisted in Settings).
|
||||||
|
/// When `Some`, that tier's chip is highlighted.
|
||||||
|
last_difficulty: Option<DifficultyLevel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Today's daily-challenge metadata as the Home picker needs it. Only
|
/// Today's daily-challenge metadata as the Home picker needs it. Only
|
||||||
@@ -784,10 +924,13 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
|
|||||||
HomeMode::Zen,
|
HomeMode::Zen,
|
||||||
HomeMode::Challenge,
|
HomeMode::Challenge,
|
||||||
HomeMode::TimeAttack,
|
HomeMode::TimeAttack,
|
||||||
|
HomeMode::PlayBySeed,
|
||||||
] {
|
] {
|
||||||
spawn_mode_card(grid, mode, &ctx);
|
spawn_mode_card(grid, mode, &ctx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
spawn_difficulty_section(body, &ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
spawn_modal_actions(card, |actions| {
|
spawn_modal_actions(card, |actions| {
|
||||||
@@ -951,6 +1094,101 @@ fn spawn_draw_mode_chip<M: Component>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collapsible difficulty-tier section injected below the mode tile grid.
|
||||||
|
///
|
||||||
|
/// Structure:
|
||||||
|
/// ```text
|
||||||
|
/// ▶ Difficulty ← HomeDifficultyToggle (Button, row)
|
||||||
|
/// [Easy] [Medium] [Hard] [Expert] [GM] [Random] ← visible only when expanded
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The toggle header despawns + respawns the home screen (same pattern as
|
||||||
|
/// the draw-mode toggle) so the chevron direction and chip row visibility
|
||||||
|
/// update without Visibility component surgery.
|
||||||
|
fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
||||||
|
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
|
||||||
|
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
|
||||||
|
|
||||||
|
let chevron = if ctx.difficulty_expanded { "▼" } else { "▶" };
|
||||||
|
|
||||||
|
// Header row — click to toggle expand/collapse.
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
HomeDifficultyToggle,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
padding: UiRect::axes(Val::Px(0.0), VAL_SPACE_1),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(chevron),
|
||||||
|
font_label.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Difficulty"),
|
||||||
|
font_label.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier chips — only rendered when expanded.
|
||||||
|
if ctx.difficulty_expanded {
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
for level in [
|
||||||
|
DifficultyLevel::Easy,
|
||||||
|
DifficultyLevel::Medium,
|
||||||
|
DifficultyLevel::Hard,
|
||||||
|
DifficultyLevel::Expert,
|
||||||
|
DifficultyLevel::Grandmaster,
|
||||||
|
DifficultyLevel::Random,
|
||||||
|
] {
|
||||||
|
let active = ctx.last_difficulty == Some(level);
|
||||||
|
let (bg, fg) = if active {
|
||||||
|
(ACCENT_PRIMARY, BG_ELEVATED)
|
||||||
|
} else {
|
||||||
|
(BG_ELEVATED_HI, TEXT_PRIMARY)
|
||||||
|
};
|
||||||
|
row.spawn((
|
||||||
|
HomeDifficultyChip(level),
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(bg),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|c| {
|
||||||
|
c.spawn((
|
||||||
|
Text::new(level.label()),
|
||||||
|
font_chip.clone(),
|
||||||
|
TextColor(fg),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
|
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
|
||||||
/// otherwise the raw number with thousands separators. Keeps chip text
|
/// otherwise the raw number with thousands separators. Keeps chip text
|
||||||
/// short enough to fit a 3-up header strip without wrapping.
|
/// short enough to fit a 3-up header strip without wrapping.
|
||||||
@@ -999,6 +1237,7 @@ fn home_mode_focus_order(mode: HomeMode) -> i32 {
|
|||||||
HomeMode::Zen => 2,
|
HomeMode::Zen => 2,
|
||||||
HomeMode::Challenge => 3,
|
HomeMode::Challenge => 3,
|
||||||
HomeMode::TimeAttack => 4,
|
HomeMode::TimeAttack => 4,
|
||||||
|
HomeMode::PlayBySeed => 5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1402,13 +1641,14 @@ mod tests {
|
|||||||
HomeMode::Zen,
|
HomeMode::Zen,
|
||||||
HomeMode::Challenge,
|
HomeMode::Challenge,
|
||||||
HomeMode::TimeAttack,
|
HomeMode::TimeAttack,
|
||||||
|
HomeMode::PlayBySeed,
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
modes.contains(&expected),
|
modes.contains(&expected),
|
||||||
"missing card for {expected:?}; found {modes:?}"
|
"missing card for {expected:?}; found {modes:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(modes.len(), 5, "exactly five cards expected");
|
assert_eq!(modes.len(), 6, "exactly six cards expected");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1600,7 +1840,7 @@ mod tests {
|
|||||||
.map(|(c, f)| (c.0, *f))
|
.map(|(c, f)| (c.0, *f))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert_eq!(cards.len(), 5, "all five cards must carry a Focusable");
|
assert_eq!(cards.len(), 6, "all six cards must carry a Focusable");
|
||||||
for (mode, focusable) in &cards {
|
for (mode, focusable) in &cards {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
focusable.group,
|
focusable.group,
|
||||||
@@ -1626,7 +1866,7 @@ mod tests {
|
|||||||
|
|
||||||
for (mode, disabled) in states {
|
for (mode, disabled) in states {
|
||||||
match mode {
|
match mode {
|
||||||
HomeMode::Classic | HomeMode::Daily => assert!(
|
HomeMode::Classic | HomeMode::Daily | HomeMode::PlayBySeed => assert!(
|
||||||
!disabled,
|
!disabled,
|
||||||
"{mode:?} must not be Disabled at level 0 (it's never locked)"
|
"{mode:?} must not be Disabled at level 0 (it's never locked)"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS,
|
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||||
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
||||||
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||||
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
@@ -715,6 +715,7 @@ fn spawn_action_button<M: Component>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(ACTION_BTN_IDLE),
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||||
@@ -1740,6 +1741,7 @@ fn update_hud(
|
|||||||
GameMode::Zen => "ZEN".to_string(),
|
GameMode::Zen => "ZEN".to_string(),
|
||||||
GameMode::Challenge => "CHALLENGE".to_string(),
|
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||||
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||||
|
GameMode::Difficulty(level) => level.label().to_uppercase(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::math::{Vec2, Vec3};
|
use bevy::math::{Vec2, Vec3};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{MonitorSelection, PrimaryWindow, WindowMode};
|
use bevy::window::PrimaryWindow;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
use bevy::window::{MonitorSelection, WindowMode};
|
||||||
use solitaire_core::card::{Card, Suit};
|
use solitaire_core::card::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -105,12 +107,16 @@ impl Plugin for InputPlugin {
|
|||||||
// Touch drag pipeline (parallel path through DragState).
|
// Touch drag pipeline (parallel path through DragState).
|
||||||
touch_start_drag,
|
touch_start_drag,
|
||||||
touch_follow_drag,
|
touch_follow_drag,
|
||||||
|
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
|
||||||
touch_end_drag.before(GameMutation),
|
touch_end_drag.before(GameMutation),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
)
|
)
|
||||||
.add_systems(Update, handle_fullscreen)
|
.add_systems(Update, reset_hint_cycle_on_state_change);
|
||||||
.add_systems(Update, reset_hint_cycle_on_state_change)
|
// F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
app.add_systems(Update, handle_fullscreen);
|
||||||
|
app
|
||||||
// Async hint pipeline: state-change drop runs before the
|
// Async hint pipeline: state-change drop runs before the
|
||||||
// poll system so a move applied this frame cancels any
|
// poll system so a move applied this frame cancels any
|
||||||
// in-flight task before its result can be surfaced.
|
// in-flight task before its result can be surfaced.
|
||||||
@@ -423,6 +429,7 @@ fn reset_hint_cycle_on_state_change(
|
|||||||
|
|
||||||
/// `F11` toggles between borderless-fullscreen and windowed mode.
|
/// `F11` toggles between borderless-fullscreen and windowed mode.
|
||||||
/// Not gated by the pause flag — the player can always resize the window.
|
/// Not gated by the pause flag — the player can always resize the window.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
fn handle_fullscreen(
|
fn handle_fullscreen(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||||
@@ -1204,12 +1211,16 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #27 — Double-click to auto-move
|
// Task #27 — Double-click / double-tap to auto-move
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Maximum seconds between two clicks to count as a double-click.
|
/// Maximum seconds between two clicks to count as a double-click.
|
||||||
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
||||||
|
|
||||||
|
/// Maximum seconds between two taps to count as a double-tap.
|
||||||
|
/// Slightly wider than the mouse window — touch screens have higher latency.
|
||||||
|
const DOUBLE_TAP_WINDOW: f32 = 0.5;
|
||||||
|
|
||||||
/// Find the best legal destination for `card` — Foundation first, then Tableau.
|
/// Find the best legal destination for `card` — Foundation first, then Tableau.
|
||||||
///
|
///
|
||||||
/// Returns `None` if no legal move exists from the card's current location.
|
/// Returns `None` if no legal move exists from the card's current location.
|
||||||
@@ -1363,6 +1374,124 @@ fn handle_double_click(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #27b — Double-tap to auto-move (touch equivalent of double-click)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// System that detects double-taps on face-up cards and fires `MoveRequestEvent`
|
||||||
|
/// to the best legal destination — the touch equivalent of [`handle_double_click`].
|
||||||
|
///
|
||||||
|
/// Must run **before** `touch_end_drag` in the system chain. At
|
||||||
|
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
|
||||||
|
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||||
|
/// are cleared and the tap/drag distinction is permanently lost.
|
||||||
|
///
|
||||||
|
/// A pure tap is identified by `drag.active_touch_id.is_some() &&
|
||||||
|
/// !drag.committed`: the touch began (so `touch_start_drag` populated
|
||||||
|
/// `drag`) but the drag threshold was never crossed.
|
||||||
|
///
|
||||||
|
/// Move priority matches [`handle_double_click`]:
|
||||||
|
/// 1. Move the single top card to its best foundation (or tableau).
|
||||||
|
/// 2. If no single-card move exists and the selection spans multiple
|
||||||
|
/// face-up cards, move the whole stack to the best tableau column.
|
||||||
|
/// 3. If both priorities fail, fire `MoveRejectedEvent` for audio + shake
|
||||||
|
/// feedback.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_double_tap(
|
||||||
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
time: Res<Time>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
mut last_tap: Local<HashMap<u32, f32>>,
|
||||||
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
|
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only active when a touch is tracked and hasn't crossed the drag threshold.
|
||||||
|
let Some(active_id) = drag.active_touch_id else { return };
|
||||||
|
if drag.committed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in touch_events.read() {
|
||||||
|
if event.id != active_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match event.phase {
|
||||||
|
TouchPhase::Canceled => {
|
||||||
|
// Cancelled touch — clear any pending tap state for these cards.
|
||||||
|
for &id in &drag.cards {
|
||||||
|
last_tap.remove(&id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TouchPhase::Ended => {}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncommitted touch ended = pure tap.
|
||||||
|
let Some(&top_card_id) = drag.cards.last() else { return };
|
||||||
|
let Some(ref pile) = drag.origin_pile else { return };
|
||||||
|
let Some(pile_cards) = game.0.piles.get(pile) else { return };
|
||||||
|
|
||||||
|
let Some(top_card) = pile_cards.cards.iter().find(|c| c.id == top_card_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !top_card.face_up {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = time.elapsed_secs();
|
||||||
|
let prev = last_tap.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
|
||||||
|
|
||||||
|
if now - prev <= DOUBLE_TAP_WINDOW {
|
||||||
|
last_tap.remove(&top_card_id);
|
||||||
|
|
||||||
|
// Priority 1: move single top card.
|
||||||
|
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||||
|
moves.write(MoveRequestEvent {
|
||||||
|
from: pile.clone(),
|
||||||
|
to: dest,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: move whole face-up stack to best tableau column.
|
||||||
|
if drag.cards.len() > 1 {
|
||||||
|
let stack_index = pile_cards.cards.len() - drag.cards.len();
|
||||||
|
if let Some(bottom_card) = pile_cards.cards.get(stack_index)
|
||||||
|
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||||
|
bottom_card,
|
||||||
|
pile,
|
||||||
|
&game.0,
|
||||||
|
drag.cards.len(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
moves.write(MoveRequestEvent {
|
||||||
|
from: pile.clone(),
|
||||||
|
to: dest,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected.write(MoveRejectedEvent {
|
||||||
|
from: pile.clone(),
|
||||||
|
to: pile.clone(),
|
||||||
|
count: drag.cards.len(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
last_tap.insert(top_card_id, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #28 — Hint system helpers
|
// Task #28 — Hint system helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -2215,5 +2344,14 @@ mod tests {
|
|||||||
"pressing H must spawn an async hint task",
|
"pressing H must spawn an async hint task",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task #27b — double-tap constants
|
||||||
|
#[test]
|
||||||
|
fn double_tap_window_is_wider_than_double_click_window() {
|
||||||
|
// Compile-time check: touch needs a wider window than mouse due to
|
||||||
|
// higher input latency. `const { assert! }` catches regressions at
|
||||||
|
// build time rather than waiting for a test run.
|
||||||
|
const { assert!(DOUBLE_TAP_WINDOW > DOUBLE_CLICK_WINDOW) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Bevy integration layer for Solitaire Quest.
|
//! Bevy integration layer for Solitaire Quest.
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub mod android_clipboard;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod card_animation;
|
pub mod card_animation;
|
||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
@@ -12,6 +14,7 @@ pub mod feedback_anim_plugin;
|
|||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
pub mod cursor_plugin;
|
pub mod cursor_plugin;
|
||||||
pub mod daily_challenge_plugin;
|
pub mod daily_challenge_plugin;
|
||||||
|
pub mod difficulty_plugin;
|
||||||
pub mod diagnostics_hud;
|
pub mod diagnostics_hud;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
@@ -24,6 +27,7 @@ pub mod layout;
|
|||||||
pub mod onboarding_plugin;
|
pub mod onboarding_plugin;
|
||||||
pub mod pause_plugin;
|
pub mod pause_plugin;
|
||||||
pub mod pending_hint;
|
pub mod pending_hint;
|
||||||
|
pub mod play_by_seed_plugin;
|
||||||
pub mod profile_plugin;
|
pub mod profile_plugin;
|
||||||
pub mod radial_menu;
|
pub mod radial_menu;
|
||||||
pub mod replay_overlay;
|
pub mod replay_overlay;
|
||||||
@@ -92,11 +96,14 @@ pub use events::{
|
|||||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||||
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent,
|
||||||
|
WinStreakMilestoneEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
|
pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin};
|
||||||
|
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
|
||||||
pub use game_plugin::{
|
pub use game_plugin::{
|
||||||
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
||||||
ReplayPath,
|
ReplayPath,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use crate::events::{
|
|||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use crate::resources::{DragState, GameStateResource};
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||||
@@ -154,6 +155,7 @@ fn toggle_pause(
|
|||||||
mut drag: Option<ResMut<DragState>>,
|
mut drag: Option<ResMut<DragState>>,
|
||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
selection: Option<Res<SelectionState>>,
|
selection: Option<Res<SelectionState>>,
|
||||||
|
replay_state: Option<Res<ReplayPlaybackState>>,
|
||||||
) {
|
) {
|
||||||
let PauseModalQueries {
|
let PauseModalQueries {
|
||||||
pause_screens: screens,
|
pause_screens: screens,
|
||||||
@@ -184,6 +186,15 @@ fn toggle_pause(
|
|||||||
if !other_modal_scrims.is_empty() {
|
if !other_modal_scrims.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
|
||||||
|
// own the Esc press — that handler stops the replay. Without this guard a
|
||||||
|
// single Esc both stops the replay AND opens the pause modal on top of the
|
||||||
|
// (now empty) board, leaving the player on a screen they didn't ask for.
|
||||||
|
// The HUD-button path is gated too; clicking Pause while watching a replay
|
||||||
|
// is almost always an accident.
|
||||||
|
if replay_state.is_some_and(|s| s.is_playing()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If a card is currently selected, let SelectionPlugin handle this Escape
|
// If a card is currently selected, let SelectionPlugin handle this Escape
|
||||||
// (it will clear the selection). Pause must not also open in the same frame.
|
// (it will clear the selection). Pause must not also open in the same frame.
|
||||||
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
||||||
|
|||||||
@@ -0,0 +1,663 @@
|
|||||||
|
//! Play-by-Seed dialog: lets the player type a decimal seed number and start
|
||||||
|
//! a Classic game with that exact deal. A live solver-verification badge
|
||||||
|
//! updates asynchronously after a short typing debounce so the player knows
|
||||||
|
//! whether the deal is provably winnable before committing.
|
||||||
|
//!
|
||||||
|
//! # Flow
|
||||||
|
//!
|
||||||
|
//! 1. `HomePlugin` fires [`StartPlayBySeedRequestEvent`] when the "Play by
|
||||||
|
//! Seed" card is clicked (or `6` is pressed in the Mode Launcher).
|
||||||
|
//! 2. `handle_open_dialog` reads the event and spawns the seed-input modal.
|
||||||
|
//! 3. `handle_text_input` appends decimal digits / handles Backspace while
|
||||||
|
//! the modal is open, updating [`SeedInputBuffer`] each frame.
|
||||||
|
//! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at
|
||||||
|
//! 60 Hz) of no input before spawning a [`try_solve`] task on
|
||||||
|
//! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task
|
||||||
|
//! by resetting the resource.
|
||||||
|
//! 5. `poll_solver_task` polls the in-flight task each frame and updates the
|
||||||
|
//! [`SolverVerdictBadge`] text node with the verdict.
|
||||||
|
//! 6. `handle_confirm` fires [`NewGameRequestEvent`] with the parsed seed and
|
||||||
|
//! despawns the dialog on Play click or `Enter`.
|
||||||
|
//! 7. `handle_cancel` despawns the dialog on Cancel click or `Escape`.
|
||||||
|
|
||||||
|
use bevy::input::ButtonInput;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||||
|
|
||||||
|
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::ui_modal::{
|
||||||
|
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, spawn_modal_header,
|
||||||
|
ButtonVariant, ScrimDismissible,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
ACCENT_PRIMARY, BG_ELEVATED_PRESSED, BORDER_SUBTLE, HighContrastBorder, RADIUS_MD,
|
||||||
|
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components and resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker on the seed-input modal scrim (the despawn root).
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct PlayBySeedScreen;
|
||||||
|
|
||||||
|
/// Holds the decimal digit string the player is typing and a frame counter
|
||||||
|
/// used to debounce solver task spawning.
|
||||||
|
#[derive(Component, Debug, Default)]
|
||||||
|
struct SeedInputBuffer {
|
||||||
|
/// Raw decimal digit string. Never longer than 20 chars (u64::MAX is 20
|
||||||
|
/// decimal digits). Empty means "no seed entered".
|
||||||
|
text: String,
|
||||||
|
/// Frames elapsed since the last keystroke. The solver task is spawned
|
||||||
|
/// once this crosses [`DEBOUNCE_FRAMES`] and the buffer is non-empty.
|
||||||
|
frames_since_change: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on the text node that renders the solver verdict caption.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SolverVerdictBadge;
|
||||||
|
|
||||||
|
/// Marker on the Play (confirm) button so `handle_confirm` can find it.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct PlayBySeedConfirmButton;
|
||||||
|
|
||||||
|
/// Marker on the Cancel button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct PlayBySeedCancelButton;
|
||||||
|
|
||||||
|
/// Marker on the input-field text node so `handle_text_input` can update
|
||||||
|
/// it without a separate query for the buffer entity.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SeedInputDisplay;
|
||||||
|
|
||||||
|
/// In-flight async solver verification task. At most one is live at a time —
|
||||||
|
/// a fresh keypress resets this resource (dropping the previous `Task<_>`)
|
||||||
|
/// before spawning the next one.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PendingVerification {
|
||||||
|
seed: Option<u64>,
|
||||||
|
handle: Option<Task<SolverResult>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Frames of no-keypress activity before the solver task is spawned.
|
||||||
|
/// 12 frames ≈ 200 ms at 60 Hz — long enough to avoid thrashing on fast
|
||||||
|
/// typists but short enough to feel responsive.
|
||||||
|
const DEBOUNCE_FRAMES: u32 = 12;
|
||||||
|
|
||||||
|
/// Maximum decimal digits accepted. 20 covers all of u64::MAX (18,446,744,073,709,551,615).
|
||||||
|
const MAX_SEED_DIGITS: usize = 20;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Registers all play-by-seed systems and resources.
|
||||||
|
pub struct PlayBySeedPlugin;
|
||||||
|
|
||||||
|
impl Plugin for PlayBySeedPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<PendingVerification>()
|
||||||
|
.add_message::<StartPlayBySeedRequestEvent>()
|
||||||
|
.add_message::<NewGameRequestEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_open_dialog,
|
||||||
|
handle_text_input,
|
||||||
|
tick_debounce_and_spawn_solver_task,
|
||||||
|
poll_solver_task,
|
||||||
|
handle_confirm,
|
||||||
|
handle_cancel,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
// Fire before GameMutation so `handle_confirm`'s
|
||||||
|
// NewGameRequestEvent is processed on the same frame.
|
||||||
|
.before(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Spawns the seed-input dialog when `StartPlayBySeedRequestEvent` fires.
|
||||||
|
fn handle_open_dialog(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut requests: MessageReader<StartPlayBySeedRequestEvent>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
existing: Query<(), With<PlayBySeedScreen>>,
|
||||||
|
) {
|
||||||
|
if requests.read().count() == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Guard against double-spawn (e.g. two events in one frame).
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font = font_res.as_deref();
|
||||||
|
let font_handle = font.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
let scrim = spawn_modal(&mut commands, PlayBySeedScreen, Z_MODAL_PANEL, |card| {
|
||||||
|
spawn_modal_header(card, "Play by Seed", font);
|
||||||
|
spawn_modal_body_text(
|
||||||
|
card,
|
||||||
|
"Enter a number to play that specific deal.",
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Input field — a bordered box that shows the typed digits.
|
||||||
|
card.spawn((
|
||||||
|
Node {
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED_PRESSED),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
SeedInputBuffer::default(),
|
||||||
|
))
|
||||||
|
.with_children(|field| {
|
||||||
|
field.spawn((
|
||||||
|
SeedInputDisplay,
|
||||||
|
Text::new(""),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_DISABLED),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Solver verdict badge — updates as solver runs.
|
||||||
|
card.spawn((
|
||||||
|
SolverVerdictBadge,
|
||||||
|
Text::new("Type a number"),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |row| {
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
PlayBySeedCancelButton,
|
||||||
|
"Cancel",
|
||||||
|
Some("Esc"),
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
PlayBySeedConfirmButton,
|
||||||
|
"Play",
|
||||||
|
Some("Enter"),
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play-by-Seed is read-only input — opt into click-outside-to-dismiss.
|
||||||
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends decimal digits and handles Backspace while the dialog is open.
|
||||||
|
fn handle_text_input(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
screen: Query<(), With<PlayBySeedScreen>>,
|
||||||
|
mut buffers: Query<&mut SeedInputBuffer>,
|
||||||
|
mut displays: Query<(&mut Text, &mut TextColor), With<SeedInputDisplay>>,
|
||||||
|
mut pending: ResMut<PendingVerification>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(mut buf) = buffers.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let digit_keys = [
|
||||||
|
(KeyCode::Digit0, '0'),
|
||||||
|
(KeyCode::Digit1, '1'),
|
||||||
|
(KeyCode::Digit2, '2'),
|
||||||
|
(KeyCode::Digit3, '3'),
|
||||||
|
(KeyCode::Digit4, '4'),
|
||||||
|
(KeyCode::Digit5, '5'),
|
||||||
|
(KeyCode::Digit6, '6'),
|
||||||
|
(KeyCode::Digit7, '7'),
|
||||||
|
(KeyCode::Digit8, '8'),
|
||||||
|
(KeyCode::Digit9, '9'),
|
||||||
|
(KeyCode::Numpad0, '0'),
|
||||||
|
(KeyCode::Numpad1, '1'),
|
||||||
|
(KeyCode::Numpad2, '2'),
|
||||||
|
(KeyCode::Numpad3, '3'),
|
||||||
|
(KeyCode::Numpad4, '4'),
|
||||||
|
(KeyCode::Numpad5, '5'),
|
||||||
|
(KeyCode::Numpad6, '6'),
|
||||||
|
(KeyCode::Numpad7, '7'),
|
||||||
|
(KeyCode::Numpad8, '8'),
|
||||||
|
(KeyCode::Numpad9, '9'),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
for (key, ch) in digit_keys {
|
||||||
|
if keys.just_pressed(key) && buf.text.len() < MAX_SEED_DIGITS {
|
||||||
|
// Drop a leading zero unless the buffer is empty (prevents "007").
|
||||||
|
if ch == '0' && buf.text.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
buf.text.push(ch);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if keys.just_pressed(KeyCode::Backspace) && !buf.text.is_empty() {
|
||||||
|
buf.text.pop();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
buf.frames_since_change = 0;
|
||||||
|
// Cancel any in-flight solver task — its seed is now stale.
|
||||||
|
*pending = PendingVerification::default();
|
||||||
|
|
||||||
|
// Update the display node.
|
||||||
|
if let Ok((mut text, mut color)) = displays.single_mut() {
|
||||||
|
if buf.text.is_empty() {
|
||||||
|
text.0 = String::new();
|
||||||
|
color.0 = TEXT_DISABLED;
|
||||||
|
} else {
|
||||||
|
text.0 = buf.text.clone();
|
||||||
|
color.0 = TEXT_PRIMARY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increments the debounce counter each frame and spawns the solver task
|
||||||
|
/// once the counter passes [`DEBOUNCE_FRAMES`] and the buffer holds a
|
||||||
|
/// valid u64.
|
||||||
|
fn tick_debounce_and_spawn_solver_task(
|
||||||
|
screen: Query<(), With<PlayBySeedScreen>>,
|
||||||
|
mut buffers: Query<&mut SeedInputBuffer>,
|
||||||
|
mut pending: ResMut<PendingVerification>,
|
||||||
|
mut badges: Query<(&mut Text, &mut TextColor), With<SolverVerdictBadge>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(mut buf) = buffers.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always update the badge when the buffer is empty.
|
||||||
|
if buf.text.is_empty() {
|
||||||
|
if let Ok((mut text, mut color)) = badges.single_mut() {
|
||||||
|
text.0 = "Type a number".to_string();
|
||||||
|
color.0 = TEXT_SECONDARY;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't spawn if a task is already running for this seed.
|
||||||
|
let parsed = buf.text.parse::<u64>().ok();
|
||||||
|
if pending.handle.is_some() && pending.seed == parsed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.frames_since_change = buf.frames_since_change.saturating_add(1);
|
||||||
|
if buf.frames_since_change < DEBOUNCE_FRAMES {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(seed) = parsed else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let draw_mode = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode.clone());
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
||||||
|
|
||||||
|
pending.seed = Some(seed);
|
||||||
|
pending.handle = Some(task);
|
||||||
|
|
||||||
|
if let Ok((mut text, mut color)) = badges.single_mut() {
|
||||||
|
text.0 = "Verifying\u{2026}".to_string();
|
||||||
|
color.0 = TEXT_SECONDARY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the in-flight solver task and updates the verdict badge on completion.
|
||||||
|
fn poll_solver_task(
|
||||||
|
mut pending: ResMut<PendingVerification>,
|
||||||
|
mut badges: Query<(&mut Text, &mut TextColor), With<SolverVerdictBadge>>,
|
||||||
|
) {
|
||||||
|
let Some(handle) = pending.handle.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(handle)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
pending.handle = None;
|
||||||
|
|
||||||
|
let Ok((mut text, mut color)) = badges.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
SolverResult::Winnable => {
|
||||||
|
text.0 = "\u{2713} Provably winnable".to_string();
|
||||||
|
color.0 = ACCENT_PRIMARY;
|
||||||
|
}
|
||||||
|
SolverResult::Inconclusive => {
|
||||||
|
text.0 = "? Likely winnable (search timed out)".to_string();
|
||||||
|
color.0 = TEXT_SECONDARY;
|
||||||
|
}
|
||||||
|
SolverResult::Unwinnable => {
|
||||||
|
text.0 = "\u{2717} Provably unwinnable".to_string();
|
||||||
|
color.0 = TEXT_DISABLED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires [`NewGameRequestEvent`] with the parsed seed when Play is clicked
|
||||||
|
/// or `Enter` is pressed, then despawns the dialog. Does nothing when the
|
||||||
|
/// buffer is empty.
|
||||||
|
fn handle_confirm(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
buttons: Query<&Interaction, (With<PlayBySeedConfirmButton>, Changed<Interaction>)>,
|
||||||
|
buffers: Query<&SeedInputBuffer>,
|
||||||
|
screen: Query<Entity, With<PlayBySeedScreen>>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let enter = keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter);
|
||||||
|
if !click && !enter {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(buf) = buffers.single() else { return };
|
||||||
|
let Ok(seed) = buf.text.parse::<u64>() else { return };
|
||||||
|
|
||||||
|
new_game.write(NewGameRequestEvent {
|
||||||
|
seed: Some(seed),
|
||||||
|
mode: None,
|
||||||
|
confirmed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for entity in &screen {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the dialog on Cancel click or `Escape`.
|
||||||
|
fn handle_cancel(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
buttons: Query<&Interaction, (With<PlayBySeedCancelButton>, Changed<Interaction>)>,
|
||||||
|
screen: Query<Entity, With<PlayBySeedScreen>>,
|
||||||
|
other_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<PlayBySeedScreen>)>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
// Esc only closes this dialog when it is the topmost modal.
|
||||||
|
let esc = keys.just_pressed(KeyCode::Escape) && other_scrims.is_empty();
|
||||||
|
if !click && !esc {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for entity in &screen {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(PlayBySeedPlugin);
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_dialog(app: &mut App) {
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(StartPlayBySeedRequestEvent);
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_key(app: &mut App, key: KeyCode) {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(key);
|
||||||
|
app.update();
|
||||||
|
// Simulate what Bevy's PreUpdate input system does: flush just_pressed /
|
||||||
|
// just_released so stale key state doesn't bleed into the next frame.
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog_present(app: &mut App) -> bool {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&PlayBySeedScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_buffer_text(app: &mut App) -> String {
|
||||||
|
let mut q = app.world_mut().query::<&SeedInputBuffer>();
|
||||||
|
q.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.map(|b| b.text.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dialog_spawns_on_request() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
assert!(!dialog_present(&mut app));
|
||||||
|
open_dialog(&mut app);
|
||||||
|
assert!(dialog_present(&mut app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit_keys_append_to_buffer() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
assert_eq!(read_buffer_text(&mut app), "42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_removes_last_char() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
press_key(&mut app, KeyCode::Backspace);
|
||||||
|
|
||||||
|
assert_eq!(read_buffer_text(&mut app), "4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_does_nothing_when_buffer_is_empty() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
// Simulate Enter with empty buffer.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Enter);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(cursor.read(msgs).next().is_none(), "no NewGameRequestEvent when buffer empty");
|
||||||
|
// Dialog should still be open.
|
||||||
|
assert!(dialog_present(&mut app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_writes_new_game_request_with_parsed_seed() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Enter);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(msgs).copied().collect();
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
assert_eq!(fired[0].seed, Some(42));
|
||||||
|
assert_eq!(fired[0].mode, None);
|
||||||
|
assert!(!fired[0].confirmed);
|
||||||
|
|
||||||
|
// Dialog should be gone.
|
||||||
|
assert!(!dialog_present(&mut app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_despawns_dialog_without_new_game_request() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Escape);
|
||||||
|
|
||||||
|
assert!(!dialog_present(&mut app));
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(cursor.read(msgs).next().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solver_task_spawns_after_debounce_window() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
// Debounce window — no task yet.
|
||||||
|
for _ in 0..DEBOUNCE_FRAMES {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = app.world().resource::<PendingVerification>();
|
||||||
|
assert!(pending.handle.is_some(), "solver task should have been spawned after debounce");
|
||||||
|
assert_eq!(pending.seed, Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keypress_mid_flight_cancels_previous_solver_task() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
// Let the debounce fire.
|
||||||
|
for _ in 0..DEBOUNCE_FRAMES {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
assert!(app.world().resource::<PendingVerification>().handle.is_some());
|
||||||
|
|
||||||
|
// New keypress should cancel the in-flight task.
|
||||||
|
press_key(&mut app, KeyCode::Digit3);
|
||||||
|
assert!(app.world().resource::<PendingVerification>().handle.is_none());
|
||||||
|
assert_eq!(app.world().resource::<PendingVerification>().seed, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solver_task_completes_and_updates_badge() {
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
// Seed 42 — solver will return some verdict.
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
// Wait for the debounce to spawn the task.
|
||||||
|
for _ in 0..DEBOUNCE_FRAMES {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll until the solver task resolves (cap at 15 s wall-clock).
|
||||||
|
let deadline = Instant::now() + std::time::Duration::from_secs(15);
|
||||||
|
while app.world().resource::<PendingVerification>().handle.is_some()
|
||||||
|
&& Instant::now() < deadline
|
||||||
|
{
|
||||||
|
app.update();
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge text should no longer read "Verifying…".
|
||||||
|
let badge_text = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<(&Text, &SolverVerdictBadge)>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.map(|(t, _)| t.0.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_ne!(badge_text, "Verifying\u{2026}", "badge should have resolved to a verdict");
|
||||||
|
assert_ne!(badge_text, "Type a number", "badge should show verdict, not idle state");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,8 @@ use crate::events::MoveRequestEvent;
|
|||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::{DragState, GameStateResource};
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, STATE_SUCCESS};
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
|
||||||
|
|
||||||
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
|
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
|
||||||
///
|
///
|
||||||
@@ -533,8 +534,17 @@ fn radial_handle_release_or_cancel(
|
|||||||
|
|
||||||
/// Despawns and respawns the radial overlay sprites every frame the
|
/// Despawns and respawns the radial overlay sprites every frame the
|
||||||
/// state is `Active`; despawns them when the state returns to `Idle`.
|
/// state is `Active`; despawns them when the state returns to `Idle`.
|
||||||
|
///
|
||||||
|
/// Reads [`SettingsResource`] so the focused-icon outline can boost to
|
||||||
|
/// [`BORDER_SUBTLE_HC`] under high-contrast mode. Per-frame respawn is
|
||||||
|
/// the simplest place to fold HC in: this is the only system that
|
||||||
|
/// owns the rim sprite, so there's no parallel paint path to fight.
|
||||||
|
/// ([`HighContrastBorder`](crate::ui_theme::HighContrastBorder) doesn't
|
||||||
|
/// apply because the rim is a `Sprite`, not a UI node with
|
||||||
|
/// `BorderColor`, and the entities don't persist across frames.)
|
||||||
fn radial_redraw_overlay(
|
fn radial_redraw_overlay(
|
||||||
state: Res<RightClickRadialState>,
|
state: Res<RightClickRadialState>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
existing_icons: Query<Entity, With<RadialIcon>>,
|
existing_icons: Query<Entity, With<RadialIcon>>,
|
||||||
existing_centres: Query<Entity, With<RadialCentre>>,
|
existing_centres: Query<Entity, With<RadialCentre>>,
|
||||||
@@ -569,13 +579,12 @@ fn radial_redraw_overlay(
|
|||||||
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
|
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
|
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
|
||||||
let focused = *hovered_index == Some(i);
|
let focused = *hovered_index == Some(i);
|
||||||
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
|
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
|
||||||
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
|
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
|
||||||
// Hovered icon gets a strong yellow rim; resting icons get a
|
let outline = radial_rim_outline(focused, high_contrast);
|
||||||
// muted purple rim so the focused one reads as the obvious target.
|
|
||||||
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
|
|
||||||
|
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -606,6 +615,27 @@ fn radial_redraw_overlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure decision logic for the radial-icon rim outline colour.
|
||||||
|
///
|
||||||
|
/// Resting icons always carry [`BORDER_SUBTLE`] so the focused icon
|
||||||
|
/// reads as the obvious target. Under high-contrast mode the focused
|
||||||
|
/// rim boosts to [`BORDER_SUBTLE_HC`] (`#a0a0a0`) instead of
|
||||||
|
/// [`BORDER_STRONG`] (`#505050`) — naive marker substitution via
|
||||||
|
/// [`HighContrastBorder`](crate::ui_theme::HighContrastBorder) would
|
||||||
|
/// invert the hierarchy because the resting colour
|
||||||
|
/// (`#353535`) is darker than `BORDER_STRONG`. This shape keeps the
|
||||||
|
/// focused rim *more* visible under HC, not less.
|
||||||
|
///
|
||||||
|
/// Factored out as a pure function so the truth-table is unit-testable
|
||||||
|
/// without spinning up the per-frame respawn system.
|
||||||
|
fn radial_rim_outline(focused: bool, high_contrast: bool) -> Color {
|
||||||
|
match (focused, high_contrast) {
|
||||||
|
(true, true) => BORDER_SUBTLE_HC,
|
||||||
|
(true, false) => BORDER_STRONG,
|
||||||
|
(false, _) => BORDER_SUBTLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -940,4 +970,33 @@ mod tests {
|
|||||||
"face-down cards must not open the radial"
|
"face-down cards must not open the radial"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// radial_rim_outline — accessibility / high-contrast truth table
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rim_resting_uses_subtle_outline_without_hc() {
|
||||||
|
assert_eq!(radial_rim_outline(false, false), BORDER_SUBTLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rim_focused_uses_strong_outline_without_hc() {
|
||||||
|
assert_eq!(radial_rim_outline(true, false), BORDER_STRONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rim_focused_boosts_to_subtle_hc_under_hc() {
|
||||||
|
assert_eq!(radial_rim_outline(true, true), BORDER_SUBTLE_HC);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rim_resting_stays_subtle_under_hc_to_preserve_hierarchy() {
|
||||||
|
// Naive marker substitution would also flip the resting outline
|
||||||
|
// to BORDER_SUBTLE_HC, which is *lighter* than BORDER_STRONG —
|
||||||
|
// that would invert the focused/resting hierarchy. Holding the
|
||||||
|
// resting colour at BORDER_SUBTLE keeps the focused icon the
|
||||||
|
// obvious target under HC.
|
||||||
|
assert_eq!(radial_rim_outline(false, true), BORDER_SUBTLE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::{Replay, ReplayMove};
|
||||||
|
|
||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
@@ -119,6 +119,15 @@ pub enum ReplayPlaybackState {
|
|||||||
cursor: usize,
|
cursor: usize,
|
||||||
/// Seconds remaining until the next move is dispatched.
|
/// Seconds remaining until the next move is dispatched.
|
||||||
secs_to_next: f32,
|
secs_to_next: f32,
|
||||||
|
/// `true` while playback is paused — `tick_replay_playback`
|
||||||
|
/// skips the `secs_to_next` decrement entirely while this is
|
||||||
|
/// set, so the cursor and the timer freeze together. The
|
||||||
|
/// overlay stays mounted (`is_playing()` still returns
|
||||||
|
/// `true`) so the player can see the paused state and the
|
||||||
|
/// Resume / Step controls. Stepping while paused fires the
|
||||||
|
/// next move directly via [`step_replay_playback`] and
|
||||||
|
/// leaves the paused flag untouched.
|
||||||
|
paused: bool,
|
||||||
},
|
},
|
||||||
/// The replay finished playing back. The overlay swaps the banner
|
/// The replay finished playing back. The overlay swaps the banner
|
||||||
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
||||||
@@ -194,6 +203,7 @@ pub fn start_replay_playback(
|
|||||||
replay,
|
replay,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +229,107 @@ pub fn stop_replay_playback(
|
|||||||
**state = ReplayPlaybackState::Inactive;
|
**state = ReplayPlaybackState::Inactive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggle the `paused` flag on the active playback. No-op when not
|
||||||
|
/// `Playing` (i.e. `Inactive` or `Completed`) — pause has no meaning
|
||||||
|
/// in those states. Returns the new paused value, or `None` if the
|
||||||
|
/// state wasn't `Playing`.
|
||||||
|
pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) -> Option<bool> {
|
||||||
|
if let ReplayPlaybackState::Playing { paused, .. } = state.as_mut() {
|
||||||
|
*paused = !*paused;
|
||||||
|
Some(*paused)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance playback by exactly one move. Only meaningful while paused
|
||||||
|
/// — when called on an unpaused playback it would race the
|
||||||
|
/// `tick_replay_playback` loop. Returns `true` when a move was fired,
|
||||||
|
/// `false` when no-op (state isn't `Playing { paused: true }` or the
|
||||||
|
/// cursor is already at the end of the move list).
|
||||||
|
///
|
||||||
|
/// Stepping the last move transitions the state to `Completed` on
|
||||||
|
/// the next `tick_replay_playback` frame — same end-of-list path the
|
||||||
|
/// normal advance loop takes.
|
||||||
|
pub fn step_replay_playback(
|
||||||
|
state: &mut ResMut<ReplayPlaybackState>,
|
||||||
|
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
||||||
|
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||||
|
) -> bool {
|
||||||
|
let ReplayPlaybackState::Playing {
|
||||||
|
replay,
|
||||||
|
cursor,
|
||||||
|
paused: true,
|
||||||
|
..
|
||||||
|
} = state.as_mut()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if *cursor >= replay.moves.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match &replay.moves[*cursor] {
|
||||||
|
ReplayMove::Move { from, to, count } => {
|
||||||
|
moves_writer.write(MoveRequestEvent {
|
||||||
|
from: from.clone(),
|
||||||
|
to: to.clone(),
|
||||||
|
count: *count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ReplayMove::StockClick => {
|
||||||
|
draws_writer.write(DrawRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*cursor += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps the replay **backwards** by exactly one move while paused.
|
||||||
|
///
|
||||||
|
/// Strategy: the live game's undo system is the source of truth for
|
||||||
|
/// reversing moves. Every move the replay forward-stepped (via
|
||||||
|
/// [`step_replay_playback`] or the auto-advance loop in
|
||||||
|
/// [`tick_replay_playback`]) was dispatched as a canonical
|
||||||
|
/// [`MoveRequestEvent`] / [`DrawRequestEvent`], which the game
|
||||||
|
/// applied and pushed onto its undo stack. So a backwards step here
|
||||||
|
/// is simply: decrement the cursor (so the about-to-apply move
|
||||||
|
/// re-points at the one we're rewinding past) and fire an
|
||||||
|
/// [`UndoRequestEvent`] so the game reverses its most-recent move
|
||||||
|
/// next frame.
|
||||||
|
///
|
||||||
|
/// Hard-gated to the paused state via destructure pattern —
|
||||||
|
/// matches the existing [`step_replay_playback`] gate so the
|
||||||
|
/// player can only scrub one direction at a time and the tick
|
||||||
|
/// loop never races a manual rewind.
|
||||||
|
///
|
||||||
|
/// Returns `false` and is a no-op in three cases:
|
||||||
|
/// - State isn't `Playing` (no replay attached).
|
||||||
|
/// - State is `Playing` but not paused (the tick loop owns the cursor).
|
||||||
|
/// - Cursor is already at 0 (nothing to rewind past).
|
||||||
|
///
|
||||||
|
/// Returns `true` on a successful step; the actual game-state
|
||||||
|
/// reversal happens next frame when `handle_undo` reads the
|
||||||
|
/// `UndoRequestEvent`.
|
||||||
|
pub fn step_backwards_replay_playback(
|
||||||
|
state: &mut ResMut<ReplayPlaybackState>,
|
||||||
|
undo_writer: &mut MessageWriter<UndoRequestEvent>,
|
||||||
|
) -> bool {
|
||||||
|
let ReplayPlaybackState::Playing {
|
||||||
|
cursor,
|
||||||
|
paused: true,
|
||||||
|
..
|
||||||
|
} = state.as_mut()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if *cursor == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*cursor -= 1;
|
||||||
|
undo_writer.write(UndoRequestEvent);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Tick system. Runs every frame; only does work when
|
/// Tick system. Runs every frame; only does work when
|
||||||
/// [`ReplayPlaybackState::is_playing`].
|
/// [`ReplayPlaybackState::is_playing`].
|
||||||
///
|
///
|
||||||
@@ -249,8 +360,15 @@ fn tick_replay_playback(
|
|||||||
replay,
|
replay,
|
||||||
cursor,
|
cursor,
|
||||||
secs_to_next,
|
secs_to_next,
|
||||||
|
paused,
|
||||||
} = state.as_mut()
|
} = state.as_mut()
|
||||||
{
|
{
|
||||||
|
// While paused, the cursor and the timer freeze together —
|
||||||
|
// skip the decrement entirely so resuming starts the next
|
||||||
|
// move from a full `secs_to_next` window. Stepping (handled
|
||||||
|
// separately) fires moves directly without touching this
|
||||||
|
// path.
|
||||||
|
if !*paused {
|
||||||
*secs_to_next -= dt;
|
*secs_to_next -= dt;
|
||||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||||
match &replay.moves[*cursor] {
|
match &replay.moves[*cursor] {
|
||||||
@@ -273,6 +391,7 @@ fn tick_replay_playback(
|
|||||||
transition_to_completed = true;
|
transition_to_completed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if transition_to_completed {
|
if transition_to_completed {
|
||||||
*state = ReplayPlaybackState::Completed;
|
*state = ReplayPlaybackState::Completed;
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ use crate::ui_modal::{
|
|||||||
};
|
};
|
||||||
use crate::ui_tooltip::Tooltip;
|
use crate::ui_tooltip::Tooltip;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBorder,
|
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground,
|
||||||
|
HighContrastBorder,
|
||||||
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||||
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
@@ -365,6 +366,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
update_high_contrast_text,
|
update_high_contrast_text,
|
||||||
update_high_contrast_borders,
|
update_high_contrast_borders,
|
||||||
|
update_high_contrast_backgrounds,
|
||||||
update_reduce_motion_text,
|
update_reduce_motion_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
@@ -674,6 +676,41 @@ fn update_high_contrast_borders(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Repaints `BackgroundColor` on every entity tagged with
|
||||||
|
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||||||
|
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||||
|
/// (`#a0a0a0`). Compares against the current background and only
|
||||||
|
/// mutates when different so Bevy's change-detection doesn't trigger
|
||||||
|
/// repaints every frame.
|
||||||
|
///
|
||||||
|
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||||||
|
/// same change-suppression idiom, different colour channel —
|
||||||
|
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||||||
|
/// separators that paint their shape directly rather than via a
|
||||||
|
/// `BorderColor` on a wider Node.
|
||||||
|
///
|
||||||
|
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||||||
|
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||||||
|
///
|
||||||
|
/// More sites can be tagged in follow-ups by adding
|
||||||
|
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||||||
|
pub(crate) fn update_high_contrast_backgrounds(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||||||
|
) {
|
||||||
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
|
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||||
|
let target = if high_contrast {
|
||||||
|
marker.hc_color
|
||||||
|
} else {
|
||||||
|
marker.default_color
|
||||||
|
};
|
||||||
|
if bg.0 != target {
|
||||||
|
*bg = BackgroundColor(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn update_reduce_motion_text(
|
fn update_reduce_motion_text(
|
||||||
settings: Res<SettingsResource>,
|
settings: Res<SettingsResource>,
|
||||||
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||||
|
|||||||
@@ -29,12 +29,13 @@ use crate::resources::GameStateResource;
|
|||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
ScrimDismissible,
|
ModalButton, ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING,
|
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||||
STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
|
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||||
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||||
|
Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bevy resource wrapping the current stats.
|
/// Bevy resource wrapping the current stats.
|
||||||
@@ -121,6 +122,13 @@ pub struct ReplayNextButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ReplaySelectorCaption;
|
pub struct ReplaySelectorCaption;
|
||||||
|
|
||||||
|
/// Marker on the detail text node that shows the selected replay's
|
||||||
|
/// `"{duration} win on {date}"` + optional `"· Shareable"` badge.
|
||||||
|
/// Repainted by `repaint_replay_selector_detail` whenever the
|
||||||
|
/// selection or history changes.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplaySelectorDetail;
|
||||||
|
|
||||||
/// Marker component on each per-mode bests row in the stats overlay.
|
/// Marker component on each per-mode bests row in the stats overlay.
|
||||||
///
|
///
|
||||||
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
|
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
|
||||||
@@ -223,7 +231,12 @@ impl Plugin for StatsPlugin {
|
|||||||
.add_systems(Update, handle_copy_share_link_button)
|
.add_systems(Update, handle_copy_share_link_button)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
|
(
|
||||||
|
handle_replay_selector_buttons,
|
||||||
|
repaint_replay_selector_caption,
|
||||||
|
repaint_replay_selector_detail,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
)
|
)
|
||||||
.add_systems(Update, scroll_stats_panel);
|
.add_systems(Update, scroll_stats_panel);
|
||||||
}
|
}
|
||||||
@@ -348,9 +361,13 @@ fn handle_copy_share_link_button(
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
toast.write(InfoToastEvent(format!(
|
match crate::android_clipboard::set_text(&url) {
|
||||||
"Share link: {url}"
|
Ok(()) => { toast.write(InfoToastEvent(format!("Copied: {url}"))); }
|
||||||
)));
|
Err(e) => {
|
||||||
|
warn!("android clipboard failed: {e}");
|
||||||
|
toast.write(InfoToastEvent(format!("Share link: {url}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +456,39 @@ fn repaint_replay_selector_caption(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Repaints the `ReplaySelectorDetail` text node whenever the
|
||||||
|
/// selection or history changes. Shows `"{duration} win on {date}"` for
|
||||||
|
/// the selected replay, with a `"· Shareable"` badge when the replay
|
||||||
|
/// carries a sync-uploaded share URL. Empty when the history is empty.
|
||||||
|
fn repaint_replay_selector_detail(
|
||||||
|
history: Res<ReplayHistoryResource>,
|
||||||
|
selected: Res<SelectedReplayIndex>,
|
||||||
|
mut q: Query<&mut Text, With<ReplaySelectorDetail>>,
|
||||||
|
) {
|
||||||
|
if !history.is_changed() && !selected.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let label = replay_selector_detail(&history.0.replays, selected.0);
|
||||||
|
for mut text in &mut q {
|
||||||
|
**text = label.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper: render the detail line for the selected replay. Returns
|
||||||
|
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
|
||||||
|
/// when a share URL is present. Empty when the history slice is empty.
|
||||||
|
pub fn replay_selector_detail(replays: &[solitaire_data::Replay], index: usize) -> String {
|
||||||
|
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let base = format_replay_caption(r);
|
||||||
|
if r.share_url.is_some() {
|
||||||
|
format!("{base} \u{2022} Shareable") // ·
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pure helper: render the selector caption shown next to the Prev /
|
/// Pure helper: render the selector caption shown next to the Prev /
|
||||||
/// Next chips. Returns `"No replays"` when the history is empty,
|
/// Next chips. Returns `"No replays"` when the history is empty,
|
||||||
/// otherwise `"Replay {1-based index} / {total}"`.
|
/// otherwise `"Replay {1-based index} / {total}"`.
|
||||||
@@ -618,14 +668,14 @@ fn toggle_stats_screen(
|
|||||||
if let Ok(entity) = screens.single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else {
|
||||||
let selected = latest_replay.0.replays.get(selected_index.0);
|
|
||||||
spawn_stats_screen(
|
spawn_stats_screen(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&stats.0,
|
&stats.0,
|
||||||
progress.as_deref().map(|p| &p.0),
|
progress.as_deref().map(|p| &p.0),
|
||||||
time_attack.as_deref(),
|
time_attack.as_deref(),
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
selected,
|
&latest_replay.0.replays,
|
||||||
|
selected_index.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,7 +701,8 @@ fn spawn_stats_screen(
|
|||||||
progress: Option<&PlayerProgress>,
|
progress: Option<&PlayerProgress>,
|
||||||
time_attack: Option<&TimeAttackResource>,
|
time_attack: Option<&TimeAttackResource>,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
latest_replay: Option<&Replay>,
|
replays: &[Replay],
|
||||||
|
selected_index: usize,
|
||||||
) {
|
) {
|
||||||
// --- primary stat cells ---
|
// --- primary stat cells ---
|
||||||
// First-launch zero-state: when no games have been played yet, render
|
// First-launch zero-state: when no games have been played yet, render
|
||||||
@@ -859,31 +910,84 @@ fn spawn_stats_screen(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Latest replay caption ---
|
// --- Replay selector ---
|
||||||
// Surfaces the most recent winning game so the player can spot
|
// Prev / Next chips step through the full replay history;
|
||||||
// whether their last victory has been recorded. The Watch
|
// `repaint_replay_selector_caption` and
|
||||||
// Replay action below is what the player clicks to revisit it.
|
// `repaint_replay_selector_detail` keep both text nodes
|
||||||
//
|
// live as the selection changes. Using `ModalButton` on
|
||||||
// When the displayed replay carries a `share_url` (uploaded
|
// the chips plugs them into the existing modal-button
|
||||||
// to a sync server, persisted by v0.19.0's share-link
|
// hover/press paint loop at no extra cost.
|
||||||
// contract), append a "Shareable" badge so the player can
|
body.spawn(Node {
|
||||||
// tell at a glance whether the Copy share link button below
|
flex_direction: FlexDirection::Row,
|
||||||
// will produce a URL — without it the button surfaces a
|
align_items: AlignItems::Center,
|
||||||
// toast explaining why nothing was copied, which is more
|
column_gap: VAL_SPACE_3,
|
||||||
// friction than necessary when a quick visual cue suffices.
|
..default()
|
||||||
let replay_caption = match latest_replay {
|
})
|
||||||
Some(r) => {
|
.with_children(|row| {
|
||||||
let base = format!("Latest win: {}", format_replay_caption(r));
|
// ← Prev chip
|
||||||
if r.share_url.is_some() {
|
row.spawn((
|
||||||
format!("{base} \u{2022} Shareable")
|
ReplayPrevButton,
|
||||||
} else {
|
ModalButton(ButtonVariant::Secondary),
|
||||||
base
|
Button,
|
||||||
}
|
Node {
|
||||||
}
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||||
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
|
justify_content: JustifyContent::Center,
|
||||||
};
|
align_items: AlignItems::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED_HI),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("\u{2190}"),
|
||||||
|
font_row.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Replay N / M" caption — rewritten live by
|
||||||
|
// `repaint_replay_selector_caption`.
|
||||||
|
row.spawn((
|
||||||
|
ReplaySelectorCaption,
|
||||||
|
Text::new(replay_selector_caption(selected_index, replays.len())),
|
||||||
|
font_row.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
|
||||||
|
// → Next chip
|
||||||
|
row.spawn((
|
||||||
|
ReplayNextButton,
|
||||||
|
ModalButton(ButtonVariant::Secondary),
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED_HI),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("\u{2192}"),
|
||||||
|
font_row.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detail line: rewritten live by `repaint_replay_selector_detail`.
|
||||||
body.spawn((
|
body.spawn((
|
||||||
Text::new(replay_caption),
|
ReplaySelectorDetail,
|
||||||
|
Text::new(replay_selector_detail(replays, selected_index)),
|
||||||
font_row.clone(),
|
font_row.clone(),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
@@ -1670,6 +1774,140 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Prev/Next replay selector spawn-site tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selector_row_spawns_when_stats_screen_opens() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Pre-populate a replay so the selector has something to show.
|
||||||
|
{
|
||||||
|
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||||
|
hist.0.replays.push(make_test_replay(90, None));
|
||||||
|
}
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyS);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let prev = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ReplayPrevButton>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
let next = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ReplayNextButton>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
let caption = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ReplaySelectorCaption>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
let detail = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ReplaySelectorDetail>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(prev, 1, "expected one ReplayPrevButton");
|
||||||
|
assert_eq!(next, 1, "expected one ReplayNextButton");
|
||||||
|
assert_eq!(caption, 1, "expected one ReplaySelectorCaption");
|
||||||
|
assert_eq!(detail, 1, "expected one ReplaySelectorDetail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selector_caption_initial_text_is_replay_one_of_one() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
{
|
||||||
|
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||||
|
hist.0.replays.push(make_test_replay(120, None));
|
||||||
|
}
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyS);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let mut q = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Text, With<ReplaySelectorCaption>>();
|
||||||
|
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
|
||||||
|
assert_eq!(texts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
texts[0],
|
||||||
|
"Replay 1 / 1",
|
||||||
|
"caption must show '1 / 1' for a single-replay history"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selector_detail_initial_text_matches_replay_caption() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
{
|
||||||
|
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||||
|
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
|
||||||
|
}
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyS);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let mut q = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Text, With<ReplaySelectorDetail>>();
|
||||||
|
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
|
||||||
|
assert_eq!(texts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
texts[0], "1:05 win on 2026-05-08",
|
||||||
|
"detail must show formatted replay caption for the selected replay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selector_detail_appends_shareable_badge_when_url_present() {
|
||||||
|
// `replay_selector_detail` is pure — no app setup needed.
|
||||||
|
let replays = vec![make_test_replay(
|
||||||
|
90,
|
||||||
|
Some("https://example.com/r/abc".to_string()),
|
||||||
|
)];
|
||||||
|
let label = replay_selector_detail(&replays, 0);
|
||||||
|
assert!(
|
||||||
|
label.contains("Shareable"),
|
||||||
|
"detail must include 'Shareable' badge when share_url is set, got: {label:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selector_caption_shows_no_replays_when_history_is_empty() {
|
||||||
|
assert_eq!(replay_selector_caption(0, 0), "No replays");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selector_caption_wraps_ordinal_correctly() {
|
||||||
|
// index 2 (0-based) in a 3-replay history → "Replay 3 / 3"
|
||||||
|
assert_eq!(replay_selector_caption(2, 3), "Replay 3 / 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal [`Replay`] for use in stats-plugin unit tests.
|
||||||
|
///
|
||||||
|
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
|
||||||
|
/// `time_seconds` and `share_url` are the only varying fields across tests.
|
||||||
|
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
|
||||||
|
let mut r = solitaire_data::Replay::new(
|
||||||
|
1,
|
||||||
|
solitaire_core::game_state::DrawMode::DrawOne,
|
||||||
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
|
time_seconds,
|
||||||
|
0,
|
||||||
|
date,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
r.share_url = share_url;
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
/// Integration: pre-set streak to 10, fire a win that bumps it to 11.
|
/// Integration: pre-set streak to 10, fire a win that bumps it to 11.
|
||||||
/// Past the highest threshold, no event must fire — the flourish
|
/// Past the highest threshold, no event must fire — the flourish
|
||||||
/// is reserved for the threshold crossing itself.
|
/// is reserved for the threshold crossing itself.
|
||||||
|
|||||||
@@ -372,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(idle_bg(variant)),
|
BackgroundColor(idle_bg(variant)),
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));
|
b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ pub const ACCENT_SECONDARY: Color = Color::srgb(0.882, 0.639, 0.933);
|
|||||||
/// from base16-eighties. `#acc267`.
|
/// from base16-eighties. `#acc267`.
|
||||||
pub const STATE_SUCCESS: Color = Color::srgb(0.675, 0.761, 0.404);
|
pub const STATE_SUCCESS: Color = Color::srgb(0.675, 0.761, 0.404);
|
||||||
|
|
||||||
|
/// High-contrast variant of [`STATE_SUCCESS`] — `#c8e862`. Brighter
|
||||||
|
/// lime that maintains the success hue while lifting luminance from
|
||||||
|
/// ~0.51 → ~0.73 so the WIN MOVE scrub-bar marker stands out from
|
||||||
|
/// the bumped notch ticks (`BORDER_SUBTLE_HC` `#a0a0a0`, L≈0.60) in
|
||||||
|
/// high-contrast mode.
|
||||||
|
pub const STATE_SUCCESS_HC: Color = Color::srgb(0.784, 0.910, 0.384);
|
||||||
|
|
||||||
/// Warning — penalty signal, daily-seed expiry countdown, sync-pending
|
/// Warning — penalty signal, daily-seed expiry countdown, sync-pending
|
||||||
/// status. Gold from base16-eighties. **Both** Undo and Recycle
|
/// status. Gold from base16-eighties. **Both** Undo and Recycle
|
||||||
/// counters use this when non-zero. `#ddb26f`.
|
/// counters use this when non-zero. `#ddb26f`.
|
||||||
@@ -252,6 +259,56 @@ impl HighContrastBorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker for entities whose [`BackgroundColor`] should swap to
|
||||||
|
/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on.
|
||||||
|
/// Parallel to [`HighContrastBorder`] but for sites that paint their
|
||||||
|
/// shape via `BackgroundColor` rather than `BorderColor` —
|
||||||
|
/// `bevy::ui` 1 px decorative strips, tick marks, fine separators
|
||||||
|
/// often render as tiny full-bleed `Node`s, not as borders, so the
|
||||||
|
/// border-marker pattern doesn't apply.
|
||||||
|
///
|
||||||
|
/// `default_color` records the off-state colour; `hc_color` the on-
|
||||||
|
/// state colour. [`with_default`] fills `hc_color` with
|
||||||
|
/// [`BORDER_SUBTLE_HC`] so the 90 % of sites that just need the
|
||||||
|
/// standard subtle-border bump can continue using a one-argument
|
||||||
|
/// constructor. [`with_hc`] overrides the HC colour for the rare
|
||||||
|
/// site (currently only the WIN MOVE scrub-bar marker) that needs a
|
||||||
|
/// domain-specific HC variant (`STATE_SUCCESS_HC` instead of a gray).
|
||||||
|
///
|
||||||
|
/// [`with_default`]: HighContrastBackground::with_default
|
||||||
|
/// [`with_hc`]: HighContrastBackground::with_hc
|
||||||
|
/// [`BackgroundColor`]: bevy::prelude::BackgroundColor
|
||||||
|
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
|
||||||
|
pub struct HighContrastBackground {
|
||||||
|
/// Background colour to use when high-contrast mode is *off* —
|
||||||
|
/// the site's normal idle / active-state colour.
|
||||||
|
pub default_color: bevy::prelude::Color,
|
||||||
|
/// Background colour to use when high-contrast mode is *on*.
|
||||||
|
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
|
||||||
|
///
|
||||||
|
/// [`with_default`]: HighContrastBackground::with_default
|
||||||
|
pub hc_color: bevy::prelude::Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HighContrastBackground {
|
||||||
|
/// Convenience constructor — HC colour defaults to
|
||||||
|
/// [`BORDER_SUBTLE_HC`].
|
||||||
|
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
||||||
|
Self { default_color, hc_color: BORDER_SUBTLE_HC }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructor for sites whose HC colour differs from the standard
|
||||||
|
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
|
||||||
|
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
|
||||||
|
/// than to a neutral gray.
|
||||||
|
pub const fn with_hc(
|
||||||
|
default_color: bevy::prelude::Color,
|
||||||
|
hc_color: bevy::prelude::Color,
|
||||||
|
) -> Self {
|
||||||
|
Self { default_color, hc_color }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Strong border — hover outline, focused button, active popover.
|
/// Strong border — hover outline, focused button, active popover.
|
||||||
/// `outline` from the design system. `#505050`.
|
/// `outline` from the design system. `#505050`.
|
||||||
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ impl ScoreBreakdown {
|
|||||||
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
||||||
let multiplier = match mode {
|
let multiplier = match mode {
|
||||||
GameMode::Zen => 0.0,
|
GameMode::Zen => 0.0,
|
||||||
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack => 1.0,
|
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0,
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
base,
|
base,
|
||||||
@@ -423,6 +423,7 @@ fn mode_display_name(mode: GameMode) -> &'static str {
|
|||||||
GameMode::Zen => "Zen",
|
GameMode::Zen => "Zen",
|
||||||
GameMode::Challenge => "Challenge",
|
GameMode::Challenge => "Challenge",
|
||||||
GameMode::TimeAttack => "Time Attack",
|
GameMode::TimeAttack => "Time Attack",
|
||||||
|
GameMode::Difficulty(level) => level.label(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user