Compare commits

...

24 Commits

Author SHA1 Message Date
funman300 bee712c5ab ci(release): replace Python heredoc with printf for signing config injection
Release / Build · Linux x86_64 (push) Has been cancelled
Release / Build · Android APK (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
The Python heredoc had TOML section lines at column 0 inside a YAML
literal block, which YAML interprets as terminating the block (parse
error, instant workflow failure). printf keeps all lines at proper
indentation within the run block while avoiding sed escaping issues
with special characters in passwords.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:58:58 -07:00
funman300 0db5e9dac4 ci(release): inject Android signing config at build time via Python
cargo-apk refuses --release builds without [package.metadata.android.
signing.release] in the package Cargo.toml. Instead of committing
credentials, the workflow now: decodes the keystore secret to a temp
file, uses a Python heredoc to append the signing section referencing
the absolute keystore path and secret env-vars, then removes the
keystore after the build. This replaces the post-build apksigner step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:56:16 -07:00
funman300 681a54d9bb fix(android): gate Monitor/PrimaryMonitor/PrimaryWindow imports to non-Android
These three bevy::window types are only referenced by
apply_smart_default_window_size, which is already cfg(not(android)).
The unconditional import triggered -D unused-imports on the Android
cross-compile. Split into a separate cfg-gated use statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:15:36 -07:00
funman300 7894559ca7 fix(android): gate had_saved_geometry and apply_smart_default_window_size
Both symbols are desktop-only: the variable feeds apply_smart_default_
window_size which is only registered inside a cfg(not(android)) block.
Without the matching cfg gate on the declaration / definition, the
Android cross-compile emits unused-variable and dead-code errors
(-D warnings turns them into hard failures).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:04:54 -07:00
funman300 ab803c07af fix(android): remove unused JValue import and fix match arm types
Two cfg(android) issues hidden from Linux CI:
- android_clipboard.rs: JValue was imported but never used (JValueOwned
  covers all call sites). Removed to satisfy -D unused-imports.
- stats_plugin.rs: both arms of the clipboard match now return () via
  explicit block+semicolon, resolving the type mismatch that pinged-pong
  between runs due to bidirectional match-arm type inference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:53:36 -07:00
funman300 e43b329fc1 fix(android): remove trailing semicolon in android clipboard match arm
The Err arm in stats_plugin.rs had a trailing semicolon on
toast.write(...) making it return () while the Ok arm returned
MessageId<InfoToastEvent>. Only caught on Android because the block is
cfg(target_os = "android") gated; the Linux CI never compiled it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:42:32 -07:00
funman300 7c07f71f02 fix(android): declare bevy dep in solitaire_data for Android target
android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
process-wide JavaVM handle, but bevy was absent from the Android-target
dep block in solitaire_data/Cargo.toml. Cargo resolved the symbol in
the workspace dev build (where bevy is reachable transitively) but the
Android cross-compile with cargo-apk failed with E0433. Adding bevy
under [target.'cfg(target_os = "android")'.dependencies] fixes it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:31:17 -07:00
funman300 c1329bbb21 ci(release): add Linux x86_64 and Android APK release workflow
Tag-triggered (v*) workflow builds a Linux tarball (binary + assets) and
a multi-arch Android APK signed with a release keystore stored in GitHub
secrets. A final job creates the GitHub Release with both files attached
so Obtainium can track and auto-download the APK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:07:56 -07:00
funman300 4303ef3f5b feat(difficulty): add difficulty-tier game mode with seed catalogs and home UI
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to
solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel).
Five seed catalogs (40 seeds each) are pre-verified by the new
gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves).
DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed →
NewGameRequestEvent; Random uses a system-time seed and bypasses the
winnable-only filter. The home overlay gets an expandable Difficulty section
between Draw Mode and the mode grid; last-played tier persists in Settings.
Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:07:49 -07:00
funman300 4df962ee07 docs(handoff): close JNI clipboard + Keystore; 1298 tests; Phase Android items done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:06:02 -07:00
funman300 f281425b45 feat(android): Android Keystore AES-GCM token storage via JNI
Replaces the four KeychainUnavailable stubs in auth_tokens.rs with a
real Android Keystore implementation:

- Device-bound AES-256/GCM/NoPadding key under alias
  'solitaire_quest_token_key'; generated on first use, survives
  restarts, destroyed on uninstall.
- Tokens serialised as JSON, encrypted to
  {data_dir}/auth_tokens.bin as [12-byte IV][ciphertext+GCM-tag];
  writes are atomic (tmp → rename).
- Key invalidation (biometric/lock change) surfaces as
  TokenError::KeychainUnavailable, matching desktop fallback semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:05:20 -07:00
funman300 2c822ba2d7 feat(android): JNI clipboard bridge for Stats share-link button
Replaces the informational "Share link: {url}" toast on Android with a
real clipboard write via ClipboardManager JNI. Falls back to the old
toast on JNI error so the user can still copy the URL manually.

Adds `jni = "0.21"` (default-features = false) as a workspace dep;
`jni 0.21.1` was already in Cargo.lock as a transitive dep so no new
packages are fetched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:05:11 -07:00
funman300 7ddf2733c9 docs(handoff): drop GPGS from punch list and resume prompt
GPGS integration will not be implemented. Removed from Phase Android
open items and from the Phase 8 (sync) description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:34:27 -07:00
funman300 585570559c docs(handoff): record double-tap, Play-by-Seed, handle_fullscreen gate; 1292 tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:32:23 -07:00
funman300 45436d0eda fix(android): gate handle_fullscreen and its imports to non-Android
F11 fullscreen toggle only makes sense on desktop; Android windows are
always full-screen.  Gates the fn and the MonitorSelection/WindowMode
imports with #[cfg(not(target_os = "android"))] to keep clippy clean
on the Android target.  The add_systems call is extracted as a separate
statement so #[cfg] can annotate it (cannot appear mid-chain).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:19:18 -07:00
funman300 2062bd06f3 feat(data): expand challenge seed pool with 75 verified wins
Adds a gen_seeds binary to solitaire_assetgen that brute-searches seeds
for hands solvable in ≤250 moves, then writes the list.  The 75 new
seeds (0xCAFEBABE prefix) are appended to CHALLENGE_SEEDS in
solitaire_data::challenge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:19:11 -07:00
funman300 0cb15872b1 feat(engine): add Play-by-Seed dialog with solver preview
Adds a numeric-input modal (PlayBySeedPlugin) that lets the player type
a decimal seed and receive an instant solver-verified verdict before the
hand is dealt.  A new HomeMode::PlayBySeed card surfaces it in the home
overlay, matched by the StartPlayBySeedRequestEvent carrier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:19:02 -07:00
funman300 395a322adc feat(android): add double-tap auto-move for touch input
Mirrors handle_double_click for the touch pipeline. A double-tap on a
face-up card fires MoveRequestEvent to the best legal destination using
the same priority order (foundation first, tableau second; stack move
as priority 2 when the tapped card is a stack base).

Implementation:
- handle_double_tap reads TouchPhase::Ended events. When
  drag.active_touch_id is set and drag.committed is false, the touch
  ended without crossing the drag threshold = pure tap. The top card ID
  from drag.cards is used as the tracking key.
- DOUBLE_TAP_WINDOW = 0.5s (wider than DOUBLE_CLICK_WINDOW = 0.35s;
  touch screens have higher input latency; pinned by a const-assert test).
- System is inserted between touch_follow_drag and touch_end_drag in
  the .chain() so drag state is readable before touch_end_drag clears it.
- touch_end_drag's uncommitted-tap cleanup path still fires after
  handle_double_tap — the drag.clear() + StateChangedEvent are
  harmless in sequence with a MoveRequestEvent already queued.

1 new test (1283 total): double_tap_window_is_wider_than_double_click_window
(compile-time const assert).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:37:22 -07:00
funman300 5199a5e499 docs(handoff): record Android launch verification; update status
Closes the APK launch verification punch-list item. Three fixes in
202a64d boot the app on Pixel_7 AVD (Android 14, x86_64). Next open
arcs: Phase 8 (sync) or Android JNI follow-ups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:23:15 -07:00
funman300 16242e6d77 chore: ignore .idea/ IDE project files
Android Studio created .idea/ when the project was opened during the
Android APK verification run. These are IDE-local and should not be
tracked; adding .gitignore entry and removing the accidentally-committed
files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:22:07 -07:00
funman300 202a64db45 fix(android): export android_main and gate desktop-only window config
Three changes to get the APK past the NativeActivity launch crash:

1. Export `android_main` — NativeActivity dlopen-s libsolitaire_app.so
   and calls `android_main` as its entry point. Without the symbol the
   app crashed immediately with UnsatisfiedLinkError. The function sets
   bevy::android::ANDROID_APP (required by WinitPlugin) then delegates
   to the existing `run()`.

2. Gate `resize_constraints` to non-Android — on Android max_width and
   max_height default to 0.0; Bevy's clamp panicked with min=800 > max=0.

3. Gate `apply_smart_default_window_size` to non-Android — the system
   calls `.clamp(800.0, logical_w)` which panics when the window surface
   reports zero dimensions during early Android lifecycle events. Window
   sizing is OS-controlled on Android so the system is irrelevant there.

Verified: app boots on x86_64 Android 14 emulator (Pixel_7 AVD,
SwiftShader Vulkan), runs for 2+ minutes without crashing. Desktop
build: clippy clean, 1282 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:21:41 -07:00
funman300 c0415eb0ee docs(handoff): record Stats selector spawn; 1282 tests; next is A or C
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:43:01 -07:00
funman300 a449f60bc5 feat(stats): spawn Prev/Next replay selector in the Stats overlay
Wire the long-dormant ReplayPrevButton / ReplaySelectorCaption /
ReplayNextButton / ReplaySelectorDetail spawn site that was missing
since v0.19.0. The click handler and repaint systems already existed;
this commit adds the actual UI nodes so players can step through all
stored replays (up to REPLAY_HISTORY_CAP) instead of always watching
the most recent win.

Also fix an assertion-on-constant clippy lint in the replay_overlay
dim-layer z-order test (const { assert!() } form required).

