Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e107f5e218 | |||
| 463b7465ed | |||
| 92a5ebb15e | |||
| 89a21c0587 | |||
| 304cb050a7 | |||
| fcc7337c97 | |||
| 16ce2b88d2 | |||
| b9aa2620b8 | |||
| 47f02a60ae | |||
| a5c3188686 | |||
| 6a289b7b50 | |||
| bee712c5ab | |||
| 0db5e9dac4 | |||
| 681a54d9bb | |||
| 7894559ca7 | |||
| ab803c07af | |||
| e43b329fc1 | |||
| 7c07f71f02 | |||
| c1329bbb21 | |||
| 4303ef3f5b | |||
| 4df962ee07 | |||
| f281425b45 | |||
| 2c822ba2d7 | |||
| 7ddf2733c9 | |||
| 585570559c | |||
| 45436d0eda | |||
| 2062bd06f3 | |||
| 0cb15872b1 | |||
| 395a322adc | |||
| 5199a5e499 | |||
| 16242e6d77 | |||
| 202a64db45 | |||
| c0415eb0ee | |||
| a449f60bc5 | |||
| ad5f613277 | |||
| c50eaf81f7 | |||
| b44d2777ec | |||
| 52407e7256 | |||
| da3e5423dc | |||
| a1864271de | |||
| f63db769ae | |||
| 4437a1aaf9 | |||
| e7345aed6c | |||
| 140251beae | |||
| d6f32d3154 | |||
| 8fdc41f36f | |||
| 2e25476d0a | |||
| d3cb1a51d4 | |||
| c8358f4275 | |||
| a2432dfe7a | |||
| 511550232c | |||
| e5c4f51a6e | |||
| 23902cdc44 | |||
| 3cc8eacafa | |||
| 90e24d9711 | |||
| decbe0bbd9 | |||
| 1873b3f9be | |||
| d11d97e677 | |||
| d322abf67b | |||
| c9e4c0b4cd | |||
| fe68861e10 | |||
| c33b39cf11 | |||
| 23ff62c397 | |||
| 0b2ffca016 | |||
| fbe48acef6 | |||
| cd79877933 | |||
| 52befa6199 | |||
| e63046700c | |||
| ab857bbb6e | |||
| 886e0cf8a1 |
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.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,11 @@
|
||||
*.tmp
|
||||
data/
|
||||
.claude/
|
||||
|
||||
# IDE project files
|
||||
.idea/
|
||||
|
||||
# Android signing keystores — never commit
|
||||
*.jks
|
||||
*.jks.bak
|
||||
*.keystore
|
||||
|
||||
+583
-2
@@ -6,8 +6,589 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
No threads in flight. v0.21.3 cut on 2026-05-08; CHANGELOG accumulates
|
||||
the next cycle here.
|
||||
## [0.22.0] — 2026-05-08
|
||||
|
||||
Adds difficulty-tier game selection, Android JNI bridges for keystore and
|
||||
clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens.
|
||||
Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
|
||||
|
||||
### Added
|
||||
|
||||
- **Difficulty-tier game mode** (this release).
|
||||
`DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster /
|
||||
Random`) added to `solitaire_core::game_state` alongside a new
|
||||
`GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed
|
||||
catalogs (40 seeds each, 200 total) are generated by the new
|
||||
`gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog
|
||||
contains seeds proven winnable at progressively larger solver budgets
|
||||
(1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent`
|
||||
→ catalog seed → `NewGameRequestEvent`; the `Random` tier uses a
|
||||
system-time seed and intentionally bypasses the winnable-only filter.
|
||||
The home overlay gains an expandable `▶ Difficulty` section between the
|
||||
Draw Mode row and the mode-card grid; the last-played tier is persisted
|
||||
in `Settings::last_difficulty` and pre-expands/highlights on re-open.
|
||||
Difficulty wins pool into Classic stats (no separate buckets).
|
||||
- **Prev/Next replay selector in the Stats overlay** (`a449f60`).
|
||||
`ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and
|
||||
`ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen`
|
||||
as a flex row of two bordered chips flanking a `"Replay N / M"`
|
||||
caption, with a detail line below showing the selected replay's
|
||||
duration + date and an optional `"· Shareable"` badge. Both chips
|
||||
carry `ModalButton(Secondary)` so the existing `repaint_modal_buttons`
|
||||
paint loop gives them hover/press feedback at zero extra cost.
|
||||
`repaint_replay_selector_detail` is wired into the existing
|
||||
`.chain()` alongside `handle_replay_selector_buttons` and
|
||||
`repaint_replay_selector_caption`. The click handler and repaint
|
||||
systems have been registered (and dormant) since v0.19.0; this
|
||||
commit is purely the missing spawn site.
|
||||
- **6 new selector unit tests** (`a449f60`). Covers: spawn-site
|
||||
presence (Prev, Next, Caption, Detail all spawn with the screen),
|
||||
caption initial text ("Replay 1 / 1"), detail initial text
|
||||
("{dur} win on {date}"), Shareable badge when `share_url` is set,
|
||||
empty-history "No replays" caption, and ordinal wrapping.
|
||||
`make_test_replay(time_seconds, share_url)` helper encapsulates
|
||||
`Replay::new(...)` + `chrono::NaiveDate`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`const { assert!() }` for dim-layer z-order test** (`a449f60`).
|
||||
Converted `assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)` in
|
||||
`replay_overlay` tests to `const { assert!(…) }` to satisfy
|
||||
`clippy::assertions_on_constants` (constant-fold at compile time
|
||||
rather than a runtime no-op).
|
||||
|
||||
### Added (post-cut, same pending release)
|
||||
|
||||
- **Double-tap auto-move on touch screens** (`395a322`).
|
||||
`handle_double_tap` fires `MoveRequestEvent` (single card to
|
||||
foundation/tableau, or a whole face-up stack via
|
||||
`best_tableau_destination_for_stack`) when two `TouchPhase::Ended`
|
||||
events on the same card arrive within `DOUBLE_TAP_WINDOW` (0.5 s,
|
||||
slightly wider than the mouse `DOUBLE_CLICK_WINDOW` to account for
|
||||
touch latency). If no legal destination exists, fires
|
||||
`MoveRejectedEvent` (audio + visual rejection feedback). The system
|
||||
is inserted into the touch drag chain immediately before
|
||||
`touch_end_drag` so `DragState.active_touch_id` and `committed` are
|
||||
still readable; the tap timestamp is tracked in a `Local<HashMap<u32,
|
||||
f32>>` keyed by card ID.
|
||||
- **Play-by-Seed dialog** (`0cb1587`).
|
||||
`PlayBySeedPlugin` adds a numeric-input modal that accepts a decimal
|
||||
seed, runs a solver preview in the background (debounced 500 ms via
|
||||
`AsyncComputeTaskPool`), and shows a win/no-win verdict before
|
||||
dealing. A new `HomeMode::PlayBySeed` card in the home overlay fires
|
||||
`StartPlayBySeedRequestEvent`; the handler in `PlayBySeedPlugin`
|
||||
spawns the dialog. Digit, Backspace, Enter (confirm), and Escape
|
||||
(cancel) are handled via `ButtonInput<KeyCode>`. Five unit tests
|
||||
cover spawn, digit append, buffer read, confirm, and cancel paths.
|
||||
- **75 new challenge seeds** (`2062bd0`).
|
||||
New `gen_seeds` binary in `solitaire_assetgen` brute-searches seeds
|
||||
in the `0xCAFEBABE…` namespace and filters for hands solvable in
|
||||
≤250 moves via the core solver. The 75 confirmed-win seeds are
|
||||
appended to `CHALLENGE_SEEDS` in `solitaire_data::challenge`.
|
||||
|
||||
### Fixed (post-cut, same pending release)
|
||||
|
||||
- **Gate `handle_fullscreen` to non-Android** (`45436d0`).
|
||||
F11 fullscreen toggle makes no sense on Android (the OS owns window
|
||||
sizing); the fn and its `MonitorSelection`/`WindowMode` imports are
|
||||
now `#[cfg(not(target_os = "android"))]`-gated. The `add_systems`
|
||||
call is extracted as a separate statement so `#[cfg]` can annotate it
|
||||
(attributes cannot appear mid-chain in Rust).
|
||||
- **Android APK launch: export `android_main`** (`202a64d`).
|
||||
`NativeActivity` dlopen-s `libsolitaire_app.so` and calls
|
||||
`android_main` as its entry point. Without the symbol the app
|
||||
crashed immediately with `UnsatisfiedLinkError`. The new function
|
||||
sets `bevy::android::ANDROID_APP` (required by `WinitPlugin`) then
|
||||
delegates to `run()` — equivalent to what `#[bevy_main]` would
|
||||
generate, but usable on an arbitrary entry point name.
|
||||
- **Android APK launch: gate `resize_constraints` to non-Android**
|
||||
(`202a64d`). On Android `max_width/max_height` default to `0.0`;
|
||||
Bevy's clamp panicked with `min=800 > max=0`.
|
||||
- **Android APK launch: gate `apply_smart_default_window_size` to
|
||||
non-Android** (`202a64d`). The system calls `.clamp(800.0,
|
||||
logical_w)` which panics when the emulator reports zero window
|
||||
dimensions during early Android lifecycle events. The OS controls
|
||||
window size on Android; the system is irrelevant there.
|
||||
- **Ignore `.idea/` IDE project files** (`16242e6`). Android Studio
|
||||
created `.idea/` when the project was opened during APK
|
||||
verification; added to `.gitignore` and removed the accidentally-
|
||||
committed files.
|
||||
|
||||
### Android verification result
|
||||
|
||||
APK boots on `x86_64-linux-android` in a Pixel_7 AVD (Android 14 /
|
||||
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
|
||||
Bevy renderer initialises, splash screen loads. This is the first
|
||||
confirmed end-to-end device run.
|
||||
|
||||
### Stats
|
||||
|
||||
- Tests: **1300+ passing** / 0 failing
|
||||
- Clippy: clean
|
||||
- Crates touched: `solitaire_core` (game_state), `solitaire_data`
|
||||
(settings, stats, difficulty_seeds, challenge), `solitaire_engine`
|
||||
(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin,
|
||||
input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs),
|
||||
`solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries)
|
||||
|
||||
## [0.21.8] — 2026-05-08
|
||||
|
||||
Patch release for replay-overlay polish. Through-line:
|
||||
**notch-label centering + WIN MOVE HC legibility + HC system extension**.
|
||||
All three items were "optional polish" flagged in the v0.21.7 handoff;
|
||||
all three ship in two commits.
|
||||
|
||||
### Added
|
||||
|
||||
- **`STATE_SUCCESS_HC` constant** (`c50eaf8`). Brighter lime
|
||||
(`#c8e862`, L≈0.73) in `ui_theme` for use wherever the
|
||||
standard `STATE_SUCCESS` (`#acc267`, L≈0.51) needs extra
|
||||
luminance under HC mode. Sits above the bumped notch ticks
|
||||
(`BORDER_SUBTLE_HC` gray, L≈0.60) so a WIN MOVE marker at
|
||||
this colour is unambiguous.
|
||||
- **`HighContrastBackground::with_hc(default, hc)` constructor**
|
||||
(`c50eaf8`). Extends `HighContrastBackground` with an
|
||||
`hc_color: Color` field (default = `BORDER_SUBTLE_HC` via
|
||||
`with_default()`). `update_high_contrast_backgrounds` now
|
||||
reads `marker.hc_color` instead of the hardcoded constant —
|
||||
backwards-compatible; all existing `with_default()` usages
|
||||
continue to bump to gray.
|
||||
- **WIN MOVE scrub-bar marker HC bump** (`c50eaf8`). Marker
|
||||
now carries `HighContrastBackground::with_hc(STATE_SUCCESS,
|
||||
STATE_SUCCESS_HC)` so the lime stays lime under HC (brighter
|
||||
lime rather than gray). Pin test locks both the default and
|
||||
HC colour fields on the spawned entity.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scrub-bar notch-label centering** (`b44d277`). Middle
|
||||
three labels ("25%", "50%", "75%") previously had their
|
||||
left edge at the notch; now their text centre coincides
|
||||
with the notch tick. Implemented using the CSS
|
||||
`translateX(-50%)` pattern for Bevy 0.18 UI: a fixed
|
||||
`SCRUB_LABEL_CENTER_WIDTH = 36 px` container with
|
||||
`margin.left = -18 px` is placed at `left: Percent(pct)`,
|
||||
and `Justify::Center` centres the text within it. Endpoint
|
||||
labels ("0%", "100%") keep their flush-left / flush-right
|
||||
anchoring. `with_default()` remains one-argument.
|
||||
|
||||
### Stats
|
||||
|
||||
- Tests: 1276 passing / 0 failing (engine: 831)
|
||||
- Clippy: clean
|
||||
- Crates touched: `solitaire_engine` (replay_overlay.rs,
|
||||
ui_theme.rs, settings_plugin.rs)
|
||||
|
||||
## [0.21.7] — 2026-05-08
|
||||
|
||||
Patch release closing the last major B-2 sub-piece. Through-line:
|
||||
**mini-tableau preview dim layer**. The mockup's "Game Peek Band at
|
||||
50 % opacity" is now implemented as a full-screen UI scrim that darkens
|
||||
the card world during replay so the chrome (banner + move-log panel)
|
||||
reads clearly against the scene.
|
||||
|
||||
### Added
|
||||
|
||||
- **Full-screen tableau dim layer** (`da3e542`). Spawns a
|
||||
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
|
||||
black) at `Z_REPLAY_DIM = Z_REPLAY_OVERLAY − 1 = 54` whenever
|
||||
a replay starts; despawned alongside the banner and move-log
|
||||
panel when the replay ends. Bevy's UI/world compositor means
|
||||
no changes to `card_plugin` are needed — UI nodes always
|
||||
render above world-space sprites regardless of `Transform.z`.
|
||||
The dim layer carries no `Interaction` component (purely
|
||||
visual; pointer events pass through). Adds `Z_REPLAY_DIM`
|
||||
and `TABLEAU_DIM_ALPHA` constants plus two new tests:
|
||||
lifecycle (spawn/despawn mirrors the floating-chip pattern)
|
||||
and z-ordering invariant (`Z_REPLAY_DIM < Z_REPLAY_OVERLAY`
|
||||
pinned). 1275 tests pass / 0 failing.
|
||||
|
||||
### Stats
|
||||
|
||||
- Tests: 1275 passing / 0 failing
|
||||
- Clippy: clean
|
||||
- Crates touched: `solitaire_engine` (replay_overlay.rs)
|
||||
|
||||
## [0.21.6] — 2026-05-08
|
||||
|
||||
Patch release for the post-v0.21.5 work. Through-line:
|
||||
**Move Log panel + scrub-UX polish**. v0.21.5 closed out the
|
||||
keyboard-accelerator surface (Space / Esc / ← / →) and the
|
||||
keybind footer; v0.21.6 builds on that with two parallel
|
||||
threads — accessibility + scrub-on-hold polish for the v0.21.5
|
||||
surfaces, plus a brand-new Move Log panel anchored to the
|
||||
viewport's bottom edge that gives players a 5-row recent-and-
|
||||
upcoming move history alongside the existing top-edge banner.
|
||||
|
||||
The Move Log panel is the first replay-overlay surface that
|
||||
*isn't* attached to the banner — it lives at a separate screen
|
||||
anchor (bottom: 0) with its own spawn/despawn lifecycle.
|
||||
Establishes the pattern for "multi-anchor replay UI" that the
|
||||
remaining B-2 sub-piece (mini-tableau preview) will inherit.
|
||||
|
||||
### Added
|
||||
|
||||
- **HC-mode coverage for the scrub track + quarter-mark notch
|
||||
ticks** (`d3cb1a5`). Adds parallel primitive
|
||||
`HighContrastBackground` to `ui_theme` and a paint system
|
||||
`update_high_contrast_backgrounds` in `settings_plugin` that
|
||||
mirrors the existing border-marker pattern but targets
|
||||
`BackgroundColor` instead of `BorderColor`. Tags the 1 px
|
||||
scrub track Node and all five quarter-mark notch ticks so
|
||||
they bump from `BORDER_SUBTLE` (`#505050`) →
|
||||
`BORDER_SUBTLE_HC` (`#a0a0a0`) under HC mode. Scrub fill
|
||||
(`ACCENT_PRIMARY`) and WIN MOVE marker (`STATE_SUCCESS`)
|
||||
don't get the marker — accent and state colours are already
|
||||
saturated and don't need an HC luminance variant.
|
||||
- **Continuous scrub on key-held arrow keys** (`2e25476`).
|
||||
Holding ← or → triggers continuous step at 100 ms cadence
|
||||
(10 steps/sec) — matches the mockup's `[← →] scrub`
|
||||
terminology while keeping single-press = single-step
|
||||
semantics. Per-key accumulators in a new
|
||||
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
|
||||
the accumulator and fire immediately. Release resets to 0
|
||||
so the next fresh press fires immediately rather than at
|
||||
half-interval.
|
||||
- **Move Log panel** (`d6f32d3` + `140251b` + `e7345ae` +
|
||||
`4437a1a`). New bottom-edge UI panel showing a 5-row window
|
||||
onto recent + upcoming moves: 2 prev rows above the active
|
||||
row + active row highlighted in `ACCENT_PRIMARY` + 2 next
|
||||
rows below. Header reads `▌ MOVE LOG · N/M` (or
|
||||
`▌ MOVE LOG · COMPLETE` when finished). Active row carries
|
||||
a `▶` focus prefix and `TEXT_PRIMARY_HC` text colour for
|
||||
legible contrast against the brick-red highlight. Prev /
|
||||
next rows render in `TEXT_SECONDARY` so the active row
|
||||
stays the focal point.
|
||||
- Sibling-of-banner pattern (separate root entity anchored
|
||||
at viewport bottom, not a banner child) — same
|
||||
spawn/despawn lifecycle as `ReplayFloatingProgressChip`,
|
||||
different screen anchor.
|
||||
- Five pure helpers handle the formatting:
|
||||
`format_pile`, `format_move_body`,
|
||||
`format_move_log_header`, `format_kth_recent_row` (active
|
||||
+ prev), `format_kth_next_row` (next). 1-indexed display
|
||||
numbers throughout (`Foundation(2)` reads as "foundation
|
||||
3" rather than the enum's 0-index).
|
||||
- Panel grows from 56 → 84 → 112 px across the four
|
||||
move-log commits. `MOVE_LOG_PREV_ROWS` and
|
||||
`MOVE_LOG_NEXT_ROWS` constants (both = 2) parameterise
|
||||
the row count; `format_kth_recent_row` and
|
||||
`format_kth_next_row` return empty for out-of-range k so
|
||||
panels gracefully under-fill at the start (cursor=1) and
|
||||
end (cursor=N-1) of a replay.
|
||||
- HC marker on the panel's top border so the 1 px edge
|
||||
bumps under HC mode (same pattern as the keybind footer).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`react_to_state_change` despawns the Move Log panel** on
|
||||
`Playing → Inactive` alongside the banner root and floating
|
||||
progress chip. Third query in the same defer-and-despawn
|
||||
cycle.
|
||||
- **Move Log panel height grew 56 → 84 → 112 px** across the
|
||||
prev-rows and next-rows commits. The panel is sized to fit
|
||||
the chosen row count + header + padding; tunable via the
|
||||
`MOVE_LOG_PANEL_HEIGHT` const.
|
||||
- **`format_active_move_row` now prefixes the `▶` focus
|
||||
marker** (`e7345ae`). Wraps `format_kth_recent_row(state, 1)`
|
||||
and prepends the prefix when the body is non-empty. Empty
|
||||
case still returns empty — cursor=0 doesn't paint a stray
|
||||
`▶` on an otherwise-empty row.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `SESSION_HANDOFF.md` refreshed twice this cycle — once
|
||||
recording the HC paint + continuous-scrub polish, then
|
||||
again as the Move Log arc shipped commit-by-commit. The
|
||||
Resume menu's B option now traces the full arc:
|
||||
notches → labels → footer → ESC → HC → arrow keys →
|
||||
HC paint → continuous scrub → move log.
|
||||
|
||||
### Stats
|
||||
|
||||
- **1273 passing tests / 0 failing** across the workspace
|
||||
(net +23 from v0.21.5's 1250 baseline):
|
||||
- 2 from `d3cb1a5` (HC marker on track + notches).
|
||||
- 2 from `2e25476` (continuous-scrub repeat-while-held +
|
||||
release-resets-accumulator).
|
||||
- 8 from `d6f32d3` (move-log panel init + 5 helpers + 3
|
||||
spawn / lifecycle scenarios).
|
||||
- 4 from `140251b` (prev rows: helper k coverage + spawn
|
||||
cardinality + spawn texts + repaint on cursor advance).
|
||||
- 3 from `e7345ae` (active row highlight: wrapper bg +
|
||||
text colour + focus prefix + cursor=0 stays empty).
|
||||
- 4 from `4437a1a` (next rows: helper k coverage + spawn
|
||||
cardinality + spawn texts + under-fill at replay end).
|
||||
- Clippy clean across the workspace.
|
||||
|
||||
## [0.21.5] — 2026-05-08
|
||||
|
||||
Patch release for the post-v0.21.4 work. One through-line:
|
||||
**replay-overlay scrubbing affordances + accessibility**. v0.21.4
|
||||
shipped pause / resume / step + the WIN MOVE marker as the first
|
||||
*scrubbing-shaped* additions to the replay overlay; v0.21.5
|
||||
fills out the rest of the scrubbing UX so the player has both
|
||||
visual anchor points (notches + labels) and a complete keyboard
|
||||
control surface (Space / Esc / ← / →) for navigating a paused
|
||||
replay.
|
||||
|
||||
Two of the six commits in this cycle are layout-changing — they
|
||||
grow the banner height from 60 px → 76 px → 92 px to make room
|
||||
for the notch labels and keybind footer. Banner geometry was
|
||||
fixed for every prior B-2 commit; this release establishes the
|
||||
"grow the container, add a flex-column child" pattern that the
|
||||
remaining B-2 sub-pieces (move-log scroller, mini-tableau
|
||||
preview) will inherit when they land.
|
||||
|
||||
### Added
|
||||
|
||||
- **Quarter-mark scrub-bar notches** (`fe68861`). Five 1 px
|
||||
vertical ticks at 0 / 25 / 50 / 75 / 100 % give the player
|
||||
visual anchor points without needing to mentally bisect the
|
||||
bar. Pure helper `scrub_notch_positions()` returns the fixed
|
||||
array; spawn loop sits next to the WIN MOVE marker spawn so
|
||||
the lifecycles match. Notches paint in `BORDER_SUBTLE` (same
|
||||
as the unfilled track) and rely on extending past the 1 px
|
||||
track (5 px tall, anchored 2 px above the track top) for
|
||||
visibility — same trick the WIN MOVE marker uses. Spawned
|
||||
*after* the WIN MOVE marker so a notch and the marker
|
||||
landing on the same percentage paint the marker on top.
|
||||
- **Percentage labels under each notch** (`d322abf`). Five
|
||||
`0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px
|
||||
row beneath the 1 px scrub track give the player explicit
|
||||
quarter-mark readouts. Banner grew from 60 → 76 px to
|
||||
accommodate the row — first **layout-changing** commit in
|
||||
the B-2 arc. Pure helper `scrub_notch_labels()` returns the
|
||||
fixed array, paired index-for-index with
|
||||
`scrub_notch_positions()`. Spawn loop applies an "endpoints
|
||||
flush, middle three percent-anchored" positioning pattern:
|
||||
leftmost label gets `left: 0`, rightmost gets `right: 0`,
|
||||
middle three anchor at `left: Val::Percent(p)` since Bevy
|
||||
0.18 UI lacks a clean CSS-style `translate-x: -50%`
|
||||
centering primitive. Label colour is `TEXT_SECONDARY`
|
||||
rather than the mockup's `BORDER_SUBTLE` (the latter would
|
||||
match the notches but is too low-contrast against
|
||||
`BG_ELEVATED_HI` to read at 12 px).
|
||||
- **Keybind-hint footer** (`1873b3f`). Vim-style mode line on
|
||||
the left (`▌ NORMAL │ replay`) plus a keybind hint on the
|
||||
right at the bottom edge of the banner. Banner grew from
|
||||
76 → 92 px to fit the 16 px footer row. Surfaces every
|
||||
wired keyboard accelerator visually so CLAUDE.md §3.3's
|
||||
UI-first contract holds for keyboard accelerators too. The
|
||||
footer lists *only* keybinds that are actually wired —
|
||||
the only-wired-keybinds discipline means each release
|
||||
cycle's hint string is a precise honest contract with the
|
||||
player. Two pure helpers (`keybind_footer_mode_text`,
|
||||
`keybind_footer_hint_text`) keep the static text testable.
|
||||
1 px top border in `BORDER_SUBTLE` separates the footer
|
||||
from the labels row.
|
||||
- **ESC keyboard accelerator for replay-stop** (`90e24d9`).
|
||||
New `handle_stop_keyboard` system parallels
|
||||
`handle_pause_keyboard` in shape — fires only when state
|
||||
is `Playing`, calls `stop_replay_playback`. Cross-plugin
|
||||
coordination via `pause_plugin::toggle_pause`: added a
|
||||
fourth defer-if check
|
||||
(`replay_state.is_some_and(|s| s.is_playing())`) right
|
||||
after the existing `other_modal_scrims` check so ESC
|
||||
during active replay belongs to the replay overlay, not
|
||||
the pause modal.
|
||||
- **HC-mode coverage for the keybind-footer top border**
|
||||
(`23902cd`).
|
||||
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
|
||||
on the footer's border-carrying Node so the existing
|
||||
`apply_high_contrast_borders` system bumps the 1 px top
|
||||
border from `#505050` → `#a0a0a0` when
|
||||
`Settings::high_contrast_mode` is on. Without the marker
|
||||
the footer reads as floating loose under HC because the
|
||||
border that anchors it to the labels row is
|
||||
near-invisible.
|
||||
- **← / → keyboard accelerators for paused stepping**
|
||||
(`e5c4f51`). New `step_backwards_replay_playback` in
|
||||
`replay_playback.rs` decrements the cursor and dispatches
|
||||
`UndoRequestEvent`; the game's `handle_undo` reads it
|
||||
next frame to reverse its most-recent move. Hooks the
|
||||
existing undo system rather than replaying-forward-from-
|
||||
zero — every replay-applied move pushes to the undo stack
|
||||
the same way a player move would, so undo is the right
|
||||
reversal primitive. Both arrow keys are paused-only via
|
||||
the same destructure-gate pattern the forward step uses.
|
||||
The mockup labels these `[← →] scrub`; single-move step
|
||||
is the closest behaviour shippable today, so the footer
|
||||
hint reads `[← →] step` — only-wired-keybinds discipline.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Banner height grew 60 → 76 → 92 px** across two
|
||||
layout-changing commits (`d322abf` then `1873b3f`). Top
|
||||
row's `flex_grow: 1.0` still consumes 59 px so the
|
||||
existing content (label / progress chip / buttons) has
|
||||
the same vertical space; the new rows (16 px labels +
|
||||
16 px footer) extend the banner downward into the
|
||||
gameplay area. Banner geometry is now mutable — every
|
||||
prior B-2 commit fit inside fixed 60 px space.
|
||||
- **Keybind-footer hint text grew alongside the wirings**:
|
||||
`[SPACE] pause/resume` →
|
||||
`[SPACE] pause/resume · [ESC] stop` →
|
||||
`[SPACE] pause/resume · [ESC] stop · [← →] step`.
|
||||
- **`pause_plugin::toggle_pause` now defers when a replay
|
||||
is active** (`90e24d9`). Adds a fourth defer-if check to
|
||||
the existing modal-stack pattern.
|
||||
- **`ReplayOverlayPlugin` registers
|
||||
`add_message::<UndoRequestEvent>()`** (`e5c4f51`).
|
||||
Defensive registration so the plugin runs cleanly under
|
||||
`MinimalPlugins` without `GamePlugin` attached.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `SESSION_HANDOFF.md` refreshed five times this cycle.
|
||||
The B option in the Resume menu now traces the full arc:
|
||||
notches → labels → footer → ESC → HC → arrow keys.
|
||||
- The pre-existing `daily_challenge` warning test that
|
||||
fails when wall-clock UTC is within 30 minutes of
|
||||
midnight is documented in this cycle's handoff. Same
|
||||
shape as the earlier `winnable_seed_search` flake —
|
||||
time-dependent, deterministically passes outside the
|
||||
trigger window.
|
||||
|
||||
### Stats
|
||||
|
||||
- **1250 total tests / 1249 passing / 1 pre-existing
|
||||
time-dependent flake** across the workspace (net +22 from
|
||||
v0.21.4's 1228 baseline):
|
||||
- 4 from `fe68861` (scrub-notch coverage)
|
||||
- 4 from `d322abf` (notch-label coverage)
|
||||
- 4 from `1873b3f` (keybind-footer coverage)
|
||||
- 3 from `90e24d9` (ESC-accelerator coverage)
|
||||
- 1 from `23902cd` (HC-marker coverage)
|
||||
- 6 from `e5c4f51` (arrow-keyboard coverage)
|
||||
- **Pre-existing flake**:
|
||||
`daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_day`
|
||||
fails when wall-clock UTC is within 30 minutes of
|
||||
midnight. Verified pre-existing by stash-and-retest
|
||||
before each commit. Will pass deterministically outside
|
||||
the trigger window. Not introduced by this release.
|
||||
- Clippy clean across the workspace.
|
||||
|
||||
## [0.21.4] — 2026-05-08
|
||||
|
||||
Patch release for the post-v0.21.3 work. One through-line:
|
||||
**replay-scrubbing accessibility**. The replay overlay used to be
|
||||
pure-passive — the player started a replay, watched it execute,
|
||||
and waited for it to end. v0.21.4 adds the scaffolding for
|
||||
*navigating within* a replay: a WIN MOVE marker on the scrub bar
|
||||
so the player can see at a glance where the winning move sits,
|
||||
and pause / resume / step controls so they can stop on any move
|
||||
and inspect the board.
|
||||
|
||||
The work is also the first three commits on the B-2 replay
|
||||
screen-takeover redesign arc. The remaining pieces (screen-
|
||||
takeover layout, move-log scroller, mini-tableau preview) are
|
||||
deferred to a future cycle because they need a layout reflow
|
||||
that the existing banner-only overlay can't carry.
|
||||
|
||||
### Added
|
||||
|
||||
- **`Replay::win_move_index: Option<usize>` data field**
|
||||
(`ab857bb`). Additive optional field on the persisted
|
||||
`Replay` shape. `#[serde(default)]` keeps older
|
||||
`latest_replay.json` / `replays.json` files loadable without
|
||||
bumping `REPLAY_SCHEMA_VERSION` — this is purely additive.
|
||||
Populated at the live recording site
|
||||
(`game_plugin::handle_game_won`) via a new builder-style
|
||||
setter `Replay::with_win_move_index`. For fresh recordings
|
||||
the value is always `Some(moves.len() - 1)` because recording
|
||||
freezes on win, but storing it explicitly lets the playback
|
||||
UI read the WIN MOVE position directly without re-deriving
|
||||
on every render.
|
||||
- **WIN MOVE scrub-bar marker** (`52befa6`). New
|
||||
`ReplayOverlayWinMoveMarker` component spawned as a sibling
|
||||
to `ReplayOverlayScrubFill` under the 1px scrub track,
|
||||
absolute-positioned at `replay.win_move_index / total %` of
|
||||
the bar. Painted in `STATE_SUCCESS` (green) so the marker
|
||||
reads as "this is where the win lives." Pure helper
|
||||
`win_move_marker_pct` returns `None` for any state where the
|
||||
marker shouldn't draw (Inactive, Completed, replay missing
|
||||
the field, empty move list); percentage clamps to `[0, 100]`
|
||||
defensively. Spawn-time only — the position never changes
|
||||
during a single playback because the underlying `Replay` is
|
||||
immutable while `Playing`.
|
||||
- **Pause / Resume / Step playback controls** (`fbe48ac`). New
|
||||
`paused: bool` field on `ReplayPlaybackState::Playing`.
|
||||
`tick_replay_playback` skips the `secs_to_next` decrement
|
||||
entirely while paused so cursor and timer freeze together;
|
||||
resuming starts the next move from a full interval. New
|
||||
public API: `toggle_pause_replay_playback` and
|
||||
`step_replay_playback` (the latter hard-gated to `Playing {
|
||||
paused: true }` via the destructure pattern itself, so
|
||||
manual stepping can't race the tick loop). On-screen Pause
|
||||
and Step buttons sit alongside the existing Stop button;
|
||||
`Space` keyboard accelerator toggles pause / resume.
|
||||
- **`Replay::with_win_move_index` builder** (`ab857bb`).
|
||||
Chainable setter so the recording site can write
|
||||
`Replay::new(...).with_win_move_index(idx)`. Keeps
|
||||
`Replay::new`'s signature stable across the 13+ existing
|
||||
test-fixture call sites that don't care about the field.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`Replay::new` writes `win_move_index: None`** (`ab857bb`).
|
||||
Existing canonical constructor stays signature-compatible
|
||||
with all existing callers. The field is opt-in via the
|
||||
builder.
|
||||
- **`game_plugin::handle_game_won` populates the new field**
|
||||
(`ab857bb`). The recording site computes
|
||||
`recording.moves.len().checked_sub(1)` as the win-move
|
||||
index. `checked_sub` rather than direct subtraction guards
|
||||
the unreachable empty-recording branch (which is also
|
||||
guarded earlier in the function).
|
||||
- **`tick_replay_playback` honors the new `paused` flag**
|
||||
(`fbe48ac`). Skipping the timer decrement is the only
|
||||
behavior change; the loop body and Completed-detection are
|
||||
unchanged. Stepping fires moves directly via
|
||||
`step_replay_playback`, bypassing the tick path entirely.
|
||||
- **Pause / Resume button label is reactive** (`fbe48ac`).
|
||||
`update_pause_button_label` walks `Children` from the
|
||||
marked button to its inner `Text` and repaints the label
|
||||
whenever `ReplayPlaybackState` changes. Pure helper
|
||||
`pause_button_label` covers all four state arms (running,
|
||||
paused, inactive, completed).
|
||||
- **25 existing `Playing { ... }` construction sites gained
|
||||
`paused: false`** (`fbe48ac`). Mechanical edit across
|
||||
`replay_overlay`, `achievement_plugin`, and
|
||||
`replay_playback` tests to satisfy the new field
|
||||
requirement. No behavioral change.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `SESSION_HANDOFF.md` refreshed three times this cycle —
|
||||
once after each post-cut feature commit. The B-2 entry in
|
||||
the Visual-identity follow-ups list now points at the
|
||||
remaining sub-pieces (screen-takeover layout, move-log
|
||||
scroller, mini-tableau preview) as a single multi-session
|
||||
arc rather than three independent ones, since they share a
|
||||
layout-reflow prerequisite.
|
||||
|
||||
### Stats
|
||||
|
||||
- **1228 passing tests / 0 failing** across the workspace
|
||||
(net +21 from v0.21.3's 1207 baseline):
|
||||
- 5 from `ab857bb`'s `win_move_index` coverage: default
|
||||
constructor, builder set / set-None, on-disk round-trip,
|
||||
legacy-JSON-loads-with-None backward-compat. The last
|
||||
test pins the no-schema-bump claim — if a future refactor
|
||||
drops the `#[serde(default)]`, that test catches it.
|
||||
- 8 from `52befa6`'s WIN MOVE marker: pure-helper truth
|
||||
table (Inactive / Completed / no-field / correct-position
|
||||
/ clamp) + spawn-presence-with-field /
|
||||
spawn-absence-without / despawn-with-overlay observables.
|
||||
- 8 from `fbe48ac`'s playback controls: label truth table,
|
||||
label repaint on state change, click-toggles-paused,
|
||||
step advances cursor by exactly one with paused
|
||||
preserved, step-while-running no-op, Space toggles
|
||||
paused.
|
||||
- Zero clippy warnings under `cargo clippy --workspace
|
||||
--all-targets -- -D warnings`.
|
||||
- `cargo test --workspace` clean.
|
||||
|
||||
## [0.21.3] — 2026-05-08
|
||||
|
||||
|
||||
Generated
+5
@@ -6967,6 +6967,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"png 0.17.16",
|
||||
"solitaire_core",
|
||||
"solitaire_data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6984,8 +6986,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
"jsonwebtoken",
|
||||
"keyring-core",
|
||||
"reqwest",
|
||||
@@ -7009,6 +7013,7 @@ dependencies = [
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
"kira",
|
||||
"resvg",
|
||||
"ron",
|
||||
|
||||
@@ -31,6 +31,7 @@ keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
arboard = { version = "3", default-features = false }
|
||||
jni = { version = "0.21", default-features = false }
|
||||
|
||||
solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
|
||||
+122
-112
@@ -1,92 +1,98 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-08 — v0.21.2 cut and tagged at `f23df3b`;
|
||||
post-cut work shipped: Toast Warning (`279e23d`) and the HC
|
||||
dynamic-paint rollout (`c153363`). Working tree clean, all
|
||||
post-tag work pushed to origin.
|
||||
**Last updated:** 2026-05-08 — **v0.21.8 tagged at `c50eaf8`**;
|
||||
nine post-cut commits on master. Push pending.
|
||||
|
||||
v0.21.2 is a patch release for the post-v0.21.1 polish work:
|
||||
extends accessibility (full HC chrome rollout across 8 surfaces;
|
||||
splash reduce-motion gating on scanline + cursor pulse), adds a
|
||||
floating MOVE chip above the destination card during replay
|
||||
playback, and lights up the first real consumer of
|
||||
`ToastVariant::Error` (a "Invalid move" toast as the third leg
|
||||
of the existing audio + visual rejection-feedback stool).
|
||||
v0.21.8 closes the last optional polish items in the B-2
|
||||
replay screen-takeover arc: **notch-label centering** (middle
|
||||
three scrub-bar labels now centred on their notch ticks via the
|
||||
CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
|
||||
MOVE HC legibility** (lime stays lime under HC mode via the
|
||||
extended `HighContrastBackground::with_hc` constructor and a
|
||||
new `STATE_SUCCESS_HC` brighter-lime constant). The replay
|
||||
overlay arc is now fully closed with no known open items.
|
||||
|
||||
Full v0.21.2 detail lives in `CHANGELOG.md` § [0.21.2]. This
|
||||
Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
|
||||
file from here on focuses on what's *open* post-cut and how to
|
||||
resume.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
|
||||
`f23df3b`; post-cut work (`279e23d` Toast Warning, `c153363`
|
||||
HC dynamic-paint rollout) rides on top of that.
|
||||
- **HEAD on origin:** matches local. v0.21.2 is fully on origin.
|
||||
- **Working tree:** clean. No WIP outstanding.
|
||||
- **HEAD locally:** `f281425` (Android Keystore JNI).
|
||||
Docs ride on top; push pending.
|
||||
- **HEAD on origin:** `395a322` (double-tap commit — last pushed).
|
||||
- **Working tree:** clean (docs uncommitted). No WIP outstanding.
|
||||
- **`artwork/` directory:** still untracked. Intentional.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean.
|
||||
- **Tests:** **1207 passing / 0 failing** across the workspace
|
||||
(net +12 from the v0.21.2 cut: 8 from Toast Warning wiring;
|
||||
4 from the radial-rim HC truth-table).
|
||||
- **Tags on origin:** `v0.9.0` through `v0.21.2`. v0.21.2 is on
|
||||
`f23df3b`; v0.21.1 stays on `daa655a`; v0.21.0 stays on
|
||||
`04f9bf9`; v0.20.0 stays on `41a009a`.
|
||||
- **Tests:** **1292 passing / 0 failing** across the workspace.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.21.8`.
|
||||
- **Android:** APK verified booting on Pixel_7 AVD (Android 14,
|
||||
x86_64). All desktop-only systems (handle_fullscreen) now gated.
|
||||
See Phase Android punch list for remaining work.
|
||||
|
||||
## Since the v0.21.2 cut
|
||||
## Since the v0.21.8 cut
|
||||
|
||||
- **`279e23d` — Toast Warning variant wired.** First in-engine
|
||||
consumer of `ToastVariant::Warning`: a 4 s amber-bordered
|
||||
toast that fires once per daily-challenge date when the
|
||||
player is within 30 min of UTC midnight reset and hasn't yet
|
||||
completed today's challenge. Mirrors the v0.21.2 Toast Error
|
||||
pattern — a domain message (`WarningToastEvent(String)`) is
|
||||
the contract between the daily plugin and the animation
|
||||
plugin's spawn handler. Suppression decided by a pure helper
|
||||
(`compute_expiry_warning_minutes`) that's exhaustively tested
|
||||
without an `App`. After this commit every `ToastVariant`
|
||||
(Info / Warning / Error / Celebration) has at least one real
|
||||
driver — the variant enum is fully load-bearing.
|
||||
- **`c153363` — HC rollout to the dynamic-paint sites.** Closes
|
||||
the v0.21.2 carve-out. Re-reading the code revealed only one
|
||||
of three "dynamic-paint" sites was actually a border-paint
|
||||
cycle — HUD action buttons and modal buttons paint
|
||||
*backgrounds* dynamically with static borders, so they take
|
||||
the existing `HighContrastBorder` marker pattern cleanly. The
|
||||
radial menu rim is the only true dynamic-painter (full
|
||||
per-frame respawn of `Sprite` entities); HC is folded into
|
||||
the spawn there with a pure helper (`radial_rim_outline`)
|
||||
that boosts the *focused* rim to `BORDER_SUBTLE_HC` under HC
|
||||
rather than `BORDER_STRONG` — naive marker substitution would
|
||||
invert the focused-vs-resting hierarchy because
|
||||
`BORDER_SUBTLE_HC` (#a0a0a0) is lighter than `BORDER_STRONG`
|
||||
(#505050). After this commit, every UI surface in the v0.21.x
|
||||
accessibility arc either carries the marker or has HC folded
|
||||
into its own spawn cycle. No "un-tagged because race-risk"
|
||||
surfaces remain.
|
||||
Seven commits since the v0.21.8 tag:
|
||||
- `a449f60` — Stats Prev/Next selector spawn site
|
||||
- `202a64d` — Android launch fixes (android_main, resize_constraints,
|
||||
apply_smart_default_window_size) — **closes APK launch verification**
|
||||
- `16242e6` — Ignore .idea/ IDE files
|
||||
- `395a322` — double-tap auto-move for touch input
|
||||
- `0cb1587` — Play-by-Seed dialog + HomeMode card
|
||||
- `2062bd0` — 75 new challenge seeds + gen_seeds binary
|
||||
- `45436d0` — gate handle_fullscreen to non-Android
|
||||
- `2c822ba` — JNI clipboard bridge for Android Stats share-link
|
||||
- `f281425` — Android Keystore AES-GCM token storage via JNI
|
||||
|
||||
For the v0.21.2 contents themselves, see `CHANGELOG.md` §
|
||||
[0.21.2].
|
||||
CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
|
||||
|
||||
Open next-step menu:
|
||||
1. **Phase 8 (sync)** — the biggest open arc. Local storage
|
||||
scaffolding, self-hosted Axum server, GPGS stub.
|
||||
2. **Android follow-ups** — JNI ClipboardManager, Android Keystore,
|
||||
GPGS. Launch verification and double-tap both closed; these
|
||||
are the remaining Phase Android items.
|
||||
3. **Move Log auto-scroll** — only relevant if the panel
|
||||
row count grows beyond the current 5-row fixed window.
|
||||
|
||||
## Open punch list
|
||||
|
||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
||||
|
||||
- **APK launch verification on AVD / device.** `adb install` then
|
||||
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
|
||||
The build works and persistence is wired, but no end-to-end
|
||||
device run has been logged. Shakes out runtime bugs the build +
|
||||
unit tests can't catch.
|
||||
- **JNI ClipboardManager bridge.** Replaces the Android stub for
|
||||
the Stats "Copy share link" toast. `arboard` doesn't ship an
|
||||
Android backend; small custom JNI call.
|
||||
- **Android Keystore for credentials.** `keyring` is target-gated
|
||||
to a stub returning `KeychainUnavailable`; replace with Android
|
||||
Keystore via JNI when sync auth ships on mobile.
|
||||
- **Google Play Games (gpgs) integration.** Listed as a
|
||||
Phase-Android target since Phase 1; now unblocked by the build
|
||||
target.
|
||||
- *APK launch verification — closed 2026-05-08 by `202a64d`.*
|
||||
Three fixes shipped: `android_main` export (missing NativeActivity
|
||||
entry point), `resize_constraints` gated to non-Android (max=0
|
||||
panic), `apply_smart_default_window_size` gated to non-Android
|
||||
(clamp panic on zero-dimension window event). Verified booting on
|
||||
Pixel_7 AVD (Android 14, x86_64, SwiftShader Vulkan), 2+ min
|
||||
runtime without crash. B0004 ECS hierarchy warnings remain
|
||||
(non-fatal; entity parent/child component mismatch); investigate
|
||||
if they surface gameplay bugs.
|
||||
- *Double-tap auto-move — closed 2026-05-08 by `395a322`.*
|
||||
`handle_double_tap` fires `MoveRequestEvent` on two rapid
|
||||
`TouchPhase::Ended` events within 0.5 s. Prefers foundation;
|
||||
falls back to tableau stack move. Fires `MoveRejectedEvent` when
|
||||
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
|
||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
||||
Either upstream a cargo-apk fix or document `--lib` as
|
||||
@@ -99,17 +105,18 @@ chrome migration, splash boot screen, replay-overlay banner,
|
||||
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
|
||||
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
||||
|
||||
- **Replay-overlay screen-takeover redesign.** The full mockup
|
||||
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a
|
||||
mini-tableau preview, playback controls, move-log scroll, and
|
||||
a WIN MOVE marker on the scrub bar. Banner-local pieces all
|
||||
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
|
||||
`e080b49`); the floating MOVE chip above the focused card
|
||||
shipped in v0.21.2 (`2fb2d63`). The screen-takeover is a
|
||||
multi-session redesign with data-layer impact — needs a new
|
||||
`win_move_index: Option<usize>` field on `Replay` (currently
|
||||
unimplemented), a move-log scroller, and a mini-tableau
|
||||
preview.
|
||||
- *Replay-overlay screen-takeover redesign — closed 2026-05-08
|
||||
across 13 commits (v0.21.4–v0.21.7).* The full mockup
|
||||
(`docs/ui-mockups/replay-overlay-mobile.html`) has shipped:
|
||||
banner chrome (v0.21.0), floating MOVE chip (v0.21.2), WIN
|
||||
MOVE scrub-bar marker (post-v0.21.3), playback controls /
|
||||
Space accelerator (post-v0.21.3), scrub notches + labels +
|
||||
keybind footer + ESC / ← / → accelerators + HC border
|
||||
(v0.21.5), Move Log panel + HC scrub track + continuous
|
||||
scrub (v0.21.6), and full-screen 50 % opacity dim layer
|
||||
(v0.21.7). Every major B-2 sub-piece is now closed. The
|
||||
only remaining items are minor polish: notch-label centering
|
||||
and WIN MOVE HC contrast bump (see Open next-step menu).*
|
||||
- *Floating `MOVE N/M` chip above the focused card during
|
||||
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
||||
`Text2d` entity sibling to the banner overlay; uses the same
|
||||
@@ -164,11 +171,19 @@ palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
||||
|
||||
### Other small candidates
|
||||
|
||||
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
|
||||
noted Prev/Next markers exist in `stats_plugin` but no spawn
|
||||
site renders them today — the Shareable badge therefore lands
|
||||
on the single-replay caption. If/when Prev/Next is plumbed,
|
||||
the badge will need to follow.
|
||||
- *Play-by-Seed dialog — closed 2026-05-08 by `0cb1587`.*
|
||||
`PlayBySeedPlugin` adds a numeric-input modal with async solver
|
||||
preview (debounced 500 ms). `HomeMode::PlayBySeed` card fires
|
||||
`StartPlayBySeedRequestEvent`. 5 unit tests. 75 new verified-win
|
||||
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
|
||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
||||
for fire-and-forget) now share visual treatment but remain
|
||||
@@ -252,22 +267,21 @@ into a v0.21.1 / v0.22.0 cut.
|
||||
```
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||
Branch: master. v0.21.2 is tagged at f23df3b (cut 2026-05-08, a
|
||||
patch release rolling up accessibility extensions, replay polish,
|
||||
and the first real `ToastVariant::Error` consumer). v0.21.1 stays
|
||||
at daa655a, v0.21.0 at 04f9bf9. Working tree clean. Post-cut
|
||||
work shipped: Toast Warning variant (`279e23d`) and the HC
|
||||
dynamic-paint rollout (`c153363`) — accessibility arc is fully
|
||||
closed, every `ToastVariant` has at least one real driver. See
|
||||
CHANGELOG.md § [0.21.2] + the "Since the v0.21.2 cut" section
|
||||
above for full detail.
|
||||
Branch: master. v0.21.8 is tagged at c50eaf8 (cut 2026-05-08,
|
||||
replay-overlay polish). Seven post-cut commits are on master (see
|
||||
"Since the v0.21.8 cut" above); push of the last four pending.
|
||||
v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
|
||||
v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
|
||||
v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
|
||||
Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
|
||||
pending. See CHANGELOG.md § [0.21.9] for full detail.
|
||||
|
||||
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
|
||||
pass (1207+; check with `cargo test --workspace`), clippy clean.
|
||||
State: HEAD locally — see `git rev-parse HEAD`. Workspace
|
||||
tests: 1292 passing / 0 failing. Clippy clean.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — [0.21.2] 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
|
||||
4. CLAUDE_SPEC.md — formal architecture spec
|
||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
@@ -282,21 +296,17 @@ READ FIRST (in order, before doing anything):
|
||||
fresh machine)
|
||||
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. APK launch verification on AVD / device — `adb install` +
|
||||
`adb logcat` to shake out runtime bugs the build / unit
|
||||
tests can't catch. Likely surfaces JNI ClipboardManager
|
||||
and Android Keystore stubs that need real bridges. Larger
|
||||
scope; needs an Android device or emulator running.
|
||||
B. Replay-overlay screen-takeover redesign — multi-session
|
||||
work: move-log scroller, mini-tableau preview, WIN MOVE
|
||||
marker on the scrub bar (needs new `Replay::win_move_index`
|
||||
field), playback controls. The smaller floating-MOVE-chip
|
||||
piece of B already shipped in v0.21.2 (`2fb2d63`).
|
||||
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,
|
||||
A. Android follow-ups — JNI ClipboardManager bridge (arboard
|
||||
has no Android backend), Android Keystore (blocked on Phase 8).
|
||||
Launch verification + double-tap are closed.
|
||||
B. Phase 8 (sync) — local storage scaffolding, self-hosted
|
||||
Axum server, `SolitaireServerClient` impl. The biggest open
|
||||
arc by scope; rolls up Android dependencies (Keystore,
|
||||
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:
|
||||
- Use the system git config (already correct).
|
||||
@@ -322,7 +332,7 @@ WORKFLOW NOTES:
|
||||
|
||||
OPEN AT THE START: ask which of A–C. Don't pick unilaterally.
|
||||
Note: every remaining option is multi-session by nature (A is
|
||||
gated on Android tooling, B and C are explicitly multi-session
|
||||
gated on Android tooling; B and C are explicitly multi-session
|
||||
arcs). A fresh session is a better fit for any of them than the
|
||||
tail of a long working stretch.
|
||||
```
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Android Playability TODO
|
||||
|
||||
**Started:** 2026-05-10 — first hardware screenshot of v0.22.3 APK
|
||||
running on a real device showed the desktop HUD projected onto a
|
||||
360 dp portrait viewport with no mobile adaptation. This list
|
||||
tracks the work needed to make the APK genuinely playable, not
|
||||
just "boots without crashing."
|
||||
|
||||
**Context:** v0.22.3 (signed release APK) builds and launches.
|
||||
JNI bridges (clipboard, keystore) compile but are untested on
|
||||
hardware. The work below is UI/UX port work — no architectural
|
||||
rewrites required.
|
||||
|
||||
---
|
||||
|
||||
## Reading from the v0.22.3 screenshot
|
||||
|
||||
| Region | Observation |
|
||||
|--------|-------------|
|
||||
| Top ~5 % | System bar (clock, signal, battery) overlapped by game HUD — no safe-area inset |
|
||||
| HUD text row | `Score:0 Pause Esc Help A Modes [] New_Game N Moves:0 0:08` all overlapping — desktop layout crammed into 360 dp |
|
||||
| Keyboard hints | `Esc`, `A`, `[]`, `N` shown next to buttons — meaningless on touch |
|
||||
| Foundations row | Leftmost foundation (♥) clipped left; rightmost tableau column (♠ 4) clipped right |
|
||||
| Card backs | Face-down cards render as solid red squares, not back-art texture |
|
||||
| Vertical use | Cards occupy top ~30 % only; bottom 70 % empty black — no portrait-aware layout |
|
||||
| Bottom edge | No accommodation for Android gesture / home-indicator area |
|
||||
|
||||
---
|
||||
|
||||
## P0 — Blocking playability
|
||||
|
||||
- [x] **Safe-area insets (top + bottom).** *Closed 2026-05-10 by
|
||||
`b9aa262`.* `SafeAreaInsets` resource + `SafeAreaInsetsPlugin`
|
||||
query `WindowInsets.getInsets(systemBars())` via JNI on Android;
|
||||
HUD anchors carry `SafeAreaAnchoredTop { base_top }` and the
|
||||
change-detection fix-up system re-applies `base_top + insets.top`
|
||||
whenever the resource updates. Bottom inset is captured but not
|
||||
yet consumed (waits for bottom-anchored UI).
|
||||
- [x] **Mobile HUD layout.** *Closed 2026-05-10.* Both the left HUD
|
||||
column and the right action button row are now capped at
|
||||
`max_width: 50 %` and the button row + tier-row child Nodes carry
|
||||
`flex_wrap: Wrap`. On a 360 dp viewport the 6-button row breaks
|
||||
to multiple lines (right-justified) and the tier rows wrap
|
||||
individually instead of overflowing into the action column. On
|
||||
desktop (≥ 1280 px) the 50 % cap is wider than any natural row
|
||||
width so the existing single-line layout is unchanged.
|
||||
- [x] **Card-back asset not rendering.** *Closed 2026-05-10 by
|
||||
`fcc7337`.* `AssetPlugin::file_path = "../assets"` was set
|
||||
unconditionally to fix the desktop `cargo run -p solitaire_app`
|
||||
CWD relativity, but on Android cargo-apk packages the same
|
||||
directory into the APK at `assets/` and Bevy's
|
||||
AndroidAssetReader is already rooted there — prepending `../`
|
||||
walked the reader out of the APK assets root and every load
|
||||
failed silently. The face-down branch then fell through to the
|
||||
`card_back_colour(0)` solid-red brick fallback. Gated the
|
||||
override behind `#[cfg(not(target_os = "android"))]`.
|
||||
- [x] **Viewport overflow.** *Closed 2026-05-10.* `compute_layout`
|
||||
was clamping the input window up to `MIN_WINDOW = 800 × 600`,
|
||||
so a 360 dp phone got laid out as if it were 800-wide and the
|
||||
outer piles fell outside the actual viewport. Lowered the floor
|
||||
to 320 × 400 (below the smallest reasonable phone) so real
|
||||
Android resolutions flow through without clamping, while keeping
|
||||
a sentinel to guard against degenerate / startup-zero windows.
|
||||
New regression test `phone_portrait_layout_fits_horizontally`
|
||||
asserts all 13 piles fit a 360 × 800 viewport.
|
||||
|
||||
## P1 — Touch UX
|
||||
|
||||
- [x] **Suppress keyboard-hint labels on Android.** *Closed
|
||||
2026-05-10.* `spawn_action_button` now nulls the `hotkey`
|
||||
argument on Android via a `#[cfg(target_os = "android")]` rebind,
|
||||
so the U / Esc / F1 / N chips next to the action row labels
|
||||
disappear on touch builds. Other hint sites (onboarding panel,
|
||||
pause-modal `Esc` hint, mode-card hotkey chips on the home
|
||||
screen, replay overlay footer, modal toggle hints in
|
||||
profile/stats/leaderboard/settings, help screen) survive — they
|
||||
live behind navigation and a touch user reaches them less often.
|
||||
Track as a P3 sweep when more screens are audited on hardware.
|
||||
- [x] **Thumb-sized hit targets.** *Closed 2026-05-10.* Action
|
||||
button Node carries `min_width: Val::Px(48.0), min_height:
|
||||
Val::Px(48.0)` — meets Material's 48 dp baseline on touch and is
|
||||
a no-op for buttons whose content already exceeds 48 px in
|
||||
either axis. Applied universally rather than cfg-gated since
|
||||
Material's guideline applies to all input modes. Cards, pile
|
||||
markers, modal close buttons not yet audited — track as P3 if
|
||||
they fall below threshold on hardware.
|
||||
- [ ] **Portrait-first card spacing.** Stretch tableau piles vertically
|
||||
to fill height; reduce inter-pile gaps so 7 columns fit in 360 dp.
|
||||
- [ ] **Double-tap auto-move visible feedback.** `handle_double_tap`
|
||||
exists since `395a322` — verify it triggers on hardware and add a
|
||||
brief source-card flash / highlight to confirm to the user.
|
||||
|
||||
## P2 — Polish
|
||||
|
||||
- [ ] **Drag responsiveness on touch.** Bevy default touch-to-mouse
|
||||
mapping can lag; confirm drag start threshold isn't too high for a
|
||||
finger.
|
||||
- [ ] **Long-press menu.** Alternative to right-click (which doesn't
|
||||
exist on touch). Wire to the existing right-click-highlight system.
|
||||
- [ ] **HUD typography.** Reduce text sizes for `Score:`, `Moves:`,
|
||||
timer so they fit cleanly in one row.
|
||||
- [ ] **Orientation lock.** Set `android:screenOrientation="portrait"`
|
||||
in cargo-apk manifest (or design a landscape layout).
|
||||
|
||||
## P3 — Asset density
|
||||
|
||||
- [ ] **Density-aware card scaling.** Currently single texture size; on
|
||||
a high-DPI phone the cards look small. Scale by
|
||||
`Window::scale_factor()` or ship multiple PNG sizes.
|
||||
- [ ] **App-icon density buckets.** Nine sizes already exist in
|
||||
`assets/icon/`; verify the manifest references them so Android's
|
||||
launcher picks the right one.
|
||||
|
||||
## P4 — Stability / runtime
|
||||
|
||||
- [ ] **B0004 ECS hierarchy warnings.** Flagged in
|
||||
`SESSION_HANDOFF.md` after APK launch verification — investigate
|
||||
whether they cause gameplay bugs on hardware vs. AVD.
|
||||
- [ ] **AVD functional tests for JNI bridges.** Clipboard (`2c822ba`)
|
||||
and Keystore (`f281425`) shipped but never tested on real device
|
||||
or AVD.
|
||||
|
||||
---
|
||||
|
||||
## Notes / decisions
|
||||
|
||||
* This list is screenshot-driven; expect more items to surface once
|
||||
P0 unblocks actually moving cards on hardware.
|
||||
* The pattern across all the bugs is "no one ran the relevant code
|
||||
path on Android yet." The hard work — Bevy 0.18 on Android,
|
||||
JNI bridges, signed CI builds — is done. What's left is a
|
||||
coordinated pass of `#[cfg(target_os = "android")]` gates plus
|
||||
making `LayoutResource` query the real surface size.
|
||||
* Where possible, prefer responsive layout (query window size) over
|
||||
branching `#[cfg]` blocks. Branches are fine for input methods
|
||||
(touch vs. mouse) but not for screen geometry — a foldable or
|
||||
desktop window of equivalent size should look the same.
|
||||
+55
-13
@@ -18,21 +18,23 @@ use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{
|
||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||
};
|
||||
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::winit::WinitWindows;
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||
SelectionPlugin, SettingsPlugin,
|
||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
};
|
||||
|
||||
/// App entry point — builds and runs the Bevy app.
|
||||
@@ -76,6 +78,7 @@ pub fn run() {
|
||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||
// 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 (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
@@ -116,6 +119,9 @@ pub fn run() {
|
||||
// small enough that a few stray dropped frames from
|
||||
// disabling vsync are imperceptible.
|
||||
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 {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
@@ -126,11 +132,20 @@ pub fn run() {
|
||||
..default()
|
||||
})
|
||||
// The `assets/` directory lives at the workspace root, but
|
||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
||||
// Point one level up so `cargo run -p solitaire_app` finds
|
||||
// card faces, backs, backgrounds, and the UI font.
|
||||
// on desktop Bevy resolves `AssetPlugin::file_path` relative
|
||||
// to the binary package's `CARGO_MANIFEST_DIR`
|
||||
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
|
||||
// miss the workspace-root `assets/` without a `../` prefix.
|
||||
//
|
||||
// On Android cargo-apk packages the same directory into the
|
||||
// APK at `assets/` (via `[package.metadata.android].assets`
|
||||
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
|
||||
// is already rooted there, so any `file_path` other than the
|
||||
// default makes it walk *out* of the APK's assets root and
|
||||
// all loads fail silently — which is what produced the
|
||||
// solid-red card-back fallback in the v0.22.3 screenshot.
|
||||
.set(bevy::asset::AssetPlugin {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
file_path: "../assets".to_string(),
|
||||
..default()
|
||||
}),
|
||||
@@ -142,6 +157,13 @@ pub fn run() {
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.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(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
@@ -158,7 +180,10 @@ pub fn run() {
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.add_plugins(ChallengePlugin)
|
||||
.add_plugins(PlayBySeedPlugin)
|
||||
.add_plugins(DifficultyPlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(SafeAreaInsetsPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
@@ -195,6 +220,8 @@ pub fn run() {
|
||||
// every fresh launch can flip `disable_smart_default_size` in
|
||||
// Settings to opt out. The flag is checked once at startup; a
|
||||
// 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 {
|
||||
app.add_systems(Update, apply_smart_default_window_size);
|
||||
}
|
||||
@@ -215,6 +242,7 @@ pub fn run() {
|
||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||
/// populates the `Monitor` entities asynchronously after winit's
|
||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn apply_smart_default_window_size(
|
||||
mut applied: Local<bool>,
|
||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||
@@ -335,6 +363,20 @@ fn set_window_icon(
|
||||
*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
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
|
||||
@@ -12,6 +12,8 @@ publish = false
|
||||
[dependencies]
|
||||
png = "0.17"
|
||||
ab_glyph = "0.2"
|
||||
solitaire_core = { path = "../solitaire_core" }
|
||||
solitaire_data = { path = "../solitaire_data" }
|
||||
|
||||
[[bin]]
|
||||
name = "gen_sfx"
|
||||
@@ -20,3 +22,11 @@ path = "src/bin/gen_sfx.rs"
|
||||
[[bin]]
|
||||
name = "gen_art"
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// - `Classic`: standard Klondike scoring, undo allowed.
|
||||
@@ -59,6 +88,8 @@ pub enum DrawMode {
|
||||
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
||||
/// countdown around the session and auto-deals a fresh game on every win
|
||||
/// (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)]
|
||||
pub enum GameMode {
|
||||
#[default]
|
||||
@@ -70,6 +101,8 @@ pub enum GameMode {
|
||||
Challenge,
|
||||
/// Play as many games as possible within 10 minutes.
|
||||
TimeAttack,
|
||||
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
|
||||
Difficulty(DifficultyLevel),
|
||||
}
|
||||
|
||||
/// Snapshot of game state used for undo.
|
||||
|
||||
@@ -26,6 +26,13 @@ tokio = { workspace = true }
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
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]
|
||||
solitaire_server = { path = "../solitaire_server" }
|
||||
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.
|
||||
// Lets `sync_client::*` compile unchanged on Android; the runtime
|
||||
// effect is "session login required every launch", same as a Linux
|
||||
// box without Secret Service.
|
||||
// Android — delegate to the JNI Keystore bridge in android_keystore.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn store_tokens(
|
||||
_username: &str,
|
||||
_access_token: &str,
|
||||
_refresh_token: &str,
|
||||
username: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<(), TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
crate::android_keystore::store_tokens(username, access_token, refresh_token)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
crate::android_keystore::load_access_token(username)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
crate::android_keystore::load_refresh_token(username)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
crate::android_keystore::delete_tokens(username)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,82 @@ pub const CHALLENGE_SEEDS: &[u64] = &[
|
||||
0xDDDD_EEEE_FFFF_0000,
|
||||
0x0101_0101_0101_0101,
|
||||
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
|
||||
|
||||
@@ -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 use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
|
||||
pub mod difficulty_seeds;
|
||||
pub use difficulty_seeds::{seeds_for, DifficultySeeds};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
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,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_keystore;
|
||||
|
||||
pub mod auth_tokens;
|
||||
pub use auth_tokens::{
|
||||
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
||||
|
||||
@@ -147,12 +147,38 @@ pub struct Replay {
|
||||
/// [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default)]
|
||||
pub share_url: Option<String>,
|
||||
/// Index into [`moves`](Self::moves) of the move that triggered
|
||||
/// the win condition (i.e. completed the last foundation pile).
|
||||
///
|
||||
/// For replays recorded by the live engine this is always
|
||||
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
||||
/// the field is stored explicitly so the playback UI can read it
|
||||
/// directly without re-deriving "the last move was the win" each
|
||||
/// time, and to leave room for future recording semantics that
|
||||
/// might capture post-win state.
|
||||
///
|
||||
/// `None` for replays loaded from disk that pre-date this field.
|
||||
/// `#[serde(default)]` keeps older `latest_replay.json` /
|
||||
/// `replays.json` files loadable without bumping
|
||||
/// [`REPLAY_SCHEMA_VERSION`] — this is an additive optional
|
||||
/// field, not a schema-breaking change.
|
||||
///
|
||||
/// Surfaced by the replay-overlay scrub bar's WIN MOVE marker
|
||||
/// (B-2 screen-takeover redesign) when present.
|
||||
#[serde(default)]
|
||||
pub win_move_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl Replay {
|
||||
/// Construct a fresh replay with the current schema version. The
|
||||
/// caller fills in the recorded fields; this is the canonical
|
||||
/// constructor used by the engine on win.
|
||||
///
|
||||
/// [`win_move_index`](Self::win_move_index) and
|
||||
/// [`share_url`](Self::share_url) default to `None` — the engine
|
||||
/// uses [`with_win_move_index`](Self::with_win_move_index) at the
|
||||
/// recording site to set the former, and `sync_plugin` writes the
|
||||
/// latter directly when the upload task resolves.
|
||||
pub fn new(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
@@ -172,8 +198,24 @@ impl Replay {
|
||||
recorded_at,
|
||||
moves,
|
||||
share_url: None,
|
||||
win_move_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder-style setter for [`win_move_index`](Self::win_move_index).
|
||||
/// Returns `self` so the recording site can chain it onto
|
||||
/// [`Replay::new`]:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
||||
/// ```
|
||||
///
|
||||
/// `None` is a valid input — useful for tests that don't care about
|
||||
/// the WIN MOVE marker's scrub-bar position.
|
||||
pub fn with_win_move_index(mut self, idx: Option<usize>) -> Self {
|
||||
self.win_move_index = idx;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling history of the player's most recent winning replays.
|
||||
@@ -737,4 +779,71 @@ mod tests {
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// win_move_index — additive optional field for the WIN MOVE marker
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn replay_new_defaults_win_move_index_to_none() {
|
||||
let r = sample_replay();
|
||||
assert_eq!(r.win_move_index, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_win_move_index_sets_value() {
|
||||
let r = sample_replay().with_win_move_index(Some(3));
|
||||
assert_eq!(r.win_move_index, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_win_move_index_accepts_none() {
|
||||
// Passing None through the builder is a valid no-op — useful for
|
||||
// tests / synthetic replays that don't care about the marker.
|
||||
let r = sample_replay().with_win_move_index(None);
|
||||
assert_eq!(r.win_move_index, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_with_win_move_index_round_trips_on_disk() {
|
||||
let path = tmp_path("win_move_index_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let original = sample_replay().with_win_move_index(Some(3));
|
||||
save_latest_replay_to(&path, &original).expect("save");
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, Some(3));
|
||||
assert_eq!(loaded, original);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Older replay files written before this field was added must still
|
||||
/// load — `#[serde(default)]` keeps `win_move_index` optional and
|
||||
/// defaults missing fields to `None`. This is the contract that lets
|
||||
/// us add the field without bumping `REPLAY_SCHEMA_VERSION`.
|
||||
#[test]
|
||||
fn replay_without_win_move_index_loads_with_none() {
|
||||
let path = tmp_path("legacy_no_win_move_index");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
||||
let v2_no_field = r#"{
|
||||
"schema_version": 2,
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": []
|
||||
}"#;
|
||||
fs::write(&path, v2_no_field).expect("write fixture");
|
||||
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, None);
|
||||
assert_eq!(loaded.schema_version, REPLAY_SCHEMA_VERSION);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
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")]
|
||||
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 {
|
||||
@@ -342,6 +349,7 @@ impl Default for Settings {
|
||||
winnable_deals_only: false,
|
||||
disable_smart_default_size: false,
|
||||
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
|
||||
// wouldn't compose with the other modes' single-game numbers.
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ zip = { workspace = true }
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
arboard = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -1445,6 +1445,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
assert!(
|
||||
@@ -1480,6 +1481,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
|
||||
@@ -1512,6 +1514,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
@@ -1534,6 +1537,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
@@ -1559,6 +1563,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Android clipboard bridge via JNI.
|
||||
///
|
||||
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
||||
/// through the JNI. Only compiled and linked on `target_os = "android"`.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn set_text(text: &str) -> Result<(), String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{
|
||||
objects::{JObject, JValueOwned},
|
||||
JavaVM,
|
||||
};
|
||||
|
||||
let app = ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||
|
||||
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
|
||||
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
|
||||
// valid for the lifetime of the process.
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
||||
|
||||
(|| -> jni::errors::Result<()> {
|
||||
// ClipboardManager cm = activity.getSystemService("clipboard")
|
||||
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
||||
let cm = env
|
||||
.call_method(
|
||||
&activity,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[svc_name.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// ClipData clip = ClipData.newPlainText("link", text)
|
||||
let label = JValueOwned::from(env.new_string("link")?);
|
||||
let java_text = JValueOwned::from(env.new_string(text)?);
|
||||
let clip_class = env.find_class("android/content/ClipData")?;
|
||||
let clip = env
|
||||
.call_static_method(
|
||||
&clip_class,
|
||||
"newPlainText",
|
||||
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
|
||||
&[label.borrow(), java_text.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// cm.setPrimaryClip(clip)
|
||||
let clip_val = JValueOwned::Object(clip);
|
||||
env.call_method(
|
||||
&cm,
|
||||
"setPrimaryClip",
|
||||
"(Landroid/content/ClipData;)V",
|
||||
&[clip_val.borrow()],
|
||||
)?
|
||||
.v()
|
||||
})()
|
||||
.map_err(|e| format!("clipboard JNI: {e}"))
|
||||
}
|
||||
@@ -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)]
|
||||
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
|
||||
/// "Stats" row alongside the existing `S` accelerator.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
|
||||
@@ -936,6 +936,11 @@ pub fn record_replay_on_win(
|
||||
if recording.moves.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Recording freezes on win, so the move that triggered the
|
||||
// win condition is the last one in the list. Storing the
|
||||
// index explicitly lets the playback UI read the WIN MOVE
|
||||
// position directly instead of re-deriving it on every render.
|
||||
let win_move_index = recording.moves.len().checked_sub(1);
|
||||
let replay = Replay::new(
|
||||
game.0.seed,
|
||||
game.0.draw_mode.clone(),
|
||||
@@ -944,7 +949,8 @@ pub fn record_replay_on_win(
|
||||
ev.score,
|
||||
Utc::now().date_naive(),
|
||||
recording.moves.clone(),
|
||||
);
|
||||
)
|
||||
.with_win_move_index(win_move_index);
|
||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||
// No persistence path configured (e.g. tests / minimal Linux
|
||||
// containers without dirs::data_dir). The in-memory replay
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
use solitaire_data::save_settings_to;
|
||||
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::{
|
||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
ToggleProfileRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
@@ -81,6 +81,27 @@ struct HomeDrawThreeButton;
|
||||
#[derive(Component, Debug)]
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -96,6 +117,7 @@ enum HomeMode {
|
||||
Zen,
|
||||
Challenge,
|
||||
TimeAttack,
|
||||
PlayBySeed,
|
||||
}
|
||||
|
||||
impl HomeMode {
|
||||
@@ -107,6 +129,7 @@ impl HomeMode {
|
||||
HomeMode::Zen => "Zen Mode",
|
||||
HomeMode::Challenge => "Challenge",
|
||||
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::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
|
||||
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
|
||||
// siblings.
|
||||
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::Challenge => "X",
|
||||
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
|
||||
// gating system is a permanent no-op for tests.
|
||||
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
|
||||
.init_resource::<DifficultyExpanded>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<StartChallengeRequestEvent>()
|
||||
.add_message::<StartTimeAttackRequestEvent>()
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<StartPlayBySeedRequestEvent>()
|
||||
.add_message::<StartDifficultyRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
@@ -245,13 +276,10 @@ impl Plugin for HomePlugin {
|
||||
// runs cleanly under MinimalPlugins headless tests too.
|
||||
.add_message::<MouseWheel>()
|
||||
// `.chain()` because several systems (M-toggle, card click,
|
||||
// cancel button, digit-key shortcut) all read the
|
||||
// `HomeScreen` entity and may queue a despawn on it in the
|
||||
// same tick. Bevy's parallel scheduler would otherwise let
|
||||
// two of them run simultaneously and double-despawn the
|
||||
// entity, panicking when the second command buffer is
|
||||
// applied. Chaining serialises these systems and keeps the
|
||||
// despawn deterministic.
|
||||
// cancel button, digit-key shortcut, difficulty handlers)
|
||||
// all read the `HomeScreen` entity and may queue a despawn
|
||||
// on it in the same tick. Chaining serialises these systems
|
||||
// and keeps the despawn deterministic.
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -262,6 +290,8 @@ impl Plugin for HomePlugin {
|
||||
handle_home_cancel_button,
|
||||
handle_home_profile_chip,
|
||||
handle_home_draw_mode_buttons,
|
||||
handle_home_difficulty_toggle,
|
||||
handle_home_difficulty_chip_click,
|
||||
handle_home_digit_keys,
|
||||
)
|
||||
.chain(),
|
||||
@@ -306,6 +336,7 @@ fn spawn_home_on_launch(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||
) {
|
||||
if shown.0
|
||||
|| !splash.is_empty()
|
||||
@@ -316,6 +347,11 @@ fn spawn_home_on_launch(
|
||||
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(
|
||||
&mut commands,
|
||||
build_home_context(
|
||||
@@ -324,6 +360,7 @@ fn spawn_home_on_launch(
|
||||
settings.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
diff_expanded.0,
|
||||
),
|
||||
);
|
||||
shown.0 = true;
|
||||
@@ -343,6 +380,7 @@ fn toggle_home_screen(
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyM) {
|
||||
return;
|
||||
@@ -358,6 +396,7 @@ fn toggle_home_screen(
|
||||
settings.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
diff_expanded.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -373,6 +412,7 @@ fn build_home_context<'a>(
|
||||
settings: Option<&SettingsResource>,
|
||||
daily: Option<&DailyChallengeResource>,
|
||||
font_res: Option<&'a FontResource>,
|
||||
difficulty_expanded: bool,
|
||||
) -> HomeContext<'a> {
|
||||
let daily_today = daily.map(|d| {
|
||||
let completed_today = progress
|
||||
@@ -398,6 +438,8 @@ fn build_home_context<'a>(
|
||||
.map(|s| s.0.draw_mode.clone())
|
||||
.unwrap_or(DrawMode::DrawOne),
|
||||
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 time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||
@@ -457,6 +500,9 @@ fn handle_home_card_click(
|
||||
HomeMode::TimeAttack => {
|
||||
time_attack.write(StartTimeAttackRequestEvent);
|
||||
}
|
||||
HomeMode::PlayBySeed => {
|
||||
play_by_seed.write(StartPlayBySeedRequestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the modal after dispatching the launch event.
|
||||
@@ -557,6 +603,7 @@ fn handle_home_draw_mode_buttons(
|
||||
stats: Option<Res<StatsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
@@ -600,10 +647,92 @@ fn handle_home_draw_mode_buttons(
|
||||
Some(settings),
|
||||
daily.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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -619,6 +748,7 @@ fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
|
||||
KeyCode::Digit3 => Some(HomeMode::Zen),
|
||||
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
||||
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
||||
KeyCode::Digit6 => Some(HomeMode::PlayBySeed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -646,6 +776,7 @@ fn handle_home_digit_keys(
|
||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
||||
) {
|
||||
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
||||
if screens.is_empty() {
|
||||
@@ -658,6 +789,7 @@ fn handle_home_digit_keys(
|
||||
KeyCode::Digit3,
|
||||
KeyCode::Digit4,
|
||||
KeyCode::Digit5,
|
||||
KeyCode::Digit6,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|k| keys.just_pressed(*k))
|
||||
@@ -687,6 +819,9 @@ fn handle_home_digit_keys(
|
||||
HomeMode::TimeAttack => {
|
||||
time_attack.write(StartTimeAttackRequestEvent);
|
||||
}
|
||||
HomeMode::PlayBySeed => {
|
||||
play_by_seed.write(StartPlayBySeedRequestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the modal after dispatching the launch event — same shape as
|
||||
@@ -717,6 +852,11 @@ struct HomeContext<'a> {
|
||||
daily_today: Option<DailyToday>,
|
||||
draw_mode: DrawMode,
|
||||
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
|
||||
@@ -784,10 +924,13 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
|
||||
HomeMode::Zen,
|
||||
HomeMode::Challenge,
|
||||
HomeMode::TimeAttack,
|
||||
HomeMode::PlayBySeed,
|
||||
] {
|
||||
spawn_mode_card(grid, mode, &ctx);
|
||||
}
|
||||
});
|
||||
|
||||
spawn_difficulty_section(body, &ctx);
|
||||
});
|
||||
|
||||
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"`,
|
||||
/// otherwise the raw number with thousands separators. Keeps chip text
|
||||
/// 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::Challenge => 3,
|
||||
HomeMode::TimeAttack => 4,
|
||||
HomeMode::PlayBySeed => 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1402,13 +1641,14 @@ mod tests {
|
||||
HomeMode::Zen,
|
||||
HomeMode::Challenge,
|
||||
HomeMode::TimeAttack,
|
||||
HomeMode::PlayBySeed,
|
||||
] {
|
||||
assert!(
|
||||
modes.contains(&expected),
|
||||
"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]
|
||||
@@ -1600,7 +1840,7 @@ mod tests {
|
||||
.map(|(c, f)| (c.0, *f))
|
||||
.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 {
|
||||
assert_eq!(
|
||||
focusable.group,
|
||||
@@ -1626,7 +1866,7 @@ mod tests {
|
||||
|
||||
for (mode, disabled) in states {
|
||||
match mode {
|
||||
HomeMode::Classic | HomeMode::Daily => assert!(
|
||||
HomeMode::Classic | HomeMode::Daily | HomeMode::PlayBySeed => assert!(
|
||||
!disabled,
|
||||
"{mode:?} must not be Disabled at level 0 (it's never locked)"
|
||||
),
|
||||
|
||||
@@ -17,6 +17,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::layout::HUD_BAND_HEIGHT;
|
||||
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
|
||||
use crate::ui_theme::SPACE_2;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||
@@ -376,11 +378,13 @@ impl Plugin for HudPlugin {
|
||||
/// bottom edge lines up exactly with the top edge of the highest
|
||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||
/// alpha, so the green felt reads through subtly.
|
||||
fn spawn_hud_band(mut commands: Commands) {
|
||||
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
||||
const BASE_TOP: f32 = 0.0;
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
commands.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(0.0),
|
||||
top: Val::Px(BASE_TOP + top_inset),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(HUD_BAND_HEIGHT),
|
||||
@@ -391,6 +395,7 @@ fn spawn_hud_band(mut commands: Commands) {
|
||||
// paint on top, but above the card sprites (which are 2D-world
|
||||
// entities and rendered behind UI regardless).
|
||||
ZIndex(Z_HUD - 1),
|
||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||
));
|
||||
}
|
||||
|
||||
@@ -413,7 +418,12 @@ fn spawn_hud_band(mut commands: Commands) {
|
||||
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||
/// transient items disappear cleanly, and uses the typography scale to
|
||||
/// make Score the visual protagonist.
|
||||
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
fn spawn_hud(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_score = TextFont {
|
||||
font: font_handle.clone(),
|
||||
@@ -434,6 +444,16 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
let row_node = || Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_3,
|
||||
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
||||
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
||||
// Auto-complete, selection chip) can collectively be wider than
|
||||
// the available space and overflow into the action-button column
|
||||
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
||||
// a second line; on a desktop window the rows stay single-line
|
||||
// because the parent column has no width cap and the row never
|
||||
// exceeds the natural line width.
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
row_gap: VAL_SPACE_1,
|
||||
align_items: AlignItems::Baseline,
|
||||
..default()
|
||||
};
|
||||
@@ -443,12 +463,21 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: VAL_SPACE_3,
|
||||
top: VAL_SPACE_2,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
flex_direction: FlexDirection::Column,
|
||||
// Cap the column at 50% of viewport so on narrow
|
||||
// (mobile) widths the inner tier rows have a bounded
|
||||
// width to wrap against, and the column can't bleed
|
||||
// into the right-anchored action button row (also
|
||||
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
||||
// wider than any tier row's natural width, so the
|
||||
// visible layout is unaffected.
|
||||
max_width: Val::Percent(50.0),
|
||||
row_gap: VAL_SPACE_1,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
))
|
||||
.with_children(|hud| {
|
||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||
@@ -568,7 +597,12 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||
/// because it's the most consequential action; the destructive button sits
|
||||
/// on its own visual edge.
|
||||
fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
|
||||
@@ -585,13 +619,28 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
top: VAL_SPACE_2,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
flex_direction: FlexDirection::Row,
|
||||
// 6 buttons total ~510 px wide; on a desktop window
|
||||
// (typically >= 1280 px) `max_width: 50%` is >= 640 px
|
||||
// and the row stays a single line. On a 360 dp phone
|
||||
// 50% is 180 px and the row wraps to two-three lines —
|
||||
// which keeps the buttons out of the left HUD column's
|
||||
// horizontal range and prevents the off-screen-left
|
||||
// clipping seen in the v0.22.3 hardware screenshot.
|
||||
max_width: Val::Percent(50.0),
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
// When the row wraps, buttons pack to the *end* of each
|
||||
// line so the row stays visually right-aligned (matches
|
||||
// the `right: VAL_SPACE_3` anchor).
|
||||
justify_content: JustifyContent::FlexEnd,
|
||||
column_gap: VAL_SPACE_2,
|
||||
row_gap: VAL_SPACE_2,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
))
|
||||
.with_children(|row| {
|
||||
// Menu and Modes don't have a single hotkey accelerator
|
||||
@@ -681,6 +730,14 @@ fn spawn_action_button<M: Component>(
|
||||
font: &TextFont,
|
||||
order: i32,
|
||||
) {
|
||||
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
||||
// touch device — the button itself is the affordance — and they
|
||||
// visibly clutter the narrow-viewport action row. Force the hint
|
||||
// off on Android; the chevrons on Menu/Modes remain because they
|
||||
// indicate dropdown behaviour and still apply on touch.
|
||||
#[cfg(target_os = "android")]
|
||||
let hotkey: Option<&'static str> = None;
|
||||
|
||||
let hotkey_font = TextFont {
|
||||
font: font.font.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
@@ -707,6 +764,14 @@ fn spawn_action_button<M: Component>(
|
||||
// companion commit). Vertical padding stays at VAL_SPACE_2
|
||||
// so button height tracks the rest of the chrome band.
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||
// 48 px floors meet Material's recommended thumb-target
|
||||
// size on touch and are a no-op on desktop for buttons
|
||||
// whose content already exceeds 48 px in either axis
|
||||
// (Menu, Modes, New Game, etc.). Without these, "Undo"
|
||||
// ends up ~46 × 33 px — comfortably tappable with a mouse
|
||||
// but right at the threshold for a finger.
|
||||
min_width: Val::Px(48.0),
|
||||
min_height: Val::Px(48.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
@@ -1741,6 +1806,7 @@ fn update_hud(
|
||||
GameMode::Zen => "ZEN".to_string(),
|
||||
GameMode::Challenge => "CHALLENGE".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::math::{Vec2, Vec3};
|
||||
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::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
@@ -105,12 +107,16 @@ impl Plugin for InputPlugin {
|
||||
// Touch drag pipeline (parallel path through DragState).
|
||||
touch_start_drag,
|
||||
touch_follow_drag,
|
||||
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
|
||||
touch_end_drag.before(GameMutation),
|
||||
)
|
||||
.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
|
||||
// poll system so a move applied this frame cancels any
|
||||
// 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.
|
||||
/// Not gated by the pause flag — the player can always resize the window.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn handle_fullscreen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
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.
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2215,5 +2344,14 @@ mod tests {
|
||||
"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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,25 @@ pub enum LayoutSystem {
|
||||
UpdateOnResize,
|
||||
}
|
||||
|
||||
/// Minimum supported window dimensions. Layout is still computed below this
|
||||
/// size but cards will be small.
|
||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
||||
/// Minimum window dimensions used as a layout floor.
|
||||
///
|
||||
/// `compute_layout` runs `window.max(MIN_WINDOW)` so a window smaller than this
|
||||
/// on either axis is laid out as if it were at least this size. The floor
|
||||
/// exists to guard against degenerate / divide-by-zero layouts on very small
|
||||
/// surfaces (Bevy can briefly report 0-size windows during startup or after
|
||||
/// minimisation on some compositors); it is not a "minimum supported playable
|
||||
/// size" — desktop builds enforce that via `WindowResizeConstraints` set in
|
||||
/// `solitaire_app::lib`.
|
||||
///
|
||||
/// The previous floor of 800×600 was set with desktop in mind and produced
|
||||
/// the wrong behaviour on Android: a 360 dp phone got laid out as if it were
|
||||
/// 800-wide, pushing the leftmost foundation past `-180` and the rightmost
|
||||
/// tableau pile past `+180`, which clipped both at the visible viewport
|
||||
/// edges (visible in the v0.22.3 hardware screenshot). 320×400 is below the
|
||||
/// smallest reasonable phone (≈ 360×640) so every real device flows through
|
||||
/// without clamping, while still being large enough that the layout math
|
||||
/// produces non-degenerate card sizes.
|
||||
pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
|
||||
|
||||
/// Aspect ratio (height / width) of a standard playing card.
|
||||
///
|
||||
@@ -205,11 +221,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn layout_below_minimum_clamps_to_minimum() {
|
||||
let below = compute_layout(Vec2::new(400.0, 300.0));
|
||||
// 200×200 sits below the floor on both axes, so the clamp pulls each
|
||||
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW).
|
||||
let below = compute_layout(Vec2::new(200.0, 200.0));
|
||||
let at_min = compute_layout(MIN_WINDOW);
|
||||
assert_eq!(below.card_size, at_min.card_size);
|
||||
}
|
||||
|
||||
/// Regression for the v0.22.3 Android viewport-overflow bug. A typical
|
||||
/// portrait-phone viewport (360 dp × 800 dp) must produce a layout
|
||||
/// where every pile fits horizontally — i.e. card_width is derived
|
||||
/// from the actual window, not a clamped-up desktop floor.
|
||||
#[test]
|
||||
fn phone_portrait_layout_fits_horizontally() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let layout = compute_layout(window);
|
||||
let half_w = window.x / 2.0;
|
||||
let half_card = layout.card_size.x / 2.0;
|
||||
for (pile, pos) in &layout.pile_positions {
|
||||
assert!(
|
||||
pos.x - half_card >= -half_w - 1e-3,
|
||||
"{:?} overflows left at portrait phone window {:?}",
|
||||
pile,
|
||||
window
|
||||
);
|
||||
assert!(
|
||||
pos.x + half_card <= half_w + 1e-3,
|
||||
"{:?} overflows right at portrait phone window {:?}",
|
||||
pile,
|
||||
window
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_columns_are_sorted_left_to_right() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Bevy integration layer for Solitaire Quest.
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_clipboard;
|
||||
pub mod assets;
|
||||
pub mod card_animation;
|
||||
pub mod achievement_plugin;
|
||||
@@ -12,6 +14,7 @@ pub mod feedback_anim_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cursor_plugin;
|
||||
pub mod daily_challenge_plugin;
|
||||
pub mod difficulty_plugin;
|
||||
pub mod diagnostics_hud;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
@@ -24,6 +27,7 @@ pub mod layout;
|
||||
pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
pub mod pending_hint;
|
||||
pub mod play_by_seed_plugin;
|
||||
pub mod profile_plugin;
|
||||
pub mod radial_menu;
|
||||
pub mod replay_overlay;
|
||||
@@ -31,6 +35,7 @@ pub mod replay_playback;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod safe_area;
|
||||
pub mod selection_plugin;
|
||||
pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
@@ -92,11 +97,14 @@ pub use events::{
|
||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
||||
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent,
|
||||
WinStreakMilestoneEvent, XpAwardedEvent,
|
||||
};
|
||||
pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin};
|
||||
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
|
||||
pub use game_plugin::{
|
||||
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
||||
ReplayPath,
|
||||
@@ -131,6 +139,7 @@ pub use settings_plugin::{
|
||||
};
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
|
||||
pub use selection_plugin::{
|
||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||
@@ -154,6 +155,7 @@ fn toggle_pause(
|
||||
mut drag: Option<ResMut<DragState>>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
replay_state: Option<Res<ReplayPlaybackState>>,
|
||||
) {
|
||||
let PauseModalQueries {
|
||||
pause_screens: screens,
|
||||
@@ -184,6 +186,15 @@ fn toggle_pause(
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
|
||||
// own the Esc press — that handler stops the replay. Without this guard a
|
||||
// single Esc both stops the replay AND opens the pause modal on top of the
|
||||
// (now empty) board, leaving the player on a screen they didn't ask for.
|
||||
// The HUD-button path is gated too; clicking Pause while watching a replay
|
||||
// is almost always an accident.
|
||||
if replay_state.is_some_and(|s| s.is_playing()) {
|
||||
return;
|
||||
}
|
||||
// If a card is currently selected, let SelectionPlugin handle this Escape
|
||||
// (it will clear the selection). Pause must not also open in the same frame.
|
||||
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
@@ -119,6 +119,15 @@ pub enum ReplayPlaybackState {
|
||||
cursor: usize,
|
||||
/// Seconds remaining until the next move is dispatched.
|
||||
secs_to_next: f32,
|
||||
/// `true` while playback is paused — `tick_replay_playback`
|
||||
/// skips the `secs_to_next` decrement entirely while this is
|
||||
/// set, so the cursor and the timer freeze together. The
|
||||
/// overlay stays mounted (`is_playing()` still returns
|
||||
/// `true`) so the player can see the paused state and the
|
||||
/// Resume / Step controls. Stepping while paused fires the
|
||||
/// next move directly via [`step_replay_playback`] and
|
||||
/// leaves the paused flag untouched.
|
||||
paused: bool,
|
||||
},
|
||||
/// The replay finished playing back. The overlay swaps the banner
|
||||
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
||||
@@ -194,6 +203,7 @@ pub fn start_replay_playback(
|
||||
replay,
|
||||
cursor: 0,
|
||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||
paused: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +229,107 @@ pub fn stop_replay_playback(
|
||||
**state = ReplayPlaybackState::Inactive;
|
||||
}
|
||||
|
||||
/// Toggle the `paused` flag on the active playback. No-op when not
|
||||
/// `Playing` (i.e. `Inactive` or `Completed`) — pause has no meaning
|
||||
/// in those states. Returns the new paused value, or `None` if the
|
||||
/// state wasn't `Playing`.
|
||||
pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) -> Option<bool> {
|
||||
if let ReplayPlaybackState::Playing { paused, .. } = state.as_mut() {
|
||||
*paused = !*paused;
|
||||
Some(*paused)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance playback by exactly one move. Only meaningful while paused
|
||||
/// — when called on an unpaused playback it would race the
|
||||
/// `tick_replay_playback` loop. Returns `true` when a move was fired,
|
||||
/// `false` when no-op (state isn't `Playing { paused: true }` or the
|
||||
/// cursor is already at the end of the move list).
|
||||
///
|
||||
/// Stepping the last move transitions the state to `Completed` on
|
||||
/// the next `tick_replay_playback` frame — same end-of-list path the
|
||||
/// normal advance loop takes.
|
||||
pub fn step_replay_playback(
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||
) -> bool {
|
||||
let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
cursor,
|
||||
paused: true,
|
||||
..
|
||||
} = state.as_mut()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if *cursor >= replay.moves.len() {
|
||||
return false;
|
||||
}
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
*cursor += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Steps the replay **backwards** by exactly one move while paused.
|
||||
///
|
||||
/// Strategy: the live game's undo system is the source of truth for
|
||||
/// reversing moves. Every move the replay forward-stepped (via
|
||||
/// [`step_replay_playback`] or the auto-advance loop in
|
||||
/// [`tick_replay_playback`]) was dispatched as a canonical
|
||||
/// [`MoveRequestEvent`] / [`DrawRequestEvent`], which the game
|
||||
/// applied and pushed onto its undo stack. So a backwards step here
|
||||
/// is simply: decrement the cursor (so the about-to-apply move
|
||||
/// re-points at the one we're rewinding past) and fire an
|
||||
/// [`UndoRequestEvent`] so the game reverses its most-recent move
|
||||
/// next frame.
|
||||
///
|
||||
/// Hard-gated to the paused state via destructure pattern —
|
||||
/// matches the existing [`step_replay_playback`] gate so the
|
||||
/// player can only scrub one direction at a time and the tick
|
||||
/// loop never races a manual rewind.
|
||||
///
|
||||
/// Returns `false` and is a no-op in three cases:
|
||||
/// - State isn't `Playing` (no replay attached).
|
||||
/// - State is `Playing` but not paused (the tick loop owns the cursor).
|
||||
/// - Cursor is already at 0 (nothing to rewind past).
|
||||
///
|
||||
/// Returns `true` on a successful step; the actual game-state
|
||||
/// reversal happens next frame when `handle_undo` reads the
|
||||
/// `UndoRequestEvent`.
|
||||
pub fn step_backwards_replay_playback(
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
undo_writer: &mut MessageWriter<UndoRequestEvent>,
|
||||
) -> bool {
|
||||
let ReplayPlaybackState::Playing {
|
||||
cursor,
|
||||
paused: true,
|
||||
..
|
||||
} = state.as_mut()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if *cursor == 0 {
|
||||
return false;
|
||||
}
|
||||
*cursor -= 1;
|
||||
undo_writer.write(UndoRequestEvent);
|
||||
true
|
||||
}
|
||||
|
||||
/// Tick system. Runs every frame; only does work when
|
||||
/// [`ReplayPlaybackState::is_playing`].
|
||||
///
|
||||
@@ -249,8 +360,15 @@ fn tick_replay_playback(
|
||||
replay,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
paused,
|
||||
} = state.as_mut()
|
||||
{
|
||||
// While paused, the cursor and the timer freeze together —
|
||||
// skip the decrement entirely so resuming starts the next
|
||||
// move from a full `secs_to_next` window. Stepping (handled
|
||||
// separately) fires moves directly without touching this
|
||||
// path.
|
||||
if !*paused {
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
@@ -273,6 +391,7 @@ fn tick_replay_playback(
|
||||
transition_to_completed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if transition_to_completed {
|
||||
*state = ReplayPlaybackState::Completed;
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Safe-area insets.
|
||||
//!
|
||||
//! Reports the OS-reserved regions around the playable surface (status
|
||||
//! bar at the top, gesture / navigation bar at the bottom on Android,
|
||||
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
|
||||
//! collisions.
|
||||
//!
|
||||
//! On non-Android targets all four edges report `0.0`. On Android the
|
||||
//! values come from `WindowInsets.getInsets(WindowInsets.Type.systemBars())`
|
||||
//! via JNI; the call is retried for the first few frames because
|
||||
//! `getRootWindowInsets()` only returns useful values after the decor
|
||||
//! view has been laid out at least once.
|
||||
//!
|
||||
//! UI that wants to respect the top inset should tag itself with the
|
||||
//! [`SafeAreaAnchoredTop`] marker carrying the layout's original top
|
||||
//! offset; [`apply_safe_area_anchors`] re-applies `base_top + insets.top`
|
||||
//! whenever the resource changes, so late inset arrival or orientation
|
||||
//! changes flow through automatically.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Pixel sizes of the system-reserved regions on each edge of the
|
||||
/// surface. Zero on desktop.
|
||||
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct SafeAreaInsets {
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
}
|
||||
|
||||
impl SafeAreaInsets {
|
||||
/// `true` when any edge has a non-zero reservation. Used by the
|
||||
/// Android polling system to know it can stop querying.
|
||||
pub fn is_populated(&self) -> bool {
|
||||
self.top > 0.0 || self.bottom > 0.0 || self.left > 0.0 || self.right > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for `Node` entities whose `top` offset should be re-applied
|
||||
/// as `base_top + SafeAreaInsets::top`.
|
||||
///
|
||||
/// `base_top` is the offset the layout would have used on a surface
|
||||
/// with no system reservation (i.e. on desktop). The fix-up system
|
||||
/// adds the current top inset on top of it whenever the resource
|
||||
/// changes.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct SafeAreaAnchoredTop {
|
||||
pub base_top: f32,
|
||||
}
|
||||
|
||||
pub struct SafeAreaInsetsPlugin;
|
||||
|
||||
impl Plugin for SafeAreaInsetsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SafeAreaInsets>()
|
||||
.add_systems(Update, apply_safe_area_anchors);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.add_systems(Update, android::refresh_insets);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-applies `base_top + insets.top` to every entity carrying the
|
||||
/// [`SafeAreaAnchoredTop`] marker whenever [`SafeAreaInsets`] changes.
|
||||
///
|
||||
/// Bevy resource change detection (`Res::is_changed`) is `true` on the
|
||||
/// frame the resource is inserted and every frame a `ResMut` borrow
|
||||
/// occurs. Combined with the Android polling loop short-circuiting
|
||||
/// once insets are populated, this runs at most a handful of times in
|
||||
/// a session.
|
||||
fn apply_safe_area_anchors(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||||
) {
|
||||
if !insets.is_changed() {
|
||||
return;
|
||||
}
|
||||
for (anchor, mut node) in &mut q {
|
||||
node.top = Val::Px(anchor.base_top + insets.top);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android {
|
||||
use super::SafeAreaInsets;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Polls Android for safe-area insets until we get a non-zero
|
||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
||||
/// is typically frame 1–3 of a fresh launch.
|
||||
pub(super) fn refresh_insets(
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
mut tries: Local<u32>,
|
||||
) {
|
||||
// Cap retries so we don't burn CPU forever on edge-to-edge
|
||||
// devices that genuinely report zero insets.
|
||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||
|
||||
if *tries >= MAX_TRIES || insets.is_populated() {
|
||||
return;
|
||||
}
|
||||
*tries += 1;
|
||||
|
||||
match query_insets() {
|
||||
Ok(v) if v.is_populated() => {
|
||||
info!(
|
||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||
v.top, v.bottom, v.left, v.right, *tries
|
||||
);
|
||||
*insets = v;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Layout not ready yet; try again next frame.
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't spam — log once and let polling continue silently.
|
||||
if *tries == 1 {
|
||||
warn!("safe_area: JNI query failed (will retry): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{objects::JObject, JavaVM};
|
||||
|
||||
let app = ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||
|
||||
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
|
||||
// runtime; valid for the lifetime of the process.
|
||||
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()` returns 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<SafeAreaInsets> {
|
||||
// Window window = activity.getWindow();
|
||||
let window = env
|
||||
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||
.l()?;
|
||||
|
||||
// View decor = window.getDecorView();
|
||||
let decor = env
|
||||
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||
.l()?;
|
||||
|
||||
// WindowInsets insets = decor.getRootWindowInsets();
|
||||
let raw_insets = env
|
||||
.call_method(
|
||||
&decor,
|
||||
"getRootWindowInsets",
|
||||
"()Landroid/view/WindowInsets;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
if raw_insets.is_null() {
|
||||
return Ok(SafeAreaInsets::default());
|
||||
}
|
||||
|
||||
// int types = WindowInsets.Type.systemBars();
|
||||
// (Static method on the WindowInsets$Type inner class.
|
||||
// Available since API 30 / Android 11.)
|
||||
let type_class = env.find_class("android/view/WindowInsets$Type")?;
|
||||
let bars_type = env
|
||||
.call_static_method(&type_class, "systemBars", "()I", &[])?
|
||||
.i()?;
|
||||
|
||||
// Insets bars = insets.getInsets(types);
|
||||
let bars = env
|
||||
.call_method(
|
||||
&raw_insets,
|
||||
"getInsets",
|
||||
"(I)Landroid/graphics/Insets;",
|
||||
&[bars_type.into()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// `Insets` exposes `top`, `bottom`, `left`, `right` as public
|
||||
// `int` fields (pixel values, not dp).
|
||||
let top = env.get_field(&bars, "top", "I")?.i()? as f32;
|
||||
let bottom = env.get_field(&bars, "bottom", "I")?.i()? as f32;
|
||||
let left = env.get_field(&bars, "left", "I")?.i()? as f32;
|
||||
let right = env.get_field(&bars, "right", "I")?.i()? as f32;
|
||||
|
||||
Ok(SafeAreaInsets {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
})
|
||||
})()
|
||||
.map_err(|e| format!("safe-area JNI: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_zero_and_not_populated() {
|
||||
let i = SafeAreaInsets::default();
|
||||
assert_eq!(i.top, 0.0);
|
||||
assert_eq!(i.bottom, 0.0);
|
||||
assert!(!i.is_populated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_populated_returns_true_for_any_nonzero_edge() {
|
||||
assert!(SafeAreaInsets {
|
||||
top: 24.0,
|
||||
..Default::default()
|
||||
}
|
||||
.is_populated());
|
||||
assert!(SafeAreaInsets {
|
||||
bottom: 16.0,
|
||||
..Default::default()
|
||||
}
|
||||
.is_populated());
|
||||
assert!(SafeAreaInsets {
|
||||
left: 8.0,
|
||||
..Default::default()
|
||||
}
|
||||
.is_populated());
|
||||
assert!(SafeAreaInsets {
|
||||
right: 8.0,
|
||||
..Default::default()
|
||||
}
|
||||
.is_populated());
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ use crate::ui_modal::{
|
||||
};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
use crate::ui_theme::{
|
||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBorder,
|
||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground,
|
||||
HighContrastBorder,
|
||||
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
};
|
||||
@@ -365,6 +366,7 @@ impl Plugin for SettingsPlugin {
|
||||
update_color_blind_text,
|
||||
update_high_contrast_text,
|
||||
update_high_contrast_borders,
|
||||
update_high_contrast_backgrounds,
|
||||
update_reduce_motion_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
@@ -674,6 +676,41 @@ fn update_high_contrast_borders(
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints `BackgroundColor` on every entity tagged with
|
||||
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||||
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||
/// (`#a0a0a0`). Compares against the current background and only
|
||||
/// mutates when different so Bevy's change-detection doesn't trigger
|
||||
/// repaints every frame.
|
||||
///
|
||||
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||||
/// same change-suppression idiom, different colour channel —
|
||||
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||||
/// separators that paint their shape directly rather than via a
|
||||
/// `BorderColor` on a wider Node.
|
||||
///
|
||||
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||||
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||||
///
|
||||
/// More sites can be tagged in follow-ups by adding
|
||||
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||||
pub(crate) fn update_high_contrast_backgrounds(
|
||||
settings: Res<SettingsResource>,
|
||||
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||||
) {
|
||||
let high_contrast = settings.0.high_contrast_mode;
|
||||
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||
let target = if high_contrast {
|
||||
marker.hc_color
|
||||
} else {
|
||||
marker.default_color
|
||||
};
|
||||
if bg.0 != target {
|
||||
*bg = BackgroundColor(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_reduce_motion_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||
|
||||
@@ -29,12 +29,13 @@ use crate::resources::GameStateResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
ModalButton, ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING,
|
||||
STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
|
||||
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
/// Bevy resource wrapping the current stats.
|
||||
@@ -121,6 +122,13 @@ pub struct ReplayNextButton;
|
||||
#[derive(Component, Debug)]
|
||||
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.
|
||||
///
|
||||
/// 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_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);
|
||||
}
|
||||
@@ -348,9 +361,13 @@ fn handle_copy_share_link_button(
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
toast.write(InfoToastEvent(format!(
|
||||
"Share link: {url}"
|
||||
)));
|
||||
match crate::android_clipboard::set_text(&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 /
|
||||
/// Next chips. Returns `"No replays"` when the history is empty,
|
||||
/// otherwise `"Replay {1-based index} / {total}"`.
|
||||
@@ -618,14 +668,14 @@ fn toggle_stats_screen(
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
let selected = latest_replay.0.replays.get(selected_index.0);
|
||||
spawn_stats_screen(
|
||||
&mut commands,
|
||||
&stats.0,
|
||||
progress.as_deref().map(|p| &p.0),
|
||||
time_attack.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>,
|
||||
time_attack: Option<&TimeAttackResource>,
|
||||
font_res: Option<&FontResource>,
|
||||
latest_replay: Option<&Replay>,
|
||||
replays: &[Replay],
|
||||
selected_index: usize,
|
||||
) {
|
||||
// --- primary stat cells ---
|
||||
// First-launch zero-state: when no games have been played yet, render
|
||||
@@ -859,31 +910,84 @@ fn spawn_stats_screen(
|
||||
));
|
||||
}
|
||||
|
||||
// --- Latest replay caption ---
|
||||
// Surfaces the most recent winning game so the player can spot
|
||||
// whether their last victory has been recorded. The Watch
|
||||
// Replay action below is what the player clicks to revisit it.
|
||||
//
|
||||
// When the displayed replay carries a `share_url` (uploaded
|
||||
// to a sync server, persisted by v0.19.0's share-link
|
||||
// contract), append a "Shareable" badge so the player can
|
||||
// tell at a glance whether the Copy share link button below
|
||||
// will produce a URL — without it the button surfaces a
|
||||
// toast explaining why nothing was copied, which is more
|
||||
// friction than necessary when a quick visual cue suffices.
|
||||
let replay_caption = match latest_replay {
|
||||
Some(r) => {
|
||||
let base = format!("Latest win: {}", format_replay_caption(r));
|
||||
if r.share_url.is_some() {
|
||||
format!("{base} \u{2022} Shareable")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
|
||||
};
|
||||
// --- Replay selector ---
|
||||
// Prev / Next chips step through the full replay history;
|
||||
// `repaint_replay_selector_caption` and
|
||||
// `repaint_replay_selector_detail` keep both text nodes
|
||||
// live as the selection changes. Using `ModalButton` on
|
||||
// the chips plugs them into the existing modal-button
|
||||
// hover/press paint loop at no extra cost.
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// ← Prev chip
|
||||
row.spawn((
|
||||
ReplayPrevButton,
|
||||
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{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((
|
||||
Text::new(replay_caption),
|
||||
ReplaySelectorDetail,
|
||||
Text::new(replay_selector_detail(replays, selected_index)),
|
||||
font_row.clone(),
|
||||
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.
|
||||
/// Past the highest threshold, no event must fire — the flourish
|
||||
/// 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`.
|
||||
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
|
||||
/// status. Gold from base16-eighties. **Both** Undo and Recycle
|
||||
/// counters use this when non-zero. `#ddb26f`.
|
||||
@@ -252,6 +259,56 @@ impl HighContrastBorder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for entities whose [`BackgroundColor`] should swap to
|
||||
/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on.
|
||||
/// Parallel to [`HighContrastBorder`] but for sites that paint their
|
||||
/// shape via `BackgroundColor` rather than `BorderColor` —
|
||||
/// `bevy::ui` 1 px decorative strips, tick marks, fine separators
|
||||
/// often render as tiny full-bleed `Node`s, not as borders, so the
|
||||
/// border-marker pattern doesn't apply.
|
||||
///
|
||||
/// `default_color` records the off-state colour; `hc_color` the on-
|
||||
/// state colour. [`with_default`] fills `hc_color` with
|
||||
/// [`BORDER_SUBTLE_HC`] so the 90 % of sites that just need the
|
||||
/// standard subtle-border bump can continue using a one-argument
|
||||
/// constructor. [`with_hc`] overrides the HC colour for the rare
|
||||
/// site (currently only the WIN MOVE scrub-bar marker) that needs a
|
||||
/// domain-specific HC variant (`STATE_SUCCESS_HC` instead of a gray).
|
||||
///
|
||||
/// [`with_default`]: HighContrastBackground::with_default
|
||||
/// [`with_hc`]: HighContrastBackground::with_hc
|
||||
/// [`BackgroundColor`]: bevy::prelude::BackgroundColor
|
||||
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
|
||||
pub struct HighContrastBackground {
|
||||
/// Background colour to use when high-contrast mode is *off* —
|
||||
/// the site's normal idle / active-state colour.
|
||||
pub default_color: bevy::prelude::Color,
|
||||
/// Background colour to use when high-contrast mode is *on*.
|
||||
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
|
||||
///
|
||||
/// [`with_default`]: HighContrastBackground::with_default
|
||||
pub hc_color: bevy::prelude::Color,
|
||||
}
|
||||
|
||||
impl HighContrastBackground {
|
||||
/// Convenience constructor — HC colour defaults to
|
||||
/// [`BORDER_SUBTLE_HC`].
|
||||
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
||||
Self { default_color, hc_color: BORDER_SUBTLE_HC }
|
||||
}
|
||||
|
||||
/// Constructor for sites whose HC colour differs from the standard
|
||||
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
|
||||
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
|
||||
/// than to a neutral gray.
|
||||
pub const fn with_hc(
|
||||
default_color: bevy::prelude::Color,
|
||||
hc_color: bevy::prelude::Color,
|
||||
) -> Self {
|
||||
Self { default_color, hc_color }
|
||||
}
|
||||
}
|
||||
|
||||
/// Strong border — hover outline, focused button, active popover.
|
||||
/// `outline` from the design system. `#505050`.
|
||||
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
||||
|
||||
@@ -352,7 +352,7 @@ impl ScoreBreakdown {
|
||||
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
||||
let multiplier = match mode {
|
||||
GameMode::Zen => 0.0,
|
||||
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack => 1.0,
|
||||
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0,
|
||||
};
|
||||
Self {
|
||||
base,
|
||||
@@ -423,6 +423,7 @@ fn mode_display_name(mode: GameMode) -> &'static str {
|
||||
GameMode::Zen => "Zen",
|
||||
GameMode::Challenge => "Challenge",
|
||||
GameMode::TimeAttack => "Time Attack",
|
||||
GameMode::Difficulty(level) => level.label(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user