Compare commits
31 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 |
@@ -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/
|
||||||
|
|||||||
+202
-2
@@ -6,8 +6,208 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
No threads in flight. v0.21.6 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
|
## [0.21.6] — 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" }
|
||||||
|
|||||||
+121
-177
@@ -1,120 +1,98 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-08 — **v0.21.5 cut and tagged at
|
**Last updated:** 2026-05-08 — **v0.21.8 tagged at `c50eaf8`**;
|
||||||
`a2432df`**, working tree clean, all post-tag work pushed to
|
nine post-cut commits on master. Push pending.
|
||||||
origin.
|
|
||||||
|
|
||||||
v0.21.5 is a patch release with one through-line:
|
v0.21.8 closes the last optional polish items in the B-2
|
||||||
**replay-overlay scrubbing affordances + accessibility**.
|
replay screen-takeover arc: **notch-label centering** (middle
|
||||||
v0.21.4 shipped pause / resume / step + the WIN MOVE marker as
|
three scrub-bar labels now centred on their notch ticks via the
|
||||||
the first scrubbing-shaped additions to the replay overlay;
|
CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
|
||||||
v0.21.5 fills out the rest of the scrubbing UX so the player
|
MOVE HC legibility** (lime stays lime under HC mode via the
|
||||||
has both visual anchor points (notches + labels) and a complete
|
extended `HighContrastBackground::with_hc` constructor and a
|
||||||
keyboard control surface (Space / Esc / ← / →) for navigating a
|
new `STATE_SUCCESS_HC` brighter-lime constant). The replay
|
||||||
paused replay.
|
overlay arc is now fully closed with no known open items.
|
||||||
|
|
||||||
Six commits on the B-2 replay screen-takeover redesign arc land
|
Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
|
||||||
here. Two of them are layout-changing — banner height grew
|
|
||||||
60 → 76 → 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.
|
|
||||||
|
|
||||||
Full v0.21.5 detail lives in `CHANGELOG.md` § [0.21.5]. 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).
|
||||||
`a2432df`; any post-cut docs edits ride on top of that.
|
Docs ride on top; push pending.
|
||||||
- **HEAD on origin:** matches local. v0.21.5 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:** **1254 passing / 0 failing** across the workspace
|
- **Tests:** **1292 passing / 0 failing** across the workspace.
|
||||||
(1250 in v0.21.5 + 2 from `d3cb1a5`'s HC-marker tests + 2
|
- **Tags on origin:** `v0.9.0` through `v0.21.8`.
|
||||||
from `2e25476`'s continuous-scrub tests). The
|
- **Android:** APK verified booting on Pixel_7 AVD (Android 14,
|
||||||
time-dependent `daily_challenge` flake noted in v0.21.5's
|
x86_64). All desktop-only systems (handle_fullscreen) now gated.
|
||||||
CHANGELOG passes again (UTC clock has moved past the
|
See Phase Android punch list for remaining work.
|
||||||
trigger window). Detail in `CHANGELOG.md` § [0.21.5] § Stats
|
|
||||||
for the v0.21.5 baseline; post-cut delta tracked in this
|
|
||||||
file's Since-cut log.
|
|
||||||
- **Tags on origin:** `v0.9.0` through `v0.21.5`. v0.21.5 is on
|
|
||||||
`a2432df`; v0.21.4 stays on `23ff62c`; v0.21.3 stays on
|
|
||||||
`3d92a91`; v0.21.2 stays on `f23df3b`; v0.21.1 stays on
|
|
||||||
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
|
|
||||||
`41a009a`.
|
|
||||||
|
|
||||||
## Since the v0.21.5 cut
|
## Since the v0.21.8 cut
|
||||||
|
|
||||||
- **`d3cb1a5` — `feat(replay): HC-mode coverage for scrub
|
Seven commits since the v0.21.8 tag:
|
||||||
track + notches`.** Adds a parallel primitive to ui_theme
|
- `a449f60` — Stats Prev/Next selector spawn site
|
||||||
(`HighContrastBackground` marker carrying `default_color`)
|
- `202a64d` — Android launch fixes (android_main, resize_constraints,
|
||||||
and a paint system in settings_plugin
|
apply_smart_default_window_size) — **closes APK launch verification**
|
||||||
(`update_high_contrast_backgrounds`) that mirrors the
|
- `16242e6` — Ignore .idea/ IDE files
|
||||||
existing border-marker pattern but targets `BackgroundColor`
|
- `395a322` — double-tap auto-move for touch input
|
||||||
instead of `BorderColor`. Tags the 1 px scrub track Node and
|
- `0cb1587` — Play-by-Seed dialog + HomeMode card
|
||||||
all five quarter-mark notch ticks with the new marker so
|
- `2062bd0` — 75 new challenge seeds + gen_seeds binary
|
||||||
they bump from `BORDER_SUBTLE` (#505050) → `BORDER_SUBTLE_HC`
|
- `45436d0` — gate handle_fullscreen to non-Android
|
||||||
(#a0a0a0) under HC mode. Scrub fill (ACCENT_PRIMARY) and
|
- `2c822ba` — JNI clipboard bridge for Android Stats share-link
|
||||||
WIN MOVE marker (STATE_SUCCESS) don't get the marker —
|
- `f281425` — Android Keystore AES-GCM token storage via JNI
|
||||||
accent and state colours are already saturated. 2 new tests;
|
|
||||||
1250 → 1252.
|
|
||||||
- **`2e25476` — `feat(replay): continuous scrub on key-held
|
|
||||||
arrow keys`.** Holding ← or → now 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. Footer text unchanged (`[← →] step`) —
|
|
||||||
held-key scrub is a discoverable enhancement to the same
|
|
||||||
keybind, not a new keybind. 2 new tests using
|
|
||||||
`TimeUpdateStrategy::ManualDuration`; 1252 → 1254.
|
|
||||||
|
|
||||||
Open next-step menu (B-2 keyboard accelerator coverage +
|
CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
|
||||||
accessibility + scrub UX are all complete):
|
|
||||||
1. **Move-log scroller / mini-tableau preview** — both need
|
|
||||||
a much larger banner-height grow (effectively the takeover
|
|
||||||
container itself). Multi-session arcs that close B-2.
|
|
||||||
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`.
|
|
||||||
2. **Polish: notch label centering.** Bevy 0.18 lacks a clean
|
|
||||||
`translate-x: -50%` primitive so middle three labels sit
|
|
||||||
slightly right-of-notch. Could use a child Text wrapper
|
|
||||||
with computed left-margin compensation. Tiny commit.
|
|
||||||
3. **Polish: WIN MOVE marker HC bump.** Currently the marker
|
|
||||||
uses `STATE_SUCCESS` lime which stays visible under HC,
|
|
||||||
but a slight saturation / contrast bump under HC would
|
|
||||||
make the marker even more legible alongside the bumped
|
|
||||||
notches. Optional.
|
|
||||||
|
|
||||||
Recommended order: option 2 (notch label centering) is the
|
Open next-step menu:
|
||||||
smallest concrete next-step. Option 1 is the multi-session
|
1. **Phase 8 (sync)** — the biggest open arc. Local storage
|
||||||
arc that closes B-2 — natural place to start a fresh session.
|
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
|
||||||
@@ -127,33 +105,18 @@ 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 floating MOVE chip above the focused card
|
Space accelerator (post-v0.21.3), scrub notches + labels +
|
||||||
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
|
keybind footer + ESC / ← / → accelerators + HC border
|
||||||
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
|
(v0.21.5), Move Log panel + HC scrub track + continuous
|
||||||
(UI). Playback controls (pause / resume / step + Space
|
scrub (v0.21.6), and full-screen 50 % opacity dim layer
|
||||||
accelerator) shipped post-v0.21.3 in `fbe48ac`. v0.21.5
|
(v0.21.7). Every major B-2 sub-piece is now closed. The
|
||||||
bundled six more commits under "replay-overlay scrubbing
|
only remaining items are minor polish: notch-label centering
|
||||||
affordances + accessibility" — scrub notches + percentage
|
and WIN MOVE HC contrast bump (see Open next-step menu).*
|
||||||
labels + keybind-hint footer + ESC and ← / → accelerators
|
|
||||||
+ HC marker for the footer top border. Banner height grew
|
|
||||||
60 → 76 → 92 px across two layout-changing commits in
|
|
||||||
v0.21.5; banner geometry is now mutable. Full per-commit
|
|
||||||
detail in `CHANGELOG.md` § [0.21.5]. Keyboard accelerator
|
|
||||||
coverage is complete. What still needs to land: HC-mode
|
|
||||||
coverage for the scrub-track / notches / WIN MOVE marker
|
|
||||||
(they render via `BackgroundColor` so the
|
|
||||||
`HighContrastBorder` marker doesn't apply — needs a
|
|
||||||
settings-aware paint), continuous scrub on key-held ← / →
|
|
||||||
(vs single-step), then the bigger pieces — a move-log
|
|
||||||
scroller and a mini-tableau preview — both screen-
|
|
||||||
takeover-only pieces that need a much larger banner height
|
|
||||||
grow (effectively the takeover container itself).
|
|
||||||
Multi-session.
|
|
||||||
- *Floating `MOVE N/M` chip above the focused card during
|
- *Floating `MOVE N/M` chip above the focused card during
|
||||||
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
||||||
`Text2d` entity sibling to the banner overlay; uses the same
|
`Text2d` entity sibling to the banner overlay; uses the same
|
||||||
@@ -208,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
|
||||||
@@ -296,25 +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.5 is tagged at a2432df (cut 2026-05-08, a
|
Branch: master. v0.21.8 is tagged at c50eaf8 (cut 2026-05-08,
|
||||||
patch release rolling up replay-overlay scrubbing affordances +
|
replay-overlay polish). Seven post-cut commits are on master (see
|
||||||
accessibility: scrub-bar notches with percentage labels, keybind-
|
"Since the v0.21.8 cut" above); push of the last four pending.
|
||||||
hint footer, ESC + ← / → keyboard accelerators, and HC-mode
|
v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
|
||||||
coverage for the footer top border). v0.21.4 stays at 23ff62c,
|
v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
|
||||||
v0.21.3 at 3d92a91, v0.21.2 at f23df3b, v0.21.1 at daa655a,
|
v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
|
||||||
v0.21.0 at 04f9bf9. Working tree clean. See CHANGELOG.md §
|
Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
|
||||||
[0.21.5] for full detail.
|
pending. See CHANGELOG.md § [0.21.9] for full detail.
|
||||||
|
|
||||||
State: HEAD locally — see `git rev-parse HEAD`. The cut commit
|
State: HEAD locally — see `git rev-parse HEAD`. Workspace
|
||||||
is a2432df; any post-cut docs edits ride on top of that.
|
tests: 1292 passing / 0 failing. Clippy clean.
|
||||||
Workspace tests: 1250 total / 1249 passing / 1 pre-existing
|
|
||||||
time-dependent flake (`daily_challenge` warning, fails when UTC
|
|
||||||
is within 30 min of midnight; verified not introduced by recent
|
|
||||||
work). 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.5] 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
|
||||||
@@ -329,40 +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
|
||||||
B. Replay-overlay screen-takeover redesign — multi-session
|
arc by scope; rolls up Android dependencies (Keystore,
|
||||||
work. v0.21.4 shipped WIN MOVE marker, pause / resume /
|
|
||||||
step + Space accelerator, plus the floating-MOVE-chip
|
|
||||||
piece from v0.21.2 (`2fb2d63`). v0.21.5 shipped scrub
|
|
||||||
notches + percentage labels + keybind-hint footer + ESC
|
|
||||||
and ← / → accelerators + HC marker for the footer top
|
|
||||||
border (six commits across CHANGELOG § [0.21.5]). Banner
|
|
||||||
height grew 60 → 76 → 92 px across two layout-changing
|
|
||||||
commits in v0.21.5; geometry is now mutable. Keyboard
|
|
||||||
accelerator coverage is complete. Natural next finite
|
|
||||||
steps:
|
|
||||||
1. **HC-mode coverage** for the scrub-track / notches /
|
|
||||||
WIN MOVE marker (render via `BackgroundColor` not
|
|
||||||
`BorderColor`, so `HighContrastBorder` doesn't apply
|
|
||||||
— needs a settings-aware paint, precedent
|
|
||||||
`radial_rim_outline`). Smallest next commit.
|
|
||||||
2. **Continuous scrub on key-held ← / →** instead of
|
|
||||||
single-step. Needs a key-held event source. Matches
|
|
||||||
the mockup's `[← →] scrub` terminology.
|
|
||||||
3. **Move-log scroller / mini-tableau preview** — both
|
|
||||||
need a much larger banner-height grow (effectively
|
|
||||||
the takeover container itself). Multi-session arcs
|
|
||||||
that close B-2.
|
|
||||||
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`.
|
|
||||||
C. 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).
|
||||||
|
C. Play-by-Seed polish — the dialog is functional but has no
|
||||||
|
visual preview of the solver verdict in the UI yet; the
|
||||||
|
HomeMode card is wired but the dialog spawn site and verdict
|
||||||
|
display could use a second pass.
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
- Use the system git config (already correct).
|
- Use the system git config (already correct).
|
||||||
@@ -388,7 +332,7 @@ WORKFLOW NOTES:
|
|||||||
|
|
||||||
OPEN AT THE START: ask which of A–C. 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
|
Note: every remaining option is multi-session by nature (A is
|
||||||
gated on Android tooling, B and C are explicitly multi-session
|
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
|
arcs). A fresh session is a better fit for any of them than the
|
||||||
tail of a long working stretch.
|
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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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}"))
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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)"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1741,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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ use solitaire_data::ReplayMove;
|
|||||||
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
|
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
|
||||||
STATE_SUCCESS, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION,
|
STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY,
|
||||||
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
|
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -60,6 +60,23 @@ use crate::ui_theme::{
|
|||||||
/// we materialise a separate constant rather than reuse the `f32` value.
|
/// we materialise a separate constant rather than reuse the `f32` value.
|
||||||
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
|
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
|
||||||
|
|
||||||
|
/// `bevy::ui` `ZIndex` for the full-screen tableau dim layer.
|
||||||
|
///
|
||||||
|
/// One rung below [`Z_REPLAY_OVERLAY`] (= 54) so the replay chrome
|
||||||
|
/// (banner + move-log panel) renders clearly on top while the dim scrim
|
||||||
|
/// darkens the card world beneath it. World-space sprites (cards,
|
||||||
|
/// badges, drop-target overlays) are always below any UI node regardless
|
||||||
|
/// of their Transform.z — the dim layer doesn't need to know their z
|
||||||
|
/// values.
|
||||||
|
const Z_REPLAY_DIM: i32 = Z_REPLAY_OVERLAY - 1;
|
||||||
|
|
||||||
|
/// Alpha for the tableau dim layer — 50 % opacity black. Dark enough
|
||||||
|
/// to visually separate the gameplay scene from the replay chrome
|
||||||
|
/// above it; light enough that card positions remain legible through
|
||||||
|
/// the scrim. Matches the mockup's "Game Peek Band at 50 % opacity"
|
||||||
|
/// spec in `docs/ui-mockups/replay-overlay-mobile.html`.
|
||||||
|
const TABLEAU_DIM_ALPHA: f32 = 0.5;
|
||||||
|
|
||||||
/// Total height of the banner in pixels. Thin enough to leave the
|
/// Total height of the banner in pixels. Thin enough to leave the
|
||||||
/// gameplay surface visible underneath, tall enough to comfortably fit
|
/// gameplay surface visible underneath, tall enough to comfortably fit
|
||||||
/// the headline-sized "▌ replay" label stacked above the
|
/// the headline-sized "▌ replay" label stacked above the
|
||||||
@@ -88,6 +105,21 @@ const SCRUB_LABEL_ROW_HEIGHT: f32 = 16.0;
|
|||||||
/// (12 px) + 4 px breathing room.
|
/// (12 px) + 4 px breathing room.
|
||||||
const KEYBIND_FOOTER_HEIGHT: f32 = 16.0;
|
const KEYBIND_FOOTER_HEIGHT: f32 = 16.0;
|
||||||
|
|
||||||
|
/// Fixed pixel width of the centred scrub-bar notch-label container.
|
||||||
|
/// Wide enough to hold the widest label ("100%" at 4 chars) while
|
||||||
|
/// narrower than the 25 % gap between adjacent notches (≈ banner_w
|
||||||
|
/// × 0.25; on a 320 px banner that's 80 px). A 36 px container
|
||||||
|
/// leaves ≥ 44 px of clearance on each side at the narrowest common
|
||||||
|
/// screen width.
|
||||||
|
///
|
||||||
|
/// Container width drives the `margin.left = -width / 2` centering
|
||||||
|
/// trick: the container's left edge is placed at `left: Percent(pct)`
|
||||||
|
/// and then shifted left by half its own width, so the container's
|
||||||
|
/// centre coincides with the notch line. `Justify::Center` then
|
||||||
|
/// renders the text centred within the container. This is the
|
||||||
|
/// CSS `translateX(-50%)` pattern adapted for Bevy 0.18 UI.
|
||||||
|
const SCRUB_LABEL_CENTER_WIDTH: f32 = 36.0;
|
||||||
|
|
||||||
/// How long a held arrow key waits before firing the next repeat
|
/// How long a held arrow key waits before firing the next repeat
|
||||||
/// step. 100 ms = 10 steps/sec — fast enough to scrub through a
|
/// step. 100 ms = 10 steps/sec — fast enough to scrub through a
|
||||||
/// hundred-move replay in ~10 seconds while held, slow enough that
|
/// hundred-move replay in ~10 seconds while held, slow enough that
|
||||||
@@ -189,6 +221,18 @@ pub struct ReplayPauseButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ReplayStepButton;
|
pub struct ReplayStepButton;
|
||||||
|
|
||||||
|
/// Marker on the full-screen tableau dim layer spawned at the start of
|
||||||
|
/// every replay. The dim layer is a 100 % × 100 % `Node` at
|
||||||
|
/// [`Z_REPLAY_DIM`] (= `Z_REPLAY_OVERLAY - 1`) with a semi-transparent
|
||||||
|
/// black `BackgroundColor`. It darkens the card world so the replay
|
||||||
|
/// chrome reads clearly against it without obscuring card positions.
|
||||||
|
///
|
||||||
|
/// Carries no [`Interaction`] component — purely visual; pointer events
|
||||||
|
/// pass through to the underlying UI and world-space systems.
|
||||||
|
/// Despawned by `react_to_state_change` when the replay ends.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayTableauDimLayer;
|
||||||
|
|
||||||
/// Marker on the small caption sitting below the "▌ replay"
|
/// Marker on the small caption sitting below the "▌ replay"
|
||||||
/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a
|
/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a
|
||||||
/// replay is playing — a compact, monotonically-increasing identifier
|
/// replay is playing — a compact, monotonically-increasing identifier
|
||||||
@@ -435,6 +479,7 @@ fn react_to_state_change(
|
|||||||
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
||||||
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
|
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
|
||||||
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
|
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
|
||||||
|
dim_layers: Query<Entity, With<ReplayTableauDimLayer>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if !state.is_changed() {
|
if !state.is_changed() {
|
||||||
@@ -463,6 +508,11 @@ fn react_to_state_change(
|
|||||||
for entity in &move_log_panels {
|
for entity in &move_log_panels {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
|
// Tableau dim layer is also a separate root entity — same
|
||||||
|
// pattern as the move-log panel.
|
||||||
|
for entity in &dim_layers {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The `should_be_visible && already_spawned` branch is a no-op here —
|
// The `should_be_visible && already_spawned` branch is a no-op here —
|
||||||
// the per-frame text update systems below repaint the banner label
|
// the per-frame text update systems below repaint the banner label
|
||||||
@@ -504,6 +554,27 @@ fn spawn_overlay(
|
|||||||
};
|
};
|
||||||
let progress_label = format_progress(state);
|
let progress_label = format_progress(state);
|
||||||
|
|
||||||
|
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
|
||||||
|
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
|
||||||
|
// in the UI stacking context. World-space sprites (cards, badges) are
|
||||||
|
// always below any UI node, so the dim layer darkens the entire
|
||||||
|
// gameplay scene without needing to touch card_plugin. No Interaction
|
||||||
|
// component — purely visual.
|
||||||
|
commands.spawn((
|
||||||
|
ReplayTableauDimLayer,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
|
||||||
|
ZIndex(Z_REPLAY_DIM),
|
||||||
|
GlobalZIndex(Z_REPLAY_DIM),
|
||||||
|
));
|
||||||
|
|
||||||
let banner_bg = Color::srgba(
|
let banner_bg = Color::srgba(
|
||||||
BG_ELEVATED_HI.to_srgba().red,
|
BG_ELEVATED_HI.to_srgba().red,
|
||||||
BG_ELEVATED_HI.to_srgba().green,
|
BG_ELEVATED_HI.to_srgba().green,
|
||||||
@@ -708,6 +779,11 @@ fn spawn_overlay(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(STATE_SUCCESS),
|
BackgroundColor(STATE_SUCCESS),
|
||||||
|
// HC bump: lime → brighter lime so the win
|
||||||
|
// marker reads clearly above the bumped
|
||||||
|
// notch ticks (BORDER_SUBTLE_HC gray) under
|
||||||
|
// high-contrast mode.
|
||||||
|
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Fixed quarter-mark notches: five 1px vertical
|
// Fixed quarter-mark notches: five 1px vertical
|
||||||
@@ -766,45 +842,63 @@ fn spawn_overlay(
|
|||||||
labels.iter().zip(positions.iter()).enumerate()
|
labels.iter().zip(positions.iter()).enumerate()
|
||||||
{
|
{
|
||||||
// Endpoints flush to the row's edges; middle
|
// Endpoints flush to the row's edges; middle
|
||||||
// three labels anchor at their percentage.
|
// three labels use the `translateX(-50%)`
|
||||||
// `i == 0` → flush left (`left: 0`), so the
|
// pattern for Bevy 0.18 UI: a fixed-width
|
||||||
// "0%" caption doesn't get clipped at the
|
// container is placed at `left: Percent(pct)`
|
||||||
// left edge. `i == last` → flush right
|
// then shifted left by half its own width via
|
||||||
// (`right: 0`) so "100%" doesn't overflow
|
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
|
||||||
// the banner. Bevy 0.18 UI has no clean
|
// `Justify::Center` renders the text centred
|
||||||
// CSS-style `translate-x: -50%` centering,
|
// within the container so the text's visual
|
||||||
// so the middle three labels sit slightly
|
// centre coincides with the notch line.
|
||||||
// right-of-notch — visually subtle at this
|
let (node, justify) = if i == 0 {
|
||||||
// font size; explicit polish target if
|
(
|
||||||
// anyone notices.
|
Node {
|
||||||
let mut node = Node {
|
position_type: PositionType::Absolute,
|
||||||
position_type: PositionType::Absolute,
|
top: Val::Px(2.0),
|
||||||
top: Val::Px(2.0),
|
left: Val::Px(0.0),
|
||||||
..default()
|
..default()
|
||||||
};
|
},
|
||||||
if i == 0 {
|
Justify::Left,
|
||||||
node.left = Val::Px(0.0);
|
)
|
||||||
} else if i == labels.len() - 1 {
|
} else if i == labels.len() - 1 {
|
||||||
node.right = Val::Px(0.0);
|
(
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(2.0),
|
||||||
|
right: Val::Px(0.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Justify::Right,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
node.left = Val::Percent(*pct);
|
(
|
||||||
}
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(2.0),
|
||||||
|
left: Val::Percent(*pct),
|
||||||
|
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
|
||||||
|
margin: UiRect {
|
||||||
|
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Justify::Center,
|
||||||
|
)
|
||||||
|
};
|
||||||
row.spawn((
|
row.spawn((
|
||||||
ReplayOverlayScrubNotchLabel,
|
ReplayOverlayScrubNotchLabel,
|
||||||
node,
|
node,
|
||||||
Text::new(*label),
|
Text::new(*label),
|
||||||
|
TextLayout::new_with_justify(justify),
|
||||||
TextFont {
|
TextFont {
|
||||||
font: font_handle_for_labels.clone(),
|
font: font_handle_for_labels.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
// The mockup's `text-outline` (BORDER_SUBTLE)
|
|
||||||
// would match the notches but reads as too
|
|
||||||
// low-contrast against `BG_ELEVATED_HI` for
|
|
||||||
// the labels to actually be legible at 12 px.
|
|
||||||
// TEXT_SECONDARY keeps the subdued visual
|
// TEXT_SECONDARY keeps the subdued visual
|
||||||
// hierarchy (caption, not headline) while
|
// hierarchy (caption, not headline) while
|
||||||
// staying readable.
|
// staying readable against BG_ELEVATED_HI.
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -2260,6 +2354,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The WIN MOVE marker carries `HighContrastBackground::with_hc(
|
||||||
|
/// STATE_SUCCESS, STATE_SUCCESS_HC)` so the lime bumps to brighter
|
||||||
|
/// lime under HC mode rather than to a neutral gray. Pin the
|
||||||
|
/// presence of the marker so a future refactor can't accidentally
|
||||||
|
/// drop it and silently regress HC legibility.
|
||||||
|
#[test]
|
||||||
|
fn win_move_marker_carries_hc_background_marker() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
set_state(
|
||||||
|
&mut app,
|
||||||
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let mut q = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&HighContrastBackground, With<ReplayOverlayWinMoveMarker>>();
|
||||||
|
let marker = q
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.expect("WIN MOVE marker must carry HighContrastBackground");
|
||||||
|
assert_eq!(
|
||||||
|
marker.default_color,
|
||||||
|
STATE_SUCCESS,
|
||||||
|
"default colour must be STATE_SUCCESS"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
marker.hc_color,
|
||||||
|
STATE_SUCCESS_HC,
|
||||||
|
"HC colour must be STATE_SUCCESS_HC (brighter lime, not gray)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// scrub_notch_positions + ReplayOverlayScrubNotch spawn behaviour
|
// scrub_notch_positions + ReplayOverlayScrubNotch spawn behaviour
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -3798,4 +3930,62 @@ mod tests {
|
|||||||
other => panic!("expected Playing, got {other:?}"),
|
other => panic!("expected Playing, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tableau dim layer spawns alongside the banner when playback
|
||||||
|
/// starts and despawns when the replay ends. Mirrors
|
||||||
|
/// `floating_chip_spawns_and_despawns_with_overlay` for the dim layer.
|
||||||
|
#[test]
|
||||||
|
fn dim_layer_spawns_and_despawns_with_overlay() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// Inactive → no dim layer yet.
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayTableauDimLayer>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"no dim layer while playback is Inactive",
|
||||||
|
);
|
||||||
|
|
||||||
|
set_state(
|
||||||
|
&mut app,
|
||||||
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(5),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayTableauDimLayer>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"dim layer must spawn when playback starts",
|
||||||
|
);
|
||||||
|
|
||||||
|
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayTableauDimLayer>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"dim layer must despawn when playback ends",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dim layer is a full-screen node (100 % × 100 %) at a lower
|
||||||
|
/// z-index than the replay chrome (z = Z_REPLAY_DIM < Z_REPLAY_OVERLAY).
|
||||||
|
/// Lock the z-ordering so a future refactor of the z constants can't
|
||||||
|
/// silently flip the intended stacking.
|
||||||
|
#[test]
|
||||||
|
fn dim_layer_z_is_below_replay_chrome() {
|
||||||
|
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -701,7 +701,7 @@ pub(crate) fn update_high_contrast_backgrounds(
|
|||||||
let high_contrast = settings.0.high_contrast_mode;
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
for (marker, mut bg) in backgrounds.iter_mut() {
|
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||||
let target = if high_contrast {
|
let target = if high_contrast {
|
||||||
BORDER_SUBTLE_HC
|
marker.hc_color
|
||||||
} else {
|
} else {
|
||||||
marker.default_color
|
marker.default_color
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -260,24 +267,45 @@ impl HighContrastBorder {
|
|||||||
/// often render as tiny full-bleed `Node`s, not as borders, so the
|
/// often render as tiny full-bleed `Node`s, not as borders, so the
|
||||||
/// border-marker pattern doesn't apply.
|
/// border-marker pattern doesn't apply.
|
||||||
///
|
///
|
||||||
/// `default_color` records the off-state colour the entity was
|
/// `default_color` records the off-state colour; `hc_color` the on-
|
||||||
/// spawned with so the system can revert when HC is toggled back
|
/// state colour. [`with_default`] fills `hc_color` with
|
||||||
/// off. The accompanying paint system is
|
/// [`BORDER_SUBTLE_HC`] so the 90 % of sites that just need the
|
||||||
/// [`update_high_contrast_backgrounds`](crate::settings_plugin::update_high_contrast_backgrounds).
|
/// 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
|
/// [`BackgroundColor`]: bevy::prelude::BackgroundColor
|
||||||
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
|
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
|
||||||
pub struct HighContrastBackground {
|
pub struct HighContrastBackground {
|
||||||
/// Background colour to use when high-contrast mode is *off* —
|
/// Background colour to use when high-contrast mode is *off* —
|
||||||
/// the site's normal idle / active-state colour.
|
/// the site's normal idle / active-state colour.
|
||||||
pub default_color: bevy::prelude::Color,
|
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 {
|
impl HighContrastBackground {
|
||||||
/// Convenience constructor —
|
/// Convenience constructor — HC colour defaults to
|
||||||
/// `HighContrastBackground::with_default(BORDER_SUBTLE)`.
|
/// [`BORDER_SUBTLE_HC`].
|
||||||
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
||||||
Self { default_color }
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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