1282 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:41:17 -07:00
funman300 ad5f613277 docs(handoff): cut v0.21.8 — replay arc fully closed; 1276 tests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:20:24 -07:00
31 changed files with 3402 additions and 186 deletions
+168
View File
@@ -0,0 +1,168 @@
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@v4
- 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@v4
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@v4
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@v4
- 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@v4
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)
run: cargo apk build -p solitaire_app --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@v4
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@v4
with:
name: linux
- uses: actions/download-artifact@v4
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"
+3
View File
@@ -7,3 +7,6 @@
*.tmp *.tmp
data/ data/
.claude/ .claude/
# IDE project files
.idea/
+172 -2
View File
@@ -6,8 +6,178 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
No threads in flight. v0.21.7 cut on 2026-05-08; CHANGELOG accumulates ## [0.22.0] — 2026-05-08
the next cycle here.
Adds difficulty-tier game selection, Android JNI bridges for keystore and
clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens.
Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
### Added
- **Difficulty-tier game mode** (this release).
`DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster /
Random`) added to `solitaire_core::game_state` alongside a new
`GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed
catalogs (40 seeds each, 200 total) are generated by the new
`gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog
contains seeds proven winnable at progressively larger solver budgets
(1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent`
→ catalog seed → `NewGameRequestEvent`; the `Random` tier uses a
system-time seed and intentionally bypasses the winnable-only filter.
The home overlay gains an expandable `▶ Difficulty` section between the
Draw Mode row and the mode-card grid; the last-played tier is persisted
in `Settings::last_difficulty` and pre-expands/highlights on re-open.
Difficulty wins pool into Classic stats (no separate buckets).
- **Prev/Next replay selector in the Stats overlay** (`a449f60`).
`ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and
`ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen`
as a flex row of two bordered chips flanking a `"Replay N / M"`
caption, with a detail line below showing the selected replay's
duration + date and an optional `"· Shareable"` badge. Both chips
carry `ModalButton(Secondary)` so the existing `repaint_modal_buttons`
paint loop gives them hover/press feedback at zero extra cost.
`repaint_replay_selector_detail` is wired into the existing
`.chain()` alongside `handle_replay_selector_buttons` and
`repaint_replay_selector_caption`. The click handler and repaint
systems have been registered (and dormant) since v0.19.0; this
commit is purely the missing spawn site.
- **6 new selector unit tests** (`a449f60`). Covers: spawn-site
presence (Prev, Next, Caption, Detail all spawn with the screen),
caption initial text ("Replay 1 / 1"), detail initial text
("{dur} win on {date}"), Shareable badge when `share_url` is set,
empty-history "No replays" caption, and ordinal wrapping.
`make_test_replay(time_seconds, share_url)` helper encapsulates
`Replay::new(...)` + `chrono::NaiveDate`.
### Fixed
- **`const { assert!() }` for dim-layer z-order test** (`a449f60`).
Converted `assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)` in
`replay_overlay` tests to `const { assert!(…) }` to satisfy
`clippy::assertions_on_constants` (constant-fold at compile time
rather than a runtime no-op).
### Added (post-cut, same pending release)
- **Double-tap auto-move on touch screens** (`395a322`).
`handle_double_tap` fires `MoveRequestEvent` (single card to
foundation/tableau, or a whole face-up stack via
`best_tableau_destination_for_stack`) when two `TouchPhase::Ended`
events on the same card arrive within `DOUBLE_TAP_WINDOW` (0.5 s,
slightly wider than the mouse `DOUBLE_CLICK_WINDOW` to account for
touch latency). If no legal destination exists, fires
`MoveRejectedEvent` (audio + visual rejection feedback). The system
is inserted into the touch drag chain immediately before
`touch_end_drag` so `DragState.active_touch_id` and `committed` are
still readable; the tap timestamp is tracked in a `Local<HashMap<u32,
f32>>` keyed by card ID.
- **Play-by-Seed dialog** (`0cb1587`).
`PlayBySeedPlugin` adds a numeric-input modal that accepts a decimal
seed, runs a solver preview in the background (debounced 500 ms via
`AsyncComputeTaskPool`), and shows a win/no-win verdict before
dealing. A new `HomeMode::PlayBySeed` card in the home overlay fires
`StartPlayBySeedRequestEvent`; the handler in `PlayBySeedPlugin`
spawns the dialog. Digit, Backspace, Enter (confirm), and Escape
(cancel) are handled via `ButtonInput<KeyCode>`. Five unit tests
cover spawn, digit append, buffer read, confirm, and cancel paths.
- **75 new challenge seeds** (`2062bd0`).
New `gen_seeds` binary in `solitaire_assetgen` brute-searches seeds
in the `0xCAFEBABE…` namespace and filters for hands solvable in
≤250 moves via the core solver. The 75 confirmed-win seeds are
appended to `CHALLENGE_SEEDS` in `solitaire_data::challenge`.
### Fixed (post-cut, same pending release)
- **Gate `handle_fullscreen` to non-Android** (`45436d0`).
F11 fullscreen toggle makes no sense on Android (the OS owns window
sizing); the fn and its `MonitorSelection`/`WindowMode` imports are
now `#[cfg(not(target_os = "android"))]`-gated. The `add_systems`
call is extracted as a separate statement so `#[cfg]` can annotate it
(attributes cannot appear mid-chain in Rust).
- **Android APK launch: export `android_main`** (`202a64d`).
`NativeActivity` dlopen-s `libsolitaire_app.so` and calls
`android_main` as its entry point. Without the symbol the app
crashed immediately with `UnsatisfiedLinkError`. The new function
sets `bevy::android::ANDROID_APP` (required by `WinitPlugin`) then
delegates to `run()` — equivalent to what `#[bevy_main]` would
generate, but usable on an arbitrary entry point name.
- **Android APK launch: gate `resize_constraints` to non-Android**
(`202a64d`). On Android `max_width/max_height` default to `0.0`;
Bevy's clamp panicked with `min=800 > max=0`.
- **Android APK launch: gate `apply_smart_default_window_size` to
non-Android** (`202a64d`). The system calls `.clamp(800.0,
logical_w)` which panics when the emulator reports zero window
dimensions during early Android lifecycle events. The OS controls
window size on Android; the system is irrelevant there.
- **Ignore `.idea/` IDE project files** (`16242e6`). Android Studio
created `.idea/` when the project was opened during APK
verification; added to `.gitignore` and removed the accidentally-
committed files.
### Android verification result
APK boots on `x86_64-linux-android` in a Pixel_7 AVD (Android 14 /
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
Bevy renderer initialises, splash screen loads. This is the first
confirmed end-to-end device run.
### Stats
- Tests: **1300+ passing** / 0 failing
- Clippy: clean
- Crates touched: `solitaire_core` (game_state), `solitaire_data`
(settings, stats, difficulty_seeds, challenge), `solitaire_engine`
(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin,
input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs),
`solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries)
## [0.21.8] — 2026-05-08
Patch release for replay-overlay polish. Through-line:
**notch-label centering + WIN MOVE HC legibility + HC system extension**.
All three items were "optional polish" flagged in the v0.21.7 handoff;
all three ship in two commits.
### Added
- **`STATE_SUCCESS_HC` constant** (`c50eaf8`). Brighter lime
(`#c8e862`, L≈0.73) in `ui_theme` for use wherever the
standard `STATE_SUCCESS` (`#acc267`, L≈0.51) needs extra
luminance under HC mode. Sits above the bumped notch ticks
(`BORDER_SUBTLE_HC` gray, L≈0.60) so a WIN MOVE marker at
this colour is unambiguous.
- **`HighContrastBackground::with_hc(default, hc)` constructor**
(`c50eaf8`). Extends `HighContrastBackground` with an
`hc_color: Color` field (default = `BORDER_SUBTLE_HC` via
`with_default()`). `update_high_contrast_backgrounds` now
reads `marker.hc_color` instead of the hardcoded constant —
backwards-compatible; all existing `with_default()` usages
continue to bump to gray.
- **WIN MOVE scrub-bar marker HC bump** (`c50eaf8`). Marker
now carries `HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC)` so the lime stays lime under HC (brighter
lime rather than gray). Pin test locks both the default and
HC colour fields on the spawned entity.
### Fixed
- **Scrub-bar notch-label centering** (`b44d277`). Middle
three labels ("25%", "50%", "75%") previously had their
left edge at the notch; now their text centre coincides
with the notch tick. Implemented using the CSS
`translateX(-50%)` pattern for Bevy 0.18 UI: a fixed
`SCRUB_LABEL_CENTER_WIDTH = 36 px` container with
`margin.left = -18 px` is placed at `left: Percent(pct)`,
and `Justify::Center` centres the text within it. Endpoint
labels ("0%", "100%") keep their flush-left / flush-right
anchoring. `with_default()` remains one-argument.
### Stats
- Tests: 1276 passing / 0 failing (engine: 831)
- Clippy: clean
- Crates touched: `solitaire_engine` (replay_overlay.rs,
ui_theme.rs, settings_plugin.rs)
## [0.21.7] — 2026-05-08 ## [0.21.7] — 2026-05-08
Generated
+4
View File
@@ -6967,6 +6967,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"png 0.17.16", "png 0.17.16",
"solitaire_core",
"solitaire_data",
] ]
[[package]] [[package]]
@@ -6986,6 +6988,7 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"dirs", "dirs",
"jni 0.21.1",
"jsonwebtoken", "jsonwebtoken",
"keyring-core", "keyring-core",
"reqwest", "reqwest",
@@ -7009,6 +7012,7 @@ dependencies = [
"bevy", "bevy",
"chrono", "chrono",
"dirs", "dirs",
"jni 0.21.1",
"kira", "kira",
"resvg", "resvg",
"ron", "ron",
+1
View File
@@ -31,6 +31,7 @@ keyring = "4"
keyring-core = "1" keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false } reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
arboard = { version = "3", default-features = false } arboard = { version = "3", default-features = false }
jni = { version = "0.21", default-features = false }
solitaire_core = { path = "solitaire_core" } solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" } solitaire_sync = { path = "solitaire_sync" }
+108 -96
View File
@@ -1,85 +1,98 @@
# Solitaire Quest — Session Handoff # Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08 — **v0.21.7 cut and tagged at **Last updated:** 2026-05-08 — **v0.21.8 tagged at `c50eaf8`**;
`da3e542`**, working tree clean (tag pending push). nine post-cut commits on master. Push pending.
v0.21.7 is a single-commit patch closing the last major B-2 v0.21.8 closes the last optional polish items in the B-2
sub-piece: **mini-tableau preview dim layer**. A full-screen replay screen-takeover arc: **notch-label centering** (middle
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity three scrub-bar labels now centred on their notch ticks via the
black) at `Z_REPLAY_DIM = 54` (one rung below the replay CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
chrome at z=55) darkens the card world during replay so the MOVE HC legibility** (lime stays lime under HC mode via the
banner and move-log panel read clearly against the scene — extended `HighContrastBackground::with_hc` constructor and a
matching the mockup's "Game Peek Band at 50 % opacity" spec new `STATE_SUCCESS_HC` brighter-lime constant). The replay
without touching `card_plugin`. 13 commits have now shipped overlay arc is now fully closed with no known open items.
across v0.21.4v0.21.7 on the B-2 replay screen-takeover
arc; every major sub-piece is closed.
Full v0.21.7 detail lives in `CHANGELOG.md` § [0.21.7]. This Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
file from here on focuses on what's *open* post-cut and how to file from here on focuses on what's *open* post-cut and how to
resume. resume.
## Status at pause ## Status at pause
- **HEAD locally:** `da3e542` (v0.21.7 commit). Tag pending — - **HEAD locally:** `f281425` (Android Keystore JNI).
push with `git tag v0.21.7 da3e542 && git push origin v0.21.7`. Docs ride on top; push pending.
- **HEAD on origin:** `f63db76` (v0.21.6). v0.21.7 commit - **HEAD on origin:** `395a322` (double-tap commit — last pushed).
not pushed yet; a docs-only edit will ride on top before push. - **Working tree:** clean (docs uncommitted). No WIP outstanding.
- **Working tree:** clean. No WIP outstanding.
- **`artwork/` directory:** still untracked. Intentional. - **`artwork/` directory:** still untracked. Intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` - **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean. clean.
- **Tests:** **1275 passing / 0 failing** across the workspace. - **Tests:** **1292 passing / 0 failing** across the workspace.
Detail in `CHANGELOG.md` § [0.21.7] § Stats. - **Tags on origin:** `v0.9.0` through `v0.21.8`.
- **Tags on origin:** `v0.9.0` through `v0.21.6`. v0.21.7 - **Android:** APK verified booting on Pixel_7 AVD (Android 14,
tag exists locally at `da3e542`; push to origin when ready. x86_64). All desktop-only systems (handle_fullscreen) now gated.
See Phase Android punch list for remaining work.
## Since the v0.21.7 cut ## Since the v0.21.8 cut
One commit in flight (not yet pushed to origin): `da3e542` Seven commits since the v0.21.8 tag:
adds the full-screen tableau dim layer. CHANGELOG and - `a449f60` — Stats Prev/Next selector spawn site
SESSION_HANDOFF updates ride on top. Push with: - `202a64d` — Android launch fixes (android_main, resize_constraints,
``` apply_smart_default_window_size) — **closes APK launch verification**
git push origin master - `16242e6` — Ignore .idea/ IDE files
git push origin v0.21.7 - `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
Open next-step menu (all major B-2 sub-pieces now closed): CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
1. **Polish: notch label centering.** Bevy 0.18 lacks a
clean `translate-x: -50%` primitive so the middle three Open next-step menu:
scrub-bar labels sit slightly right-of-notch. Could use a 1. **Phase 8 (sync)** — the biggest open arc. Local storage
child Text wrapper with computed left-margin compensation. scaffolding, self-hosted Axum server, GPGS stub.
Tiny commit, requires visual review. 2. **Android follow-ups** — JNI ClipboardManager, Android Keystore,
2. **Polish: WIN MOVE marker HC bump.** Currently uses GPGS. Launch verification and double-tap both closed; these
`STATE_SUCCESS` lime which stays visible under HC, but a are the remaining Phase Android items.
contrast bump under HC would make it even more legible 3. **Move Log auto-scroll** — only relevant if the panel
alongside the bumped notches. Optional.
3. **Move Log auto-scroll** — only relevant if the panel's
row count grows beyond the current 5-row fixed window. row count grows beyond the current 5-row fixed window.
Currently the prev-2 / active / next-2 layout fits all
visible content, so auto-scroll is unneeded.
Recommended order: options 1 and 2 are tiny polish commits
that benefit from visual review. Option 3 is a non-starter
unless the panel's row capacity grows.
## Open punch list ## Open punch list
### Phase Android (build + persistence shipped; runtime gaps remain) ### Phase Android (build + persistence shipped; runtime gaps remain)
- **APK launch verification on AVD / device.** `adb install` then - *APK launch verification — closed 2026-05-08 by `202a64d`.*
`adb logcat` against the `bevy_test` AVD or an x86_64 device. Three fixes shipped: `android_main` export (missing NativeActivity
The build works and persistence is wired, but no end-to-end entry point), `resize_constraints` gated to non-Android (max=0
device run has been logged. Shakes out runtime bugs the build + panic), `apply_smart_default_window_size` gated to non-Android
unit tests can't catch. (clamp panic on zero-dimension window event). Verified booting on
- **JNI ClipboardManager bridge.** Replaces the Android stub for Pixel_7 AVD (Android 14, x86_64, SwiftShader Vulkan), 2+ min
the Stats "Copy share link" toast. `arboard` doesn't ship an runtime without crash. B0004 ECS hierarchy warnings remain
Android backend; small custom JNI call. (non-fatal; entity parent/child component mismatch); investigate
- **Android Keystore for credentials.** `keyring` is target-gated if they surface gameplay bugs.
to a stub returning `KeychainUnavailable`; replace with Android - *Double-tap auto-move — closed 2026-05-08 by `395a322`.*
Keystore via JNI when sync auth ships on mobile. `handle_double_tap` fires `MoveRequestEvent` on two rapid
- **Google Play Games (gpgs) integration.** Listed as a `TouchPhase::Ended` events within 0.5 s. Prefers foundation;
Phase-Android target since Phase 1; now unblocked by the build falls back to tableau stack move. Fires `MoveRejectedEvent` when
target. no legal destination exists. System runs before `touch_end_drag`
in the chain so drag state is readable.
- *F11 fullscreen gate — closed 2026-05-08 by `45436d0`.*
`handle_fullscreen` and its `MonitorSelection`/`WindowMode`
imports are `#[cfg(not(target_os = "android"))]`-gated. The
`add_systems` call is a separate statement (not mid-chain).
- *JNI ClipboardManager bridge — closed 2026-05-08 by `2c822ba`.*
`android_clipboard::set_text(url)` calls `ClipboardManager` via
JNI. Stats share-link button now writes to the clipboard with a
"Copied: {url}" toast; falls back to "Share link: {url}" on JNI
error. Requires AVD functional test (see verification steps in
the approved plan).
- *Android Keystore for credentials — closed 2026-05-08 by `f281425`.*
`android_keystore` module: AES-256/GCM/NoPadding device-bound key,
tokens serialised to JSON and stored atomically at
`{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+tag]`.
`auth_tokens.rs` Android stubs now delegate to it. Key
invalidation (biometric reset) → `TokenError::KeychainUnavailable`.
Requires AVD functional test before Phase 8 sync goes live on
Android.
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign - **Cosmetic `cargo apk build --lib` workaround.** Post-sign
panic doesn't affect the APK on disk but produces noisy stderr. panic doesn't affect the APK on disk but produces noisy stderr.
Either upstream a cargo-apk fix or document `--lib` as Either upstream a cargo-apk fix or document `--lib` as
@@ -158,11 +171,19 @@ palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
### Other small candidates ### Other small candidates
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5` - *Play-by-Seed dialog — closed 2026-05-08 by `0cb1587`.*
noted Prev/Next markers exist in `stats_plugin` but no spawn `PlayBySeedPlugin` adds a numeric-input modal with async solver
site renders them today — the Shareable badge therefore lands preview (debounced 500 ms). `HomeMode::PlayBySeed` card fires
on the single-replay caption. If/when Prev/Next is plumbed, `StartPlayBySeedRequestEvent`. 5 unit tests. 75 new verified-win
the badge will need to follow. seeds (`2062bd0`) expand `CHALLENGE_SEEDS` via the new
`solitaire_assetgen::gen_seeds` binary.
- *Prev/Next selector chips spawn site — closed 2026-05-08 by
`a449f60`.* `ReplayPrevButton` / `ReplayNextButton` /
`ReplaySelectorCaption` / `ReplaySelectorDetail` now spawn in
`spawn_stats_screen` as a compact chip row above the Watch
Replay action. The Shareable badge is in the detail line.
The click handler and repaint systems were already live since
v0.19.0; this was purely the missing spawn site.
- **Toast queue / immediate unification.** The two toast paths - **Toast queue / immediate unification.** The two toast paths
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast` (`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
for fire-and-forget) now share visual treatment but remain for fire-and-forget) now share visual treatment but remain
@@ -246,22 +267,21 @@ into a v0.21.1 / v0.22.0 cut.
``` ```
You are a senior Rust + Bevy developer working on Solitaire Quest. You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine>. Working directory: <Rusty_Solitaire clone path on this machine>.
Branch: master. v0.21.7 is tagged at da3e542 (cut 2026-05-08, Branch: master. v0.21.8 is tagged at c50eaf8 (cut 2026-05-08,
closes the last major B-2 sub-piece: full-screen tableau dim replay-overlay polish). Seven post-cut commits are on master (see
layer — 50 % opacity black UI scrim at z=54 that darkens the "Since the v0.21.8 cut" above); push of the last four pending.
card world during replay so the chrome reads clearly above it). v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
v0.21.6 stays at f63db76, v0.21.5 at a2432df, v0.21.4 at v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b, v0.21.1 at v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
daa655a, v0.21.0 at 04f9bf9. Working tree clean (CHANGELOG + Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
SESSION_HANDOFF docs ride on top of da3e542; push pending). pending. See CHANGELOG.md § [0.21.9] for full detail.
See CHANGELOG.md § [0.21.7] for full detail.
State: HEAD locally — see `git rev-parse HEAD`. Workspace State: HEAD locally — see `git rev-parse HEAD`. Workspace
tests: 1275 passing / 0 failing. Clippy clean. tests: 1292 passing / 0 failing. Clippy clean.
READ FIRST (in order, before doing anything): READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — this file 1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.21.6] section is the most recent cut 2. CHANGELOG.md — [0.21.9] section has the pending-cut items
3. CLAUDE.md — unified-3.0 rule set 3. CLAUDE.md — unified-3.0 rule set
4. CLAUDE_SPEC.md — formal architecture spec 4. CLAUDE_SPEC.md — formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow 5. ARCHITECTURE.md — crate responsibilities + data flow
@@ -276,25 +296,17 @@ READ FIRST (in order, before doing anything):
fresh machine) fresh machine)
DECISION TO ASK THE PLAYER FIRST: DECISION TO ASK THE PLAYER FIRST:
A. APK launch verification on AVD / device — `adb install` + A. Android follow-ups — JNI ClipboardManager bridge (arboard
`adb logcat` to shake out runtime bugs the build / unit has no Android backend), Android Keystore (blocked on Phase 8).
tests can't catch. Likely surfaces JNI ClipboardManager Launch verification + double-tap are closed.
and Android Keystore stubs that need real bridges. Larger B. Phase 8 (sync) — local storage scaffolding, self-hosted
scope; needs an Android device or emulator running. Axum server, `SolitaireServerClient` impl. The biggest open
B. Replay-overlay polish (B-2 arc fully closed in v0.21.7). arc by scope; rolls up Android dependencies (Keystore,
All 13 planned sub-pieces shipped. Remaining items are
minor polish: (a) scrub-bar notch-label centering — middle
three labels sit slightly right-of-notch due to Bevy 0.18
lacking `translate-x: -50%`; tiny commit, needs visual
review. (b) WIN MOVE marker HC contrast bump — optional
luminance boost under HC mode. Both are single commits
requiring visual review; recommend treating as a v0.21.8
polish pass after manual testing.
C. Phase 8 (sync) — local storage scaffolding, self-hosted
Axum server, `SolitaireServerClient` impl, GPGS stub
wired into Settings. The biggest open arc by scope; rolls
up several Phase Android dependencies (Keystore,
ClipboardManager). ClipboardManager).
C. Play-by-Seed polish — the dialog is functional but has no
visual preview of the solver verdict in the UI yet; the
HomeMode card is wired but the dialog spawn site and verdict
display could use a second pass.
WORKFLOW NOTES: WORKFLOW NOTES:
- Use the system git config (already correct). - Use the system git config (already correct).
@@ -320,7 +332,7 @@ WORKFLOW NOTES:
OPEN AT THE START: ask which of AC. Don't pick unilaterally. OPEN AT THE START: ask which of AC. Don't pick unilaterally.
Note: every remaining option is multi-session by nature (A is Note: every remaining option is multi-session by nature (A is
gated on Android tooling, B and C are explicitly multi-session gated on Android tooling; B and C are explicitly multi-session
arcs). A fresh session is a better fit for any of them than the arcs). A fresh session is a better fit for any of them than the
tail of a long working stretch. tail of a long working stretch.
``` ```
+40 -9
View File
@@ -18,21 +18,22 @@ use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition, #[cfg(not(target_os = "android"))]
}; use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use bevy::winit::WinitWindows; use bevy::winit::WinitWindows;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{ use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin, CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
}; };
/// App entry point — builds and runs the Bevy app. /// App entry point — builds and runs the Bevy app.
@@ -76,6 +77,7 @@ pub fn run() {
// primary monitor) — `apply_smart_default_window_size` will resize // primary monitor) — `apply_smart_default_window_size` will resize
// up to a monitor-relative target on the first frame so HiDPI / 4K // up to a monitor-relative target on the first frame so HiDPI / 4K
// sessions don't end up with a comparatively tiny window. // sessions don't end up with a comparatively tiny window.
#[cfg(not(target_os = "android"))]
let had_saved_geometry = settings.window_geometry.is_some(); let had_saved_geometry = settings.window_geometry.is_some();
let (window_resolution, window_position) = match settings.window_geometry { let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => ( Some(geom) => (
@@ -116,6 +118,9 @@ pub fn run() {
// small enough that a few stray dropped frames from // small enough that a few stray dropped frames from
// disabling vsync are imperceptible. // disabling vsync are imperceptible.
present_mode: PresentMode::AutoNoVsync, present_mode: PresentMode::AutoNoVsync,
// Android windows always fill the screen; max_width/max_height
// default to 0.0, which panics Bevy's clamp when min > max.
#[cfg(not(target_os = "android"))]
resize_constraints: bevy::window::WindowResizeConstraints { resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0, min_width: 800.0,
min_height: 600.0, min_height: 600.0,
@@ -142,6 +147,13 @@ pub fn run() {
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
.add_plugins(CardPlugin) .add_plugins(CardPlugin)
// Cursor-icon feedback is desktop-only; Android has no pointer cursor.
// The drop-target highlight systems (update_drop_highlights,
// update_drop_target_overlays) live in CursorPlugin but ARE useful
// on Android — they've been left running because their Bevy system
// params compile and function on Android; only the CursorIcon insert
// is inert. Gate the whole plugin if the cursor APIs ever cause
// Android linker issues; for now it's harmless to leave it registered.
.add_plugins(CursorPlugin) .add_plugins(CursorPlugin)
.add_plugins(InputPlugin) .add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin) .add_plugins(RadialMenuPlugin)
@@ -158,6 +170,8 @@ pub fn run() {
.add_plugins(DailyChallengePlugin) .add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin) .add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin) .add_plugins(ChallengePlugin)
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(TimeAttackPlugin) .add_plugins(TimeAttackPlugin)
.add_plugins(HudPlugin) .add_plugins(HudPlugin)
.add_plugins(HelpPlugin) .add_plugins(HelpPlugin)
@@ -195,6 +209,8 @@ pub fn run() {
// every fresh launch can flip `disable_smart_default_size` in // every fresh launch can flip `disable_smart_default_size` in
// Settings to opt out. The flag is checked once at startup; a // Settings to opt out. The flag is checked once at startup; a
// mid-session change applies on the next launch. // mid-session change applies on the next launch.
// Android windows are always full-screen; the OS controls sizing.
#[cfg(not(target_os = "android"))]
if !had_saved_geometry && !settings.disable_smart_default_size { if !had_saved_geometry && !settings.disable_smart_default_size {
app.add_systems(Update, apply_smart_default_window_size); app.add_systems(Update, apply_smart_default_window_size);
} }
@@ -215,6 +231,7 @@ pub fn run() {
/// a dedicated resource. The Update tick is necessary because Bevy /// a dedicated resource. The Update tick is necessary because Bevy
/// populates the `Monitor` entities asynchronously after winit's /// populates the `Monitor` entities asynchronously after winit's
/// Resumed event fires; they may not exist on the first Startup pass. /// Resumed event fires; they may not exist on the first Startup pass.
#[cfg(not(target_os = "android"))]
fn apply_smart_default_window_size( fn apply_smart_default_window_size(
mut applied: Local<bool>, mut applied: Local<bool>,
monitors: Query<&Monitor, With<PrimaryMonitor>>, monitors: Query<&Monitor, With<PrimaryMonitor>>,
@@ -335,6 +352,20 @@ fn set_window_icon(
*applied = true; *applied = true;
} }
/// Android entry point called by NativeActivity after dlopen-ing the `.so`.
/// Sets the `AndroidApp` handle that Bevy's winit backend reads before
/// constructing the event loop, then delegates to [`run`].
///
/// The `#[bevy_main]` proc-macro would generate the same code but only
/// works on a function named `main`; our shared entry point is `run`, so
/// we emit the equivalent expansion manually.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let _ = bevy::android::ANDROID_APP.set(android_app);
run();
}
/// Wraps the default panic hook with one that also appends a crash log /// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook /// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are /// still runs afterwards, so stderr output and debugger integration are
+10
View File
@@ -12,6 +12,8 @@ publish = false
[dependencies] [dependencies]
png = "0.17" png = "0.17"
ab_glyph = "0.2" ab_glyph = "0.2"
solitaire_core = { path = "../solitaire_core" }
solitaire_data = { path = "../solitaire_data" }
[[bin]] [[bin]]
name = "gen_sfx" name = "gen_sfx"
@@ -20,3 +22,11 @@ path = "src/bin/gen_sfx.rs"
[[bin]] [[bin]]
name = "gen_art" name = "gen_art"
path = "src/bin/gen_art.rs" path = "src/bin/gen_art.rs"
[[bin]]
name = "gen_seeds"
path = "src/bin/gen_seeds.rs"
[[bin]]
name = "gen_difficulty_seeds"
path = "src/bin/gen_difficulty_seeds.rs"
@@ -0,0 +1,195 @@
//! Generate difficulty-stratified seed catalogs for `EASY_SEEDS`, `MEDIUM_SEEDS`,
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
//! `solitaire_data/src/difficulty_seeds.rs`.
//!
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
//! provably-winnable seeds).
//!
//! # Usage
//!
//! ```bash
//! cargo run -p solitaire_assetgen --bin gen_difficulty_seeds --release -- \
//! --start 0xD1FF0000_00000000 --per-tier 40
//! ```
//!
//! Flags:
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xD1FF000000000000)
//! --per-tier Seeds to emit per tier (default 40)
//! --help Print this message
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
// Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable.
const BUDGETS: &[(&str, u64, usize)] = &[
("Easy", 1_000, 1_000),
("Medium", 5_000, 5_000),
("Hard", 25_000, 25_000),
("Expert", 100_000, 100_000),
("Grandmaster", 200_000, 200_000),
];
fn main() {
let mut args = std::env::args().skip(1).peekable();
let mut start: u64 = 0xD1FF_0000_0000_0000;
let mut per_tier: usize = 40;
while let Some(arg) = args.next() {
match arg.as_str() {
"--start" => {
let val = args.next().unwrap_or_else(|| {
eprintln!("error: --start requires a value");
std::process::exit(1);
});
start = parse_u64(&val);
}
"--per-tier" => {
let val = args.next().unwrap_or_else(|| {
eprintln!("error: --per-tier requires a value");
std::process::exit(1);
});
per_tier = val.parse().unwrap_or_else(|_| {
eprintln!("error: --per-tier must be a positive integer");
std::process::exit(1);
});
}
"--help" | "-h" => {
eprintln!("gen_difficulty_seeds: generate tiered seed catalogs");
eprintln!(" --start <seed> starting seed (hex or decimal)");
eprintln!(" --per-tier <n> seeds per tier (default 40)");
return;
}
other => {
eprintln!("error: unknown argument: {other}");
std::process::exit(1);
}
}
}
if per_tier == 0 {
eprintln!("error: --per-tier must be > 0");
std::process::exit(1);
}
let draw_mode = DrawMode::DrawOne;
let num_tiers = BUDGETS.len();
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
let mut tried: u64 = 0;
let mut seed = start;
eprintln!(
"gen_difficulty_seeds: finding {} seeds per tier from 0x{start:016X} (DrawOne) …",
per_tier
);
eprintln!(
" Tiers: {}",
BUDGETS.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")
);
while buckets.iter().any(|b| b.len() < per_tier) {
tried += 1;
'tier: for (i, &(name, move_budget, state_budget)) in BUDGETS.iter().enumerate() {
if buckets[i].len() >= per_tier {
continue;
}
let cfg = SolverConfig { move_budget, state_budget };
match try_solve(seed, draw_mode.clone(), &cfg) {
SolverResult::Winnable => {
buckets[i].push(seed);
eprintln!(
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
buckets[i].len(),
per_tier
);
break 'tier; // assign to the cheapest tier that proves it winnable
}
SolverResult::Unwinnable => {
// Definitely unsolvable — skip all remaining tiers.
break 'tier;
}
SolverResult::Inconclusive => {
// Budget exhausted without proof — try the next larger tier.
// If this is the last tier, the seed is discarded (Inconclusive
// at max budget means "probably but not provably winnable").
if i == num_tiers - 1 {
break 'tier;
}
}
}
}
seed = seed.wrapping_add(1);
}
eprintln!("\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n");
let date = current_date();
for (i, (tier_name, _, _)) in BUDGETS.iter().enumerate() {
println!(
" // Generated by solitaire_assetgen::gen_difficulty_seeds \
(tier={tier_name}, date={date})"
);
for chunk in buckets[i].chunks(5) {
for s in chunk {
println!(
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
(s >> 48) & 0xFFFF,
(s >> 32) & 0xFFFF,
(s >> 16) & 0xFFFF,
s & 0xFFFF,
);
}
}
println!();
}
}
fn parse_u64(s: &str) -> u64 {
let cleaned = s.replace('_', "");
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
eprintln!("error: could not parse '{s}' as a hex u64");
std::process::exit(1);
})
} else {
cleaned.parse().unwrap_or_else(|_| {
eprintln!("error: could not parse '{s}' as a decimal u64");
std::process::exit(1);
})
}
}
fn current_date() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86400;
let mut y = 1970u64;
let mut d = days;
loop {
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
let days_in_year = if leap { 366 } else { 365 };
if d < days_in_year {
break;
}
d -= days_in_year;
y += 1;
}
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
let month_days: [u64; 12] = [
31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
];
let mut m = 0usize;
for &md in &month_days {
if d < md {
break;
}
d -= md;
m += 1;
}
format!("{y}-{:02}-{:02}", m + 1, d + 1)
}
+157
View File
@@ -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)
}
+33
View File
@@ -50,6 +50,35 @@ pub enum DrawMode {
DrawThree, DrawThree,
} }
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
/// catalog is drawn from. `Random` skips verification entirely and uses a
/// system-time seed — deals may or may not be winnable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum DifficultyLevel {
#[default]
Easy,
Medium,
Hard,
Expert,
Grandmaster,
/// Unverified system-time seed — may or may not be winnable.
Random,
}
impl DifficultyLevel {
/// Short human-readable label shown in the HUD and win summary.
pub fn label(self) -> &'static str {
match self {
Self::Easy => "Easy",
Self::Medium => "Medium",
Self::Hard => "Hard",
Self::Expert => "Expert",
Self::Grandmaster => "Grandmaster",
Self::Random => "Random",
}
}
}
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour. /// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
/// ///
/// - `Classic`: standard Klondike scoring, undo allowed. /// - `Classic`: standard Klondike scoring, undo allowed.
@@ -59,6 +88,8 @@ pub enum DrawMode {
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute /// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
/// countdown around the session and auto-deals a fresh game on every win /// countdown around the session and auto-deals a fresh game on every win
/// (see `solitaire_engine::TimeAttackPlugin`). /// (see `solitaire_engine::TimeAttackPlugin`).
/// - `Difficulty(DifficultyLevel)`: seed drawn from a pre-verified per-tier catalog
/// (or system-time for `Random`). Rules identical to Classic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode { pub enum GameMode {
#[default] #[default]
@@ -70,6 +101,8 @@ pub enum GameMode {
Challenge, Challenge,
/// Play as many games as possible within 10 minutes. /// Play as many games as possible within 10 minutes.
TimeAttack, TimeAttack,
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
Difficulty(DifficultyLevel),
} }
/// Snapshot of game state used for undo. /// Snapshot of game state used for undo.
+7
View File
@@ -26,6 +26,13 @@ tokio = { workspace = true }
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
keyring-core = { workspace = true } keyring-core = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
# process-wide JavaVM handle for JNI. Must be listed here so the
# symbol resolves when cross-compiling for Android targets.
bevy = { workspace = true }
[dev-dependencies] [dev-dependencies]
solitaire_server = { path = "../solitaire_server" } solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
+409
View File
@@ -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()
})
}
+11 -17
View File
@@ -131,35 +131,29 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Android stub — same public API, always returns KeychainUnavailable. // Android — delegate to the JNI Keystore bridge in android_keystore.
// Lets `sync_client::*` compile unchanged on Android; the runtime
// effect is "session login required every launch", same as a Linux
// box without Secret Service.
// ------------------------------------------------------------------- // -------------------------------------------------------------------
#[cfg(target_os = "android")]
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub fn store_tokens( pub fn store_tokens(
_username: &str, username: &str,
_access_token: &str, access_token: &str,
_refresh_token: &str, refresh_token: &str,
) -> Result<(), TokenError> { ) -> Result<(), TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) crate::android_keystore::store_tokens(username, access_token, refresh_token)
} }
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub fn load_access_token(_username: &str) -> Result<String, TokenError> { pub fn load_access_token(username: &str) -> Result<String, TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) crate::android_keystore::load_access_token(username)
} }
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> { pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) crate::android_keystore::load_refresh_token(username)
} }
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> { pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) crate::android_keystore::delete_tokens(username)
} }
+76
View File
@@ -40,6 +40,82 @@ pub const CHALLENGE_SEEDS: &[u64] = &[
0xDDDD_EEEE_FFFF_0000, 0xDDDD_EEEE_FFFF_0000,
0x0101_0101_0101_0101, 0x0101_0101_0101_0101,
0xA1B2_C3D4_E5F6_0718, 0xA1B2_C3D4_E5F6_0718,
// Generated by solitaire_assetgen::gen_seeds (start=0xCAFEBABE00000000, count=75, date=2026-05-09)
0xCAFE_BABE_0000_0000,
0xCAFE_BABE_0000_0002,
0xCAFE_BABE_0000_0004,
0xCAFE_BABE_0000_0008,
0xCAFE_BABE_0000_000B,
0xCAFE_BABE_0000_000D,
0xCAFE_BABE_0000_000E,
0xCAFE_BABE_0000_0010,
0xCAFE_BABE_0000_0011,
0xCAFE_BABE_0000_0014,
0xCAFE_BABE_0000_0016,
0xCAFE_BABE_0000_0019,
0xCAFE_BABE_0000_001A,
0xCAFE_BABE_0000_001F,
0xCAFE_BABE_0000_0020,
0xCAFE_BABE_0000_0021,
0xCAFE_BABE_0000_0024,
0xCAFE_BABE_0000_0025,
0xCAFE_BABE_0000_0027,
0xCAFE_BABE_0000_002B,
0xCAFE_BABE_0000_002D,
0xCAFE_BABE_0000_0030,
0xCAFE_BABE_0000_0034,
0xCAFE_BABE_0000_0036,
0xCAFE_BABE_0000_003A,
0xCAFE_BABE_0000_003B,
0xCAFE_BABE_0000_003D,
0xCAFE_BABE_0000_0042,
0xCAFE_BABE_0000_0043,
0xCAFE_BABE_0000_0044,
0xCAFE_BABE_0000_004C,
0xCAFE_BABE_0000_004D,
0xCAFE_BABE_0000_004F,
0xCAFE_BABE_0000_0050,
0xCAFE_BABE_0000_0051,
0xCAFE_BABE_0000_0054,
0xCAFE_BABE_0000_0055,
0xCAFE_BABE_0000_0056,
0xCAFE_BABE_0000_0059,
0xCAFE_BABE_0000_005B,
0xCAFE_BABE_0000_005C,
0xCAFE_BABE_0000_005E,
0xCAFE_BABE_0000_0060,
0xCAFE_BABE_0000_0062,
0xCAFE_BABE_0000_0064,
0xCAFE_BABE_0000_0067,
0xCAFE_BABE_0000_0069,
0xCAFE_BABE_0000_006A,
0xCAFE_BABE_0000_006B,
0xCAFE_BABE_0000_006C,
0xCAFE_BABE_0000_006D,
0xCAFE_BABE_0000_006E,
0xCAFE_BABE_0000_006F,
0xCAFE_BABE_0000_0072,
0xCAFE_BABE_0000_0073,
0xCAFE_BABE_0000_0074,
0xCAFE_BABE_0000_0079,
0xCAFE_BABE_0000_007A,
0xCAFE_BABE_0000_007D,
0xCAFE_BABE_0000_007E,
0xCAFE_BABE_0000_007F,
0xCAFE_BABE_0000_0082,
0xCAFE_BABE_0000_0083,
0xCAFE_BABE_0000_0084,
0xCAFE_BABE_0000_0085,
0xCAFE_BABE_0000_0089,
0xCAFE_BABE_0000_008A,
0xCAFE_BABE_0000_008D,
0xCAFE_BABE_0000_008E,
0xCAFE_BABE_0000_0090,
0xCAFE_BABE_0000_0094,
0xCAFE_BABE_0000_0095,
0xCAFE_BABE_0000_0098,
0xCAFE_BABE_0000_0099,
0xCAFE_BABE_0000_009F,
]; ];
/// Resolve a `challenge_index` to its corresponding seed, wrapping when /// Resolve a `challenge_index` to its corresponding seed, wrapping when
+320
View File
@@ -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"
);
}
}
}
+6
View File
@@ -138,6 +138,9 @@ pub use weekly::{
pub mod challenge; pub mod challenge;
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS}; pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod difficulty_seeds;
pub use difficulty_seeds::{seeds_for, DifficultySeeds};
pub mod settings; pub mod settings;
pub use settings::{ pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend, load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
@@ -147,6 +150,9 @@ pub use settings::{
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
}; };
#[cfg(target_os = "android")]
mod android_keystore;
pub mod auth_tokens; pub mod auth_tokens;
pub use auth_tokens::{ pub use auth_tokens::{
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
+9 -1
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::{DifficultyLevel, DrawMode};
const APP_DIR_NAME: &str = "solitaire_quest"; const APP_DIR_NAME: &str = "solitaire_quest";
const SETTINGS_FILE_NAME: &str = "settings.json"; const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -224,6 +224,13 @@ pub struct Settings {
/// `#[serde(default = "default_replay_move_interval_secs")]`. /// `#[serde(default = "default_replay_move_interval_secs")]`.
#[serde(default = "default_replay_move_interval_secs")] #[serde(default = "default_replay_move_interval_secs")]
pub replay_move_interval_secs: f32, pub replay_move_interval_secs: f32,
/// Last difficulty tier the player selected. `None` means the player has
/// never used the difficulty picker. When `Some`, the difficulty section in
/// the home overlay opens pre-expanded and highlights this tier. Older
/// `settings.json` files written before this field existed deserialize
/// cleanly to `None` via `#[serde(default)]`.
#[serde(default)]
pub last_difficulty: Option<DifficultyLevel>,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -342,6 +349,7 @@ impl Default for Settings {
winnable_deals_only: false, winnable_deals_only: false,
disable_smart_default_size: false, disable_smart_default_size: false,
replay_move_interval_secs: default_replay_move_interval_secs(), replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None,
} }
} }
} }
+7
View File
@@ -104,6 +104,13 @@ impl StatsExt for StatsSnapshot {
// Time Attack uses its own session-level scoring; a per-game best // Time Attack uses its own session-level scoring; a per-game best
// wouldn't compose with the other modes' single-game numbers. // wouldn't compose with the other modes' single-game numbers.
GameMode::TimeAttack => {} GameMode::TimeAttack => {}
// Difficulty games pool into the Classic best-score/time buckets per
// the user's stats preference.
GameMode::Difficulty(_) => {
self.classic_best_score = self.classic_best_score.max(score_u32);
self.classic_fastest_win_seconds =
min_ignore_zero(self.classic_fastest_win_seconds, time_seconds);
}
} }
self.last_modified = Utc::now(); self.last_modified = Utc::now();
} }
+3
View File
@@ -32,6 +32,9 @@ zip = { workspace = true }
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
arboard = { workspace = true } arboard = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
[dev-dependencies] [dev-dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
+65
View File
@@ -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}"))
}
+235
View File
@@ -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"
);
}
}
+17
View File
@@ -172,6 +172,23 @@ pub struct StartTimeAttackRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct StartDailyChallengeRequestEvent; pub struct StartDailyChallengeRequestEvent;
/// Request to open the Play-by-Seed dialog. Fired by the Home overlay
/// "Play by Seed" mode card. The handler in `play_by_seed_plugin` spawns
/// a numeric-input modal where the player types a decimal seed and
/// optionally sees a solver-verified verdict before dealing.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StartPlayBySeedRequestEvent;
/// Request to start a game at a specific difficulty tier. Fired by the
/// difficulty section in the home overlay. The handler in `difficulty_plugin`
/// picks a seed from the corresponding pre-verified catalog (or generates a
/// random system-time seed for `DifficultyLevel::Random`) and writes a
/// `NewGameRequestEvent`.
#[derive(Message, Debug, Clone, Copy)]
pub struct StartDifficultyRequestEvent {
pub level: solitaire_core::game_state::DifficultyLevel,
}
/// Request to toggle the Stats overlay. Fired by the HUD Menu-popover /// Request to toggle the Stats overlay. Fired by the HUD Menu-popover
/// "Stats" row alongside the existing `S` accelerator. /// "Stats" row alongside the existing `S` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
+253 -13
View File
@@ -16,15 +16,15 @@
use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_data::save_settings_to; use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
ToggleProfileRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
@@ -81,6 +81,27 @@ struct HomeDrawThreeButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct HomeScrollable; struct HomeScrollable;
/// Marker on the "▶ Difficulty" / "▼ Difficulty" toggle button that
/// expands / collapses the difficulty tier chip row.
#[derive(Component, Debug)]
struct HomeDifficultyToggle;
/// Marker on each difficulty tier chip inside the expanded difficulty
/// section. The wrapped `DifficultyLevel` identifies which tier was
/// clicked so the handler can fire `StartDifficultyRequestEvent`.
#[derive(Component, Debug)]
struct HomeDifficultyChip(DifficultyLevel);
/// Whether the difficulty section is currently expanded. Toggled by
/// `handle_home_difficulty_toggle` and checked by `spawn_home_screen`
/// to determine initial render state.
///
/// Initialised at plugin startup; `spawn_home_on_launch` upgrades it
/// to `true` when `settings.last_difficulty` is already set so
/// returning players see their tier pre-expanded.
#[derive(Resource, Default, Debug)]
pub struct DifficultyExpanded(pub bool);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private mode-card data shape // Private mode-card data shape
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -96,6 +117,7 @@ enum HomeMode {
Zen, Zen,
Challenge, Challenge,
TimeAttack, TimeAttack,
PlayBySeed,
} }
impl HomeMode { impl HomeMode {
@@ -107,6 +129,7 @@ impl HomeMode {
HomeMode::Zen => "Zen Mode", HomeMode::Zen => "Zen Mode",
HomeMode::Challenge => "Challenge", HomeMode::Challenge => "Challenge",
HomeMode::TimeAttack => "Time Attack", HomeMode::TimeAttack => "Time Attack",
HomeMode::PlayBySeed => "Play by Seed",
} }
} }
@@ -118,6 +141,7 @@ impl HomeMode {
HomeMode::Zen => "No timer, no score. Just the cards.", HomeMode::Zen => "No timer, no score. Just the cards.",
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.", HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
HomeMode::TimeAttack => "How many can you finish in ten minutes?", HomeMode::TimeAttack => "How many can you finish in ten minutes?",
HomeMode::PlayBySeed => "Enter any number to play a specific deal.",
} }
} }
@@ -150,6 +174,9 @@ impl HomeMode {
// ships ▲ (up triangle) but evidently not the sideways // ships ▲ (up triangle) but evidently not the sideways
// siblings. // siblings.
HomeMode::TimeAttack => "\u{2192}", HomeMode::TimeAttack => "\u{2192}",
// Number sign — ASCII, universally available. Reads as
// "a specific number / seed ID".
HomeMode::PlayBySeed => "#",
} }
} }
@@ -162,6 +189,7 @@ impl HomeMode {
HomeMode::Zen => "Z", HomeMode::Zen => "Z",
HomeMode::Challenge => "X", HomeMode::Challenge => "X",
HomeMode::TimeAttack => "T", HomeMode::TimeAttack => "T",
HomeMode::PlayBySeed => "6",
} }
} }
@@ -233,11 +261,14 @@ impl Plugin for HomePlugin {
// Pre-mark the auto-show as already done in headless mode so the // Pre-mark the auto-show as already done in headless mode so the
// gating system is a permanent no-op for tests. // gating system is a permanent no-op for tests.
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch)) app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
.init_resource::<DifficultyExpanded>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<StartZenRequestEvent>() .add_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>() .add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>() .add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<StartPlayBySeedRequestEvent>()
.add_message::<StartDifficultyRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ToggleProfileRequestEvent>() .add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
@@ -245,13 +276,10 @@ impl Plugin for HomePlugin {
// runs cleanly under MinimalPlugins headless tests too. // runs cleanly under MinimalPlugins headless tests too.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
// `.chain()` because several systems (M-toggle, card click, // `.chain()` because several systems (M-toggle, card click,
// cancel button, digit-key shortcut) all read the // cancel button, digit-key shortcut, difficulty handlers)
// `HomeScreen` entity and may queue a despawn on it in the // all read the `HomeScreen` entity and may queue a despawn
// same tick. Bevy's parallel scheduler would otherwise let // on it in the same tick. Chaining serialises these systems
// two of them run simultaneously and double-despawn the // and keeps the despawn deterministic.
// entity, panicking when the second command buffer is
// applied. Chaining serialises these systems and keeps the
// despawn deterministic.
.add_systems( .add_systems(
Update, Update,
( (
@@ -262,6 +290,8 @@ impl Plugin for HomePlugin {
handle_home_cancel_button, handle_home_cancel_button,
handle_home_profile_chip, handle_home_profile_chip,
handle_home_draw_mode_buttons, handle_home_draw_mode_buttons,
handle_home_difficulty_toggle,
handle_home_difficulty_chip_click,
handle_home_digit_keys, handle_home_digit_keys,
) )
.chain(), .chain(),
@@ -306,6 +336,7 @@ fn spawn_home_on_launch(
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
daily: Option<Res<DailyChallengeResource>>, daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
mut diff_expanded: ResMut<DifficultyExpanded>,
) { ) {
if shown.0 if shown.0
|| !splash.is_empty() || !splash.is_empty()
@@ -316,6 +347,11 @@ fn spawn_home_on_launch(
return; return;
} }
// Pre-expand the difficulty section when the player has a saved preference.
if settings.as_ref().is_some_and(|s| s.0.last_difficulty.is_some()) {
diff_expanded.0 = true;
}
spawn_home_screen( spawn_home_screen(
&mut commands, &mut commands,
build_home_context( build_home_context(
@@ -324,6 +360,7 @@ fn spawn_home_on_launch(
settings.as_deref(), settings.as_deref(),
daily.as_deref(), daily.as_deref(),
font_res.as_deref(), font_res.as_deref(),
diff_expanded.0,
), ),
); );
shown.0 = true; shown.0 = true;
@@ -343,6 +380,7 @@ fn toggle_home_screen(
daily: Option<Res<DailyChallengeResource>>, daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>, screens: Query<Entity, With<HomeScreen>>,
diff_expanded: Res<DifficultyExpanded>,
) { ) {
if !keys.just_pressed(KeyCode::KeyM) { if !keys.just_pressed(KeyCode::KeyM) {
return; return;
@@ -358,6 +396,7 @@ fn toggle_home_screen(
settings.as_deref(), settings.as_deref(),
daily.as_deref(), daily.as_deref(),
font_res.as_deref(), font_res.as_deref(),
diff_expanded.0,
), ),
); );
} }
@@ -373,6 +412,7 @@ fn build_home_context<'a>(
settings: Option<&SettingsResource>, settings: Option<&SettingsResource>,
daily: Option<&DailyChallengeResource>, daily: Option<&DailyChallengeResource>,
font_res: Option<&'a FontResource>, font_res: Option<&'a FontResource>,
difficulty_expanded: bool,
) -> HomeContext<'a> { ) -> HomeContext<'a> {
let daily_today = daily.map(|d| { let daily_today = daily.map(|d| {
let completed_today = progress let completed_today = progress
@@ -398,6 +438,8 @@ fn build_home_context<'a>(
.map(|s| s.0.draw_mode.clone()) .map(|s| s.0.draw_mode.clone())
.unwrap_or(DrawMode::DrawOne), .unwrap_or(DrawMode::DrawOne),
font_res, font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
} }
} }
@@ -423,6 +465,7 @@ fn handle_home_card_click(
mut challenge: MessageWriter<StartChallengeRequestEvent>, mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>, mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>, mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>, mut info_toast: MessageWriter<InfoToastEvent>,
) { ) {
let level = progress.as_ref().map_or(0, |p| p.0.level); let level = progress.as_ref().map_or(0, |p| p.0.level);
@@ -457,6 +500,9 @@ fn handle_home_card_click(
HomeMode::TimeAttack => { HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent); time_attack.write(StartTimeAttackRequestEvent);
} }
HomeMode::PlayBySeed => {
play_by_seed.write(StartPlayBySeedRequestEvent);
}
} }
// Close the modal after dispatching the launch event. // Close the modal after dispatching the launch event.
@@ -557,6 +603,7 @@ fn handle_home_draw_mode_buttons(
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
daily: Option<Res<DailyChallengeResource>>, daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
diff_expanded: Res<DifficultyExpanded>,
) { ) {
if screens.is_empty() { if screens.is_empty() {
return; return;
@@ -600,10 +647,92 @@ fn handle_home_draw_mode_buttons(
Some(settings), Some(settings),
daily.as_deref(), daily.as_deref(),
font_res.as_deref(), font_res.as_deref(),
diff_expanded.0,
), ),
); );
} }
// ---------------------------------------------------------------------------
// Difficulty section handlers
// ---------------------------------------------------------------------------
/// Click on the "▶/▼ Difficulty" header — toggle `DifficultyExpanded` and
/// repaint the Home modal so the chevron and chip row update. Mirrors
/// `handle_home_draw_mode_buttons`: despawn + respawn keeps all styling in
/// `spawn_difficulty_section` rather than scattered across mutation helpers.
#[allow(clippy::too_many_arguments)]
fn handle_home_difficulty_toggle(
mut commands: Commands,
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
mut diff_expanded: ResMut<DifficultyExpanded>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
) {
if screens.is_empty() {
return;
}
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
return;
}
diff_expanded.0 = !diff_expanded.0;
for entity in &screens {
commands.entity(entity).despawn();
}
spawn_home_screen(
&mut commands,
build_home_context(
progress.as_deref(),
stats.as_deref(),
settings.as_deref(),
daily.as_deref(),
font_res.as_deref(),
diff_expanded.0,
),
);
}
/// Click on a difficulty tier chip — persist `last_difficulty`, fire
/// `StartDifficultyRequestEvent`, and close the Home modal.
#[allow(clippy::too_many_arguments)]
fn handle_home_difficulty_chip_click(
mut commands: Commands,
chips: Query<(&Interaction, &HomeDifficultyChip), Changed<Interaction>>,
screens: Query<Entity, With<HomeScreen>>,
mut difficulty_ev: MessageWriter<StartDifficultyRequestEvent>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
if screens.is_empty() {
return;
}
let Some((_, chip)) = chips.iter().find(|(i, _)| **i == Interaction::Pressed) else {
return;
};
let level = chip.0;
if let Some(s) = settings.as_mut() {
s.0.last_difficulty = Some(level);
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, &s.0)
{
warn!("home: failed to persist last_difficulty: {e}");
}
changed.write(SettingsChangedEvent(s.0.clone()));
}
difficulty_ev.write(StartDifficultyRequestEvent { level });
for entity in &screens {
commands.entity(entity).despawn();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped // Digit-key shortcuts (1-5) — modal-scoped
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -619,6 +748,7 @@ fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
KeyCode::Digit3 => Some(HomeMode::Zen), KeyCode::Digit3 => Some(HomeMode::Zen),
KeyCode::Digit4 => Some(HomeMode::Challenge), KeyCode::Digit4 => Some(HomeMode::Challenge),
KeyCode::Digit5 => Some(HomeMode::TimeAttack), KeyCode::Digit5 => Some(HomeMode::TimeAttack),
KeyCode::Digit6 => Some(HomeMode::PlayBySeed),
_ => None, _ => None,
} }
} }
@@ -646,6 +776,7 @@ fn handle_home_digit_keys(
mut challenge: MessageWriter<StartChallengeRequestEvent>, mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>, mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>, mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
) { ) {
// Modal-scoped: do nothing when the Mode Launcher isn't open. // Modal-scoped: do nothing when the Mode Launcher isn't open.
if screens.is_empty() { if screens.is_empty() {
@@ -658,6 +789,7 @@ fn handle_home_digit_keys(
KeyCode::Digit3, KeyCode::Digit3,
KeyCode::Digit4, KeyCode::Digit4,
KeyCode::Digit5, KeyCode::Digit5,
KeyCode::Digit6,
] ]
.into_iter() .into_iter()
.find(|k| keys.just_pressed(*k)) .find(|k| keys.just_pressed(*k))
@@ -687,6 +819,9 @@ fn handle_home_digit_keys(
HomeMode::TimeAttack => { HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent); time_attack.write(StartTimeAttackRequestEvent);
} }
HomeMode::PlayBySeed => {
play_by_seed.write(StartPlayBySeedRequestEvent);
}
} }
// Close the modal after dispatching the launch event — same shape as // Close the modal after dispatching the launch event — same shape as
@@ -717,6 +852,11 @@ struct HomeContext<'a> {
daily_today: Option<DailyToday>, daily_today: Option<DailyToday>,
draw_mode: DrawMode, draw_mode: DrawMode,
font_res: Option<&'a FontResource>, font_res: Option<&'a FontResource>,
/// Whether the difficulty section header is currently expanded.
difficulty_expanded: bool,
/// The last difficulty tier the player selected (persisted in Settings).
/// When `Some`, that tier's chip is highlighted.
last_difficulty: Option<DifficultyLevel>,
} }
/// Today's daily-challenge metadata as the Home picker needs it. Only /// Today's daily-challenge metadata as the Home picker needs it. Only
@@ -784,10 +924,13 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
HomeMode::Zen, HomeMode::Zen,
HomeMode::Challenge, HomeMode::Challenge,
HomeMode::TimeAttack, HomeMode::TimeAttack,
HomeMode::PlayBySeed,
] { ] {
spawn_mode_card(grid, mode, &ctx); spawn_mode_card(grid, mode, &ctx);
} }
}); });
spawn_difficulty_section(body, &ctx);
}); });
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
@@ -951,6 +1094,101 @@ fn spawn_draw_mode_chip<M: Component>(
}); });
} }
/// Collapsible difficulty-tier section injected below the mode tile grid.
///
/// Structure:
/// ```text
/// ▶ Difficulty ← HomeDifficultyToggle (Button, row)
/// [Easy] [Medium] [Hard] [Expert] [GM] [Random] ← visible only when expanded
/// ```
///
/// The toggle header despawns + respawns the home screen (same pattern as
/// the draw-mode toggle) so the chevron direction and chip row visibility
/// update without Visibility component surgery.
fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
let chevron = if ctx.difficulty_expanded { "" } else { "" };
// Header row — click to toggle expand/collapse.
parent
.spawn((
HomeDifficultyToggle,
Button,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
padding: UiRect::axes(Val::Px(0.0), VAL_SPACE_1),
..default()
},
))
.with_children(|row| {
row.spawn((
Text::new(chevron),
font_label.clone(),
TextColor(TEXT_SECONDARY),
));
row.spawn((
Text::new("Difficulty"),
font_label.clone(),
TextColor(TEXT_SECONDARY),
));
});
// Tier chips — only rendered when expanded.
if ctx.difficulty_expanded {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_2,
column_gap: VAL_SPACE_2,
width: Val::Percent(100.0),
..default()
})
.with_children(|row| {
for level in [
DifficultyLevel::Easy,
DifficultyLevel::Medium,
DifficultyLevel::Hard,
DifficultyLevel::Expert,
DifficultyLevel::Grandmaster,
DifficultyLevel::Random,
] {
let active = ctx.last_difficulty == Some(level);
let (bg, fg) = if active {
(ACCENT_PRIMARY, BG_ELEVATED)
} else {
(BG_ELEVATED_HI, TEXT_PRIMARY)
};
row.spawn((
HomeDifficultyChip(level),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|c| {
c.spawn((
Text::new(level.label()),
font_chip.clone(),
TextColor(fg),
));
});
}
});
}
}
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`, /// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
/// otherwise the raw number with thousands separators. Keeps chip text /// otherwise the raw number with thousands separators. Keeps chip text
/// short enough to fit a 3-up header strip without wrapping. /// short enough to fit a 3-up header strip without wrapping.
@@ -999,6 +1237,7 @@ fn home_mode_focus_order(mode: HomeMode) -> i32 {
HomeMode::Zen => 2, HomeMode::Zen => 2,
HomeMode::Challenge => 3, HomeMode::Challenge => 3,
HomeMode::TimeAttack => 4, HomeMode::TimeAttack => 4,
HomeMode::PlayBySeed => 5,
} }
} }
@@ -1402,13 +1641,14 @@ mod tests {
HomeMode::Zen, HomeMode::Zen,
HomeMode::Challenge, HomeMode::Challenge,
HomeMode::TimeAttack, HomeMode::TimeAttack,
HomeMode::PlayBySeed,
] { ] {
assert!( assert!(
modes.contains(&expected), modes.contains(&expected),
"missing card for {expected:?}; found {modes:?}" "missing card for {expected:?}; found {modes:?}"
); );
} }
assert_eq!(modes.len(), 5, "exactly five cards expected"); assert_eq!(modes.len(), 6, "exactly six cards expected");
} }
#[test] #[test]
@@ -1600,7 +1840,7 @@ mod tests {
.map(|(c, f)| (c.0, *f)) .map(|(c, f)| (c.0, *f))
.collect(); .collect();
assert_eq!(cards.len(), 5, "all five cards must carry a Focusable"); assert_eq!(cards.len(), 6, "all six cards must carry a Focusable");
for (mode, focusable) in &cards { for (mode, focusable) in &cards {
assert_eq!( assert_eq!(
focusable.group, focusable.group,
@@ -1626,7 +1866,7 @@ mod tests {
for (mode, disabled) in states { for (mode, disabled) in states {
match mode { match mode {
HomeMode::Classic | HomeMode::Daily => assert!( HomeMode::Classic | HomeMode::Daily | HomeMode::PlayBySeed => assert!(
!disabled, !disabled,
"{mode:?} must not be Disabled at level 0 (it's never locked)" "{mode:?} must not be Disabled at level 0 (it's never locked)"
), ),
+1
View File
@@ -1741,6 +1741,7 @@ fn update_hud(
GameMode::Zen => "ZEN".to_string(), GameMode::Zen => "ZEN".to_string(),
GameMode::Challenge => "CHALLENGE".to_string(), GameMode::Challenge => "CHALLENGE".to_string(),
GameMode::TimeAttack => "TIME ATTACK".to_string(), GameMode::TimeAttack => "TIME ATTACK".to_string(),
GameMode::Difficulty(level) => level.label().to_uppercase(),
}; };
} }
+142 -4
View File
@@ -23,7 +23,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches};
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::math::{Vec2, Vec3}; use bevy::math::{Vec2, Vec3};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{MonitorSelection, PrimaryWindow, WindowMode}; use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::card::{Card, Suit}; use solitaire_core::card::{Card, Suit};
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
@@ -105,12 +107,16 @@ impl Plugin for InputPlugin {
// Touch drag pipeline (parallel path through DragState). // Touch drag pipeline (parallel path through DragState).
touch_start_drag, touch_start_drag,
touch_follow_drag, touch_follow_drag,
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
touch_end_drag.before(GameMutation), touch_end_drag.before(GameMutation),
) )
.chain(), .chain(),
) )
.add_systems(Update, handle_fullscreen) .add_systems(Update, reset_hint_cycle_on_state_change);
.add_systems(Update, reset_hint_cycle_on_state_change) // F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
#[cfg(not(target_os = "android"))]
app.add_systems(Update, handle_fullscreen);
app
// Async hint pipeline: state-change drop runs before the // Async hint pipeline: state-change drop runs before the
// poll system so a move applied this frame cancels any // poll system so a move applied this frame cancels any
// in-flight task before its result can be surfaced. // in-flight task before its result can be surfaced.
@@ -423,6 +429,7 @@ fn reset_hint_cycle_on_state_change(
/// `F11` toggles between borderless-fullscreen and windowed mode. /// `F11` toggles between borderless-fullscreen and windowed mode.
/// Not gated by the pause flag — the player can always resize the window. /// Not gated by the pause flag — the player can always resize the window.
#[cfg(not(target_os = "android"))]
fn handle_fullscreen( fn handle_fullscreen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>, mut windows: Query<&mut Window, With<PrimaryWindow>>,
@@ -1204,12 +1211,16 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #27 — Double-click to auto-move // Task #27 — Double-click / double-tap to auto-move
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Maximum seconds between two clicks to count as a double-click. /// Maximum seconds between two clicks to count as a double-click.
const DOUBLE_CLICK_WINDOW: f32 = 0.35; const DOUBLE_CLICK_WINDOW: f32 = 0.35;
/// Maximum seconds between two taps to count as a double-tap.
/// Slightly wider than the mouse window — touch screens have higher latency.
const DOUBLE_TAP_WINDOW: f32 = 0.5;
/// Find the best legal destination for `card` — Foundation first, then Tableau. /// Find the best legal destination for `card` — Foundation first, then Tableau.
/// ///
/// Returns `None` if no legal move exists from the card's current location. /// Returns `None` if no legal move exists from the card's current location.
@@ -1363,6 +1374,124 @@ fn handle_double_click(
} }
} }
// ---------------------------------------------------------------------------
// Task #27b — Double-tap to auto-move (touch equivalent of double-click)
// ---------------------------------------------------------------------------
/// System that detects double-taps on face-up cards and fires `MoveRequestEvent`
/// to the best legal destination — the touch equivalent of [`handle_double_click`].
///
/// Must run **before** `touch_end_drag` in the system chain. At
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
/// are cleared and the tap/drag distinction is permanently lost.
///
/// A pure tap is identified by `drag.active_touch_id.is_some() &&
/// !drag.committed`: the touch began (so `touch_start_drag` populated
/// `drag`) but the drag threshold was never crossed.
///
/// Move priority matches [`handle_double_click`]:
/// 1. Move the single top card to its best foundation (or tableau).
/// 2. If no single-card move exists and the selection spans multiple
/// face-up cards, move the whole stack to the best tableau column.
/// 3. If both priorities fail, fire `MoveRejectedEvent` for audio + shake
/// feedback.
#[allow(clippy::too_many_arguments)]
fn handle_double_tap(
mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>,
time: Res<Time>,
drag: Res<DragState>,
game: Res<GameStateResource>,
mut last_tap: Local<HashMap<u32, f32>>,
mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Only active when a touch is tracked and hasn't crossed the drag threshold.
let Some(active_id) = drag.active_touch_id else { return };
if drag.committed {
return;
}
for event in touch_events.read() {
if event.id != active_id {
continue;
}
match event.phase {
TouchPhase::Canceled => {
// Cancelled touch — clear any pending tap state for these cards.
for &id in &drag.cards {
last_tap.remove(&id);
}
return;
}
TouchPhase::Ended => {}
_ => continue,
}
// Uncommitted touch ended = pure tap.
let Some(&top_card_id) = drag.cards.last() else { return };
let Some(ref pile) = drag.origin_pile else { return };
let Some(pile_cards) = game.0.piles.get(pile) else { return };
let Some(top_card) = pile_cards.cards.iter().find(|c| c.id == top_card_id) else {
return;
};
if !top_card.face_up {
return;
}
let now = time.elapsed_secs();
let prev = last_tap.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
if now - prev <= DOUBLE_TAP_WINDOW {
last_tap.remove(&top_card_id);
// Priority 1: move single top card.
if let Some(dest) = best_destination(top_card, &game.0) {
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count: 1,
});
return;
}
// Priority 2: move whole face-up stack to best tableau column.
if drag.cards.len() > 1 {
let stack_index = pile_cards.cards.len() - drag.cards.len();
if let Some(bottom_card) = pile_cards.cards.get(stack_index)
&& let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card,
pile,
&game.0,
drag.cards.len(),
)
{
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count,
});
return;
}
}
rejected.write(MoveRejectedEvent {
from: pile.clone(),
to: pile.clone(),
count: drag.cards.len(),
});
} else {
last_tap.insert(top_card_id, now);
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #28 — Hint system helpers // Task #28 — Hint system helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2215,5 +2344,14 @@ mod tests {
"pressing H must spawn an async hint task", "pressing H must spawn an async hint task",
); );
} }
// Task #27b — double-tap constants
#[test]
fn double_tap_window_is_wider_than_double_click_window() {
// Compile-time check: touch needs a wider window than mouse due to
// higher input latency. `const { assert! }` catches regressions at
// build time rather than waiting for a test run.
const { assert!(DOUBLE_TAP_WINDOW > DOUBLE_CLICK_WINDOW) }
}
} }
+11 -4
View File
@@ -1,5 +1,7 @@
//! Bevy integration layer for Solitaire Quest. //! Bevy integration layer for Solitaire Quest.
#[cfg(target_os = "android")]
pub mod android_clipboard;
pub mod assets; pub mod assets;
pub mod card_animation; pub mod card_animation;
pub mod achievement_plugin; pub mod achievement_plugin;
@@ -12,6 +14,7 @@ pub mod feedback_anim_plugin;
pub mod challenge_plugin; pub mod challenge_plugin;
pub mod cursor_plugin; pub mod cursor_plugin;
pub mod daily_challenge_plugin; pub mod daily_challenge_plugin;
pub mod difficulty_plugin;
pub mod diagnostics_hud; pub mod diagnostics_hud;
pub mod events; pub mod events;
pub mod game_plugin; pub mod game_plugin;
@@ -24,6 +27,7 @@ pub mod layout;
pub mod onboarding_plugin; pub mod onboarding_plugin;
pub mod pause_plugin; pub mod pause_plugin;
pub mod pending_hint; pub mod pending_hint;
pub mod play_by_seed_plugin;
pub mod profile_plugin; pub mod profile_plugin;
pub mod radial_menu; pub mod radial_menu;
pub mod replay_overlay; pub mod replay_overlay;
@@ -92,11 +96,14 @@ pub use events::{
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent, ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent,
WinStreakMilestoneEvent, XpAwardedEvent,
}; };
pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin};
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
pub use game_plugin::{ pub use game_plugin::{
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay, ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
ReplayPath, ReplayPath,
+663
View File
@@ -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");
}
}
+1 -4
View File
@@ -3986,9 +3986,6 @@ mod tests {
/// silently flip the intended stacking. /// silently flip the intended stacking.
#[test] #[test]
fn dim_layer_z_is_below_replay_chrome() { fn dim_layer_z_is_below_replay_chrome() {
assert!( const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
Z_REPLAY_DIM < Z_REPLAY_OVERLAY,
"dim layer (z={Z_REPLAY_DIM}) must be below replay chrome (z={Z_REPLAY_OVERLAY})",
);
} }
} }
+273 -35
View File
@@ -29,12 +29,13 @@ use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible, ModalButton, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL,
}; };
/// Bevy resource wrapping the current stats. /// Bevy resource wrapping the current stats.
@@ -121,6 +122,13 @@ pub struct ReplayNextButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplaySelectorCaption; pub struct ReplaySelectorCaption;
/// Marker on the detail text node that shows the selected replay's
/// `"{duration} win on {date}"` + optional `"· Shareable"` badge.
/// Repainted by `repaint_replay_selector_detail` whenever the
/// selection or history changes.
#[derive(Component, Debug)]
pub struct ReplaySelectorDetail;
/// Marker component on each per-mode bests row in the stats overlay. /// Marker component on each per-mode bests row in the stats overlay.
/// ///
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic, /// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
@@ -223,7 +231,12 @@ impl Plugin for StatsPlugin {
.add_systems(Update, handle_copy_share_link_button) .add_systems(Update, handle_copy_share_link_button)
.add_systems( .add_systems(
Update, Update,
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(), (
handle_replay_selector_buttons,
repaint_replay_selector_caption,
repaint_replay_selector_detail,
)
.chain(),
) )
.add_systems(Update, scroll_stats_panel); .add_systems(Update, scroll_stats_panel);
} }
@@ -348,9 +361,13 @@ fn handle_copy_share_link_button(
} }
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
toast.write(InfoToastEvent(format!( match crate::android_clipboard::set_text(&url) {
"Share link: {url}" Ok(()) => { toast.write(InfoToastEvent(format!("Copied: {url}"))); }
))); Err(e) => {
warn!("android clipboard failed: {e}");
toast.write(InfoToastEvent(format!("Share link: {url}")));
}
}
} }
} }
@@ -439,6 +456,39 @@ fn repaint_replay_selector_caption(
} }
} }
/// Repaints the `ReplaySelectorDetail` text node whenever the
/// selection or history changes. Shows `"{duration} win on {date}"` for
/// the selected replay, with a `"· Shareable"` badge when the replay
/// carries a sync-uploaded share URL. Empty when the history is empty.
fn repaint_replay_selector_detail(
history: Res<ReplayHistoryResource>,
selected: Res<SelectedReplayIndex>,
mut q: Query<&mut Text, With<ReplaySelectorDetail>>,
) {
if !history.is_changed() && !selected.is_changed() {
return;
}
let label = replay_selector_detail(&history.0.replays, selected.0);
for mut text in &mut q {
**text = label.clone();
}
}
/// Pure helper: render the detail line for the selected replay. Returns
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
/// when a share URL is present. Empty when the history slice is empty.
pub fn replay_selector_detail(replays: &[solitaire_data::Replay], index: usize) -> String {
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
return String::new();
};
let base = format_replay_caption(r);
if r.share_url.is_some() {
format!("{base} \u{2022} Shareable") // ·
} else {
base
}
}
/// Pure helper: render the selector caption shown next to the Prev / /// Pure helper: render the selector caption shown next to the Prev /
/// Next chips. Returns `"No replays"` when the history is empty, /// Next chips. Returns `"No replays"` when the history is empty,
/// otherwise `"Replay {1-based index} / {total}"`. /// otherwise `"Replay {1-based index} / {total}"`.
@@ -618,14 +668,14 @@ fn toggle_stats_screen(
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else {
let selected = latest_replay.0.replays.get(selected_index.0);
spawn_stats_screen( spawn_stats_screen(
&mut commands, &mut commands,
&stats.0, &stats.0,
progress.as_deref().map(|p| &p.0), progress.as_deref().map(|p| &p.0),
time_attack.as_deref(), time_attack.as_deref(),
font_res.as_deref(), font_res.as_deref(),
selected, &latest_replay.0.replays,
selected_index.0,
); );
} }
} }
@@ -651,7 +701,8 @@ fn spawn_stats_screen(
progress: Option<&PlayerProgress>, progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>, time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
latest_replay: Option<&Replay>, replays: &[Replay],
selected_index: usize,
) { ) {
// --- primary stat cells --- // --- primary stat cells ---
// First-launch zero-state: when no games have been played yet, render // First-launch zero-state: when no games have been played yet, render
@@ -859,31 +910,84 @@ fn spawn_stats_screen(
)); ));
} }
// --- Latest replay caption --- // --- Replay selector ---
// Surfaces the most recent winning game so the player can spot // Prev / Next chips step through the full replay history;
// whether their last victory has been recorded. The Watch // `repaint_replay_selector_caption` and
// Replay action below is what the player clicks to revisit it. // `repaint_replay_selector_detail` keep both text nodes
// // live as the selection changes. Using `ModalButton` on
// When the displayed replay carries a `share_url` (uploaded // the chips plugs them into the existing modal-button
// to a sync server, persisted by v0.19.0's share-link // hover/press paint loop at no extra cost.
// contract), append a "Shareable" badge so the player can body.spawn(Node {
// tell at a glance whether the Copy share link button below flex_direction: FlexDirection::Row,
// will produce a URL — without it the button surfaces a align_items: AlignItems::Center,
// toast explaining why nothing was copied, which is more column_gap: VAL_SPACE_3,
// friction than necessary when a quick visual cue suffices. ..default()
let replay_caption = match latest_replay { })
Some(r) => { .with_children(|row| {
let base = format!("Latest win: {}", format_replay_caption(r)); // ← Prev chip
if r.share_url.is_some() { row.spawn((
format!("{base} \u{2022} Shareable") ReplayPrevButton,
} else { ModalButton(ButtonVariant::Secondary),
base Button,
} Node {
} padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
None => "No replay recorded yet \u{2014} win a game first.".to_string(), justify_content: JustifyContent::Center,
}; align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((
Text::new("\u{2190}"),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
});
// "Replay N / M" caption — rewritten live by
// `repaint_replay_selector_caption`.
row.spawn((
ReplaySelectorCaption,
Text::new(replay_selector_caption(selected_index, replays.len())),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
// → Next chip
row.spawn((
ReplayNextButton,
ModalButton(ButtonVariant::Secondary),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((
Text::new("\u{2192}"),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
});
});
// Detail line: rewritten live by `repaint_replay_selector_detail`.
body.spawn(( body.spawn((
Text::new(replay_caption), ReplaySelectorDetail,
Text::new(replay_selector_detail(replays, selected_index)),
font_row.clone(), font_row.clone(),
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
@@ -1670,6 +1774,140 @@ mod tests {
); );
} }
// -----------------------------------------------------------------------
// Prev/Next replay selector spawn-site tests
// -----------------------------------------------------------------------
#[test]
fn selector_row_spawns_when_stats_screen_opens() {
let mut app = headless_app();
// Pre-populate a replay so the selector has something to show.
{
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(90, None));
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let prev = app
.world_mut()
.query::<&ReplayPrevButton>()
.iter(app.world())
.count();
let next = app
.world_mut()
.query::<&ReplayNextButton>()
.iter(app.world())
.count();
let caption = app
.world_mut()
.query::<&ReplaySelectorCaption>()
.iter(app.world())
.count();
let detail = app
.world_mut()
.query::<&ReplaySelectorDetail>()
.iter(app.world())
.count();
assert_eq!(prev, 1, "expected one ReplayPrevButton");
assert_eq!(next, 1, "expected one ReplayNextButton");
assert_eq!(caption, 1, "expected one ReplaySelectorCaption");
assert_eq!(detail, 1, "expected one ReplaySelectorDetail");
}
#[test]
fn selector_caption_initial_text_is_replay_one_of_one() {
let mut app = headless_app();
{
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(120, None));
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplaySelectorCaption>>();
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
assert_eq!(texts.len(), 1);
assert_eq!(
texts[0],
"Replay 1 / 1",
"caption must show '1 / 1' for a single-replay history"
);
}
#[test]
fn selector_detail_initial_text_matches_replay_caption() {
let mut app = headless_app();
{
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplaySelectorDetail>>();
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
assert_eq!(texts.len(), 1);
assert_eq!(
texts[0], "1:05 win on 2026-05-08",
"detail must show formatted replay caption for the selected replay"
);
}
#[test]
fn selector_detail_appends_shareable_badge_when_url_present() {
// `replay_selector_detail` is pure — no app setup needed.
let replays = vec![make_test_replay(
90,
Some("https://example.com/r/abc".to_string()),
)];
let label = replay_selector_detail(&replays, 0);
assert!(
label.contains("Shareable"),
"detail must include 'Shareable' badge when share_url is set, got: {label:?}"
);
}
#[test]
fn selector_caption_shows_no_replays_when_history_is_empty() {
assert_eq!(replay_selector_caption(0, 0), "No replays");
}
#[test]
fn selector_caption_wraps_ordinal_correctly() {
// index 2 (0-based) in a 3-replay history → "Replay 3 / 3"
assert_eq!(replay_selector_caption(2, 3), "Replay 3 / 3");
}
/// Build a minimal [`Replay`] for use in stats-plugin unit tests.
///
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
/// `time_seconds` and `share_url` are the only varying fields across tests.
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
let mut r = solitaire_data::Replay::new(
1,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
time_seconds,
0,
date,
vec![],
);
r.share_url = share_url;
r
}
/// Integration: pre-set streak to 10, fire a win that bumps it to 11. /// Integration: pre-set streak to 10, fire a win that bumps it to 11.
/// Past the highest threshold, no event must fire — the flourish /// Past the highest threshold, no event must fire — the flourish
/// is reserved for the threshold crossing itself. /// is reserved for the threshold crossing itself.
+2 -1
View File
@@ -352,7 +352,7 @@ impl ScoreBreakdown {
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 }; let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
let multiplier = match mode { let multiplier = match mode {
GameMode::Zen => 0.0, GameMode::Zen => 0.0,
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack => 1.0, GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0,
}; };
Self { Self {
base, base,
@@ -423,6 +423,7 @@ fn mode_display_name(mode: GameMode) -> &'static str {
GameMode::Zen => "Zen", GameMode::Zen => "Zen",
GameMode::Challenge => "Challenge", GameMode::Challenge => "Challenge",
GameMode::TimeAttack => "Time Attack", GameMode::TimeAttack => "Time Attack",
GameMode::Difficulty(level) => level.label(),
} }
} }