Compare commits

..

84 Commits

Author SHA1 Message Date
funman300 e107f5e218 fix(android): 48dp min hit targets on action buttons
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
Action buttons sized to text + 8 px padding made "Undo" end up
~46 x 33 px — fine for a mouse but at the threshold of a finger.
Adds `min_width: 48 px` and `min_height: 48 px` to the button
Node so every button meets Material's 48 dp thumb-target guideline.

Applied universally; the floor is a no-op for buttons whose
content already exceeds 48 px on either axis (Menu, Modes,
New Game, Pause, Help all clear 48 px wide; height was the
binding constraint at ~33 px).

Closes P1 #2 of docs/android/PLAYABILITY_TODO.md. Engine tests
pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:53:40 -07:00
funman300 463b7465ed fix(android): hide keyboard-hint chips on action buttons
The U / Esc / F1 / N caption chips next to the HUD action buttons
are meaningless on a touch device and visibly clutter the
narrow-viewport action row (visible as "Esc A [] N" in the v0.22.3
screenshot). `spawn_action_button` now rebinds `hotkey` to `None`
under `#[cfg(target_os = "android")]` so the chip-spawn branch is
skipped on touch builds.

Menu / Modes chevrons are unaffected — they indicate dropdown
behaviour and still apply on touch. Other hint surfaces
(onboarding, pause modal Esc hint, mode-card chips, replay
footer, modal toggle chips, help screen) live behind navigation
and are tracked as a P3 sweep in PLAYABILITY_TODO.md.

Closes P1 #1 of docs/android/PLAYABILITY_TODO.md. 855 engine
tests pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:52:12 -07:00
funman300 92a5ebb15e fix(android): lower MIN_WINDOW floor so phone viewports lay out correctly
`compute_layout` runs `window.max(MIN_WINDOW)`, which acts as a
component-wise floor: any window smaller than MIN_WINDOW on either
axis gets clamped up. The previous floor of 800x600 was set with
desktop in mind, but on Android the OS-provided window size is the
device resolution (~360 dp wide on a typical phone) and the clamp
silently re-laid the board for an 800 dp width.

Side effect: total grid width (9 * card_width) became ~800 px on a
360 dp viewport, so the leftmost foundation x-position fell past
-180 and the rightmost tableau pile past +180 — both clipped at
the visible edges, matching the v0.22.3 hardware screenshot.

Lowered MIN_WINDOW to 320x400, below the smallest reasonable phone
(~360x640), so every real device flows through compute_layout
unclamped. The floor is preserved as a sentinel against degenerate
windows (Bevy can briefly report 0-size during startup or after
minimisation on some compositors). Desktop's "minimum supported
playable size" is enforced separately via WindowResizeConstraints
in solitaire_app.

Updates `layout_below_minimum_clamps_to_minimum` to use values
below the new floor, and adds a new regression test
`phone_portrait_layout_fits_horizontally` that asserts all 13
piles fit inside a 360 x 800 dp viewport.

Closes P0 #4 of docs/android/PLAYABILITY_TODO.md. 855 engine tests
pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:48:56 -07:00
funman300 89a21c0587 fix(android): wrap HUD column and action button row on narrow viewport
The v0.22.3 hardware screenshot showed the 6-button action row
(~510 px when laid out) overflowing into a 360 dp viewport from
the right anchor, with Menu and Undo clipped off-screen left and
Pause/Help/Modes/New_Game overlapping the left HUD column's
Score / Moves / Timer text.

Cap both clusters at `max_width: 50 %` so on mobile each takes
half the viewport (~180 px) and on desktop the cap is wider than
either cluster's natural width so the existing single-line
layout is preserved.

- Action button row: adds `flex_wrap: Wrap`, `row_gap`, and
  `justify_content: FlexEnd` so the row breaks to multiple
  right-aligned lines instead of clipping. 6 buttons become 2-3
  lines of 2-3 buttons.
- HUD column tier rows: add `flex_wrap: Wrap` and `row_gap` to
  the shared `row_node` helper so a long Mode/Challenge/Draw-cycle
  combo soft-wraps onto two lines instead of pushing into the
  action button column.

Closes P0 #2 of docs/android/PLAYABILITY_TODO.md. All 854 engine
tests pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:45:41 -07:00
funman300 304cb050a7 docs(android): close P0 safe-area + card-back items in playability TODO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:41:28 -07:00
funman300 fcc7337c97 fix(android): gate AssetPlugin file_path override to desktop only
`AssetPlugin::file_path = "../assets"` was set unconditionally to
make `cargo run -p solitaire_app` find the workspace-root assets
directory from inside `solitaire_app/`. On Android cargo-apk packages
the same directory into the APK at `assets/`, and Bevy's
AndroidAssetReader is already rooted there — prepending `../` walked
the reader out of the APK assets root and every load failed silently.

The cascade: CardImageSet handles were inserted but pointed at
non-existent paths, so `card_sprite` saw `Some(set)` but the textures
never resolved. The face-down branch then rendered with `Color::WHITE`
over a missing texture — which on hardware showed as the
`card_back_colour(0)` solid-red brick fallback that's *supposed* to
only fire under MinimalPlugins in tests.

Gates the `file_path` override behind
`#[cfg(not(target_os = "android"))]` so Android picks up the default
empty path. Closes P0 #3 of docs/android/PLAYABILITY_TODO.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:41:06 -07:00
funman300 16ce2b88d2 chore: gitignore keystores and refresh Cargo.lock
Adds *.jks / *.jks.bak / *.keystore to .gitignore so the
release signing material can never be committed accidentally.

Cargo.lock drift catches up with 7c07f71 (bevy dep added to
solitaire_data for Android target) — the prior commit edited
solitaire_data/Cargo.toml but didn't regenerate the lockfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:37:11 -07:00
funman300 b9aa2620b8 feat(android): safe-area insets for HUD positioning
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.

UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.

Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).

Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:37:06 -07:00
funman300 47f02a60ae docs(android): add screenshot-driven playability TODO
Captures the gap between "boots without crashing" (v0.22.3 status)
and "actually playable on a phone." Tracks P0-P4 work items grouped
by impact: safe-area, HUD layout, card-back rendering, viewport
overflow, touch UX, density.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:36:33 -07:00
funman300 a5c3188686 ci(release): scope cargo apk build to --lib to avoid post-sign panic
Release / Build · Android APK (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
Release / Build · Linux x86_64 (push) Has been cancelled
cargo-apk panics with "Bin is not compatible with Cdylib" after
successfully signing the APK, when cargo-subcommand's artifact
iterator walks the bin target after the cdylib has been produced.
The APK file survives the panic on disk, but the non-zero exit
fails the workflow step before the upload runs.

Passing --lib scopes the build to the cdylib target only.
SESSION_HANDOFF.md documented this as the canonical workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:18:46 -07:00
funman300 6a289b7b50 ci: bump GitHub Actions to v5 for Node 24 compatibility
actions/checkout, actions/cache, actions/upload-artifact, and
actions/download-artifact bumped from v4 to v5 across both
ci.yml and release.yml. Pre-empts the 2026-06-02 Node 20
deprecation deadline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:18:35 -07:00
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
funman300 c50eaf81f7 feat(replay): add HC bump for WIN MOVE scrub-bar marker; extend HighContrastBackground
HighContrastBackground gains an optional hc_color field so sites can
specify a domain-specific HC variant rather than always bumping to
BORDER_SUBTLE_HC (gray). with_default() fills hc_color = BORDER_SUBTLE_HC
preserving all existing behaviour; new with_hc(default, hc) lets callers
specify both ends. update_high_contrast_backgrounds reads marker.hc_color
instead of the hardcoded constant.

STATE_SUCCESS_HC (#c8e862, L≈0.73) added to ui_theme — a brighter lime
that maintains the success hue while standing out from bumped notch
ticks (BORDER_SUBTLE_HC gray, L≈0.60) under HC mode.

WIN MOVE marker now carries HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC): lime stays lime under HC instead of turning gray.
Unit test pins both the default and hc color fields on the spawned marker.

1276 tests pass / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:19:00 -07:00
funman300 b44d2777ec fix(replay): centre scrub-bar notch labels on their notch ticks
The three middle scrub-bar labels (25%, 50%, 75%) previously had their
left edge anchored at the notch percentage, making them read as
"starting after" the notch. Apply the CSS translateX(-50%) pattern for
Bevy 0.18 UI: give each middle label a fixed-width container
(SCRUB_LABEL_CENTER_WIDTH = 36px), offset the container's left edge by
-width/2 via margin.left, and add Justify::Center so the text renders
centred within the container. The container's centre then coincides with
the notch line at the chosen percentage.

Endpoints (0%, 100%) keep their flush-left / flush-right anchoring
unchanged. 1275 tests pass / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:14:14 -07:00
funman300 52407e7256 docs(handoff): cut v0.21.7 — B-2 replay arc closed; dim layer ships
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:03:32 -07:00
funman300 da3e5423dc feat(replay): add full-screen tableau dim layer for mini-tableau preview
Spawn a `ReplayTableauDimLayer` UI node (100% × 100%, 50% opacity black)
at z=54 (Z_REPLAY_OVERLAY − 1) whenever a replay starts. The dim layer
darkens the entire card world so the replay chrome (banner at z=55,
move-log panel at z=55) reads clearly against the scene without
obscuring card positions — matching the mockup's "Game Peek Band at
50% opacity" spec. Bevy's UI/world compositor means no changes to
card_plugin are needed: UI nodes always render above world-space sprites
regardless of Transform.z values.

The dim layer carries no Interaction component (purely visual; pointer
events pass through). Despawned alongside the banner and move-log panel
in `react_to_state_change` when the replay ends.

Adds Z_REPLAY_DIM (= 54) and TABLEAU_DIM_ALPHA (= 0.5) constants plus
two new tests: lifecycle (spawn/despawn mirrors floating chip pattern)
and z-ordering invariant (Z_REPLAY_DIM < Z_REPLAY_OVERLAY pinned).

1275 tests pass / 0 failing. Closes the last major B-2 sub-piece.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:01:22 -07:00
funman300 a1864271de docs(handoff): refresh post-v0.21.6 — anchor to new tag, reset menu state
Fold the six post-v0.21.5 commit narratives into CHANGELOG §
[0.21.6] (now the source of truth for that release's scope).
Reset the Since-cut log to "no threads in flight." Update
status (HEAD f63db76, tags through v0.21.6, tests 1273
passing). Resume prompt now anchors at v0.21.6.

The post-cut menu's main item is now the mini-tableau preview
— the only major B-2 sub-piece left after Move Log panel
shipped. Architectural change (touches card_plugin rendering),
best tackled in a fresh session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:48:51 -07:00
funman300 f63db769ae docs: cut v0.21.6 — Move Log panel + scrub-UX polish
Patch release rolling up six post-v0.21.5 commits under the
through-line "Move Log panel + scrub-UX polish":

- d3cb1a5: HC-mode coverage for scrub track + notches
- 2e25476: continuous scrub on key-held ← / → at 100ms cadence
- d6f32d3: Move Log panel + active row (header + format helpers)
- 140251b: 2 prev rows above active
- e7345ae: active-row highlight with ACCENT_PRIMARY background
- 4437a1a: 2 next rows below active

The Move Log panel is the first replay-overlay surface that
isn't attached to the banner — it lives at a separate screen
anchor (bottom: 0) with its own spawn/despawn lifecycle.
Establishes the multi-anchor replay UI pattern that the
remaining B-2 sub-piece (mini-tableau preview) will inherit.

Panel grows 56 → 84 → 112 px across the four move-log commits.
HighContrastBackground primitive lifted to ui_theme parallel
to HighContrastBorder; settings_plugin gains
update_high_contrast_backgrounds for the BackgroundColor
repaint cycle. Continuous scrub uses a per-key accumulator
resource (ReplayScrubKeyHold) gated on SCRUB_REPEAT_INTERVAL_SECS
(0.1s).

Tests: 1250 → 1273 (+23 net new). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:46:24 -07:00
funman300 4437a1aaf9 feat(replay): add 2 next rows below active row in Move Log panel
Symmetric to the prev-rows commit. Adds 2 about-to-apply move
rows below the active row so the panel now shows a full 5-row
window: prev offset 2 → prev offset 1 → active → next offset 1
→ next offset 2. Panel grows from 84 → 112 px to fit the
additional rows.

Format helper `format_kth_next_row(state, k)` returns the kth
about-to-apply move's text:
- k=1 → moves[cursor], displayed as "{cursor + 1} │ {body}"
- k=2 → moves[cursor + 1], displayed as "{cursor + 2} │ ..."
- Returns empty when cursor + k - 1 >= moves.len() (under-fill
  late in the replay) or k=0 (degenerate).

Symmetric implementation:
- New `ReplayOverlayMoveLogNextRow { offset: u8 }` component
- Spawn loop iterates 1..=MOVE_LOG_NEXT_ROWS in order so offset
  1 sits directly below active, offset 2 below that
- Per-frame `update_move_log_next_rows` system mirrors the
  prev-rows updater
- TEXT_SECONDARY (matching prev rows) keeps the active row's
  highlight as the focal point

For post-game replays the next rows aren't spoilers (the game
is already won). If a future use case reuses the panel during
live play, the preview-shape would need rethinking.

4 new tests:
- format_kth_next_row: k=1, 2 in-range cases + k beyond
  moves.len() out-of-range + k=0 degenerate.
- move_log_next_rows_spawn_with_panel: cardinality matches
  MOVE_LOG_NEXT_ROWS.
- move_log_next_rows_paint_helper_strings_at_spawn: text
  matches helper output per offset.
- move_log_next_rows_underfill_at_replay_end: offset 1
  populates at cursor=9/10, offset 2 stays empty.

Tests: 1269 → 1273. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:44:59 -07:00
funman300 e7345aed6c feat(replay): highlight active row in Move Log panel
Wraps the active-row Text in a Node with
BackgroundColor(ACCENT_PRIMARY) so the row reads as "current
focus" against the panel's elevated background. Inner Text
colour bumps from TEXT_PRIMARY (#d0d0d0) to TEXT_PRIMARY_HC
(#f5f5f5) for legible contrast against the brick-red highlight.

format_active_move_row now prefixes the row with `▶` (the focus
marker) so the visual hierarchy is reinforced even before the
background paints (HC mode, future palette tweaks). The empty
case still returns empty — cursor=0 doesn't paint a stray "▶ "
prefix on an otherwise-empty row.

Mirrors the mockup at docs/ui-mockups/replay-overlay-mobile.html
§ "Move Log Card" where the active row has bg-suit-red-cb
(brick-red equivalent) + dark text + the ▶ marker.

3 new tests:
- active_row_wrapper_carries_accent_primary_background: walks
  from the active-row Text to its parent Node and asserts the
  wrapper carries BackgroundColor(ACCENT_PRIMARY).
- active_row_text_uses_high_contrast_color_for_highlight: pins
  the TextColor as TEXT_PRIMARY_HC.
- active_row_format_includes_focus_prefix: pure-helper guard for
  the ▶ prefix + the cursor=0-stays-empty contract.

Plus 2 existing tests updated for the new prefixed format
(format_active_move_row_handles_cursor_zero_and_positive,
move_log_active_row_repaints_on_cursor_advance).

Tests: 1266 → 1269 (+3 net new, +2 updated). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:41:14 -07:00
funman300 140251beae feat(replay): add 2 prev rows above active row in Move Log panel
Extends the Move Log panel's single active-row to a 3-row recent-
history window: 2 prev rows showing the moves applied just before
the active one, then the active row. Display order top-to-bottom:
header → prev offset 2 (oldest) → prev offset 1 → active.

Panel grows from 56 → 84 px to fit the additional rows. Active
row keeps TEXT_PRIMARY; prev rows render in TEXT_SECONDARY so
the active row stands out from context rows even without an
explicit highlight. (Active-row highlight is a follow-up commit.)

The format helper generalises:
- New `format_kth_recent_row(state, k)` returns the text for the
  kth-most-recently-applied move (k=1 is active, k=2 is row above,
  etc.). Returns empty when k > cursor (early-replay under-fill)
  or k = 0 (degenerate).
- `format_active_move_row` becomes a thin wrapper for k=1, kept
  at module scope so call sites stay readable.

New `ReplayOverlayMoveLogPrevRow { offset: u8 }` component carries
the row's offset (1 = just-before-active, 2 = before that). Spawn
loop iterates `MOVE_LOG_PREV_ROWS..=1` in reverse so the highest-
offset (oldest) row sits topmost in the panel's flex column.

Per-frame `update_move_log_prev_rows` system reads each row's
offset, computes k = offset + 1, and repaints via
format_kth_recent_row. Empty-when-out-of-range means panels gracefully
under-fill at cursor=1 (only active populated) and cursor=2
(active + offset 1, offset 2 empty).

4 new tests:
- format_kth_recent_row: k=1, 2, 3 in-range cases + k>cursor
  out-of-range + k=0 degenerate.
- move_log_prev_rows_spawn_with_panel: cardinality matches the
  MOVE_LOG_PREV_ROWS const.
- move_log_prev_rows_paint_helper_strings_at_spawn: text matches
  helper output per offset.
- move_log_prev_rows_repaint_on_cursor_advance: drives cursor=2
  → cursor=5 and asserts offset 1 / offset 2 texts follow.

Tests: 1262 → 1266. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:35:07 -07:00
funman300 d6f32d3154 feat(replay): add Move Log panel with active-row readout
First slice of the move-log mockup at
docs/ui-mockups/replay-overlay-mobile.html § "Move Log Card".
Adds a separate root UI entity anchored to the viewport's bottom
edge (sibling-of-banner pattern, mirrors ReplayFloatingProgressChip
lifecycle) carrying a `▌ MOVE LOG · N/M` header plus a single row
showing the most-recently-applied move.

Subsequent commits in this multi-session arc add prev/next rows,
active-row highlight, and auto-scroll on cursor advance. Splitting
the work at "panel + active row only" lands the structural piece
(panel exists, lifecycle works, format helpers proven) before
tackling the harder questions about rendering un-applied future
moves and scrolling.

Position decision: bottom-of-viewport (matches mockup), separate
root entity from the 92 px top banner. Keeps the banner from
growing further into a top-heavy 170+ px strip; the
top-status + bottom-info paradigm reads as vim/IDE-style buffer
chrome that players intuitively scan.

Four pure helpers handle the formatting:
- format_pile(p) → lowercase, 1-indexed display string
  ("foundation 3" rather than enum's 0-indexed Foundation(2))
- format_move_body(m) → "{from} → {to}" or "stock cycle"
- format_move_log_header(state) → "▌ MOVE LOG · N/M",
  "▌ MOVE LOG · COMPLETE" for `Completed`, empty for `Inactive`
- format_active_move_row(state) → "{cursor} │ {body}" with
  1-based cursor for player display, empty at cursor=0

Two per-frame update systems (update_move_log_header,
update_move_log_active_row) repaint the texts on resource change
with the standard early-exit-on-no-change idiom.

Despawn handling: react_to_state_change gains a third query for
ReplayOverlayMoveLogPanel entities and despawns them on
Playing → Inactive alongside the banner root and floating chip.

Panel border carries HighContrastBorder so the 1 px top edge
bumps under HC mode — same pattern as the keybind footer.

8 new tests:
- format_pile pile-name + 1-index pinning
- format_move_body both-variant pinning
- format_move_log_header three-state coverage
- format_active_move_row cursor=0 vs cursor>0
- move_log_panel spawn cardinality (exactly one)
- move_log_panel header paints helper string at spawn
- move_log_active_row repaints on cursor advance
- move_log_panel despawn parity with overlay tree

Tests: 1254 → 1262. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:29:37 -07:00
funman300 8fdc41f36f docs(handoff): record post-v0.21.5 polish; recommend notch-label centering
Two carve-outs land on top of v0.21.5:
- d3cb1a5: HC-mode coverage for scrub track + notches via new
  HighContrastBackground primitive in ui_theme + paint system
  in settings_plugin.
- 2e25476: continuous scrub on key-held ← / → at 100ms cadence;
  matches mockup's "[← →] scrub" terminology while keeping
  single-press = single-step semantics.

Update Since-cut log, status (1250 → 1254 tests passing,
flake cleared), and next-step menu. B-2 keyboard accelerator
coverage + accessibility + scrub UX are all complete; remaining
options are notch-label centering polish (smallest), the
move-log/mini-tableau multi-session arcs that close B-2, or
WIN MOVE marker HC bump (optional).

Recommended next-step: notch label centering (small).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:20:51 -07:00
funman300 2e25476d0a feat(replay): continuous scrub on key-held arrow keys
Holding ← or → now triggers continuous step at 100 ms cadence
(10 steps/sec) — matches the mockup's `[← →] scrub`
terminology while keeping single-press = single-step semantics.

Implementation: per-key accumulators in a new
`ReplayScrubKeyHold` resource. Each frame the key is held, the
corresponding accumulator absorbs `time.delta_secs()`; when it
exceeds `SCRUB_REPEAT_INTERVAL_SECS` (0.1s) the handler fires
another step and resets the accumulator. `just_pressed` events
bypass the accumulator entirely and fire immediately —
release resets to 0 so the next fresh press also fires
immediately rather than at half-interval.

Symmetric handling for ← (backwards step via undo) and →
(forward step). Both keys remain paused-only via the same
destructure-gate pattern in the underlying step helpers.

Footer text unchanged (`[← →] step`) — the only-wired-keybinds
discipline says "list what works"; held-key continuous scrub
is a discoverable enhancement to the same keybind, not a new
keybind.

`handle_arrow_keyboard` gains `Res<Time>` and
`ResMut<ReplayScrubKeyHold>` parameters. `Time` is provided by
MinimalPlugins's TimePlugin so headless tests already have it.

2 new tests (in addition to the 4 existing arrow scenarios):
- arrow_right_keyboard_repeats_while_held: drives time at
  exactly SCRUB_REPEAT_INTERVAL_SECS per tick and asserts that
  a second step fires after the just_pressed one.
- arrow_keyboard_release_resets_accumulator: verifies the
  release branch zeros the per-key accumulator.

Tests: 1252 → 1254. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:19:46 -07:00
funman300 d3cb1a51d4 feat(replay): HC-mode coverage for scrub track + notches
The 1 px scrub track and 5 quarter-mark notch ticks paint their
shape via BackgroundColor (not BorderColor — they're tiny
full-bleed Nodes, not borders on wider containers), so the
existing HighContrastBorder marker doesn't apply to them.

Add a parallel primitive in ui_theme: HighContrastBackground
marker carrying default_color, mirroring HighContrastBorder's
shape exactly. Add update_high_contrast_backgrounds system in
settings_plugin alongside update_high_contrast_borders — same
on/off rule (off → marker.default_color, on → BORDER_SUBTLE_HC),
same change-suppression idiom (only mutate when different so
Bevy's change-detection doesn't trigger per-frame repaints).

Tag the scrub track Node and all five notch Nodes with
HighContrastBackground::with_default(BORDER_SUBTLE) so the
existing settings repaint cycle picks them up under HC mode.

The scrub fill (ACCENT_PRIMARY brick-red) and WIN MOVE marker
(STATE_SUCCESS lime-green) don't get the marker — accent and
state colours are already saturated and don't need an HC
luminance variant.

2 new tests: spawn-time marker presence on the track and
cardinality-matches-notch-count on the ticks.

Tests: 1250 → 1252. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:14:03 -07:00
funman300 c8358f4275 docs(handoff): refresh post-v0.21.5 — anchor to new tag, reset menu state
Fold the six post-v0.21.4 commit narratives into CHANGELOG §
[0.21.5] (now the source of truth for that release's scope).
Reset the Since-cut log to "no threads in flight." Update
status (HEAD `a2432df`, tags through v0.21.5, tests still
1250/1249 passing pending the time-dependent flake clearing).
Resume prompt now anchors at v0.21.5 with the smaller post-cut
menu of next-finite-steps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:08:56 -07:00
funman300 a2432dfe7a docs: cut v0.21.5 — replay-overlay scrubbing affordances + accessibility
Patch release rolling up six post-v0.21.4 commits under the
through-line "replay-overlay scrubbing affordances + accessibility":

- fe68861: quarter-mark scrub-bar notches
- d322abf: percentage labels under notches (banner 60 → 76 px)
- 1873b3f: keybind-hint footer (banner 76 → 92 px)
- 90e24d9: ESC accelerator + cross-plugin pause-modal gate
- 23902cd: HC-mode coverage for footer top border
- e5c4f51: ← / → keyboard accelerators for paused stepping

v0.21.4 shipped pause / resume / step + the WIN MOVE marker as
the first scrubbing-shaped additions; v0.21.5 fills out the rest
of the scrubbing UX so the player has both visual anchor points
(notches + labels) and a complete keyboard control surface
(Space / Esc / ← / →) for navigating a paused replay.

Two of the six commits are layout-changing — they grow the
banner from 60 → 76 → 92 px to make room for the notch labels
and keybind footer. Banner geometry was fixed for every prior
B-2 commit; this release establishes the "grow the container,
add a flex-column child" pattern that the remaining B-2
sub-pieces (move-log scroller, mini-tableau preview) will
inherit when they land.

Tests: 1228 → 1250 (+22 net new), 1249 passing, 1 pre-existing
time-dependent flake (daily_challenge warning, fails when UTC
clock is within 30 min of midnight; verified not introduced by
this release).

Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:05:03 -07:00
funman300 511550232c docs(handoff): record HC marker + ← / → wiring; recommend v0.21.5 cut
Two more post-v0.21.4 carve-outs land:
- 23902cd: HC-mode coverage for keybind-footer top border
  (HighContrastBorder marker so apply_high_contrast_borders
  bumps the 1 px top border under HC).
- e5c4f51: ← / → keyboard accelerators for paused stepping
  (hooks game's undo system for backwards step; footer
  extended to [SPACE] pause/resume · [ESC] stop · [← →] step).

Update Since-cut log, visual-identity bullet, B option in the
Resume menu, status (1244 → 1250 total tests / 1249 passing /
1 pre-existing flake), and HEAD hint.

Six post-v0.21.4 commits now form a coherent through-line:
replay-overlay scrubbing affordances + accessibility. Resume
menu's B option now recommends cutting v0.21.5 as the natural
next boundary.

Pre-existing flake noted: daily_challenge warning test fails
when wall-clock UTC is within 30 minutes of midnight (the
warning window the test asserts against). Verified not
introduced by recent commits via stash-and-retest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:59:08 -07:00
funman300 e5c4f51a6e feat(replay): wire ← / → keyboard accelerators for paused stepping
→ during a paused replay advances by one move (mirrors the Stop
button's existing forward-step semantics). ← decrements the
cursor and dispatches `UndoRequestEvent`, which the game's
`handle_undo` reads next frame to reverse its most-recent move
— hooking the existing undo system rather than replaying
forward from cursor 0 (every replay-applied move pushes to the
undo stack the same way a player move would, so undo is the
right reversal primitive).

Both accelerators are paused-only — backwards via a new
`step_backwards_replay_playback` in `replay_playback.rs` that
hard-gates with the same destructure pattern as
`step_replay_playback`. Pressing → during running playback or ←
at cursor 0 are silent no-ops; the player learns "pause first,
then arrow."

The mockup labels these `[← →] scrub` (continuous fast scan).
Single-move step is the closest behaviour shippable today —
continuous scrub would need either a key-held event source or
an internal speed-up loop. Footer hint reads
`[← →] step` to match what's wired rather than the aspirational
"scrub."

Footer hint extended in lockstep:
`[SPACE] pause/resume · [ESC] stop · [← →] step` — the
only-wired-keybinds discipline holds.

ReplayOverlayPlugin gains `add_message::<UndoRequestEvent>()`
defensively so the plugin can run under MinimalPlugins without
GamePlugin attached (idempotent registration; harmless when
GamePlugin is also present).

6 new tests (2 hint pins + 4 keyboard scenarios) + 1 helper-pin
update for the new hint string.

Pre-existing flake noted: `daily_challenge_plugin::tests::
check_system_fires_warning_event_only_once_per_day` is failing
because wall-clock UTC is currently within 30 minutes of
midnight, inside the daily-expiry warning window the test
asserts against. Verified pre-existing by stashing all changes
and re-running — failure persists. Same shape as the
`winnable_seed_search` flake the handoff documented earlier
this session: time-dependent, deterministically passes under
different clock conditions. Not introduced by this commit.

Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:50:59 -07:00
funman300 23902cdc44 feat(replay): HC-mode coverage for keybind-footer top border
Tag the footer's border-carrying Node with
`HighContrastBorder::with_default(BORDER_SUBTLE)` so the existing
`apply_high_contrast_borders` system bumps the 1 px top border
from `BORDER_SUBTLE` (#505050) to `BORDER_SUBTLE_HC` (#a0a0a0)
when `Settings::high_contrast_mode` is on.

Without this the footer reads as floating loose under HC because
the border that visually anchors it to the labels row above is
near-invisible at #505050 against the elevated banner background.

The footer's text colours (`TEXT_SECONDARY` on both the
mode-line and the hint) don't need an HC bump — `TEXT_SECONDARY`
is already at `#a0a0a0`, the same luminance as `BORDER_SUBTLE_HC`.
There's no `TEXT_SECONDARY_HC` constant in the palette because
secondary text is already at HC-border level by design.

The notch labels also use `TEXT_SECONDARY` and inherit the same
"already HC-bright" property — no marker needed there either.

The 1 px scrub track, notch ticks, and WIN MOVE marker render
via `BackgroundColor` (not `BorderColor`) so the
`HighContrastBorder` marker doesn't apply. HC coverage for those
decorative pieces would need a custom settings-aware paint
system (precedent: `radial_rim_outline` in `radial_menu`) and is
deferred to a follow-up commit.

1 new test pinning the marker on spawn. 1243 → 1244. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:41:49 -07:00
funman300 3cc8eacafa docs(handoff): record ESC accelerator; B's next step is HC polish
Post-v0.21.4 fourth carve-out: 90e24d9 wires ESC for replay-stop
with a cross-plugin gate in pause_plugin to defer when replay is
playing. Footer extended in lockstep to
[SPACE] pause/resume · [ESC] stop. Update Since-cut log,
visual-identity bullet, B option in the Resume menu, status
(1240 → 1243 tests), and HEAD hint.

B option's next-step menu now has three branches: HC polish
(smallest), ← / → wiring (medium, needs backwards-step path),
and the multi-session move-log/preview arcs that close B-2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:07:33 -07:00
funman300 90e24d9711 feat(replay): wire ESC accelerator for stop, gate pause modal
ESC during an active replay now stops it (mirrors the existing
Stop button click). UI-first contract from CLAUDE.md §3.3 holds
for the keyboard accelerator: every keybind the footer surfaces
points at a wired action.

Cross-plugin coordination: pause_plugin's `toggle_pause` already
listens for ESC and would otherwise open the pause modal on the
same press. Resolved by adding a fourth defer-if check to the
existing modal-stack pattern in `toggle_pause` —
`replay_state.is_some_and(|s| s.is_playing())` slots in right
after `other_modal_scrims` and before `selection`. Symmetric
shape to the existing forfeit / modal-scrim / selection /
game-over / drag gates.

Footer hint extended from `[SPACE] pause/resume` to
`[SPACE] pause/resume · [ESC] stop` in lockstep — the
"only-wired-keybinds" discipline holds.

3 new tests:
- esc_keyboard_stops_active_replay (positive: Esc → Inactive,
  overlay despawns next frame)
- esc_keyboard_is_noop_when_not_playing (negative: doesn't fire
  on Inactive state, lets global Esc listeners own those frames)
- keybind_footer_hint_lists_space_and_esc (footer text contains
  both keybinds)

Plus updated helper-pin test for the new hint string. Existing
pause_plugin tests unaffected (they don't insert a
ReplayPlaybackState resource so the new gate is a no-op for
them).

Tests: 1240 → 1243 (+3). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:06:02 -07:00
funman300 decbe0bbd9 docs(handoff): record keybind footer; B's next step is ESC accelerator
Post-v0.21.4 third carve-out: 1873b3f ships a keybind-hint footer
(vim-style mode line + `[SPACE] pause/resume`) at the bottom of
the banner (76 → 92 px). Update Since-cut log, visual-identity
bullet, B option in the Resume menu, status (1236 → 1240 tests),
and HEAD hint.

Footer lists only wired keybinds. Next finite step on B-2: wire
ESC for stop and extend the footer to `[SPACE] pause/resume ·
[ESC] stop` — small, single-axis, surfaces another keyboard
accelerator alongside the existing Stop button.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:59:57 -07:00
funman300 1873b3f9be feat(replay): add keybind-hint footer to overlay banner
Vim-style mode line on the left (`▌ NORMAL │ replay`) plus a
keybind-hint on the right (`[SPACE] pause/resume`) gives the
existing Space accelerator a visible UI counterpart, satisfying
the UI-first contract from CLAUDE.md §3.3 for the keyboard
accelerator that v0.21.4 shipped.

The footer lists only keybinds that are *actually wired today*.
Future commits that wire ESC for stop or ← / → for prev/next
move will extend the right-hand text in lockstep — the footer
never lists aspirational keybinds (would lie to users).

Banner height grew from 76 → 92 px to make room for the 16 px
footer row. Second layout-changing commit in B-2's screen-
takeover arc; same "grow container, add flex-column child"
pattern as the notch-labels commit. 1px top border in
BORDER_SUBTLE separates the footer from the notch-label row.

Two pure helpers (`keybind_footer_mode_text`,
`keybind_footer_hint_text`) keep the static text testable
without per-text marker components on the inner Text entities.
The shared `font_handle_for_labels` clone covers both label and
footer text spawns since the labels closure only `.clone()`s
the handle (never moves it).

4 new tests: pure-helper guards, footer-spawn cardinality
(exactly one), text-set assertion (both helper strings appear as
descendants), lifecycle parity with the overlay tree.

Tests: 1236 → 1240 (+4). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:58:28 -07:00
funman300 d11d97e677 docs(handoff): record notch labels; B's next step is keybind footer
Post-v0.21.4 second carve-out: d322abf ships percentage labels
under each scrub-bar notch (banner 60 → 76 px — first real layout
change in B-2's arc). Update Since-cut log, visual-identity
bullet, B option in the Resume menu, status (1232 → 1236 tests),
and HEAD hint.

Banner geometry is now mutable; future B-2 sub-pieces follow the
same "grow container, add flex-column child" pattern. Next
finite step: keybind-hint footer (small) before the bigger
move-log / mini-tableau pieces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:52:28 -07:00
funman300 d322abf67b feat(replay): add percentage labels under scrub-bar notches
Five `0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px
row beneath the 1 px scrub track give the player explicit
quarter-mark readouts to pair with the notch ticks.

Pure helper `scrub_notch_labels()` returns the fixed array,
paired index-for-index with `scrub_notch_positions()`. Spawn loop
zips both helpers and applies an "endpoints flush, middle three
percent-anchored" positioning pattern: leftmost label gets
`left: 0` (no clip on `0%`), rightmost gets `right: 0` (no overflow
on `100%`), middle three anchor at `left: Val::Percent(p)` since
Bevy 0.18 UI lacks a clean CSS-style `translate-x: -50%` centering
primitive. The slight right-of-notch offset on the middle three
is visually subtle at TYPE_CAPTION; explicit polish target if
anyone notices.

Banner height grew from 60 → 76 px to make room for the label row
(76 = top row 59 flex-grow + scrub track 1 + label row 16). First
real layout change in B-2's screen-takeover arc — every prior
B-2 commit was additive at fixed banner geometry.

Label color is TEXT_SECONDARY rather than mockup's `text-outline`
(BORDER_SUBTLE) — the latter would match the notches but is too
low-contrast against BG_ELEVATED_HI to read at 12 px. TEXT_SECONDARY
keeps the subdued caption hierarchy while staying legible.

4 new tests: pure-helper guard pinning the array + helper-positions
pairing invariant, spawn cardinality, set equality between spawned
texts and helper output, lifecycle parity with the overlay tree.

Tests: 1232 → 1236 (+4). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:51:09 -07:00
funman300 c9e4c0b4cd docs(handoff): record scrub-bar notches; B's next step is notch labels
Post-v0.21.4 carve-out: fe68861 ships quarter-mark notches on the
scrub bar. Update Since-cut log, visual-identity bullet, B option
in the Resume menu, status (1228 → 1232 tests), and HEAD hint.

Next finite step on B-2: percentage labels under each notch —
forces banner height to grow from 60 px to ~76 px, making it the
first real layout change in the screen-takeover arc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:44:05 -07:00
funman300 fe68861e10 feat(replay): add quarter-mark notches to scrub bar
Five 1px vertical ticks at 0/25/50/75/100% give the player visual
anchor points for "where am I, relative to the quarter-marks of the
replay" without needing to mentally bisect the bar.

Pure helper `scrub_notch_positions()` returns the fixed array; the
spawn loop sits next to the WIN MOVE marker spawn so the two
overlays share their lifecycle with the rest of the overlay tree.
Notches paint in BORDER_SUBTLE (same as the unfilled track) and
extend vertically past the 1px track (5px tall, anchored 2px above
the track top) — same visibility trick the WIN MOVE marker uses.

Spawned after the WIN MOVE marker so a notch and the marker landing
on the same percentage paint the marker on top.

Mirrors the notch ladder in the screen-takeover mockup at
docs/ui-mockups/replay-overlay-mobile.html. First finite step toward
B-2's screen-takeover layout reflow; labels under each notch land in
a follow-up commit when the banner height grows to accommodate them.

4 new tests: pure-helper guard pinning the [0,25,50,75,100] array,
spawn-cardinality matching helper.len(), lifecycle parity with the
overlay tree, independence from win_move_index.

Tests: 1228 → 1232 (+4). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:42:37 -07:00
funman300 c33b39cf11 docs(handoff): refresh post-v0.21.4 — anchor to new tag, reset menu state
Anchors handoff to v0.21.4 at `23ff62c`, resets the "Since the cut"
section to placeholder, updates the READ FIRST CHANGELOG pointer,
bumps the Resume-prompt summary to reflect replay-scrubbing
accessibility as the v0.21.4 through-line, and identifies the
screen-takeover layout reflow as the remaining multi-session arc
on B (with move-log scroller + mini-tableau preview as small
sub-pieces inside it).

Resume menu stays at A/B/C — A and C unchanged; B's prerequisite
sub-pieces shipped in v0.21.4 so the entry now points cleanly at
the layout reflow as the single remaining multi-session piece.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:28:50 -07:00
funman300 23ff62c397 docs: cut v0.21.4 — replay-scrubbing accessibility
Patch release for the three post-v0.21.3 commits on the B-2 replay
screen-takeover redesign arc. One through-line: the replay overlay
gains scrubbing affordances. The player can see at a glance where
the winning move sits (WIN MOVE marker on the scrub bar) and stop
on any move to inspect the board (pause / resume / step controls
plus a Space keyboard accelerator).

Also adds the data foundation that makes the marker possible:
`Replay::win_move_index: Option<usize>`, an additive serde-default
field that doesn't bump `REPLAY_SCHEMA_VERSION` because legacy
on-disk replays load with `None` and simply don't get a marker.

Remaining B-2 work — screen-takeover layout, move-log scroller,
mini-tableau preview — shares a layout-reflow prerequisite the
banner-only overlay can't carry, so it's deferred to a future
cycle that can take it as a single multi-session arc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:26:54 -07:00
funman300 0b2ffca016 docs(handoff): record playback controls; B's next step is takeover layout
Captures `fbe48ac` (pause / resume / step + Space accelerator) under
"Since the v0.21.3 cut", marks playback controls closed in the
Visual-identity follow-ups list, identifies the screen-takeover
layout itself (with move-log scroller + mini-tableau preview as its
sub-pieces) as the next finite step on B, and bumps the test count
to 1228.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:21:48 -07:00
funman300 fbe48acef6 feat(replay): playback controls — pause / resume / step + Space accelerator
Third commit on the B-2 replay screen-takeover redesign. Adds the
ability to pause an in-flight replay, step through it one move at
a time while paused, and resume — both via on-screen buttons
(UI-first contract per CLAUDE.md §3.3) and the optional `Space`
keyboard accelerator.

State shape: a new `paused: bool` field on
`ReplayPlaybackState::Playing`. The `tick_replay_playback` system
skips the `secs_to_next` decrement entirely while `paused` is set
so cursor and timer freeze together — resuming starts the next
move from a full interval. Stepping fires the next move directly
via a new `step_replay_playback` API that bypasses the tick path
and is hard-gated to `Playing { paused: true }` so it can't race
the running tick loop.

Public API additions:
- `toggle_pause_replay_playback(state)` — flips the flag, returns
  the new value (or None when not Playing).
- `step_replay_playback(state, moves_writer, draws_writer)` —
  advances exactly one move when paused; returns true on dispatch,
  false on any guard miss.

UI:
- Pause / Resume button next to Stop. Label repaints reactively
  via `update_pause_button_label`, which walks `Children` from
  the marked button to its inner `Text` so the spawn path doesn't
  need a second marker.
- Step button next to Pause. Click fires the next move; while
  unpaused the click is a no-op (guarded inside
  `step_replay_playback`).
- `Space` keyboard handler reads `Option<Res<ButtonInput>>` and
  no-ops when missing — keeps test-app compatibility under
  `MinimalPlugins`.

Test coverage: pause-button label truth table, label repaint on
state change, click-toggles-paused, step advances cursor exactly
one with paused flag preserved, step-while-running is no-op,
Space toggles paused flag. 8 new tests (1220 → 1228).

Side-effect: 25 existing `Playing { ... }` construction sites
across `replay_overlay`, `achievement_plugin`, and
`replay_playback` tests gained `paused: false` to satisfy the new
field requirement. Mechanical edit; no behavioral change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:20:45 -07:00
funman300 cd79877933 docs(handoff): record WIN MOVE marker ship; B's next finite step
Captures `52befa6` (WIN MOVE marker on the scrub bar) under "Since
the v0.21.3 cut", marks the marker piece of B-2 closed in the
Visual-identity follow-ups list, identifies playback controls
(play/pause/step) as the next bounded commit on B, and bumps the
test count to 1220.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:54:44 -07:00
funman300 52befa6199 feat(replay): WIN MOVE marker on the scrub bar
Second commit on the B-2 replay screen-takeover redesign — the UI
that consumes the data field landed in `ab857bb`. Adds a small
green tick on the scrub bar at `replay.win_move_index / total`,
positioned so the playback cursor reaches the marker exactly when
the move it's about to apply IS the winning move.

Implementation: a new `ReplayOverlayWinMoveMarker` component
spawned alongside `ReplayOverlayScrubFill` as a sibling under the
1px scrub track. Position computed by a pure helper
`win_move_marker_pct` that returns `None` for any of: state not
`Playing`, replay's `win_move_index` is `None` (older replay
loaded from disk pre-dating the field), or empty move list. The
percentage is clamped to `[0, 100]` defensively. Marker is
absolute-positioned with `top: -1px` so the 3px-tall tick is
centered on the 1px track line — 1px above and 1px below.

Lifecycle is "spawn-time only" — the marker position never changes
during a single playback because the underlying replay is
immutable while `Playing`. Despawned with the rest of the overlay
tree when the state returns to `Inactive`.

8 new tests cover: pure helper for Inactive / Completed / no-field /
correct-position / clamp; spawn presence with field; spawn absence
without field; despawn-with-overlay lifecycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:53:40 -07:00
funman300 e63046700c docs(handoff): record win_move_index data field; B's next finite step
Captures `ab857bb` (Replay::win_move_index data field) under "Since
the v0.21.3 cut". Updates the Visual-identity follow-up entry for
B-2 to flag the data-layer prerequisite as landed and identifies
the WIN MOVE scrub-bar marker UI as the natural next finite commit.
Bumps test count to 1212.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:45:59 -07:00
funman300 ab857bbb6e feat(data): add Replay::win_move_index for the WIN MOVE scrub marker
First finite step toward the B-2 replay screen-takeover redesign:
the data foundation. Adds an additive optional `win_move_index:
Option<usize>` field on `Replay`, defaulting to `None` via
`#[serde(default)]` so older `latest_replay.json` /
`replays.json` files load unchanged — no `REPLAY_SCHEMA_VERSION`
bump needed since the field is purely additive and nullable.

Populated at the live recording site (`game_plugin::handle_game_won`)
via a new builder-style setter `Replay::with_win_move_index`. For
fresh recordings the value is always `Some(moves.len() - 1)`
because recording freezes on win, but storing the index
explicitly lets the playback UI read the WIN MOVE position
directly without re-deriving it on every render — and leaves
room for future recording semantics that capture post-win state.

UI consumption (the WIN MOVE marker on the scrub bar, plus the
broader screen-takeover redesign — move-log scroller, mini-
tableau preview, playback controls) lands in subsequent commits.

Test coverage: default value, builder set / set-None, on-disk
round-trip, and the legacy-JSON-loads-with-None backward-compat
contract (the test that pins the no-schema-bump claim).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:45:02 -07:00
funman300 886e0cf8a1 docs(handoff): refresh post-v0.21.3 — anchor to new tag, reset menu state
Anchors handoff to v0.21.3 at `3d92a91`, resets the "Since the cut"
section to placeholder, updates the READ FIRST CHANGELOG pointer,
and bumps the Resume-prompt summary to reflect the accessibility
arc closure as the v0.21.3 through-line. Resume menu stays at
A/B/C since v0.21.3 closes only post-v0.21.2 carve-outs (the
remaining options were already heavy / multi-session).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:41:02 -07:00
funman300 3d92a91e3b docs: cut v0.21.3 — accessibility arc closure + Toast Warning driver
Patch release for the two post-v0.21.2 commits. One through-line:
the v0.21.2 "dynamic-paint sites stay un-tagged" carve-out turned
out to be over-cautious — re-reading the code showed only the
radial rim was actually a border-paint cycle. v0.21.3 closes the
carve-out: HUD action buttons + modal buttons take the existing
`HighContrastBorder` marker pattern; the radial rim folds HC into
its per-frame respawn via `radial_rim_outline`.

Bonus: `ToastVariant::Warning` gets its first real consumer in
this cycle (daily-challenge expiry < 30 min from UTC reset). Every
`ToastVariant` now has at least one driver — the enum is fully
load-bearing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:39:46 -07:00
funman300 9113cdb483 docs(handoff): record HC dynamic-paint rollout; menu drops D → 3 options
Marks the HC dynamic-paint rollout (`c153363`) closed under the
High-contrast accessibility entry, captures it in "Since the v0.21.2
cut", bumps the test count to 1207, and trims the Resume prompt
menu from 4 → 3 options (A Android, B replay screen-takeover,
C Phase 8 sync). All three remaining options are multi-session by
nature; the resume prompt now flags that explicitly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:36:00 -07:00
funman300 c153363626 feat(accessibility): finish HC rollout — HUD + modal buttons + radial rim
Closes the v0.21.2 carve-out: dynamic-paint sites that were left
un-tagged because their paint cycles were assumed to race
`update_high_contrast_borders`. Re-reading the code revealed only
one of three sites is actually a border-paint cycle — the other
two paint backgrounds, with static borders that take the marker
pattern cleanly:

* HUD action buttons (`spawn_action_button`): `paint_action_buttons`
  only mutates `BackgroundColor`. Tag the spawn with
  `HighContrastBorder::with_default(BORDER_SUBTLE)`.
* Modal buttons (`spawn_modal_button`): `paint_modal_buttons` also
  only mutates `BackgroundColor`. Same marker pattern.
* Radial menu rim (`radial_redraw_overlay`): full despawn-respawn
  every frame; sprites, not UI nodes; the marker can't apply. Folds
  the HC choice into the spawn site instead — under HC the
  *focused* rim boosts to `BORDER_SUBTLE_HC` rather than
  `BORDER_STRONG`. Naive marker substitution would invert the
  visual hierarchy because `BORDER_SUBTLE_HC` (#a0a0a0) is lighter
  than `BORDER_STRONG` (#505050); folding the choice in keeps the
  focused rim *more* visible under HC, not less.

Decision logic for the rim is extracted to `radial_rim_outline` —
a pure function with a 4-row truth-table test (focused × HC).

After this commit, every UI surface tagged in v0.21.x's
accessibility arc either carries `HighContrastBorder` or has its
HC behaviour folded into its own spawn cycle. No "un-tagged
because race-risk" surfaces remain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:34:05 -07:00
funman300 93b67f1d0b docs(handoff): record Toast Warning wiring; menu drops C → 4 options
Marks the daily-challenge-expiry Warning toast (`279e23d`) closed in
the Visual-identity follow-ups list, captures it in "Since the
v0.21.2 cut", bumps the test count to 1203, and trims the Resume
prompt menu from 5 → 4 options (A Android, B-2 replay takeover,
C Phase 8 sync, D HC dynamic-paint).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:25:10 -07:00
funman300 279e23d0af feat(toast): wire ToastVariant::Warning for daily-challenge expiry
Adds the first in-engine consumer of `ToastVariant::Warning` — a 4s
amber-bordered toast that fires once per daily-challenge date when the
player is within 30 minutes of UTC midnight reset and hasn't yet
completed today's challenge.

Mirrors the v0.21.2 `ToastVariant::Error` wiring: a domain-event
message (`WarningToastEvent(String)`) crosses the plugin boundary;
`animation_plugin::handle_warning_toast` reads it and spawns the
fire-and-forget toast. Suppression is decided by a pure helper
(`compute_expiry_warning_minutes`) that's exhaustively covered by 7
unit tests + 1 in-Bevy idempotence test.

After this lands, every `ToastVariant` (Info, Warning, Error,
Celebration) has at least one real driver — closing the "is this enum
scaffolding or load-bearing?" ambiguity that's been latent since the
variant was introduced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:22:58 -07:00
funman300 12fba2157a docs(handoff): refresh post-v0.21.2 — anchor to new tag, update menu
Mirrors the post-v0.21.0 → v0.21.1 → v0.21.2 cut-then-refresh
pattern. Cut commit (f23df3b) edited only CHANGELOG; this
follow-up resets the handoff so a fresh session picks up cleanly
post-v0.21.2.

Updated:
- Header points to v0.21.2 at f23df3b; opening paragraph
  summarizes the patch's three threads (accessibility
  extensions, replay polish, first real Toast Error consumer).
- Status at pause: tests bumped to 1195 (net +3 from v0.21.1's
  1192); tags list extended through v0.21.2.
- "Since the v0.21.1 cut" → "Since the v0.21.2 cut" with the
  closure narratives dropped (now in CHANGELOG.md § [0.21.2]).
  Section reset to "no threads in flight" placeholder.
- Visual-identity follow-ups: marked floating MOVE chip closed
  by v0.21.2 (`2fb2d63`), Toast Error closed by v0.21.2
  (`68d50b5`); HC + reduce-motion entries updated to reflect
  v0.21.2's HC chrome rollout (8 surfaces) and splash
  reduce-motion gating. Toast Warning still open with a
  candidate driver suggestion (daily-challenge expiry).
- Resume prompt menu retuned: A (Android) and D (Phase 8)
  unchanged; B narrowed to just the screen-takeover redesign
  (the floating chip piece shipped); C narrowed to just
  Warning variant (Error done); new E added for
  HC+reduce-motion on dynamic-paint sites (HUD action buttons,
  etc — explicitly carved out of the v0.21.2 HC rollout
  because of paint-cycle races).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:08:17 -07:00
funman300 f23df3b805 docs: cut v0.21.2 — accessibility extensions + replay polish + first real Toast Error consumer
Promotes [Unreleased] to [0.21.2] dated 2026-05-08 and opens a
fresh empty [Unreleased]. Patch release covering 6 substantive
post-v0.21.1 commits (plus the v0.21.1 handoff refresh).

Three through-lines:

- **Accessibility extensions.** Closes the two threads v0.21.1
  left explicitly open. Reduce-motion was previously gated only
  on card slide_secs; v0.21.2 extends it to splash scanline +
  cursor pulse (`ed152e2`). HC borders had `BORDER_SUBTLE_HC`
  defined but no consumers; v0.21.2 builds the
  `HighContrastBorder` marker + `update_high_contrast_borders`
  system (`c9af1ea`) and rolls it out across 8 surfaces
  (`d87761d` + `ec804d5`).

- **Replay polish.** New floating MOVE chip rendered above the
  destination pile of the most-recently-applied move during
  playback (`2fb2d63`). World-space `Text2d` entity that
  reuses the same `LayoutResource` pile coordinates as every
  other piece of pile geometry — stays correctly positioned
  through window resizes without any UI / camera math.

- **First real `ToastVariant::Error` consumer.** Wires
  `MoveRejectedEvent` to a 2-second pink-bordered "Invalid move"
  toast (`68d50b5`). Joins the existing `card_invalid.wav`
  audio + destination-pile shake visual as the
  accessibility-focused readable text channel.

cargo clippy --workspace --all-targets -- -D warnings clean.
1195 passing / 0 failing (net +3 from v0.21.1's 1192).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:06:14 -07:00
funman300 68d50b5021 feat(toast): wire ToastVariant::Error for invalid-move feedback
Resume-prompt Option C — first in-engine consumer of
`ToastVariant::Error`. The variant has had a slot in the enum
since v0.20.0's toast system landed; this commit wires a real
driver event so the slot is no longer dead code.

### Driver: MoveRejectedEvent

When a player tries an illegal placement (drops dragged cards on
a real pile but the move violates the rules), `MoveRejectedEvent`
fires. The existing rejection-feedback chain plays
`card_invalid.wav` (audio cue) and triggers the destination-pile
shake (visual cue via `feedback_anim_plugin`). This commit adds a
third leg — a 2-second pink-bordered Error toast reading
"Invalid move" — primarily for accessibility:

- **Audio cue alone** doesn't help deaf players.
- **Visual shake alone** is brief and easy to miss for low-vision
  players or anyone with reduce-motion enabled (which gates the
  shake's animation timing).
- **Toast text** is persistent ~2 s, readable, and unambiguous.

The three legs together cover the major perception channels.

### Implementation

New `handle_move_rejected_toast` system in `animation_plugin`
mirrors the shape of `handle_xp_awarded_toast` — read events,
fire `spawn_toast(commands, "Invalid move", 2.0,
ToastVariant::Error)`. Registered in the plugin's Update set
between `handle_xp_awarded_toast` and `tick_toasts` so the toast
spawn pipeline picks it up the same frame the event fires.

`AnimationPlugin::build` gains
`.add_message::<MoveRejectedEvent>()` so the message is
initialized when the plugin runs under MinimalPlugins (tests).
The message is also registered by `feedback_anim_plugin` —
Bevy's `add_message` is idempotent, so both registrations
coexist cleanly.

Also drops the `#[allow(dead_code)]` from `ToastVariant::Error`
(stale now that the variant has a real consumer) and updates the
variant's doc comment to point at `handle_move_rejected_toast`.

### Test

New `move_rejected_event_spawns_error_toast` pins the wiring:
firing a `MoveRejectedEvent` spawns exactly one `ToastOverlay`
on the next tick. Matches the shape of the existing
`info_toast_event_spawns_toast_overlay` test. 1195 passing
(+1 from prior 1194).

Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:59:39 -07:00
funman300 ec804d54c6 feat(accessibility): finish HC chrome rollout — home + settings panel borders
Continues the rollout from `c9af1ea` (modal scaffold) and
`d87761d` (tooltip + 3 panels). Tags the remaining 7 static-
border surfaces in the chrome so the HC chrome thread is
effectively complete:

- **`home_plugin.rs` × 3**: the home-screen Level/XP/Score
  summary row (line 842), the home-screen mode-selector
  buttons (line 945), the home-screen mode-hotkey chips
  (line 1158).
- **`settings_plugin.rs` × 4**: the card-back picker swatches
  (line 1952), the theme picker swatches (line 2093), the
  Sync Now button (line 2214), and the swatch glyph buttons
  (line 2274).

Pre-tagging audit: confirmed none of these sites have a
dynamic-paint system that would race the
`update_high_contrast_borders` system. `paint_action_buttons`
in `hud_plugin.rs` only paints entities tagged with the
`ActionButton` marker (HUD buttons only). The focus-overlay
system in `ui_focus.rs` spawns *separate* overlay entities for
focus indication, never mutating the original `BorderColor`.
Settings panel buttons / swatches use their own
`SettingsButton` enum for click routing; their `BorderColor`
is set at spawn time and not touched again.

After this commit, every `BorderColor::all(BORDER_SUBTLE)` site
in the chrome (excluding the dynamic-paint sites that are
intentionally skipped — HUD action buttons, modal buttons,
radial menu rim) carries a `HighContrastBorder` marker. The
HC thread for chrome borders is closed; the dynamic-paint
sites remain open for a future iteration that needs a
different shape (folding HC into the dynamic-paint logic, or
having HC consult hover/focus state).

1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level lifecycle of `HighContrastBorder`
was already covered by the modal-scaffold scaffolding in
`c9af1ea`). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:47:58 -07:00
funman300 d87761d451 feat(accessibility): roll HighContrastBorder out to tooltip + 3 panel borders
Continues the HC chrome rollout started by `c9af1ea` (which wired
just the modal scaffold). Tags four more static-border surfaces
so they boost to `BORDER_SUBTLE_HC` (#a0a0a0) when high-contrast
mode is on:

- **Tooltip** (`ui_tooltip.rs:191`). The hover-revealed caption
  popup. Border legibility matters because tooltips are usually
  brief — if the player has to squint to find the panel edge,
  the tooltip dismisses before they've parsed it.
- **Onboarding banner key chips** (`onboarding_plugin.rs:388`).
  The first-run UI's "press H or ?" key chips. First-run
  onboarding has the highest stakes for accessibility — a
  low-vision player who can't see the chips can't discover
  the help system.
- **Help panel key chips** (`help_plugin.rs:265`). Same
  treatment as the onboarding chips: keyboard-shortcut chips
  inside the F1 cheat sheet.
- **Stats panel cells** (`stats_plugin.rs:1019`). The S-key
  overlay's individual stat cells. A dense grid of bordered
  numbers is exactly the kind of surface where HC's
  `#505050 → #a0a0a0` boost makes the layout legible.

Each tagging is one line on the spawn tuple plus an import. The
existing `update_high_contrast_borders` system in
`settings_plugin` (added in `c9af1ea`) handles all tagged
entities uniformly — no system changes needed.

### Skipped on this pass

Sites with dynamic hover/focus paint systems (HUD action
buttons, modal buttons, radial menu rim) intentionally not
tagged because their existing paint cycles would race the HC
system. Wiring HC into those needs a different shape — either
fold HC into the dynamic-paint logic, or have HC consult the
hover/focus state. Future scope.

Other HC-tagging candidates (`home_plugin.rs:842/945/1158` home
menu element borders, `settings_plugin.rs:1952/2093/2214/2274`
settings panel rows) are likely fine to tag but I'm capping
this commit at four to keep it reviewable. Pattern is
established; future commits can extend.

1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level test in `c9af1ea`'s scaffolding
covers all tagged entities uniformly). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:43:04 -07:00
funman300 2fb2d638bf feat(replay): floating MOVE chip above the focused card during playback
Resume-prompt Option B (smaller scope variant) — closes the
"floating MOVE chip" piece flagged as future scope in v0.21.1's
replay-overlay punch list. Leaves the multi-session screen-
takeover redesign for a future B-2.

The existing banner-anchored MOVE chip stays put — it provides
the at-a-glance overview. The new floating chip mirrors the same
text but renders above the destination pile of the most-recently-
applied move, keeping progress at the player's focal point so they
don't have to look up at the banner during fast-paced playback.

### Architecture

- New `ReplayFloatingProgressChip` marker component on a
  `Text2d` entity rendered in 2D world space. World-space
  placement (rather than UI-space + camera projection) keeps
  the math trivial — the chip uses the same `LayoutResource`
  pile coordinates that drive every other piece of pile
  geometry, so it stays correctly positioned through window
  resizes without any extra wiring.
- Lifecycle matches the banner overlay: `spawn_overlay` spawns
  the chip alongside the banner when a replay starts;
  `react_to_state_change` despawns it when the replay ends.
  The chip lives outside the UI tree (because it's world-space)
  so the despawn needs its own query — added a second
  `Query<Entity, With<ReplayFloatingProgressChip>>` parameter.
- Z = 100 keeps the chip above every card stack
  (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular tableau
  cards stack to the low double digits at most).

### Position + visibility logic

`update_floating_progress_chip` runs each Update tick:

- Resolves the destination pile of the last-applied move
  (`replay.moves[cursor - 1]`'s `to`).
- Hides the chip when `cursor == 0` (no moves applied yet —
  nowhere meaningful to land) or when the last move was a
  `StockClick` (no destination pile, and stock-click feedback
  already lives at the stock pile — letting the chip jitter
  back to the stock every cycle would be visual noise).
- Otherwise positions the chip at `pile_position + (0,
  card_size.y * 0.6)` — half a card lifts above the pile
  centre, the extra 10 % is breathing room above the card's
  top edge so the chip doesn't visually clip.
- Updates the chip text via `format_progress(&state)` —
  shares the same MOVE N/M format with the banner chip.

### Test

New `floating_chip_spawns_and_despawns_with_overlay` pins the
lifecycle: chip absent on Inactive, exactly one chip on Playing,
absent again on return to Inactive. Position correctness needs
`LayoutResource` (which the headless fixture doesn't set up);
covered via running-game verification rather than a unit test —
the system's gate logic is small enough that pixel positioning
isn't load-bearing on a test.

1194 passing (+1 from prior 1193). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:29:38 -07:00
funman300 c9af1ead22 feat(accessibility): wire BORDER_SUBTLE_HC into the modal scaffold
Resume-prompt Option E, part 2 of 2 — HC chrome borders. Pairs
with the reduce-motion gating in `ed152e2`.

v0.21.1 introduced `BORDER_SUBTLE_HC` (#a0a0a0) but never wired
it: the constant existed, no consumer used it. Spec at
`design-system.md` §Accessibility (#2) mandates outline boost
from `#505050` (BORDER_STRONG) to `#a0a0a0` under high-contrast
mode so panels and popovers stay legible on low-quality
displays.

### Architecture

- New `HighContrastBorder` component in `ui_theme` carrying a
  `default_color: Color` field that records the off-state colour
  the entity was spawned with. Tag any UI node where border
  legibility is accessibility-critical.
- New `update_high_contrast_borders` system in `settings_plugin`
  walks all tagged entities each Update tick, sets `BorderColor`
  to `BORDER_SUBTLE_HC` when `Settings::high_contrast_mode` is
  on, otherwise to `marker.default_color`. Compares against
  current `BorderColor` and only mutates when different so
  Bevy's change-detection doesn't trigger repaints every frame.

### Tagged in this commit

- The modal scaffold's card border (`ui_modal::spawn_modal`).
  This is the primary accessibility target — modals demand
  attention and a low-vision player needs to perceive the panel
  boundary. Default colour: `BORDER_STRONG` (#505050); HC
  variant: `BORDER_SUBTLE_HC` (#a0a0a0).

### Future scope

Other `BORDER_SUBTLE` / `BORDER_STRONG` consumer sites (help
panel, stats panel, tooltip, action buttons, settings rows,
etc.) can be tagged in follow-ups by adding
`HighContrastBorder::with_default(...)` to their spawn tuple.
The system handles any entity carrying the marker — no further
changes needed once a site is tagged. Started small here to
keep the commit reviewable and prove the architecture before
rolling out broadly.

Workspace clippy + cargo test --workspace clean. 1193 passing
(unchanged from prior — no new tests added; the system is
small enough that the running-game verification is the meaningful
check).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:13:13 -07:00
funman300 ed152e2d8f feat(accessibility): gate splash scanline + cursor pulse on reduce-motion
Resume-prompt Option E, part 1 of 2 (the reduce-motion piece;
HC chrome borders follow in a separate commit).

v0.21.1 wired `Settings::reduce_motion_mode` through
`effective_slide_secs` so cards snap instead of sliding under
reduce-motion. The design-system spec at §Accessibility (#3)
calls out two more sources of non-essential motion that
reduce-motion should suppress: the splash CRT scanline effect
and the splash cursor pulse. This commit gates both.

### Splash cursor pulse (`pulse_splash_cursor`)

Previously sine-pulsed every frame regardless of settings. Now
reads `Settings::reduce_motion_mode` and skips the pulse
multiplier when on — the cursor still fades in / out with the
global splash alpha (essential timing), but doesn't blink
(decorative motion). The fade is preserved on purpose: skipping
it would hard-cut the splash on/off, which is jarring; the spec
specifically calls out *non-essential* motion as the reduce-
motion target, and a decorative blink is more clearly
non-essential than a fade timeline.

### Splash scanline overlay (`spawn_splash`)

Previously generated and spawned unconditionally when
`Assets<Image>` was available. Now skipped entirely when
reduce-motion is on — without the scanline overlay the boot
screen still reads as terminal-themed (foreground content,
borders, palette swatches all unchanged); the scanlines are
purely decorative.

### Test

New `splash_skips_scanline_overlay_under_reduce_motion` pins
the gate behaviour: under `reduce_motion_mode = true`, the
splash root still spawns (essential motion intact) but the
`SplashScanlineOverlay` entity is absent. 1193 passing
(+1 from prior 1192).

Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:07:51 -07:00
funman300 279a834f9d docs(handoff): refresh post-v0.21.1 — anchor to new tag, renumber Resume menu
Mirrors the post-v0.20.0 → v0.21.0 → v0.21.1 cut-then-refresh
pattern. Cut commit (daa655a) edited only CHANGELOG; this
follow-up resets the handoff so a fresh session picks up cleanly
post-v0.21.1.

Updated:
- Last-updated header points to v0.21.1 at daa655a; opening
  paragraph summarizes the patch's three threads (icon,
  accessibility, card-visual iteration with two bug fixes).
- Status at pause: tests bumped to 1192 (net +8 from
  v0.21.0's 1184); tags list extended through v0.21.1.
- "Since the v0.21.0 cut" → "Since the v0.21.1 cut" with the
  closure narratives dropped (now in CHANGELOG.md § [0.21.1]).
  Section reset to "no threads in flight" placeholder so
  future post-cut work has a clean starting point.
- Resume prompt menu trimmed: A and F closure entries dropped
  (preserved in CHANGELOG); remaining options renumbered A-E
  with the v0.21.1 closure callouts inline. New option E
  added: "extend HC through chrome borders + reduce-motion to
  splash/warning-chip" — both small finite items that v0.21.1
  flagged as future scope.
- Workflow notes gain the doc-vs-implementation-drift pattern
  observation from the pile-marker fix: when a module's
  top-level doc comment claims "X happens" but no code enforces
  it, the gap is invisible until a player notices the missing
  behaviour. Worth checking such claims and adding tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:59:24 -07:00
50 changed files with 8657 additions and 313 deletions
+4 -4
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -33,7 +33,7 @@ jobs:
libxkbcommon-dev libxkbcommon-dev
- name: Cache cargo registry and build artifacts - name: Cache cargo registry and build artifacts
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: | path: |
~/.cargo/registry ~/.cargo/registry
@@ -59,7 +59,7 @@ jobs:
needs: test needs: test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -74,7 +74,7 @@ jobs:
libxkbcommon-dev libxkbcommon-dev
- name: Cache cargo registry and build artifacts - name: Cache cargo registry and build artifacts
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: | path: |
~/.cargo/registry ~/.cargo/registry
+174
View File
@@ -0,0 +1,174 @@
name: Release
# Triggered by pushing a version tag, e.g. `git tag v0.22.0 && git push origin v0.22.0`.
# Builds a Linux x86_64 tarball and a signed Android APK, then publishes
# both as assets on a GitHub Release. Obtainium can track this repo's
# releases and download the APK automatically.
#
# Required repository secrets (Settings → Secrets and variables → Actions):
# ANDROID_KEYSTORE_BASE64 base64-encoded .jks file (see README for gen command)
# ANDROID_KEYSTORE_PASSWORD password used with -storepass when creating the keystore
# ANDROID_KEY_ALIAS alias used with -alias when creating the keystore
# ANDROID_KEY_PASSWORD password used with -keypass when creating the keystore
on:
push:
tags:
- 'v*'
permissions:
contents: write # gh release create needs write access
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# ---------------------------------------------------------------------------
# Job 1: Linux x86_64 binary + assets tarball
# ---------------------------------------------------------------------------
jobs:
build-linux:
name: Build · Linux x86_64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install system deps
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Cache cargo registry + build artifacts
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: linux-release-
- name: Build release binary
run: cargo build --release -p solitaire_app
- name: Package tarball
run: |
mkdir solitaire-quest
cp target/release/solitaire_app solitaire-quest/
cp -r assets solitaire-quest/
tar -czf solitaire-quest-linux-x86_64.tar.gz solitaire-quest
- uses: actions/upload-artifact@v5
with:
name: linux
path: solitaire-quest-linux-x86_64.tar.gz
# ---------------------------------------------------------------------------
# Job 2: Android APK (multi-arch) — release-built and signed via cargo-apk
# ---------------------------------------------------------------------------
build-android:
name: Build · Android APK
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust stable + Android targets
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Expose NDK root to cargo-apk
# ANDROID_NDK_LATEST_HOME is set by the GitHub-hosted runner.
# cargo-apk reads ANDROID_NDK_ROOT; write it to GITHUB_ENV so
# all subsequent steps in this job inherit it.
run: echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
- name: Cache cargo registry + cargo-apk binary + build artifacts
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
target
key: android-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: android-release-
- name: Install cargo-apk
# --locked: use the dependency versions cargo-apk was tested with.
# cargo install is a no-op when the cached binary is already current.
run: cargo install --locked cargo-apk
- name: Inject release signing config
# cargo-apk --release requires [package.metadata.android.signing.release]
# in solitaire_app/Cargo.toml. Appended at CI time so secrets never
# live in the repo. printf keeps every line inside the YAML run block,
# avoiding the YAML parse error a heredoc with column-0 content causes.
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > release.keystore
{
printf '\n[package.metadata.android.signing.release]\n'
printf 'path = "%s"\n' "${GITHUB_WORKSPACE}/release.keystore"
printf 'keystore_password = "%s"\n' "$ANDROID_KEYSTORE_PASSWORD"
printf 'key_alias = "%s"\n' "$ANDROID_KEY_ALIAS"
printf 'key_password = "%s"\n' "$ANDROID_KEY_PASSWORD"
} >> solitaire_app/Cargo.toml
- name: Build and sign APK (release profile)
# `--lib` scopes cargo-apk to the cdylib target only.
# Without it, cargo-apk panics post-sign with
# "Bin is not compatible with Cdylib" (cargo-subcommand
# artifact iteration walks the bin target after the
# cdylib APK is already produced). See SESSION_HANDOFF.md
# "Cosmetic cargo apk build --lib workaround."
run: cargo apk build -p solitaire_app --lib --release
- name: Stage APK for upload
run: |
cp target/release/apk/solitaire-quest.apk \
"solitaire-quest-${{ github.ref_name }}.apk"
rm release.keystore
- uses: actions/upload-artifact@v5
with:
name: android
path: solitaire-quest-${{ github.ref_name }}.apk
# ---------------------------------------------------------------------------
# Job 3: Create the GitHub Release once both builds succeed
# ---------------------------------------------------------------------------
release:
name: Publish GitHub Release
runs-on: ubuntu-latest
needs: [build-linux, build-android]
steps:
- uses: actions/download-artifact@v5
with:
name: linux
- uses: actions/download-artifact@v5
with:
name: android
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--repo "${{ github.repository }}" \
--title "Solitaire Quest ${{ github.ref_name }}" \
--generate-notes \
"solitaire-quest-linux-x86_64.tar.gz" \
"solitaire-quest-${{ github.ref_name }}.apk"
+8
View File
@@ -7,3 +7,11 @@
*.tmp *.tmp
data/ data/
.claude/ .claude/
# IDE project files
.idea/
# Android signing keystores — never commit
*.jks
*.jks.bak
*.keystore
+808 -2
View File
@@ -6,8 +6,814 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
No threads in flight. v0.21.1 cut on 2026-05-08; CHANGELOG accumulates ## [0.22.0] — 2026-05-08
the next cycle here.
Adds difficulty-tier game selection, Android JNI bridges for keystore and
clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens.
Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
### Added
- **Difficulty-tier game mode** (this release).
`DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster /
Random`) added to `solitaire_core::game_state` alongside a new
`GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed
catalogs (40 seeds each, 200 total) are generated by the new
`gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog
contains seeds proven winnable at progressively larger solver budgets
(1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent`
→ catalog seed → `NewGameRequestEvent`; the `Random` tier uses a
system-time seed and intentionally bypasses the winnable-only filter.
The home overlay gains an expandable `▶ Difficulty` section between the
Draw Mode row and the mode-card grid; the last-played tier is persisted
in `Settings::last_difficulty` and pre-expands/highlights on re-open.
Difficulty wins pool into Classic stats (no separate buckets).
- **Prev/Next replay selector in the Stats overlay** (`a449f60`).
`ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and
`ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen`
as a flex row of two bordered chips flanking a `"Replay N / M"`
caption, with a detail line below showing the selected replay's
duration + date and an optional `"· Shareable"` badge. Both chips
carry `ModalButton(Secondary)` so the existing `repaint_modal_buttons`
paint loop gives them hover/press feedback at zero extra cost.
`repaint_replay_selector_detail` is wired into the existing
`.chain()` alongside `handle_replay_selector_buttons` and
`repaint_replay_selector_caption`. The click handler and repaint
systems have been registered (and dormant) since v0.19.0; this
commit is purely the missing spawn site.
- **6 new selector unit tests** (`a449f60`). Covers: spawn-site
presence (Prev, Next, Caption, Detail all spawn with the screen),
caption initial text ("Replay 1 / 1"), detail initial text
("{dur} win on {date}"), Shareable badge when `share_url` is set,
empty-history "No replays" caption, and ordinal wrapping.
`make_test_replay(time_seconds, share_url)` helper encapsulates
`Replay::new(...)` + `chrono::NaiveDate`.
### Fixed
- **`const { assert!() }` for dim-layer z-order test** (`a449f60`).
Converted `assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)` in
`replay_overlay` tests to `const { assert!(…) }` to satisfy
`clippy::assertions_on_constants` (constant-fold at compile time
rather than a runtime no-op).
### Added (post-cut, same pending release)
- **Double-tap auto-move on touch screens** (`395a322`).
`handle_double_tap` fires `MoveRequestEvent` (single card to
foundation/tableau, or a whole face-up stack via
`best_tableau_destination_for_stack`) when two `TouchPhase::Ended`
events on the same card arrive within `DOUBLE_TAP_WINDOW` (0.5 s,
slightly wider than the mouse `DOUBLE_CLICK_WINDOW` to account for
touch latency). If no legal destination exists, fires
`MoveRejectedEvent` (audio + visual rejection feedback). The system
is inserted into the touch drag chain immediately before
`touch_end_drag` so `DragState.active_touch_id` and `committed` are
still readable; the tap timestamp is tracked in a `Local<HashMap<u32,
f32>>` keyed by card ID.
- **Play-by-Seed dialog** (`0cb1587`).
`PlayBySeedPlugin` adds a numeric-input modal that accepts a decimal
seed, runs a solver preview in the background (debounced 500 ms via
`AsyncComputeTaskPool`), and shows a win/no-win verdict before
dealing. A new `HomeMode::PlayBySeed` card in the home overlay fires
`StartPlayBySeedRequestEvent`; the handler in `PlayBySeedPlugin`
spawns the dialog. Digit, Backspace, Enter (confirm), and Escape
(cancel) are handled via `ButtonInput<KeyCode>`. Five unit tests
cover spawn, digit append, buffer read, confirm, and cancel paths.
- **75 new challenge seeds** (`2062bd0`).
New `gen_seeds` binary in `solitaire_assetgen` brute-searches seeds
in the `0xCAFEBABE…` namespace and filters for hands solvable in
≤250 moves via the core solver. The 75 confirmed-win seeds are
appended to `CHALLENGE_SEEDS` in `solitaire_data::challenge`.
### Fixed (post-cut, same pending release)
- **Gate `handle_fullscreen` to non-Android** (`45436d0`).
F11 fullscreen toggle makes no sense on Android (the OS owns window
sizing); the fn and its `MonitorSelection`/`WindowMode` imports are
now `#[cfg(not(target_os = "android"))]`-gated. The `add_systems`
call is extracted as a separate statement so `#[cfg]` can annotate it
(attributes cannot appear mid-chain in Rust).
- **Android APK launch: export `android_main`** (`202a64d`).
`NativeActivity` dlopen-s `libsolitaire_app.so` and calls
`android_main` as its entry point. Without the symbol the app
crashed immediately with `UnsatisfiedLinkError`. The new function
sets `bevy::android::ANDROID_APP` (required by `WinitPlugin`) then
delegates to `run()` — equivalent to what `#[bevy_main]` would
generate, but usable on an arbitrary entry point name.
- **Android APK launch: gate `resize_constraints` to non-Android**
(`202a64d`). On Android `max_width/max_height` default to `0.0`;
Bevy's clamp panicked with `min=800 > max=0`.
- **Android APK launch: gate `apply_smart_default_window_size` to
non-Android** (`202a64d`). The system calls `.clamp(800.0,
logical_w)` which panics when the emulator reports zero window
dimensions during early Android lifecycle events. The OS controls
window size on Android; the system is irrelevant there.
- **Ignore `.idea/` IDE project files** (`16242e6`). Android Studio
created `.idea/` when the project was opened during APK
verification; added to `.gitignore` and removed the accidentally-
committed files.
### Android verification result
APK boots on `x86_64-linux-android` in a Pixel_7 AVD (Android 14 /
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
Bevy renderer initialises, splash screen loads. This is the first
confirmed end-to-end device run.
### Stats
- Tests: **1300+ passing** / 0 failing
- Clippy: clean
- Crates touched: `solitaire_core` (game_state), `solitaire_data`
(settings, stats, difficulty_seeds, challenge), `solitaire_engine`
(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin,
input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs),
`solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries)
## [0.21.8] — 2026-05-08
Patch release for replay-overlay polish. Through-line:
**notch-label centering + WIN MOVE HC legibility + HC system extension**.
All three items were "optional polish" flagged in the v0.21.7 handoff;
all three ship in two commits.
### Added
- **`STATE_SUCCESS_HC` constant** (`c50eaf8`). Brighter lime
(`#c8e862`, L≈0.73) in `ui_theme` for use wherever the
standard `STATE_SUCCESS` (`#acc267`, L≈0.51) needs extra
luminance under HC mode. Sits above the bumped notch ticks
(`BORDER_SUBTLE_HC` gray, L≈0.60) so a WIN MOVE marker at
this colour is unambiguous.
- **`HighContrastBackground::with_hc(default, hc)` constructor**
(`c50eaf8`). Extends `HighContrastBackground` with an
`hc_color: Color` field (default = `BORDER_SUBTLE_HC` via
`with_default()`). `update_high_contrast_backgrounds` now
reads `marker.hc_color` instead of the hardcoded constant —
backwards-compatible; all existing `with_default()` usages
continue to bump to gray.
- **WIN MOVE scrub-bar marker HC bump** (`c50eaf8`). Marker
now carries `HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC)` so the lime stays lime under HC (brighter
lime rather than gray). Pin test locks both the default and
HC colour fields on the spawned entity.
### Fixed
- **Scrub-bar notch-label centering** (`b44d277`). Middle
three labels ("25%", "50%", "75%") previously had their
left edge at the notch; now their text centre coincides
with the notch tick. Implemented using the CSS
`translateX(-50%)` pattern for Bevy 0.18 UI: a fixed
`SCRUB_LABEL_CENTER_WIDTH = 36 px` container with
`margin.left = -18 px` is placed at `left: Percent(pct)`,
and `Justify::Center` centres the text within it. Endpoint
labels ("0%", "100%") keep their flush-left / flush-right
anchoring. `with_default()` remains one-argument.
### Stats
- Tests: 1276 passing / 0 failing (engine: 831)
- Clippy: clean
- Crates touched: `solitaire_engine` (replay_overlay.rs,
ui_theme.rs, settings_plugin.rs)
## [0.21.7] — 2026-05-08
Patch release closing the last major B-2 sub-piece. Through-line:
**mini-tableau preview dim layer**. The mockup's "Game Peek Band at
50 % opacity" is now implemented as a full-screen UI scrim that darkens
the card world during replay so the chrome (banner + move-log panel)
reads clearly against the scene.
### Added
- **Full-screen tableau dim layer** (`da3e542`). Spawns a
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
black) at `Z_REPLAY_DIM = Z_REPLAY_OVERLAY 1 = 54` whenever
a replay starts; despawned alongside the banner and move-log
panel when the replay ends. Bevy's UI/world compositor means
no changes to `card_plugin` are needed — UI nodes always
render above world-space sprites regardless of `Transform.z`.
The dim layer carries no `Interaction` component (purely
visual; pointer events pass through). Adds `Z_REPLAY_DIM`
and `TABLEAU_DIM_ALPHA` constants plus two new tests:
lifecycle (spawn/despawn mirrors the floating-chip pattern)
and z-ordering invariant (`Z_REPLAY_DIM < Z_REPLAY_OVERLAY`
pinned). 1275 tests pass / 0 failing.
### Stats
- Tests: 1275 passing / 0 failing
- Clippy: clean
- Crates touched: `solitaire_engine` (replay_overlay.rs)
## [0.21.6] — 2026-05-08
Patch release for the post-v0.21.5 work. Through-line:
**Move Log panel + scrub-UX polish**. v0.21.5 closed out the
keyboard-accelerator surface (Space / Esc / ← / →) and the
keybind footer; v0.21.6 builds on that with two parallel
threads — accessibility + scrub-on-hold polish for the v0.21.5
surfaces, plus a brand-new Move Log panel anchored to the
viewport's bottom edge that gives players a 5-row recent-and-
upcoming move history alongside the existing top-edge banner.
The Move Log panel is the first replay-overlay surface that
*isn't* attached to the banner — it lives at a separate screen
anchor (bottom: 0) with its own spawn/despawn lifecycle.
Establishes the pattern for "multi-anchor replay UI" that the
remaining B-2 sub-piece (mini-tableau preview) will inherit.
### Added
- **HC-mode coverage for the scrub track + quarter-mark notch
ticks** (`d3cb1a5`). Adds parallel primitive
`HighContrastBackground` to `ui_theme` and a paint system
`update_high_contrast_backgrounds` in `settings_plugin` that
mirrors the existing border-marker pattern but targets
`BackgroundColor` instead of `BorderColor`. Tags the 1 px
scrub track Node and all five quarter-mark notch ticks so
they bump from `BORDER_SUBTLE` (`#505050`) →
`BORDER_SUBTLE_HC` (`#a0a0a0`) under HC mode. Scrub fill
(`ACCENT_PRIMARY`) and WIN MOVE marker (`STATE_SUCCESS`)
don't get the marker — accent and state colours are already
saturated and don't need an HC luminance variant.
- **Continuous scrub on key-held arrow keys** (`2e25476`).
Holding ← or → triggers continuous step at 100 ms cadence
(10 steps/sec) — matches the mockup's `[← →] scrub`
terminology while keeping single-press = single-step
semantics. Per-key accumulators in a new
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
the accumulator and fire immediately. Release resets to 0
so the next fresh press fires immediately rather than at
half-interval.
- **Move Log panel** (`d6f32d3` + `140251b` + `e7345ae` +
`4437a1a`). New bottom-edge UI panel showing a 5-row window
onto recent + upcoming moves: 2 prev rows above the active
row + active row highlighted in `ACCENT_PRIMARY` + 2 next
rows below. Header reads `▌ MOVE LOG · N/M` (or
`▌ MOVE LOG · COMPLETE` when finished). Active row carries
a `▶` focus prefix and `TEXT_PRIMARY_HC` text colour for
legible contrast against the brick-red highlight. Prev /
next rows render in `TEXT_SECONDARY` so the active row
stays the focal point.
- Sibling-of-banner pattern (separate root entity anchored
at viewport bottom, not a banner child) — same
spawn/despawn lifecycle as `ReplayFloatingProgressChip`,
different screen anchor.
- Five pure helpers handle the formatting:
`format_pile`, `format_move_body`,
`format_move_log_header`, `format_kth_recent_row` (active
+ prev), `format_kth_next_row` (next). 1-indexed display
numbers throughout (`Foundation(2)` reads as "foundation
3" rather than the enum's 0-index).
- Panel grows from 56 → 84 → 112 px across the four
move-log commits. `MOVE_LOG_PREV_ROWS` and
`MOVE_LOG_NEXT_ROWS` constants (both = 2) parameterise
the row count; `format_kth_recent_row` and
`format_kth_next_row` return empty for out-of-range k so
panels gracefully under-fill at the start (cursor=1) and
end (cursor=N-1) of a replay.
- HC marker on the panel's top border so the 1 px edge
bumps under HC mode (same pattern as the keybind footer).
### Changed
- **`react_to_state_change` despawns the Move Log panel** on
`Playing → Inactive` alongside the banner root and floating
progress chip. Third query in the same defer-and-despawn
cycle.
- **Move Log panel height grew 56 → 84 → 112 px** across the
prev-rows and next-rows commits. The panel is sized to fit
the chosen row count + header + padding; tunable via the
`MOVE_LOG_PANEL_HEIGHT` const.
- **`format_active_move_row` now prefixes the `▶` focus
marker** (`e7345ae`). Wraps `format_kth_recent_row(state, 1)`
and prepends the prefix when the body is non-empty. Empty
case still returns empty — cursor=0 doesn't paint a stray
`▶` on an otherwise-empty row.
### Documentation
- `SESSION_HANDOFF.md` refreshed twice this cycle — once
recording the HC paint + continuous-scrub polish, then
again as the Move Log arc shipped commit-by-commit. The
Resume menu's B option now traces the full arc:
notches → labels → footer → ESC → HC → arrow keys →
HC paint → continuous scrub → move log.
### Stats
- **1273 passing tests / 0 failing** across the workspace
(net +23 from v0.21.5's 1250 baseline):
- 2 from `d3cb1a5` (HC marker on track + notches).
- 2 from `2e25476` (continuous-scrub repeat-while-held +
release-resets-accumulator).
- 8 from `d6f32d3` (move-log panel init + 5 helpers + 3
spawn / lifecycle scenarios).
- 4 from `140251b` (prev rows: helper k coverage + spawn
cardinality + spawn texts + repaint on cursor advance).
- 3 from `e7345ae` (active row highlight: wrapper bg +
text colour + focus prefix + cursor=0 stays empty).
- 4 from `4437a1a` (next rows: helper k coverage + spawn
cardinality + spawn texts + under-fill at replay end).
- Clippy clean across the workspace.
## [0.21.5] — 2026-05-08
Patch release for the post-v0.21.4 work. One through-line:
**replay-overlay scrubbing affordances + accessibility**. v0.21.4
shipped pause / resume / step + the WIN MOVE marker as the first
*scrubbing-shaped* additions to the replay overlay; v0.21.5
fills out the rest of the scrubbing UX so the player has both
visual anchor points (notches + labels) and a complete keyboard
control surface (Space / Esc / ← / →) for navigating a paused
replay.
Two of the six commits in this cycle are layout-changing — they
grow the banner height from 60 px → 76 px → 92 px to make room
for the notch labels and keybind footer. Banner geometry was
fixed for every prior B-2 commit; this release establishes the
"grow the container, add a flex-column child" pattern that the
remaining B-2 sub-pieces (move-log scroller, mini-tableau
preview) will inherit when they land.
### Added
- **Quarter-mark scrub-bar notches** (`fe68861`). Five 1 px
vertical ticks at 0 / 25 / 50 / 75 / 100 % give the player
visual anchor points without needing to mentally bisect the
bar. Pure helper `scrub_notch_positions()` returns the fixed
array; spawn loop sits next to the WIN MOVE marker spawn so
the lifecycles match. Notches paint in `BORDER_SUBTLE` (same
as the unfilled track) and rely on extending past the 1 px
track (5 px tall, anchored 2 px above the track top) for
visibility — same trick the WIN MOVE marker uses. Spawned
*after* the WIN MOVE marker so a notch and the marker
landing on the same percentage paint the marker on top.
- **Percentage labels under each notch** (`d322abf`). Five
`0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px
row beneath the 1 px scrub track give the player explicit
quarter-mark readouts. Banner grew from 60 → 76 px to
accommodate the row — first **layout-changing** commit in
the B-2 arc. Pure helper `scrub_notch_labels()` returns the
fixed array, paired index-for-index with
`scrub_notch_positions()`. Spawn loop applies an "endpoints
flush, middle three percent-anchored" positioning pattern:
leftmost label gets `left: 0`, rightmost gets `right: 0`,
middle three anchor at `left: Val::Percent(p)` since Bevy
0.18 UI lacks a clean CSS-style `translate-x: -50%`
centering primitive. Label colour is `TEXT_SECONDARY`
rather than the mockup's `BORDER_SUBTLE` (the latter would
match the notches but is too low-contrast against
`BG_ELEVATED_HI` to read at 12 px).
- **Keybind-hint footer** (`1873b3f`). Vim-style mode line on
the left (`▌ NORMAL │ replay`) plus a keybind hint on the
right at the bottom edge of the banner. Banner grew from
76 → 92 px to fit the 16 px footer row. Surfaces every
wired keyboard accelerator visually so CLAUDE.md §3.3's
UI-first contract holds for keyboard accelerators too. The
footer lists *only* keybinds that are actually wired —
the only-wired-keybinds discipline means each release
cycle's hint string is a precise honest contract with the
player. Two pure helpers (`keybind_footer_mode_text`,
`keybind_footer_hint_text`) keep the static text testable.
1 px top border in `BORDER_SUBTLE` separates the footer
from the labels row.
- **ESC keyboard accelerator for replay-stop** (`90e24d9`).
New `handle_stop_keyboard` system parallels
`handle_pause_keyboard` in shape — fires only when state
is `Playing`, calls `stop_replay_playback`. Cross-plugin
coordination via `pause_plugin::toggle_pause`: added a
fourth defer-if check
(`replay_state.is_some_and(|s| s.is_playing())`) right
after the existing `other_modal_scrims` check so ESC
during active replay belongs to the replay overlay, not
the pause modal.
- **HC-mode coverage for the keybind-footer top border**
(`23902cd`).
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
on the footer's border-carrying Node so the existing
`apply_high_contrast_borders` system bumps the 1 px top
border from `#505050``#a0a0a0` when
`Settings::high_contrast_mode` is on. Without the marker
the footer reads as floating loose under HC because the
border that anchors it to the labels row is
near-invisible.
- **← / → keyboard accelerators for paused stepping**
(`e5c4f51`). New `step_backwards_replay_playback` in
`replay_playback.rs` decrements the cursor and dispatches
`UndoRequestEvent`; the game's `handle_undo` reads it
next frame to reverse its most-recent move. Hooks the
existing undo system rather than replaying-forward-from-
zero — every replay-applied move pushes to the undo stack
the same way a player move would, so undo is the right
reversal primitive. Both arrow keys are paused-only via
the same destructure-gate pattern the forward step uses.
The mockup labels these `[← →] scrub`; single-move step
is the closest behaviour shippable today, so the footer
hint reads `[← →] step` — only-wired-keybinds discipline.
### Changed
- **Banner height grew 60 → 76 → 92 px** across two
layout-changing commits (`d322abf` then `1873b3f`). Top
row's `flex_grow: 1.0` still consumes 59 px so the
existing content (label / progress chip / buttons) has
the same vertical space; the new rows (16 px labels +
16 px footer) extend the banner downward into the
gameplay area. Banner geometry is now mutable — every
prior B-2 commit fit inside fixed 60 px space.
- **Keybind-footer hint text grew alongside the wirings**:
`[SPACE] pause/resume`
`[SPACE] pause/resume · [ESC] stop`
`[SPACE] pause/resume · [ESC] stop · [← →] step`.
- **`pause_plugin::toggle_pause` now defers when a replay
is active** (`90e24d9`). Adds a fourth defer-if check to
the existing modal-stack pattern.
- **`ReplayOverlayPlugin` registers
`add_message::<UndoRequestEvent>()`** (`e5c4f51`).
Defensive registration so the plugin runs cleanly under
`MinimalPlugins` without `GamePlugin` attached.
### Documentation
- `SESSION_HANDOFF.md` refreshed five times this cycle.
The B option in the Resume menu now traces the full arc:
notches → labels → footer → ESC → HC → arrow keys.
- The pre-existing `daily_challenge` warning test that
fails when wall-clock UTC is within 30 minutes of
midnight is documented in this cycle's handoff. Same
shape as the earlier `winnable_seed_search` flake —
time-dependent, deterministically passes outside the
trigger window.
### Stats
- **1250 total tests / 1249 passing / 1 pre-existing
time-dependent flake** across the workspace (net +22 from
v0.21.4's 1228 baseline):
- 4 from `fe68861` (scrub-notch coverage)
- 4 from `d322abf` (notch-label coverage)
- 4 from `1873b3f` (keybind-footer coverage)
- 3 from `90e24d9` (ESC-accelerator coverage)
- 1 from `23902cd` (HC-marker coverage)
- 6 from `e5c4f51` (arrow-keyboard coverage)
- **Pre-existing flake**:
`daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_day`
fails when wall-clock UTC is within 30 minutes of
midnight. Verified pre-existing by stash-and-retest
before each commit. Will pass deterministically outside
the trigger window. Not introduced by this release.
- Clippy clean across the workspace.
## [0.21.4] — 2026-05-08
Patch release for the post-v0.21.3 work. One through-line:
**replay-scrubbing accessibility**. The replay overlay used to be
pure-passive — the player started a replay, watched it execute,
and waited for it to end. v0.21.4 adds the scaffolding for
*navigating within* a replay: a WIN MOVE marker on the scrub bar
so the player can see at a glance where the winning move sits,
and pause / resume / step controls so they can stop on any move
and inspect the board.
The work is also the first three commits on the B-2 replay
screen-takeover redesign arc. The remaining pieces (screen-
takeover layout, move-log scroller, mini-tableau preview) are
deferred to a future cycle because they need a layout reflow
that the existing banner-only overlay can't carry.
### Added
- **`Replay::win_move_index: Option<usize>` data field**
(`ab857bb`). Additive optional field on the persisted
`Replay` shape. `#[serde(default)]` keeps older
`latest_replay.json` / `replays.json` files loadable without
bumping `REPLAY_SCHEMA_VERSION` — this is purely additive.
Populated at the live recording site
(`game_plugin::handle_game_won`) via a new builder-style
setter `Replay::with_win_move_index`. For fresh recordings
the value is always `Some(moves.len() - 1)` because recording
freezes on win, but storing it explicitly lets the playback
UI read the WIN MOVE position directly without re-deriving
on every render.
- **WIN MOVE scrub-bar marker** (`52befa6`). New
`ReplayOverlayWinMoveMarker` component spawned as a sibling
to `ReplayOverlayScrubFill` under the 1px scrub track,
absolute-positioned at `replay.win_move_index / total %` of
the bar. Painted in `STATE_SUCCESS` (green) so the marker
reads as "this is where the win lives." Pure helper
`win_move_marker_pct` returns `None` for any state where the
marker shouldn't draw (Inactive, Completed, replay missing
the field, empty move list); percentage clamps to `[0, 100]`
defensively. Spawn-time only — the position never changes
during a single playback because the underlying `Replay` is
immutable while `Playing`.
- **Pause / Resume / Step playback controls** (`fbe48ac`). New
`paused: bool` field on `ReplayPlaybackState::Playing`.
`tick_replay_playback` skips the `secs_to_next` decrement
entirely while paused so cursor and timer freeze together;
resuming starts the next move from a full interval. New
public API: `toggle_pause_replay_playback` and
`step_replay_playback` (the latter hard-gated to `Playing {
paused: true }` via the destructure pattern itself, so
manual stepping can't race the tick loop). On-screen Pause
and Step buttons sit alongside the existing Stop button;
`Space` keyboard accelerator toggles pause / resume.
- **`Replay::with_win_move_index` builder** (`ab857bb`).
Chainable setter so the recording site can write
`Replay::new(...).with_win_move_index(idx)`. Keeps
`Replay::new`'s signature stable across the 13+ existing
test-fixture call sites that don't care about the field.
### Changed
- **`Replay::new` writes `win_move_index: None`** (`ab857bb`).
Existing canonical constructor stays signature-compatible
with all existing callers. The field is opt-in via the
builder.
- **`game_plugin::handle_game_won` populates the new field**
(`ab857bb`). The recording site computes
`recording.moves.len().checked_sub(1)` as the win-move
index. `checked_sub` rather than direct subtraction guards
the unreachable empty-recording branch (which is also
guarded earlier in the function).
- **`tick_replay_playback` honors the new `paused` flag**
(`fbe48ac`). Skipping the timer decrement is the only
behavior change; the loop body and Completed-detection are
unchanged. Stepping fires moves directly via
`step_replay_playback`, bypassing the tick path entirely.
- **Pause / Resume button label is reactive** (`fbe48ac`).
`update_pause_button_label` walks `Children` from the
marked button to its inner `Text` and repaints the label
whenever `ReplayPlaybackState` changes. Pure helper
`pause_button_label` covers all four state arms (running,
paused, inactive, completed).
- **25 existing `Playing { ... }` construction sites gained
`paused: false`** (`fbe48ac`). Mechanical edit across
`replay_overlay`, `achievement_plugin`, and
`replay_playback` tests to satisfy the new field
requirement. No behavioral change.
### Documentation
- `SESSION_HANDOFF.md` refreshed three times this cycle —
once after each post-cut feature commit. The B-2 entry in
the Visual-identity follow-ups list now points at the
remaining sub-pieces (screen-takeover layout, move-log
scroller, mini-tableau preview) as a single multi-session
arc rather than three independent ones, since they share a
layout-reflow prerequisite.
### Stats
- **1228 passing tests / 0 failing** across the workspace
(net +21 from v0.21.3's 1207 baseline):
- 5 from `ab857bb`'s `win_move_index` coverage: default
constructor, builder set / set-None, on-disk round-trip,
legacy-JSON-loads-with-None backward-compat. The last
test pins the no-schema-bump claim — if a future refactor
drops the `#[serde(default)]`, that test catches it.
- 8 from `52befa6`'s WIN MOVE marker: pure-helper truth
table (Inactive / Completed / no-field / correct-position
/ clamp) + spawn-presence-with-field /
spawn-absence-without / despawn-with-overlay observables.
- 8 from `fbe48ac`'s playback controls: label truth table,
label repaint on state change, click-toggles-paused,
step advances cursor by exactly one with paused
preserved, step-while-running no-op, Space toggles
paused.
- Zero clippy warnings under `cargo clippy --workspace
--all-targets -- -D warnings`.
- `cargo test --workspace` clean.
## [0.21.3] — 2026-05-08
Patch release for the post-v0.21.2 work. One through-line:
**accessibility arc closure**. v0.21.2 explicitly carved out
"dynamic-paint sites" (HUD action buttons, modal buttons, radial
menu rim) on the assumption that their existing paint cycles would
race the central `update_high_contrast_borders` system. v0.21.3
walks the actual code, finds the carve-out was over-cautious, and
closes it. Bonus: the first real consumer of `ToastVariant::Warning`
also lands here, making the `ToastVariant` enum fully load-bearing
(every variant has at least one driver).
### Added
- **`WarningToastEvent(String)` — first `ToastVariant::Warning`
consumer** (`279e23d`). Generic carrier message that any system
can fire to spawn a 4 s amber-bordered fire-and-forget toast.
Mirrors the v0.21.2 `MoveRejectedEvent` → `Error` toast wiring:
domain message crosses the plugin boundary, the animation
plugin's `handle_warning_toast` system reads it and spawns. Not
queued (Warning is alert-shaped, not info-shaped — should never
block on a queue).
- **Daily-challenge-expiry warning** (`279e23d`). First in-engine
driver of `WarningToastEvent`. New
`daily_challenge_plugin::check_daily_expiry_warning` system
fires at most once per `DailyChallengeResource::date` when the
player is within 30 min of UTC midnight reset and today's
challenge isn't yet complete. Suppression decided by a pure
helper (`compute_expiry_warning_minutes`) covering: already-
completed-today, already-shown-for-this-date, outside the
threshold window, post-midnight rollover. Pure-helper-plus-
thin-system shape because `Utc::now()` can't be pinned without
injecting a clock resource — overkill for one consumer.
- **`radial_rim_outline` pure helper** (`c153363`). Decision
logic for the radial-menu rim outline colour. Resting outlines
always carry `BORDER_SUBTLE`; focused outlines carry
`BORDER_STRONG` normally and `BORDER_SUBTLE_HC` under HC. Naive
marker substitution would invert the focused-vs-resting
hierarchy because `BORDER_SUBTLE_HC` (`#a0a0a0`) is *lighter*
than `BORDER_STRONG` (`#505050`); folding the choice in here
keeps the focused rim more visible under HC, not less.
### Changed
- **HC marker pattern extended to HUD action buttons + modal
buttons** (`c153363`). Re-reading the code revealed both sites'
paint systems (`paint_action_buttons`, `paint_modal_buttons`)
only mutate `BackgroundColor` — `BorderColor` is set once at
spawn and never touched. So the existing
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
pattern works cleanly for both, no race. v0.21.2's carve-out
comment was based on assumed-but-not-actual race risk; this
cycle treats it as the doc-vs-implementation drift pattern in
the wild and verifies before trusting.
- **Radial menu rim folds HC into per-frame respawn**
(`c153363`). The rim is the only true dynamic-painter of the
three carved-out sites — `radial_redraw_overlay` despawns and
respawns all rim sprites every frame the radial is `Active`.
The `HighContrastBorder` marker can't apply (entities don't
persist across frames) so HC is read directly in the system
via `Option<Res<SettingsResource>>` and routed through
`radial_rim_outline`. The `Option<Res<...>>` shape preserves
test compatibility under `MinimalPlugins`.
- **Animation plugin registers `WarningToastEvent`** (`279e23d`).
Joins `InfoToastEvent`, `MoveRejectedEvent` etc. in
`AnimationPlugin::build`. Daily-challenge plugin also
registers it (idempotent) so the message exists when running
the daily plugin under `MinimalPlugins` without the animation
plugin attached.
### Documentation
- `SESSION_HANDOFF.md` refreshed twice this cycle — once after
the Toast Warning wiring (menu trimmed 5 → 4 options), and
again after the HC dynamic-paint rollout (menu trimmed 4 → 3,
with all remaining options now flagged as multi-session). The
`High-contrast accessibility mode` entry in the Visual-identity
follow-ups list is updated to reflect that no "un-tagged
because race-risk" surfaces remain.
### Stats
- **1207 passing tests / 0 failing** across the workspace
(net +12 from v0.21.2's 1195 baseline):
- 7 tests for `compute_expiry_warning_minutes` (`279e23d`)
covering each suppression rule + the inclusive boundary at
exactly 30 min remaining.
- 1 in-Bevy test (`check_system_fires_warning_event_only_once_per_day`)
pinning `DailyExpiryWarningShown`'s once-per-date
suppression and the symmetric "already-completed-today"
suppression.
- 4 truth-table tests for `radial_rim_outline` (`c153363`):
focused × HC. The "resting stays subtle under HC" test
explicitly documents *why* — it's the hierarchy-preservation
invariant a future refactor might be tempted to break.
- Zero clippy warnings under `cargo clippy --workspace
--all-targets -- -D warnings`.
- `cargo test --workspace` clean.
## [0.21.2] — 2026-05-08
Patch release for the post-v0.21.1 polish work. Three through-
lines: **accessibility extensions** (reduce-motion gating for
splash animations, full HC chrome rollout across 8 surfaces),
**replay polish** (floating MOVE chip above the focused card
during playback), and the **first real consumer of
`ToastVariant::Error`** (invalid-move feedback as the third leg
of the existing audio + visual rejection-feedback stool).
The accessibility extensions close two threads v0.21.1 left
explicitly open: reduce-motion was previously gated only on card
slide_secs, and HC borders had `BORDER_SUBTLE_HC` defined but no
consumers. v0.21.2 finishes both — non-essential motion in the
splash boot screen now respects reduce-motion, and every static-
border chrome surface (modal scaffold, tooltip, help / stats /
home / settings panels) boosts to the HC variant under high-
contrast mode. Dynamic-paint sites (HUD action buttons, modal
buttons, radial menu rim) intentionally stay un-tagged because
their existing paint cycles would race the HC system; they
remain open for a future iteration that needs a different shape.
### Added
- **`sync_pile_marker_visibility` system precursor was v0.21.1's;
this cycle adds**: `update_high_contrast_borders` system in
`settings_plugin` (`c9af1ea`). Walks all entities tagged with
`HighContrastBorder` each Update tick, swaps `BorderColor` to
`BORDER_SUBTLE_HC` when high-contrast mode is on. Compares
current colour and only mutates when different so Bevy's
change-detection doesn't trigger repaints every frame. New
`HighContrastBorder { default_color: Color }` component carries
the off-state colour at each tagged site so the system can
revert correctly.
- **HC chrome rollout — 8 tagged surfaces** (`c9af1ea` modal
scaffold; `d87761d` tooltip + onboarding key chips + help
panel key chips + stats panel cells; `ec804d5` home Level/XP/
Score row + home mode-selector buttons + home mode-hotkey
chips + 4 settings panel surfaces). Each tagging is one line
on the spawn tuple. The marker-component architecture pays
back proportionally to the number of consumers — the per-
commit cost dropped from ~75 lines (foundation + first
surface) to ~13 lines (4 surfaces) to ~9 lines (7 surfaces).
- **Floating MOVE chip during replay** (`2fb2d63`). New
`ReplayFloatingProgressChip` marker on a `Text2d` entity
rendered in 2D world space above the destination pile of the
most-recently-applied move. Sibling of the banner overlay (not
a child) because it lives in world-space coordinates, not the
UI tree. Lifecycle matches the banner: `spawn_overlay` spawns
the chip alongside the banner when a replay starts;
`react_to_state_change` despawns it when the replay ends.
World-space placement (rather than UI-space + camera projection)
uses the same `LayoutResource` pile coordinates that drive
every other piece of pile geometry — stays correctly positioned
through window resizes for free. Hidden when cursor=0 (no
moves applied yet) or when the last applied move was a
`StockClick` (no destination pile to follow).
- **`handle_move_rejected_toast` system + first real
`ToastVariant::Error` consumer** (`68d50b5`). When
`MoveRejectedEvent` fires (illegal placement attempt), spawns
a 2-second pink-bordered "Invalid move" toast. Joins the
existing `card_invalid.wav` (audio cue) and destination-pile
shake (visual cue) as the accessibility-focused readable text
channel — covers deaf players (no audio reliance) and
reduce-motion players (no shake reliance) with a persistent
~2 s text cue. Drops the `#[allow(dead_code)]` from
`ToastVariant::Error` and updates its doc to point at the new
consumer.
### Changed
- **Splash scanline overlay skipped under reduce-motion**
(`ed152e2`). `spawn_splash` reads `Settings::reduce_motion_mode`
and skips the scanline texture / overlay node entirely when
on. Without the scanlines the boot screen still reads as
terminal-themed (foreground content, borders, palette swatches
unchanged); the scanlines are decorative.
- **Splash cursor pulse held under reduce-motion** (`ed152e2`).
`pulse_splash_cursor` reads `Settings::reduce_motion_mode` and
skips the per-frame sine-pulse multiplier when on — the cursor
still fades in / out with the global splash alpha (essential
timing) but doesn't blink. Spec calls out non-essential motion
as the reduce-motion target; the global fade is essential
(otherwise the splash would hard-cut on/off, which is
jarring), and the cursor blink is decorative.
- **`AnimationPlugin::build` registers
`MoveRejectedEvent`** (`68d50b5`). Bevy's `add_message` is
idempotent, so the duplicate registration with
`feedback_anim_plugin` (which already registered the message)
coexists cleanly. Required for the new
`handle_move_rejected_toast` system to run under
MinimalPlugins (tests).
### Documentation
- `docs/ui-mockups/design-system.md` and `SESSION_HANDOFF.md`
refreshed in lockstep with the rollouts. The handoff's
Resume-prompt menu trimmed twice this cycle as Options A and F
closed in v0.21.1, then this commit cycle's accessibility
extensions implicitly closed the "future scope" footnotes
v0.21.1 left on F's documentation.
### Stats
- **1195 passing tests / 0 failing** across the workspace
(net +3 from v0.21.1's 1192 baseline). New tests added by
this cycle:
- `splash_skips_scanline_overlay_under_reduce_motion`
(`ed152e2`) pins the reduce-motion gate on the splash
scanline overlay. Discovered an asset-fixture bootstrapping
detail along the way: under `MinimalPlugins`,
`Assets<Image>` isn't auto-inserted; the test had to add
`bevy::asset::AssetPlugin::default()` and
`init_asset::<bevy::image::Image>()`. Pattern flagged for
future asset-using tests.
- `floating_chip_spawns_and_despawns_with_overlay`
(`2fb2d63`) pins the floating MOVE chip's lifecycle:
absent on Inactive, exactly one on Playing, absent again
on return to Inactive.
- `move_rejected_event_spawns_error_toast` (`68d50b5`) pins
the new toast wiring: firing a `MoveRejectedEvent` spawns
exactly one `ToastOverlay` on the next tick.
- Zero clippy warnings under `cargo clippy --workspace
--all-targets -- -D warnings`.
- `cargo test --workspace` clean.
## [0.21.1] — 2026-05-08 ## [0.21.1] — 2026-05-08
Generated
+5
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]]
@@ -6984,8 +6986,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"bevy",
"chrono", "chrono",
"dirs", "dirs",
"jni 0.21.1",
"jsonwebtoken", "jsonwebtoken",
"keyring-core", "keyring-core",
"reqwest", "reqwest",
@@ -7009,6 +7013,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" }
+169 -144
View File
@@ -1,94 +1,98 @@
# Solitaire Quest — Session Handoff # Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08 — **v0.21.0 cut and tagged at `04f9bf9`**, **Last updated:** 2026-05-08 — **v0.21.8 tagged at `c50eaf8`**;
working tree clean, all post-tag work pushed to origin. nine post-cut commits on master. Push pending.
v0.21.0 closes the visual-identity arc opened in v0.20.0. Three v0.21.8 closes the last optional polish items in the B-2
through-lines landed in this cycle: the **card-face / suit / replay screen-takeover arc: **notch-label centering** (middle
card-back artwork migration** that v0.20.0 deliberately deferred three scrub-bar labels now centred on their notch ticks via the
(both rendering paths in lockstep — `assets/cards/*.png` fallback CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
plus the bundled-default theme SVGs at MOVE HC legibility** (lime stays lime under HC mode via the
`solitaire_engine/assets/themes/default/*.svg` that extended `HighContrastBackground::with_hc` constructor and a
`include_bytes!()`-embed into the binary), the **splash boot- new `STATE_SUCCESS_HC` brighter-lime constant). The replay
screen + replay-overlay polish** that closed Resume-prompt overlay arc is now fully closed with no known open items.
Options B and C, and a late-cycle **`ACCENT_PRIMARY` palette
swap** from cyan `#6fc2ef` to brick red `#a54242` after a quick
stakeholder review of the shipped art.
Full v0.21.0 detail lives in `CHANGELOG.md` § [0.21.0]. This Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
file from here on focuses on what's *open* post-cut and how to file from here on focuses on what's *open* post-cut and how to
resume. resume.
## Status at pause ## Status at pause
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is - **HEAD locally:** `f281425` (Android Keystore JNI).
`04f9bf9`; any post-cut docs edits ride on top of that. Docs ride on top; push pending.
- **HEAD on origin:** matches local. v0.21.0 is fully on origin. - **HEAD on origin:** `395a322` (double-tap commit — last pushed).
- **Working tree:** clean. No WIP outstanding. - **Working tree:** clean (docs uncommitted). No WIP outstanding.
- **`artwork/` directory:** still untracked. Intentional. - **`artwork/` directory:** still untracked. Intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` - **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean. clean.
- **Tests:** **1184 passing / 0 failing** across the workspace - **Tests:** **1292 passing / 0 failing** across the workspace.
(net +8 from v0.20.0's 1176 baseline). Detail in - **Tags on origin:** `v0.9.0` through `v0.21.8`.
`CHANGELOG.md` § [0.21.0] § Stats. - **Android:** APK verified booting on Pixel_7 AVD (Android 14,
- **Tags on origin:** `v0.9.0` through `v0.21.0`. v0.21.0 is on x86_64). All desktop-only systems (handle_fullscreen) now gated.
`04f9bf9`; v0.20.0 stays on `41a009a`. See Phase Android punch list for remaining work.
## Since the v0.21.0 cut ## Since the v0.21.8 cut
Two Resume-prompt options closed post-tag (2026-05-08): Seven commits since the v0.21.8 tag:
- `a449f60` — Stats Prev/Next selector spawn site
- `202a64d` — Android launch fixes (android_main, resize_constraints,
apply_smart_default_window_size) — **closes APK launch verification**
- `16242e6` — Ignore .idea/ IDE files
- `395a322` — double-tap auto-move for touch input
- `0cb1587` — Play-by-Seed dialog + HomeMode card
- `2062bd0` — 75 new challenge seeds + gen_seeds binary
- `45436d0` — gate handle_fullscreen to non-Android
- `2c822ba` — JNI clipboard bridge for Android Stats share-link
- `f281425` — Android Keystore AES-GCM token storage via JNI
- **Option A — App icon round** (`3eb3a26` + `716a025`). 9-size CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
PNG hierarchy in `assets/icon/` (16/24/32/48/64/128/256/512/
1024 px), generated by a new `icon_generator` example from a
shared `icon_svg` builder (Terminal `▌RS` mark on dark
`#151515` with brick-red accent). Runtime `Window::icon`
wired via `WinitWindows` on desktop only (Android draws its
launcher icon from the APK manifest). The follow-up fix
`716a025` wraps `NonSend<WinitWindows>` in `Option<...>`
to satisfy Bevy 0.18's stricter system-param validation —
the resource doesn't exist on the first few frames before
winit's `Resumed` event fires. New deps (target-gated
non-Android): direct `winit = "0.30"` for `Icon`
construction, direct `tiny-skia` for PNG → RGBA decode.
Pin test `icon_svg_pin` guards future rasteriser drift.
- **Option F — Accessibility modes** (`c5787c6` + `07e0357`).
High-contrast and reduce-motion settings flags wired through
the engine and surfaced as Settings panel toggles. HC boosts
`RED_SUIT_COLOUR` to `#ff8aa0` and `BLACK_SUIT_COLOUR` to
`#f5f5f5` for card text rendering; reduce-motion forces
`effective_slide_secs` to 0 regardless of `AnimSpeed`. CBM
and HC compose: lime CBM wins on red when both are on; HC
still applies to black suits when both are on. Six new
tests pin the truth tables. UI toggles sit alongside the
Color-blind row in Settings → Cosmetic; tab-walk visits
all three accessibility flags in one vertical run.
Three Resume-prompt options remain live: B (APK launch Open next-step menu:
verification), C (replay-overlay extensions), D (Toast 1. **Phase 8 (sync)** — the biggest open arc. Local storage
Warning/Error wiring), E (Phase 8 sync). The visible-payoff scaffolding, self-hosted Axum server, GPGS stub.
pieces of the post-v0.21.0 menu have shipped; what's left is 2. **Android follow-ups** — JNI ClipboardManager, Android Keystore,
Android runtime work, replay-overlay polish, sync infrastructure, GPGS. Launch verification and double-tap both closed; these
and toast-event sourcing. are the remaining Phase Android items.
3. **Move Log auto-scroll** — only relevant if the panel
row count grows beyond the current 5-row fixed window.
## Open punch list ## Open punch list
### Phase Android (build + persistence shipped; runtime gaps remain) ### Phase Android (build + persistence shipped; runtime gaps remain)
- **APK launch verification on AVD / device.** `adb install` then - *APK launch verification — closed 2026-05-08 by `202a64d`.*
`adb logcat` against the `bevy_test` AVD or an x86_64 device. Three fixes shipped: `android_main` export (missing NativeActivity
The build works and persistence is wired, but no end-to-end entry point), `resize_constraints` gated to non-Android (max=0
device run has been logged. Shakes out runtime bugs the build + panic), `apply_smart_default_window_size` gated to non-Android
unit tests can't catch. (clamp panic on zero-dimension window event). Verified booting on
- **JNI ClipboardManager bridge.** Replaces the Android stub for Pixel_7 AVD (Android 14, x86_64, SwiftShader Vulkan), 2+ min
the Stats "Copy share link" toast. `arboard` doesn't ship an runtime without crash. B0004 ECS hierarchy warnings remain
Android backend; small custom JNI call. (non-fatal; entity parent/child component mismatch); investigate
- **Android Keystore for credentials.** `keyring` is target-gated if they surface gameplay bugs.
to a stub returning `KeychainUnavailable`; replace with Android - *Double-tap auto-move — closed 2026-05-08 by `395a322`.*
Keystore via JNI when sync auth ships on mobile. `handle_double_tap` fires `MoveRequestEvent` on two rapid
- **Google Play Games (gpgs) integration.** Listed as a `TouchPhase::Ended` events within 0.5 s. Prefers foundation;
Phase-Android target since Phase 1; now unblocked by the build falls back to tableau stack move. Fires `MoveRejectedEvent` when
target. no legal destination exists. System runs before `touch_end_drag`
in the chain so drag state is readable.
- *F11 fullscreen gate — closed 2026-05-08 by `45436d0`.*
`handle_fullscreen` and its `MonitorSelection`/`WindowMode`
imports are `#[cfg(not(target_os = "android"))]`-gated. The
`add_systems` call is a separate statement (not mid-chain).
- *JNI ClipboardManager bridge — closed 2026-05-08 by `2c822ba`.*
`android_clipboard::set_text(url)` calls `ClipboardManager` via
JNI. Stats share-link button now writes to the clipboard with a
"Copied: {url}" toast; falls back to "Share link: {url}" on JNI
error. Requires AVD functional test (see verification steps in
the approved plan).
- *Android Keystore for credentials — closed 2026-05-08 by `f281425`.*
`android_keystore` module: AES-256/GCM/NoPadding device-bound key,
tokens serialised to JSON and stored atomically at
`{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+tag]`.
`auth_tokens.rs` Android stubs now delegate to it. Key
invalidation (biometric reset) → `TokenError::KeychainUnavailable`.
Requires AVD functional test before Phase 8 sync goes live on
Android.
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign - **Cosmetic `cargo apk build --lib` workaround.** Post-sign
panic doesn't affect the APK on disk but produces noisy stderr. panic doesn't affect the APK on disk but produces noisy stderr.
Either upstream a cargo-apk fix or document `--lib` as Either upstream a cargo-apk fix or document `--lib` as
@@ -101,34 +105,57 @@ chrome migration, splash boot screen, replay-overlay banner,
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY` card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open: palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
- **Replay-overlay screen-takeover redesign.** The full mockup - *Replay-overlay screen-takeover redesign — closed 2026-05-08
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a across 13 commits (v0.21.4v0.21.7).* The full mockup
mini-tableau preview, playback controls, move-log scroll, and (`docs/ui-mockups/replay-overlay-mobile.html`) has shipped:
a WIN MOVE marker on the scrub bar. Banner-local pieces all banner chrome (v0.21.0), floating MOVE chip (v0.21.2), WIN
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` + MOVE scrub-bar marker (post-v0.21.3), playback controls /
`e080b49`); the screen-takeover is a multi-session redesign Space accelerator (post-v0.21.3), scrub notches + labels +
with data-layer impact (move-log scroller; WIN MOVE needs a keybind footer + ESC / ← / → accelerators + HC border
`win_move_index` field on `Replay` that doesn't yet exist). (v0.21.5), Move Log panel + HC scrub track + continuous
- **Floating `MOVE N/M` chip above the focused card during scrub (v0.21.6), and full-screen 50 % opacity dim layer
playback.** Cross-plugin work — `update_progress_text` writes (v0.21.7). Every major B-2 sub-piece is now closed. The
the banner chip but the card-position lookup belongs in only remaining items are minor polish: notch-label centering
`card_plugin`. Smaller scope than the screen-takeover. and WIN MOVE HC contrast bump (see Open next-step menu).*
- **Toast Warning / Error variants.** `ToastVariant` has slots - *Floating `MOVE N/M` chip above the focused card during
for `Warning` (gold) and `Error` (pink) but no in-engine playback — closed 2026-05-08 by `2fb2d63`.* World-space
event uses them yet. Wire when a warning- or error-flavoured `Text2d` entity sibling to the banner overlay; uses the same
toast event materialises. `LayoutResource` pile coordinates so it survives window
resizes without UI/camera math.
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
Daily-challenge-expiry toast fires once per `daily.date` when
within 30 min of UTC midnight reset and today is incomplete.
`ToastVariant` is now fully load-bearing (every variant has at
least one real driver). Future Warning drivers can either reuse
the generic `WarningToastEvent(String)` carrier or add their
own domain message + `animation_plugin` handler.
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
`MoveRejectedEvent` now fires a 2-second pink-bordered
"Invalid move" toast as the third leg of the
audio + visual + text rejection-feedback stool.
- *High-contrast accessibility mode — closed 2026-05-08 by - *High-contrast accessibility mode — closed 2026-05-08 by
`c5787c6` + `07e0357`.* Card text rendering picks up `c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
`TEXT_PRIMARY_HC` (`#f5f5f5`) and `RED_SUIT_COLOUR_HC` rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
(`#ff8aa0`); Settings panel has a toggle. Future scope: dynamic-paint rollout (`c153363`).* Card text rendering plus
extend HC through chrome borders (`BORDER_SUBTLE_HC` already 8 static-border chrome surfaces (modal scaffold, tooltip,
defined, not yet consumed), buttons, popover edges. onboarding key chips, help panel key chips, stats panel
- *Reduced-motion mode — closed 2026-05-08 by the same pair.* cells, home Level/XP/Score row, home mode buttons, home
`effective_slide_secs` forces 0 when on, regardless of the mode-hotkey chips, 4 settings panel surfaces) all boost
`AnimSpeed` setting. Future scope: gate splash scanline borders to `BORDER_SUBTLE_HC` under HC via the
overlay + cursor pulse animation on the same flag, gate `HighContrastBorder` marker. The previously-carved-out
warning-chip pulse, gate any future card-lift z-bump dynamic-paint sites are now also covered: HUD action buttons
animation. and modal buttons take the same marker (their paint cycles
only mutate `BackgroundColor`, so no race); the radial menu
rim folds HC into its per-frame spawn via
`radial_rim_outline` so the focused rim boosts to
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
hierarchy that naive marker substitution would invert).
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
card animations; `pulse_splash_cursor` skips the per-frame
pulse multiplier; `spawn_splash` skips the scanline overlay
entirely. Future scope: gate any future card-lift z-bump
animation, warning-chip pulse (when one materialises).
### Carried forward from v0.19.0 ### Carried forward from v0.19.0
@@ -144,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
@@ -232,20 +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.0 is tagged at 04f9bf9 (cut 2026-05-08). Branch: master. v0.21.8 is tagged at c50eaf8 (cut 2026-05-08,
Working tree clean. v0.21.0 closed the visual-identity arc that replay-overlay polish). Seven post-cut commits are on master (see
v0.20.0 deferred — full Terminal cards on both rendering paths "Since the v0.21.8 cut" above); push of the last four pending.
(asset PNGs + bundled-default theme SVGs), splash boot screen, v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
replay-overlay banner enrichments, and a project-wide ACCENT_PRIMARY v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
swap from cyan to brick red `#a54242`. See CHANGELOG.md § [0.21.0] v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
for full detail. Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
pending. See CHANGELOG.md § [0.21.9] for full detail.
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests State: HEAD locally — see `git rev-parse HEAD`. Workspace
pass (1184+; check with `cargo test --workspace`), clippy clean. tests: 1292 passing / 0 failing. Clippy clean.
READ FIRST (in order, before doing anything): READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — this file 1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.21.0] 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
@@ -260,38 +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. *Closed 2026-05-08 by `3eb3a26` + `716a025`.* App icon A. Android follow-ups — JNI ClipboardManager bridge (arboard
round — runtime `Window::icon` wired plus a 9-size PNG has no Android backend), Android Keystore (blocked on Phase 8).
hierarchy at `assets/icon/`. `.ico` / `.icns` bundle Launch verification + double-tap are closed.
formats stay open if the project later ships as a B. Phase 8 (sync) — local storage scaffolding, self-hosted
packaged macOS / Windows app. Axum server, `SolitaireServerClient` impl. The biggest open
B. APK launch verification on AVD / device — `adb install` + arc by scope; rolls up Android dependencies (Keystore,
`adb logcat` to shake out runtime bugs the build / unit
tests can't catch. Likely surfaces JNI ClipboardManager
and Android Keystore stubs that need real bridges. Larger
scope; needs an Android device or emulator running.
C. Replay-overlay extensions — either the floating `MOVE N/M`
chip above the focused card (smaller, cross-plugin; needs
cursor → card-position plumbing in `card_plugin`) or the
full screen-takeover redesign (multi-session: move-log
scroll, mini tableau preview, WIN MOVE marker, data-layer
impact for `Replay::win_move_index`).
D. Toast Warning / Error variant wiring. UI infrastructure
exists in `ToastVariant`; no in-engine event uses Warning
(gold) or Error (pink) yet. Wire when a real warning- or
error-flavoured event materialises.
E. 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).
F. *Closed 2026-05-08 by `c5787c6` + `07e0357`.* High-contrast C. Play-by-Seed polish — the dialog is functional but has no
and reduced-motion accessibility modes — Settings flags visual preview of the solver verdict in the UI yet; the
+ UI toggles + engine wiring. Card text rendering uses HomeMode card is wired but the dialog spawn site and verdict
HC variants when on; card slide_secs forces to 0 when display could use a second pass.
reduce-motion is on. Future scope: extend HC through
chrome borders, buttons; gate splash + warning-chip
animations on reduce-motion.
WORKFLOW NOTES: WORKFLOW NOTES:
- Use the system git config (already correct). - Use the system git config (already correct).
@@ -308,6 +323,16 @@ WORKFLOW NOTES:
migration walked past this" follow-ups that all matched migration walked past this" follow-ups that all matched
this shape — codified here so future similar work can this shape — codified here so future similar work can
pattern-match instead of rediscovering. pattern-match instead of rediscovering.
- Doc-vs-implementation drift pattern: v0.21.1's pile-marker
visibility fix (`4d48cad`) implemented an invariant that
had been declared in a module doc comment but was never
enforced in code. When future work touches a module with
a "this does X" doc comment, verify the code actually does
X and add a test if not. Two layers, two checks.
OPEN AT THE START: ask which of AF. 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
gated on Android tooling; B and C are explicitly multi-session
arcs). A fresh session is a better fit for any of them than the
tail of a long working stretch.
``` ```
+137
View File
@@ -0,0 +1,137 @@
# Android Playability TODO
**Started:** 2026-05-10 — first hardware screenshot of v0.22.3 APK
running on a real device showed the desktop HUD projected onto a
360 dp portrait viewport with no mobile adaptation. This list
tracks the work needed to make the APK genuinely playable, not
just "boots without crashing."
**Context:** v0.22.3 (signed release APK) builds and launches.
JNI bridges (clipboard, keystore) compile but are untested on
hardware. The work below is UI/UX port work — no architectural
rewrites required.
---
## Reading from the v0.22.3 screenshot
| Region | Observation |
|--------|-------------|
| Top ~5 % | System bar (clock, signal, battery) overlapped by game HUD — no safe-area inset |
| HUD text row | `Score:0 Pause Esc Help A Modes [] New_Game N Moves:0 0:08` all overlapping — desktop layout crammed into 360 dp |
| Keyboard hints | `Esc`, `A`, `[]`, `N` shown next to buttons — meaningless on touch |
| Foundations row | Leftmost foundation (♥) clipped left; rightmost tableau column (♠ 4) clipped right |
| Card backs | Face-down cards render as solid red squares, not back-art texture |
| Vertical use | Cards occupy top ~30 % only; bottom 70 % empty black — no portrait-aware layout |
| Bottom edge | No accommodation for Android gesture / home-indicator area |
---
## P0 — Blocking playability
- [x] **Safe-area insets (top + bottom).** *Closed 2026-05-10 by
`b9aa262`.* `SafeAreaInsets` resource + `SafeAreaInsetsPlugin`
query `WindowInsets.getInsets(systemBars())` via JNI on Android;
HUD anchors carry `SafeAreaAnchoredTop { base_top }` and the
change-detection fix-up system re-applies `base_top + insets.top`
whenever the resource updates. Bottom inset is captured but not
yet consumed (waits for bottom-anchored UI).
- [x] **Mobile HUD layout.** *Closed 2026-05-10.* Both the left HUD
column and the right action button row are now capped at
`max_width: 50 %` and the button row + tier-row child Nodes carry
`flex_wrap: Wrap`. On a 360 dp viewport the 6-button row breaks
to multiple lines (right-justified) and the tier rows wrap
individually instead of overflowing into the action column. On
desktop (≥ 1280 px) the 50 % cap is wider than any natural row
width so the existing single-line layout is unchanged.
- [x] **Card-back asset not rendering.** *Closed 2026-05-10 by
`fcc7337`.* `AssetPlugin::file_path = "../assets"` was set
unconditionally to fix the desktop `cargo run -p solitaire_app`
CWD relativity, but on Android cargo-apk packages the same
directory into the APK at `assets/` and Bevy's
AndroidAssetReader is already rooted there — prepending `../`
walked the reader out of the APK assets root and every load
failed silently. The face-down branch then fell through to the
`card_back_colour(0)` solid-red brick fallback. Gated the
override behind `#[cfg(not(target_os = "android"))]`.
- [x] **Viewport overflow.** *Closed 2026-05-10.* `compute_layout`
was clamping the input window up to `MIN_WINDOW = 800 × 600`,
so a 360 dp phone got laid out as if it were 800-wide and the
outer piles fell outside the actual viewport. Lowered the floor
to 320 × 400 (below the smallest reasonable phone) so real
Android resolutions flow through without clamping, while keeping
a sentinel to guard against degenerate / startup-zero windows.
New regression test `phone_portrait_layout_fits_horizontally`
asserts all 13 piles fit a 360 × 800 viewport.
## P1 — Touch UX
- [x] **Suppress keyboard-hint labels on Android.** *Closed
2026-05-10.* `spawn_action_button` now nulls the `hotkey`
argument on Android via a `#[cfg(target_os = "android")]` rebind,
so the U / Esc / F1 / N chips next to the action row labels
disappear on touch builds. Other hint sites (onboarding panel,
pause-modal `Esc` hint, mode-card hotkey chips on the home
screen, replay overlay footer, modal toggle hints in
profile/stats/leaderboard/settings, help screen) survive — they
live behind navigation and a touch user reaches them less often.
Track as a P3 sweep when more screens are audited on hardware.
- [x] **Thumb-sized hit targets.** *Closed 2026-05-10.* Action
button Node carries `min_width: Val::Px(48.0), min_height:
Val::Px(48.0)` — meets Material's 48 dp baseline on touch and is
a no-op for buttons whose content already exceeds 48 px in
either axis. Applied universally rather than cfg-gated since
Material's guideline applies to all input modes. Cards, pile
markers, modal close buttons not yet audited — track as P3 if
they fall below threshold on hardware.
- [ ] **Portrait-first card spacing.** Stretch tableau piles vertically
to fill height; reduce inter-pile gaps so 7 columns fit in 360 dp.
- [ ] **Double-tap auto-move visible feedback.** `handle_double_tap`
exists since `395a322` — verify it triggers on hardware and add a
brief source-card flash / highlight to confirm to the user.
## P2 — Polish
- [ ] **Drag responsiveness on touch.** Bevy default touch-to-mouse
mapping can lag; confirm drag start threshold isn't too high for a
finger.
- [ ] **Long-press menu.** Alternative to right-click (which doesn't
exist on touch). Wire to the existing right-click-highlight system.
- [ ] **HUD typography.** Reduce text sizes for `Score:`, `Moves:`,
timer so they fit cleanly in one row.
- [ ] **Orientation lock.** Set `android:screenOrientation="portrait"`
in cargo-apk manifest (or design a landscape layout).
## P3 — Asset density
- [ ] **Density-aware card scaling.** Currently single texture size; on
a high-DPI phone the cards look small. Scale by
`Window::scale_factor()` or ship multiple PNG sizes.
- [ ] **App-icon density buckets.** Nine sizes already exist in
`assets/icon/`; verify the manifest references them so Android's
launcher picks the right one.
## P4 — Stability / runtime
- [ ] **B0004 ECS hierarchy warnings.** Flagged in
`SESSION_HANDOFF.md` after APK launch verification — investigate
whether they cause gameplay bugs on hardware vs. AVD.
- [ ] **AVD functional tests for JNI bridges.** Clipboard (`2c822ba`)
and Keystore (`f281425`) shipped but never tested on real device
or AVD.
---
## Notes / decisions
* This list is screenshot-driven; expect more items to surface once
P0 unblocks actually moving cards on hardware.
* The pattern across all the bugs is "no one ran the relevant code
path on Android yet." The hard work — Bevy 0.18 on Android,
JNI bridges, signed CI builds — is done. What's left is a
coordinated pass of `#[cfg(target_os = "android")]` gates plus
making `LayoutResource` query the real surface size.
* Where possible, prefer responsive layout (query window size) over
branching `#[cfg]` blocks. Branches are fine for input methods
(touch vs. mouse) but not for screen geometry — a foldable or
desktop window of equivalent size should look the same.
+55 -13
View File
@@ -18,21 +18,23 @@ 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, SafeAreaInsetsPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, SelectionPlugin, SettingsPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
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 +78,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 +119,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,
@@ -126,11 +132,20 @@ pub fn run() {
..default() ..default()
}) })
// The `assets/` directory lives at the workspace root, but // The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the // on desktop Bevy resolves `AssetPlugin::file_path` relative
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`). // to the binary package's `CARGO_MANIFEST_DIR`
// Point one level up so `cargo run -p solitaire_app` finds // (`solitaire_app/`), so `cargo run -p solitaire_app` would
// card faces, backs, backgrounds, and the UI font. // miss the workspace-root `assets/` without a `../` prefix.
//
// On Android cargo-apk packages the same directory into the
// APK at `assets/` (via `[package.metadata.android].assets`
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
// is already rooted there, so any `file_path` other than the
// default makes it walk *out* of the APK's assets root and
// all loads fail silently — which is what produced the
// solid-red card-back fallback in the v0.22.3 screenshot.
.set(bevy::asset::AssetPlugin { .set(bevy::asset::AssetPlugin {
#[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(), file_path: "../assets".to_string(),
..default() ..default()
}), }),
@@ -142,6 +157,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,7 +180,10 @@ 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(SafeAreaInsetsPlugin)
.add_plugins(HudPlugin) .add_plugins(HudPlugin)
.add_plugins(HelpPlugin) .add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default()) .add_plugins(HomePlugin::default())
@@ -195,6 +220,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 +242,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 +363,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,
+109
View File
@@ -147,12 +147,38 @@ pub struct Replay {
/// [`REPLAY_SCHEMA_VERSION`]. /// [`REPLAY_SCHEMA_VERSION`].
#[serde(default)] #[serde(default)]
pub share_url: Option<String>, pub share_url: Option<String>,
/// Index into [`moves`](Self::moves) of the move that triggered
/// the win condition (i.e. completed the last foundation pile).
///
/// For replays recorded by the live engine this is always
/// `Some(moves.len() - 1)` because recording freezes on win — but
/// the field is stored explicitly so the playback UI can read it
/// directly without re-deriving "the last move was the win" each
/// time, and to leave room for future recording semantics that
/// might capture post-win state.
///
/// `None` for replays loaded from disk that pre-date this field.
/// `#[serde(default)]` keeps older `latest_replay.json` /
/// `replays.json` files loadable without bumping
/// [`REPLAY_SCHEMA_VERSION`] — this is an additive optional
/// field, not a schema-breaking change.
///
/// Surfaced by the replay-overlay scrub bar's WIN MOVE marker
/// (B-2 screen-takeover redesign) when present.
#[serde(default)]
pub win_move_index: Option<usize>,
} }
impl Replay { impl Replay {
/// Construct a fresh replay with the current schema version. The /// Construct a fresh replay with the current schema version. The
/// caller fills in the recorded fields; this is the canonical /// caller fills in the recorded fields; this is the canonical
/// constructor used by the engine on win. /// constructor used by the engine on win.
///
/// [`win_move_index`](Self::win_move_index) and
/// [`share_url`](Self::share_url) default to `None` — the engine
/// uses [`with_win_move_index`](Self::with_win_move_index) at the
/// recording site to set the former, and `sync_plugin` writes the
/// latter directly when the upload task resolves.
pub fn new( pub fn new(
seed: u64, seed: u64,
draw_mode: DrawMode, draw_mode: DrawMode,
@@ -172,8 +198,24 @@ impl Replay {
recorded_at, recorded_at,
moves, moves,
share_url: None, share_url: None,
win_move_index: None,
} }
} }
/// Builder-style setter for [`win_move_index`](Self::win_move_index).
/// Returns `self` so the recording site can chain it onto
/// [`Replay::new`]:
///
/// ```ignore
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
/// ```
///
/// `None` is a valid input — useful for tests that don't care about
/// the WIN MOVE marker's scrub-bar position.
pub fn with_win_move_index(mut self, idx: Option<usize>) -> Self {
self.win_move_index = idx;
self
}
} }
/// Rolling history of the player's most recent winning replays. /// Rolling history of the player's most recent winning replays.
@@ -737,4 +779,71 @@ mod tests {
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
// -----------------------------------------------------------------------
// win_move_index — additive optional field for the WIN MOVE marker
// -----------------------------------------------------------------------
#[test]
fn replay_new_defaults_win_move_index_to_none() {
let r = sample_replay();
assert_eq!(r.win_move_index, None);
}
#[test]
fn with_win_move_index_sets_value() {
let r = sample_replay().with_win_move_index(Some(3));
assert_eq!(r.win_move_index, Some(3));
}
#[test]
fn with_win_move_index_accepts_none() {
// Passing None through the builder is a valid no-op — useful for
// tests / synthetic replays that don't care about the marker.
let r = sample_replay().with_win_move_index(None);
assert_eq!(r.win_move_index, None);
}
#[test]
fn replay_with_win_move_index_round_trips_on_disk() {
let path = tmp_path("win_move_index_round_trip");
let _ = fs::remove_file(&path);
let original = sample_replay().with_win_move_index(Some(3));
save_latest_replay_to(&path, &original).expect("save");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, Some(3));
assert_eq!(loaded, original);
let _ = fs::remove_file(&path);
}
/// Older replay files written before this field was added must still
/// load — `#[serde(default)]` keeps `win_move_index` optional and
/// defaults missing fields to `None`. This is the contract that lets
/// us add the field without bumping `REPLAY_SCHEMA_VERSION`.
#[test]
fn replay_without_win_move_index_loads_with_none() {
let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path);
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
let v2_no_field = r#"{
"schema_version": 2,
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}"#;
fs::write(&path, v2_no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None);
assert_eq!(loaded.schema_version, REPLAY_SCHEMA_VERSION);
let _ = fs::remove_file(&path);
}
} }
+9 -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 }
@@ -1445,6 +1445,7 @@ mod tests {
replay: dummy_replay(), replay: dummy_replay(),
cursor: 0, cursor: 0,
secs_to_next: 0.0, secs_to_next: 0.0,
paused: false,
}; };
app.update(); app.update();
assert!( assert!(
@@ -1480,6 +1481,7 @@ mod tests {
replay: dummy_replay(), replay: dummy_replay(),
cursor: 0, cursor: 0,
secs_to_next: 0.0, secs_to_next: 0.0,
paused: false,
}; };
app.update(); app.update();
@@ -1512,6 +1514,7 @@ mod tests {
replay: dummy_replay(), replay: dummy_replay(),
cursor: 0, cursor: 0,
secs_to_next: 0.0, secs_to_next: 0.0,
paused: false,
}; };
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = *app.world_mut().resource_mut::<ReplayPlaybackState>() =
@@ -1534,6 +1537,7 @@ mod tests {
replay: dummy_replay(), replay: dummy_replay(),
cursor: 0, cursor: 0,
secs_to_next: 0.0, secs_to_next: 0.0,
paused: false,
}; };
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = *app.world_mut().resource_mut::<ReplayPlaybackState>() =
@@ -1559,6 +1563,7 @@ mod tests {
replay: dummy_replay(), replay: dummy_replay(),
cursor: 0, cursor: 0,
secs_to_next: 0.0, secs_to_next: 0.0,
paused: false,
}; };
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = *app.world_mut().resource_mut::<ReplayPlaybackState>() =
+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}"))
}
+92 -5
View File
@@ -21,8 +21,10 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent; use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent}; use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{InfoToastEvent, XpAwardedEvent}; use crate::events::{
use crate::events::{AchievementUnlockedEvent, GameWonEvent}; AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, WarningToastEvent,
XpAwardedEvent,
};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
@@ -162,6 +164,8 @@ impl Plugin for AnimationPlugin {
.add_message::<ChallengeAdvancedEvent>() .add_message::<ChallengeAdvancedEvent>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
@@ -183,6 +187,8 @@ impl Plugin for AnimationPlugin {
handle_settings_toast, handle_settings_toast,
handle_auto_complete_toast, handle_auto_complete_toast,
handle_xp_awarded_toast, handle_xp_awarded_toast,
handle_move_rejected_toast,
handle_warning_toast,
tick_toasts, tick_toasts,
(enqueue_toasts, drive_toast_display).chain(), (enqueue_toasts, drive_toast_display).chain(),
) )
@@ -565,9 +571,11 @@ pub enum ToastVariant {
/// event; kept so future warning-flavoured toasts have a slot. /// event; kept so future warning-flavoured toasts have a slot.
#[allow(dead_code)] #[allow(dead_code)]
Warning, Warning,
/// Failure / rejected action — pink border. Currently unused; kept so /// Failure / rejected action — pink border. Used by
/// future error-flavoured toasts have a slot. /// [`handle_move_rejected_toast`] for illegal-placement
#[allow(dead_code)] /// feedback; the third leg of the rejection-feedback stool
/// alongside `card_invalid.wav` (audio) and the destination-
/// pile shake (visual).
Error, Error,
/// Reward / milestone — lavender border. Used for XP awards, /// Reward / milestone — lavender border. Used for XP awards,
/// achievement unlocks, level-ups, daily/weekly/challenge completions. /// achievement unlocks, level-ups, daily/weekly/challenge completions.
@@ -622,6 +630,47 @@ fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpA
} }
} }
/// Spawns a 2-second pink-bordered Error toast when the player tries an
/// illegal placement (`MoveRejectedEvent`). Adds a third leg to the
/// existing rejection feedback stool — `card_invalid.wav` already plays
/// (audio cue) and `feedback_anim_plugin::queue_shake_for_rejected_move`
/// fires the destination-pile shake (visual cue). The toast is the
/// accessibility-focused leg: persistent ~2 s text that's readable for
/// deaf players and impossible to miss for players who blink during the
/// shake. First in-engine consumer of `ToastVariant::Error` — exercises
/// the variant's pink border accent and the design-system "rejected
/// action" semantic.
fn handle_move_rejected_toast(
mut commands: Commands,
mut events: MessageReader<MoveRejectedEvent>,
) {
for _ev in events.read() {
spawn_toast(
&mut commands,
"Invalid move".to_string(),
2.0,
ToastVariant::Error,
);
}
}
/// Spawns a 4-second amber-bordered Warning toast for every incoming
/// [`WarningToastEvent`]. First in-engine consumer of
/// [`ToastVariant::Warning`] — exercises the variant's amber accent and
/// the design-system "act soon" semantic.
///
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
/// event (not a domain-specific one) because Warning has multiple
/// candidate drivers and the call-site knows the message wording.
fn handle_warning_toast(
mut commands: Commands,
mut events: MessageReader<WarningToastEvent>,
) {
for ev in events.read() {
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
}
}
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires. /// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
/// ///
/// Skipped while the game is paused so toast countdowns freeze along with the /// Skipped while the game is paused so toast countdowns freeze along with the
@@ -966,6 +1015,44 @@ mod tests {
let _ = count; let _ = count;
} }
#[test]
fn move_rejected_event_spawns_error_toast() {
// The first in-engine consumer of `ToastVariant::Error`. Firing
// a `MoveRejectedEvent` (illegal placement) must spawn exactly
// one `ToastOverlay` carrying the rejection-feedback message.
// Pairs the existing audio (`card_invalid.wav`) and visual
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
// with an accessibility-focused readable text cue.
use solitaire_core::pile::PileType;
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
// Baseline: no toast overlays exist before the event.
let before = app
.world_mut()
.query::<&ToastOverlay>()
.iter(app.world())
.count();
app.world_mut().write_message(MoveRejectedEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let after = app
.world_mut()
.query::<&ToastOverlay>()
.iter(app.world())
.count();
assert_eq!(
after,
before + 1,
"MoveRejectedEvent must spawn exactly one error toast",
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Task #67 — Toast queue pure-function tests // Task #67 — Toast queue pure-function tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+223 -3
View File
@@ -14,13 +14,13 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use chrono::{Local, NaiveDate}; use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to}; use solitaire_data::{daily_seed_for, save_progress_to};
use solitaire_sync::ChallengeGoal; use solitaire_sync::ChallengeGoal;
use crate::events::{ use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
XpAwardedEvent, WarningToastEvent, XpAwardedEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
@@ -30,6 +30,11 @@ use crate::sync_plugin::SyncProviderResource;
/// Bonus XP awarded for completing today's daily challenge. /// Bonus XP awarded for completing today's daily challenge.
pub const DAILY_BONUS_XP: u64 = 100; pub const DAILY_BONUS_XP: u64 = 100;
/// Minutes before UTC midnight at which the daily-challenge expiry warning
/// fires. The reset is global (UTC), so the warning is global too — local
/// midnight may be hours away or already past.
pub const DAILY_EXPIRY_WARNING_MINUTES: i64 = 30;
/// The active daily challenge — date + RNG seed for that date's deal, /// The active daily challenge — date + RNG seed for that date's deal,
/// plus optional goal metadata fetched from the server. /// plus optional goal metadata fetched from the server.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
@@ -74,6 +79,16 @@ pub struct DailyChallengeCompletedEvent {
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>); struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
/// already fired for, so the toast spawns at most once per day.
///
/// `None` until the first warning fires; thereafter holds the date the
/// warning was shown for. When `daily.date` advances (a new local day rolls
/// over while the app stays open), this becomes stale and the next warning
/// can fire.
#[derive(Resource, Default, Debug)]
struct DailyExpiryWarningShown(Option<NaiveDate>);
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion. /// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game. /// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
pub struct DailyChallengePlugin; pub struct DailyChallengePlugin;
@@ -82,18 +97,21 @@ impl Plugin for DailyChallengePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.insert_resource(DailyChallengeResource::for_today()) app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>() .init_resource::<DailyChallengeTask>()
.init_resource::<DailyExpiryWarningShown>()
.add_message::<DailyChallengeCompletedEvent>() .add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>() .add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge) .add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge) .add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight // record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame. // ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate)) .add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation)); .add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning);
} }
} }
@@ -215,6 +233,71 @@ fn handle_start_daily_request(
announce.write(DailyGoalAnnouncementEvent(desc)); announce.write(DailyGoalAnnouncementEvent(desc));
} }
/// Pure decision logic for the daily-challenge expiry warning. Returns the
/// integer minutes-until-UTC-midnight if a warning toast should fire on this
/// frame, or `None` if any suppression condition holds.
///
/// Suppression rules (in order):
/// 1. Player has already completed today's daily challenge.
/// 2. The warning has already fired for `daily_date`.
/// 3. UTC midnight is more than [`DAILY_EXPIRY_WARNING_MINUTES`] away.
/// 4. UTC midnight has already passed for the current calendar day (the
/// minutes-remaining is negative — happens for at most one frame at the
/// rollover boundary).
///
/// Factored out so the threshold/clock behavior is unit-testable without an
/// `App`.
fn compute_expiry_warning_minutes(
daily_date: NaiveDate,
last_completed: Option<NaiveDate>,
last_shown: Option<NaiveDate>,
now_utc: DateTime<Utc>,
threshold_mins: i64,
) -> Option<i64> {
if last_completed == Some(daily_date) {
return None;
}
if last_shown == Some(daily_date) {
return None;
}
let next_midnight = (now_utc.date_naive() + Duration::days(1))
.and_hms_opt(0, 0, 0)?
.and_utc();
let mins_remaining = (next_midnight - now_utc).num_minutes();
if !(0..=threshold_mins).contains(&mins_remaining) {
return None;
}
Some(mins_remaining)
}
/// Each-frame check for the daily-challenge expiry warning. Fires a single
/// [`WarningToastEvent`] when the player is within
/// [`DAILY_EXPIRY_WARNING_MINUTES`] of UTC midnight reset and hasn't yet
/// completed today's challenge.
///
/// Idempotent — `DailyExpiryWarningShown` ensures the toast spawns at most
/// once per `daily.date`.
fn check_daily_expiry_warning(
daily: Res<DailyChallengeResource>,
progress: Res<ProgressResource>,
mut shown: ResMut<DailyExpiryWarningShown>,
mut warning: MessageWriter<WarningToastEvent>,
) {
let Some(mins) = compute_expiry_warning_minutes(
daily.date,
progress.0.daily_challenge_last_completed,
shown.0,
Utc::now(),
DAILY_EXPIRY_WARNING_MINUTES,
) else {
return;
};
shown.0 = Some(daily.date);
warning.write(WarningToastEvent(format!(
"Daily challenge expires in {mins} min"
)));
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -385,4 +468,141 @@ mod tests {
assert_eq!(r.target_score, Some(1_000)); assert_eq!(r.target_score, Some(1_000));
assert_eq!(r.max_time_secs, Some(300)); assert_eq!(r.max_time_secs, Some(300));
} }
// -----------------------------------------------------------------------
// Daily-expiry warning toast (compute_expiry_warning_minutes + system)
// -----------------------------------------------------------------------
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// Construct a UTC `DateTime` at the given calendar position. Used to
/// drive the pure helper through every threshold edge.
fn utc_at(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
ymd(y, m, d).and_hms_opt(h, min, 0).unwrap().and_utc()
}
#[test]
fn warning_fires_inside_threshold_when_incomplete_and_unseen() {
// 23:50 UTC, 10 min until reset, < 30 min threshold.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_fires_at_exact_threshold_boundary() {
// 23:30 UTC, exactly 30 min remaining — the inclusive boundary.
let now = utc_at(2026, 5, 8, 23, 30);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, Some(30));
}
#[test]
fn warning_suppressed_outside_threshold() {
// 23:00 UTC, 60 min remaining — outside the 30 min window.
let now = utc_at(2026, 5, 8, 23, 0);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, None);
}
#[test]
fn warning_suppressed_when_already_completed_today() {
// 23:50 UTC inside threshold, but today is already done.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 8)),
None,
now,
30,
);
assert_eq!(mins, None);
}
#[test]
fn warning_suppressed_when_yesterdays_completion_is_stale() {
// Yesterday's completion is irrelevant — we want to warn about today.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 7)),
None,
now,
30,
);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_suppressed_when_already_shown_for_this_date() {
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 8)),
now,
30,
);
assert_eq!(mins, None);
}
#[test]
fn warning_fires_when_last_shown_was_yesterday() {
// Player kept the app open across a midnight rollover. Stale
// "shown" date doesn't suppress today's warning.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 7)),
now,
30,
);
assert_eq!(mins, Some(10));
}
#[test]
fn check_system_fires_warning_event_only_once_per_day() {
// The pure helper is exhaustively tested above. This test verifies
// the system that consumes it correctly stores the "shown" date so
// the WarningToastEvent fires at most once per `daily.date`, even
// when the system runs many frames in a row inside the threshold.
//
// The system reads `Utc::now()` directly, so we can't pin the clock.
// Instead, we simulate the post-warning state by pre-populating
// `DailyExpiryWarningShown` with `daily.date` and asserting nothing
// fires; then we verify the symmetric "completed today" suppression.
let mut app = headless_app();
let today = app.world().resource::<DailyChallengeResource>().date;
// Pre-mark warning as already shown for today.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today);
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
"no warning fires when DailyExpiryWarningShown already covers today"
);
// Reset shown, mark today as completed.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = None;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.daily_challenge_last_completed = Some(today);
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
"no warning fires when today is already completed"
);
}
} }
+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"
);
}
}
+32
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)]
@@ -212,6 +229,21 @@ pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct InfoToastEvent(pub String); pub struct InfoToastEvent(pub String);
/// Generic warning toast message. Spawns a fire-and-forget
/// [`ToastVariant::Warning`](crate::animation_plugin::ToastVariant) toast.
///
/// Distinct from [`InfoToastEvent`] in two ways:
/// 1. **Variant.** Warning carries the design-system warning border accent,
/// not the neutral info accent — so the player can distinguish "you might
/// want to act" from "here's some neutral information".
/// 2. **No queue.** Warnings are alerts, not a stream. Each event spawns its
/// own toast immediately rather than waiting for the info queue to drain.
///
/// First in-engine driver: daily-challenge expiry warning fired by
/// `daily_challenge_plugin` when < 30 min from UTC midnight reset.
#[derive(Message, Debug, Clone)]
pub struct WarningToastEvent(pub String);
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the /// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
/// animation layer can display a "+N XP" toast alongside the win cascade. /// animation layer can display a "+N XP" toast alongside the win cascade.
#[derive(Message, Debug, Clone, Copy)] #[derive(Message, Debug, Clone, Copy)]
+7 -1
View File
@@ -936,6 +936,11 @@ pub fn record_replay_on_win(
if recording.moves.is_empty() { if recording.moves.is_empty() {
continue; continue;
} }
// Recording freezes on win, so the move that triggered the
// win condition is the last one in the list. Storing the
// index explicitly lets the playback UI read the WIN MOVE
// position directly instead of re-deriving it on every render.
let win_move_index = recording.moves.len().checked_sub(1);
let replay = Replay::new( let replay = Replay::new(
game.0.seed, game.0.seed,
game.0.draw_mode.clone(), game.0.draw_mode.clone(),
@@ -944,7 +949,8 @@ pub fn record_replay_on_win(
ev.score, ev.score,
Utc::now().date_naive(), Utc::now().date_naive(),
recording.moves.clone(), recording.moves.clone(),
); )
.with_win_move_index(win_move_index);
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else { let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
// No persistence path configured (e.g. tests / minimal Linux // No persistence path configured (e.g. tests / minimal Linux
// containers without dirs::data_dir). The in-memory replay // containers without dirs::data_dir). The in-memory replay
+3 -2
View File
@@ -14,8 +14,8 @@ use crate::ui_modal::{
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
}; };
/// Marker on the help overlay root node. /// Marker on the help overlay root node.
@@ -263,6 +263,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
+258 -15
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;
@@ -38,8 +38,8 @@ use crate::ui_modal::{
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, RADIUS_MD, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
}; };
@@ -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| {
@@ -840,6 +983,7 @@ fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<
}, },
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|row| { .with_children(|row| {
for (label, value) in [ for (label, value) in [
@@ -943,12 +1087,108 @@ fn spawn_draw_mode_chip<M: Component>(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|c| { .with_children(|c| {
c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg))); c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg)));
}); });
} }
/// 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.
@@ -997,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,
} }
} }
@@ -1156,6 +1397,7 @@ fn spawn_mode_card(
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
@@ -1399,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]
@@ -1597,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,
@@ -1623,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)"
), ),
+74 -7
View File
@@ -17,9 +17,11 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT; use crate::layout::HUD_BAND_HEIGHT;
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS, MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
@@ -376,11 +378,13 @@ impl Plugin for HudPlugin {
/// bottom edge lines up exactly with the top edge of the highest /// bottom edge lines up exactly with the top edge of the highest
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70 /// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
/// alpha, so the green felt reads through subtly. /// alpha, so the green felt reads through subtly.
fn spawn_hud_band(mut commands: Commands) { fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
const BASE_TOP: f32 = 0.0;
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
commands.spawn(( commands.spawn((
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
top: Val::Px(0.0), top: Val::Px(BASE_TOP + top_inset),
left: Val::Px(0.0), left: Val::Px(0.0),
width: Val::Percent(100.0), width: Val::Percent(100.0),
height: Val::Px(HUD_BAND_HEIGHT), height: Val::Px(HUD_BAND_HEIGHT),
@@ -391,6 +395,7 @@ fn spawn_hud_band(mut commands: Commands) {
// paint on top, but above the card sprites (which are 2D-world // paint on top, but above the card sprites (which are 2D-world
// entities and rendered behind UI regardless). // entities and rendered behind UI regardless).
ZIndex(Z_HUD - 1), ZIndex(Z_HUD - 1),
SafeAreaAnchoredTop { base_top: BASE_TOP },
)); ));
} }
@@ -413,7 +418,12 @@ fn spawn_hud_band(mut commands: Commands) {
/// player's #1 complaint. This restructure groups by purpose, lets /// player's #1 complaint. This restructure groups by purpose, lets
/// transient items disappear cleanly, and uses the typography scale to /// transient items disappear cleanly, and uses the typography scale to
/// make Score the visual protagonist. /// make Score the visual protagonist.
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) { fn spawn_hud(
font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont { let font_score = TextFont {
font: font_handle.clone(), font: font_handle.clone(),
@@ -434,6 +444,16 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let row_node = || Node { let row_node = || Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3, column_gap: VAL_SPACE_3,
// On a narrow viewport the four tier rows (Score/Moves/Timer,
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
// Auto-complete, selection chip) can collectively be wider than
// the available space and overflow into the action-button column
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
// a second line; on a desktop window the rows stay single-line
// because the parent column has no width cap and the row never
// exceeds the natural line width.
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_1,
align_items: AlignItems::Baseline, align_items: AlignItems::Baseline,
..default() ..default()
}; };
@@ -443,12 +463,21 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
left: VAL_SPACE_3, left: VAL_SPACE_3,
top: VAL_SPACE_2, top: Val::Px(SPACE_2 + top_inset),
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
// Cap the column at 50% of viewport so on narrow
// (mobile) widths the inner tier rows have a bounded
// width to wrap against, and the column can't bleed
// into the right-anchored action button row (also
// capped at 50%). On desktop 50% of 1920 = 960 px,
// wider than any tier row's natural width, so the
// visible layout is unaffected.
max_width: Val::Percent(50.0),
row_gap: VAL_SPACE_1, row_gap: VAL_SPACE_1,
..default() ..default()
}, },
ZIndex(Z_HUD), ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
)) ))
.with_children(|hud| { .with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE); // Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
@@ -568,7 +597,12 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost /// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
/// because it's the most consequential action; the destructive button sits /// because it's the most consequential action; the destructive button sits
/// on its own visual edge. /// on its own visual edge.
fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Commands) { fn spawn_action_buttons(
font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let font = TextFont { let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the // TYPE_BODY (14.0) — was a hardcoded `16.0` until the
@@ -585,13 +619,28 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
right: VAL_SPACE_3, right: VAL_SPACE_3,
top: VAL_SPACE_2, top: Val::Px(SPACE_2 + top_inset),
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
// 6 buttons total ~510 px wide; on a desktop window
// (typically >= 1280 px) `max_width: 50%` is >= 640 px
// and the row stays a single line. On a 360 dp phone
// 50% is 180 px and the row wraps to two-three lines —
// which keeps the buttons out of the left HUD column's
// horizontal range and prevents the off-screen-left
// clipping seen in the v0.22.3 hardware screenshot.
max_width: Val::Percent(50.0),
flex_wrap: FlexWrap::Wrap,
// When the row wraps, buttons pack to the *end* of each
// line so the row stays visually right-aligned (matches
// the `right: VAL_SPACE_3` anchor).
justify_content: JustifyContent::FlexEnd,
column_gap: VAL_SPACE_2, column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
align_items: AlignItems::Center, align_items: AlignItems::Center,
..default() ..default()
}, },
ZIndex(Z_HUD), ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
)) ))
.with_children(|row| { .with_children(|row| {
// Menu and Modes don't have a single hotkey accelerator // Menu and Modes don't have a single hotkey accelerator
@@ -681,6 +730,14 @@ fn spawn_action_button<M: Component>(
font: &TextFont, font: &TextFont,
order: i32, order: i32,
) { ) {
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
// touch device — the button itself is the affordance — and they
// visibly clutter the narrow-viewport action row. Force the hint
// off on Android; the chevrons on Menu/Modes remain because they
// indicate dropdown behaviour and still apply on touch.
#[cfg(target_os = "android")]
let hotkey: Option<&'static str> = None;
let hotkey_font = TextFont { let hotkey_font = TextFont {
font: font.font.clone(), font: font.font.clone(),
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
@@ -707,6 +764,14 @@ fn spawn_action_button<M: Component>(
// companion commit). Vertical padding stays at VAL_SPACE_2 // companion commit). Vertical padding stays at VAL_SPACE_2
// so button height tracks the rest of the chrome band. // so button height tracks the rest of the chrome band.
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
// 48 px floors meet Material's recommended thumb-target
// size on touch and are a no-op on desktop for buttons
// whose content already exceeds 48 px in either axis
// (Menu, Modes, New Game, etc.). Without these, "Undo"
// ends up ~46 × 33 px — comfortably tappable with a mouse
// but right at the threshold for a finger.
min_width: Val::Px(48.0),
min_height: Val::Px(48.0),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
@@ -715,6 +780,7 @@ fn spawn_action_button<M: Component>(
}, },
BackgroundColor(ACTION_BTN_IDLE), BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
@@ -1740,6 +1806,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) }
}
} }
+48 -4
View File
@@ -21,9 +21,25 @@ pub enum LayoutSystem {
UpdateOnResize, UpdateOnResize,
} }
/// Minimum supported window dimensions. Layout is still computed below this /// Minimum window dimensions used as a layout floor.
/// size but cards will be small. ///
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0); /// `compute_layout` runs `window.max(MIN_WINDOW)` so a window smaller than this
/// on either axis is laid out as if it were at least this size. The floor
/// exists to guard against degenerate / divide-by-zero layouts on very small
/// surfaces (Bevy can briefly report 0-size windows during startup or after
/// minimisation on some compositors); it is not a "minimum supported playable
/// size" — desktop builds enforce that via `WindowResizeConstraints` set in
/// `solitaire_app::lib`.
///
/// The previous floor of 800×600 was set with desktop in mind and produced
/// the wrong behaviour on Android: a 360 dp phone got laid out as if it were
/// 800-wide, pushing the leftmost foundation past `-180` and the rightmost
/// tableau pile past `+180`, which clipped both at the visible viewport
/// edges (visible in the v0.22.3 hardware screenshot). 320×400 is below the
/// smallest reasonable phone (≈ 360×640) so every real device flows through
/// without clamping, while still being large enough that the layout math
/// produces non-degenerate card sizes.
pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
/// Aspect ratio (height / width) of a standard playing card. /// Aspect ratio (height / width) of a standard playing card.
/// ///
@@ -205,11 +221,39 @@ mod tests {
#[test] #[test]
fn layout_below_minimum_clamps_to_minimum() { fn layout_below_minimum_clamps_to_minimum() {
let below = compute_layout(Vec2::new(400.0, 300.0)); // 200×200 sits below the floor on both axes, so the clamp pulls each
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW).
let below = compute_layout(Vec2::new(200.0, 200.0));
let at_min = compute_layout(MIN_WINDOW); let at_min = compute_layout(MIN_WINDOW);
assert_eq!(below.card_size, at_min.card_size); assert_eq!(below.card_size, at_min.card_size);
} }
/// Regression for the v0.22.3 Android viewport-overflow bug. A typical
/// portrait-phone viewport (360 dp × 800 dp) must produce a layout
/// where every pile fits horizontally — i.e. card_width is derived
/// from the actual window, not a clamped-up desktop floor.
#[test]
fn phone_portrait_layout_fits_horizontally() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window);
let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions {
assert!(
pos.x - half_card >= -half_w - 1e-3,
"{:?} overflows left at portrait phone window {:?}",
pile,
window
);
assert!(
pos.x + half_card <= half_w + 1e-3,
"{:?} overflows right at portrait phone window {:?}",
pile,
window
);
}
}
#[test] #[test]
fn tableau_columns_are_sorted_left_to_right() { fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0));
+13 -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;
@@ -31,6 +35,7 @@ pub mod replay_playback;
pub mod settings_plugin; pub mod settings_plugin;
pub mod progress_plugin; pub mod progress_plugin;
pub mod resources; pub mod resources;
pub mod safe_area;
pub mod selection_plugin; pub mod selection_plugin;
pub mod splash_plugin; pub mod splash_plugin;
pub mod stats_plugin; pub mod stats_plugin;
@@ -92,11 +97,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,
@@ -131,6 +139,7 @@ pub use settings_plugin::{
}; };
pub use layout::{compute_layout, Layout, LayoutResource}; pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource}; pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{ pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState, KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
}; };
+3 -2
View File
@@ -32,8 +32,8 @@ use crate::ui_modal::{
spawn_modal_header, ButtonVariant, spawn_modal_header, ButtonVariant,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
BORDER_SUBTLE, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_CAPTION, TYPE_BODY, VAL_SPACE_1, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -386,6 +386,7 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
+11
View File
@@ -30,6 +30,7 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::{GameOverScreen, GameStatePath}; use crate::game_plugin::{GameOverScreen, GameStatePath};
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
use crate::selection_plugin::{SelectionKeySet, SelectionState}; use crate::selection_plugin::{SelectionKeySet, SelectionState};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
@@ -154,6 +155,7 @@ fn toggle_pause(
mut drag: Option<ResMut<DragState>>, mut drag: Option<ResMut<DragState>>,
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
selection: Option<Res<SelectionState>>, selection: Option<Res<SelectionState>>,
replay_state: Option<Res<ReplayPlaybackState>>,
) { ) {
let PauseModalQueries { let PauseModalQueries {
pause_screens: screens, pause_screens: screens,
@@ -184,6 +186,15 @@ fn toggle_pause(
if !other_modal_scrims.is_empty() { if !other_modal_scrims.is_empty() {
return; return;
} }
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
// own the Esc press — that handler stops the replay. Without this guard a
// single Esc both stops the replay AND opens the pause modal on top of the
// (now empty) board, leaving the player on a screen they didn't ask for.
// The HUD-button path is gated too; clicking Pause while watching a replay
// is almost always an accident.
if replay_state.is_some_and(|s| s.is_playing()) {
return;
}
// If a card is currently selected, let SelectionPlugin handle this Escape // If a card is currently selected, let SelectionPlugin handle this Escape
// (it will clear the selection). Pause must not also open in the same frame. // (it will clear the selection). Pause must not also open in the same frame.
if selection.is_some_and(|s| s.selected_pile.is_some()) { if selection.is_some_and(|s| s.selected_pile.is_some()) {
+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");
}
}
+63 -4
View File
@@ -56,7 +56,8 @@ use crate::events::MoveRequestEvent;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, STATE_SUCCESS}; use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
/// Sprite-space `Transform.z` for radial-menu overlay sprites. /// Sprite-space `Transform.z` for radial-menu overlay sprites.
/// ///
@@ -533,8 +534,17 @@ fn radial_handle_release_or_cancel(
/// Despawns and respawns the radial overlay sprites every frame the /// Despawns and respawns the radial overlay sprites every frame the
/// state is `Active`; despawns them when the state returns to `Idle`. /// state is `Active`; despawns them when the state returns to `Idle`.
///
/// Reads [`SettingsResource`] so the focused-icon outline can boost to
/// [`BORDER_SUBTLE_HC`] under high-contrast mode. Per-frame respawn is
/// the simplest place to fold HC in: this is the only system that
/// owns the rim sprite, so there's no parallel paint path to fight.
/// ([`HighContrastBorder`](crate::ui_theme::HighContrastBorder) doesn't
/// apply because the rim is a `Sprite`, not a UI node with
/// `BorderColor`, and the entities don't persist across frames.)
fn radial_redraw_overlay( fn radial_redraw_overlay(
state: Res<RightClickRadialState>, state: Res<RightClickRadialState>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands, mut commands: Commands,
existing_icons: Query<Entity, With<RadialIcon>>, existing_icons: Query<Entity, With<RadialIcon>>,
existing_centres: Query<Entity, With<RadialCentre>>, existing_centres: Query<Entity, With<RadialCentre>>,
@@ -569,13 +579,12 @@ fn radial_redraw_overlay(
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01), Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
)); ));
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() { for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
let focused = *hovered_index == Some(i); let focused = *hovered_index == Some(i);
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 }; let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY }; let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
// Hovered icon gets a strong yellow rim; resting icons get a let outline = radial_rim_outline(focused, high_contrast);
// muted purple rim so the focused one reads as the obvious target.
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
commands commands
.spawn(( .spawn((
@@ -606,6 +615,27 @@ fn radial_redraw_overlay(
} }
} }
/// Pure decision logic for the radial-icon rim outline colour.
///
/// Resting icons always carry [`BORDER_SUBTLE`] so the focused icon
/// reads as the obvious target. Under high-contrast mode the focused
/// rim boosts to [`BORDER_SUBTLE_HC`] (`#a0a0a0`) instead of
/// [`BORDER_STRONG`] (`#505050`) — naive marker substitution via
/// [`HighContrastBorder`](crate::ui_theme::HighContrastBorder) would
/// invert the hierarchy because the resting colour
/// (`#353535`) is darker than `BORDER_STRONG`. This shape keeps the
/// focused rim *more* visible under HC, not less.
///
/// Factored out as a pure function so the truth-table is unit-testable
/// without spinning up the per-frame respawn system.
fn radial_rim_outline(focused: bool, high_contrast: bool) -> Color {
match (focused, high_contrast) {
(true, true) => BORDER_SUBTLE_HC,
(true, false) => BORDER_STRONG,
(false, _) => BORDER_SUBTLE,
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -940,4 +970,33 @@ mod tests {
"face-down cards must not open the radial" "face-down cards must not open the radial"
); );
} }
// -----------------------------------------------------------------------
// radial_rim_outline — accessibility / high-contrast truth table
// -----------------------------------------------------------------------
#[test]
fn rim_resting_uses_subtle_outline_without_hc() {
assert_eq!(radial_rim_outline(false, false), BORDER_SUBTLE);
}
#[test]
fn rim_focused_uses_strong_outline_without_hc() {
assert_eq!(radial_rim_outline(true, false), BORDER_STRONG);
}
#[test]
fn rim_focused_boosts_to_subtle_hc_under_hc() {
assert_eq!(radial_rim_outline(true, true), BORDER_SUBTLE_HC);
}
#[test]
fn rim_resting_stays_subtle_under_hc_to_preserve_hierarchy() {
// Naive marker substitution would also flip the resting outline
// to BORDER_SUBTLE_HC, which is *lighter* than BORDER_STRONG —
// that would invert the focused/resting hierarchy. Holding the
// resting colour at BORDER_SUBTLE keeps the focused icon the
// obvious target under HC.
assert_eq!(radial_rim_outline(false, true), BORDER_SUBTLE);
}
} }
File diff suppressed because it is too large Load Diff
+137 -18
View File
@@ -42,7 +42,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{Replay, ReplayMove}; use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::game_plugin::{GameMutation, RecordingReplay}; use crate::game_plugin::{GameMutation, RecordingReplay};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
@@ -119,6 +119,15 @@ pub enum ReplayPlaybackState {
cursor: usize, cursor: usize,
/// Seconds remaining until the next move is dispatched. /// Seconds remaining until the next move is dispatched.
secs_to_next: f32, secs_to_next: f32,
/// `true` while playback is paused — `tick_replay_playback`
/// skips the `secs_to_next` decrement entirely while this is
/// set, so the cursor and the timer freeze together. The
/// overlay stays mounted (`is_playing()` still returns
/// `true`) so the player can see the paused state and the
/// Resume / Step controls. Stepping while paused fires the
/// next move directly via [`step_replay_playback`] and
/// leaves the paused flag untouched.
paused: bool,
}, },
/// The replay finished playing back. The overlay swaps the banner /// The replay finished playing back. The overlay swaps the banner
/// label to "Replay complete" until [`auto_clear_completed_replay`] /// label to "Replay complete" until [`auto_clear_completed_replay`]
@@ -194,6 +203,7 @@ pub fn start_replay_playback(
replay, replay,
cursor: 0, cursor: 0,
secs_to_next: REPLAY_MOVE_INTERVAL_SECS, secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
paused: false,
}; };
} }
@@ -219,6 +229,107 @@ pub fn stop_replay_playback(
**state = ReplayPlaybackState::Inactive; **state = ReplayPlaybackState::Inactive;
} }
/// Toggle the `paused` flag on the active playback. No-op when not
/// `Playing` (i.e. `Inactive` or `Completed`) — pause has no meaning
/// in those states. Returns the new paused value, or `None` if the
/// state wasn't `Playing`.
pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) -> Option<bool> {
if let ReplayPlaybackState::Playing { paused, .. } = state.as_mut() {
*paused = !*paused;
Some(*paused)
} else {
None
}
}
/// Advance playback by exactly one move. Only meaningful while paused
/// — when called on an unpaused playback it would race the
/// `tick_replay_playback` loop. Returns `true` when a move was fired,
/// `false` when no-op (state isn't `Playing { paused: true }` or the
/// cursor is already at the end of the move list).
///
/// Stepping the last move transitions the state to `Completed` on
/// the next `tick_replay_playback` frame — same end-of-list path the
/// normal advance loop takes.
pub fn step_replay_playback(
state: &mut ResMut<ReplayPlaybackState>,
moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool {
let ReplayPlaybackState::Playing {
replay,
cursor,
paused: true,
..
} = state.as_mut()
else {
return false;
};
if *cursor >= replay.moves.len() {
return false;
}
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
moves_writer.write(MoveRequestEvent {
from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
*cursor += 1;
true
}
/// Steps the replay **backwards** by exactly one move while paused.
///
/// Strategy: the live game's undo system is the source of truth for
/// reversing moves. Every move the replay forward-stepped (via
/// [`step_replay_playback`] or the auto-advance loop in
/// [`tick_replay_playback`]) was dispatched as a canonical
/// [`MoveRequestEvent`] / [`DrawRequestEvent`], which the game
/// applied and pushed onto its undo stack. So a backwards step here
/// is simply: decrement the cursor (so the about-to-apply move
/// re-points at the one we're rewinding past) and fire an
/// [`UndoRequestEvent`] so the game reverses its most-recent move
/// next frame.
///
/// Hard-gated to the paused state via destructure pattern —
/// matches the existing [`step_replay_playback`] gate so the
/// player can only scrub one direction at a time and the tick
/// loop never races a manual rewind.
///
/// Returns `false` and is a no-op in three cases:
/// - State isn't `Playing` (no replay attached).
/// - State is `Playing` but not paused (the tick loop owns the cursor).
/// - Cursor is already at 0 (nothing to rewind past).
///
/// Returns `true` on a successful step; the actual game-state
/// reversal happens next frame when `handle_undo` reads the
/// `UndoRequestEvent`.
pub fn step_backwards_replay_playback(
state: &mut ResMut<ReplayPlaybackState>,
undo_writer: &mut MessageWriter<UndoRequestEvent>,
) -> bool {
let ReplayPlaybackState::Playing {
cursor,
paused: true,
..
} = state.as_mut()
else {
return false;
};
if *cursor == 0 {
return false;
}
*cursor -= 1;
undo_writer.write(UndoRequestEvent);
true
}
/// Tick system. Runs every frame; only does work when /// Tick system. Runs every frame; only does work when
/// [`ReplayPlaybackState::is_playing`]. /// [`ReplayPlaybackState::is_playing`].
/// ///
@@ -249,28 +360,36 @@ fn tick_replay_playback(
replay, replay,
cursor, cursor,
secs_to_next, secs_to_next,
paused,
} = state.as_mut() } = state.as_mut()
{ {
*secs_to_next -= dt; // While paused, the cursor and the timer freeze together —
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() { // skip the decrement entirely so resuming starts the next
match &replay.moves[*cursor] { // move from a full `secs_to_next` window. Stepping (handled
ReplayMove::Move { from, to, count } => { // separately) fires moves directly without touching this
moves_writer.write(MoveRequestEvent { // path.
from: from.clone(), if !*paused {
to: to.clone(), *secs_to_next -= dt;
count: *count, while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
}); match &replay.moves[*cursor] {
} ReplayMove::Move { from, to, count } => {
ReplayMove::StockClick => { moves_writer.write(MoveRequestEvent {
draws_writer.write(DrawRequestEvent); from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
} }
*cursor += 1;
*secs_to_next += interval;
} }
*cursor += 1;
*secs_to_next += interval;
}
if *cursor >= replay.moves.len() { if *cursor >= replay.moves.len() {
transition_to_completed = true; transition_to_completed = true;
}
} }
} }
+242
View File
@@ -0,0 +1,242 @@
//! Safe-area insets.
//!
//! Reports the OS-reserved regions around the playable surface (status
//! bar at the top, gesture / navigation bar at the bottom on Android,
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
//! collisions.
//!
//! On non-Android targets all four edges report `0.0`. On Android the
//! values come from `WindowInsets.getInsets(WindowInsets.Type.systemBars())`
//! via JNI; the call is retried for the first few frames because
//! `getRootWindowInsets()` only returns useful values after the decor
//! view has been laid out at least once.
//!
//! UI that wants to respect the top inset should tag itself with the
//! [`SafeAreaAnchoredTop`] marker carrying the layout's original top
//! offset; [`apply_safe_area_anchors`] re-applies `base_top + insets.top`
//! whenever the resource changes, so late inset arrival or orientation
//! changes flow through automatically.
use bevy::prelude::*;
/// Pixel sizes of the system-reserved regions on each edge of the
/// surface. Zero on desktop.
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
pub struct SafeAreaInsets {
pub top: f32,
pub bottom: f32,
pub left: f32,
pub right: f32,
}
impl SafeAreaInsets {
/// `true` when any edge has a non-zero reservation. Used by the
/// Android polling system to know it can stop querying.
pub fn is_populated(&self) -> bool {
self.top > 0.0 || self.bottom > 0.0 || self.left > 0.0 || self.right > 0.0
}
}
/// Marker for `Node` entities whose `top` offset should be re-applied
/// as `base_top + SafeAreaInsets::top`.
///
/// `base_top` is the offset the layout would have used on a surface
/// with no system reservation (i.e. on desktop). The fix-up system
/// adds the current top inset on top of it whenever the resource
/// changes.
#[derive(Component, Debug, Clone, Copy)]
pub struct SafeAreaAnchoredTop {
pub base_top: f32,
}
pub struct SafeAreaInsetsPlugin;
impl Plugin for SafeAreaInsetsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SafeAreaInsets>()
.add_systems(Update, apply_safe_area_anchors);
#[cfg(target_os = "android")]
app.add_systems(Update, android::refresh_insets);
}
}
/// Re-applies `base_top + insets.top` to every entity carrying the
/// [`SafeAreaAnchoredTop`] marker whenever [`SafeAreaInsets`] changes.
///
/// Bevy resource change detection (`Res::is_changed`) is `true` on the
/// frame the resource is inserted and every frame a `ResMut` borrow
/// occurs. Combined with the Android polling loop short-circuiting
/// once insets are populated, this runs at most a handful of times in
/// a session.
fn apply_safe_area_anchors(
insets: Res<SafeAreaInsets>,
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) {
if !insets.is_changed() {
return;
}
for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + insets.top);
}
}
#[cfg(target_os = "android")]
mod android {
use super::SafeAreaInsets;
use bevy::prelude::*;
/// Polls Android for safe-area insets until we get a non-zero
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
/// all-zero `Insets`) until the decor view has been laid out, which
/// is typically frame 13 of a fresh launch.
pub(super) fn refresh_insets(
mut insets: ResMut<SafeAreaInsets>,
mut tries: Local<u32>,
) {
// Cap retries so we don't burn CPU forever on edge-to-edge
// devices that genuinely report zero insets.
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
if *tries >= MAX_TRIES || insets.is_populated() {
return;
}
*tries += 1;
match query_insets() {
Ok(v) if v.is_populated() => {
info!(
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
v.top, v.bottom, v.left, v.right, *tries
);
*insets = v;
}
Ok(_) => {
// Layout not ready yet; try again next frame.
}
Err(e) => {
// Don't spam — log once and let polling continue silently.
if *tries == 1 {
warn!("safe_area: JNI query failed (will retry): {e}");
}
}
}
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP;
use jni::{objects::JObject, JavaVM};
let app = ANDROID_APP
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
// runtime; valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
// pointer — valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<SafeAreaInsets> {
// Window window = activity.getWindow();
let window = env
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?;
// View decor = window.getDecorView();
let decor = env
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
.l()?;
// WindowInsets insets = decor.getRootWindowInsets();
let raw_insets = env
.call_method(
&decor,
"getRootWindowInsets",
"()Landroid/view/WindowInsets;",
&[],
)?
.l()?;
if raw_insets.is_null() {
return Ok(SafeAreaInsets::default());
}
// int types = WindowInsets.Type.systemBars();
// (Static method on the WindowInsets$Type inner class.
// Available since API 30 / Android 11.)
let type_class = env.find_class("android/view/WindowInsets$Type")?;
let bars_type = env
.call_static_method(&type_class, "systemBars", "()I", &[])?
.i()?;
// Insets bars = insets.getInsets(types);
let bars = env
.call_method(
&raw_insets,
"getInsets",
"(I)Landroid/graphics/Insets;",
&[bars_type.into()],
)?
.l()?;
// `Insets` exposes `top`, `bottom`, `left`, `right` as public
// `int` fields (pixel values, not dp).
let top = env.get_field(&bars, "top", "I")?.i()? as f32;
let bottom = env.get_field(&bars, "bottom", "I")?.i()? as f32;
let left = env.get_field(&bars, "left", "I")?.i()? as f32;
let right = env.get_field(&bars, "right", "I")?.i()? as f32;
Ok(SafeAreaInsets {
top,
bottom,
left,
right,
})
})()
.map_err(|e| format!("safe-area JNI: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_zero_and_not_populated() {
let i = SafeAreaInsets::default();
assert_eq!(i.top, 0.0);
assert_eq!(i.bottom, 0.0);
assert!(!i.is_populated());
}
#[test]
fn is_populated_returns_true_for_any_nonzero_edge() {
assert!(SafeAreaInsets {
top: 24.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
bottom: 16.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
left: 8.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
right: 8.0,
..Default::default()
}
.is_populated());
}
}
+81 -3
View File
@@ -34,9 +34,10 @@ use crate::ui_modal::{
}; };
use crate::ui_tooltip::Tooltip; use crate::ui_tooltip::Tooltip;
use crate::ui_theme::{ use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, HighContrastBorder,
Z_MODAL_PANEL, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
}; };
/// Side length of a swatch button in the card-back / background pickers. /// Side length of a swatch button in the card-back / background pickers.
@@ -364,6 +365,8 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text, update_anim_speed_text,
update_color_blind_text, update_color_blind_text,
update_high_contrast_text, update_high_contrast_text,
update_high_contrast_borders,
update_high_contrast_backgrounds,
update_reduce_motion_text, update_reduce_motion_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
@@ -637,6 +640,77 @@ fn update_high_contrast_text(
} }
} }
/// Repaints `BorderColor` on every entity tagged with
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current border colour and
/// only mutates when different so Bevy's change-detection
/// doesn't trigger repaints every frame.
///
/// Spec at `design-system.md` §Accessibility (#2): under HC,
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
/// modal panels, popover edges, and focus-ring carriers stay
/// legible on low-quality displays / for low-vision users.
///
/// Tagged sites in v0.21.x: the modal scaffold's card border
/// (`ui_modal::spawn_modal`). More sites can be tagged in
/// follow-ups by adding `HighContrastBorder::with_default(...)`
/// to their spawn tuple.
fn update_high_contrast_borders(
settings: Res<SettingsResource>,
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
) {
let high_contrast = settings.0.high_contrast_mode;
for (marker, mut border) in borders.iter_mut() {
let target = if high_contrast {
BORDER_SUBTLE_HC
} else {
marker.default_color
};
// Only mutate when actually different — avoids per-frame
// change-detection churn. `border.left` is representative
// because every tagged site uses `BorderColor::all(...)`.
if border.left != target {
*border = BorderColor::all(target);
}
}
}
/// Repaints `BackgroundColor` on every entity tagged with
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current background and only
/// mutates when different so Bevy's change-detection doesn't trigger
/// repaints every frame.
///
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
/// same change-suppression idiom, different colour channel —
/// `BackgroundColor` for tick marks, decorative strips, fine
/// separators that paint their shape directly rather than via a
/// `BorderColor` on a wider Node.
///
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
///
/// More sites can be tagged in follow-ups by adding
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
pub(crate) fn update_high_contrast_backgrounds(
settings: Res<SettingsResource>,
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
) {
let high_contrast = settings.0.high_contrast_mode;
for (marker, mut bg) in backgrounds.iter_mut() {
let target = if high_contrast {
marker.hc_color
} else {
marker.default_color
};
if bg.0 != target {
*bg = BackgroundColor(target);
}
}
}
fn update_reduce_motion_text( fn update_reduce_motion_text(
settings: Res<SettingsResource>, settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>, mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
@@ -1913,6 +1987,7 @@ fn picker_row(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY }; let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
@@ -2054,6 +2129,7 @@ fn theme_picker_row(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
spawn_thumbnail_pair(b, entry.thumbnails.as_ref()); spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
@@ -2175,6 +2251,7 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((
@@ -2235,6 +2312,7 @@ fn icon_button(
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY))); b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY)));
+72 -3
View File
@@ -219,12 +219,27 @@ fn spawn_splash(
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Settings is borrowed twice — once for the first_run_complete
// gate above, once here for the reduce-motion gate. The borrow
// above already happened (and was let-go via the `settings.as_deref()`
// pattern's auto-drop), so this re-read is safe.
let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
// Generate the scanline texture handle up-front (when the asset // Generate the scanline texture handle up-front (when the asset
// store is available — always true in production; opt-out under // store is available — always true in production; opt-out under
// bare `MinimalPlugins` test fixtures so existing tests that // bare `MinimalPlugins` test fixtures so existing tests that
// don't init `Assets<Image>` keep working with the rest of the // don't init `Assets<Image>` keep working with the rest of the
// splash content unchanged). // splash content unchanged). Also skipped when reduce-motion is
let scanline_handle = images.map(|mut images| images.add(build_scanline_image())); // on — the scanline overlay is the "CRT scanline effect" the
// design-system spec calls out as non-essential motion under
// reduce-motion (`design-system.md` §Accessibility #3). Without
// it the boot screen still reads as terminal-themed; the
// scanlines are decorative.
let scanline_handle = if reduce_motion {
None
} else {
images.map(|mut images| images.add(build_scanline_image()))
};
commands commands
.spawn(( .spawn((
@@ -712,15 +727,29 @@ fn cursor_pulse_factor(age: Duration, period: f32, min: f32) -> f32 {
/// ///
/// No-op when no `SplashRoot` exists (the splash has already /// No-op when no `SplashRoot` exists (the splash has already
/// despawned, or we're under a test fixture that doesn't spawn one). /// despawned, or we're under a test fixture that doesn't spawn one).
///
/// Under `Settings::reduce_motion_mode`, the per-frame pulse
/// multiplier is skipped — the cursor still fades in / out with
/// the global splash alpha (essential timing) but doesn't blink
/// (decorative motion). Spec at `design-system.md` §Accessibility
/// (#3): reduce-motion suppresses non-essential motion only;
/// fade-in / fade-out timelines stay intact because the splash
/// itself would otherwise hard-cut on/off, which is jarring.
fn pulse_splash_cursor( fn pulse_splash_cursor(
roots: Query<&SplashAge, With<SplashRoot>>, roots: Query<&SplashAge, With<SplashRoot>>,
settings: Option<Res<SettingsResource>>,
mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>, mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>,
) { ) {
let Some(age) = roots.iter().next() else { let Some(age) = roots.iter().next() else {
return; return;
}; };
let global = splash_alpha(age.0).unwrap_or(0.0); let global = splash_alpha(age.0).unwrap_or(0.0);
let pulse = cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN); let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
let pulse = if reduce_motion {
1.0
} else {
cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN)
};
let combined = (global * pulse).clamp(0.0, 1.0); let combined = (global * pulse).clamp(0.0, 1.0);
for (fadable, mut bg) in &mut pulses { for (fadable, mut bg) in &mut pulses {
let mut c = fadable.base_color; let mut c = fadable.base_color;
@@ -954,6 +983,46 @@ mod tests {
); );
} }
#[test]
fn splash_skips_scanline_overlay_under_reduce_motion() {
// The CRT scanline overlay is decorative motion that
// `Settings::reduce_motion_mode` suppresses per the
// design-system spec (§Accessibility #3). The splash
// root itself still spawns — the cursor still fades in
// and out (essential timing), but the scanline overlay
// node is omitted entirely.
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(bevy::asset::AssetPlugin::default())
.init_asset::<bevy::image::Image>()
.add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
app.insert_resource(SettingsResource(Settings {
first_run_complete: false,
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
// The splash root spawns (essential motion intact)
assert_eq!(
count_splash_roots(&mut app),
1,
"splash should still spawn under reduce-motion — only the scanline + pulse are gated",
);
// The scanline overlay is gone
let scanline_count = app
.world_mut()
.query::<&SplashScanlineOverlay>()
.iter(app.world())
.count();
assert_eq!(
scanline_count, 0,
"scanline overlay must NOT spawn under reduce-motion",
);
}
#[test] #[test]
fn splash_despawns_after_total_duration() { fn splash_despawns_after_total_duration() {
let mut app = headless_app(); let mut app = headless_app();
+274 -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, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
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),
)); ));
@@ -1017,6 +1121,7 @@ fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str)
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|cell| { .with_children(|cell| {
// Large value label — accent yellow makes the number sing // Large value label — accent yellow makes the number sing
@@ -1669,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.
+11 -2
View File
@@ -58,8 +58,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, HighContrastBorder, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, VAL_SPACE_5,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -230,6 +231,13 @@ where
Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)), Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)),
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_STRONG), BorderColor::all(BORDER_STRONG),
// Honour `Settings::high_contrast_mode`: under HC the
// border boosts from `BORDER_STRONG` (#505050) to
// `BORDER_SUBTLE_HC` (#a0a0a0) so the modal panel
// edge stays clearly visible against the scrim and
// surface beneath. `update_high_contrast_borders` in
// `settings_plugin` does the per-frame swap.
HighContrastBorder::with_default(BORDER_STRONG),
)) ))
.with_children(build_card); .with_children(build_card);
}) })
@@ -364,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
}, },
BackgroundColor(idle_bg(variant)), BackgroundColor(idle_bg(variant)),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label.into()), font_label, TextColor(label_color))); b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));
+83
View File
@@ -93,6 +93,13 @@ pub const ACCENT_SECONDARY: Color = Color::srgb(0.882, 0.639, 0.933);
/// from base16-eighties. `#acc267`. /// from base16-eighties. `#acc267`.
pub const STATE_SUCCESS: Color = Color::srgb(0.675, 0.761, 0.404); pub const STATE_SUCCESS: Color = Color::srgb(0.675, 0.761, 0.404);
/// High-contrast variant of [`STATE_SUCCESS`] — `#c8e862`. Brighter
/// lime that maintains the success hue while lifting luminance from
/// ~0.51 → ~0.73 so the WIN MOVE scrub-bar marker stands out from
/// the bumped notch ticks (`BORDER_SUBTLE_HC` `#a0a0a0`, L≈0.60) in
/// high-contrast mode.
pub const STATE_SUCCESS_HC: Color = Color::srgb(0.784, 0.910, 0.384);
/// Warning — penalty signal, daily-seed expiry countdown, sync-pending /// Warning — penalty signal, daily-seed expiry countdown, sync-pending
/// status. Gold from base16-eighties. **Both** Undo and Recycle /// status. Gold from base16-eighties. **Both** Undo and Recycle
/// counters use this when non-zero. `#ddb26f`. /// counters use this when non-zero. `#ddb26f`.
@@ -226,6 +233,82 @@ pub const BORDER_SUBTLE: Color = Color::srgba(0.208, 0.208, 0.208, 1.0);
/// vision users. /// vision users.
pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0); pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0);
/// Marker for entities whose [`BorderColor`] should swap to
/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on.
/// Tag any UI node where border legibility is accessibility-critical
/// — modal panels, popovers, settings rows, focus-ring carriers —
/// then add the `apply_high_contrast_borders` system to react to
/// settings changes.
///
/// `default_color` records the off-state colour the entity was
/// spawned with so the system can revert when HC is toggled back
/// off. Different sites use different defaults (`BORDER_SUBTLE` for
/// idle popover edges, `BORDER_STRONG` for active modal cards) — the
/// marker captures whichever one applies at this entity.
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
pub struct HighContrastBorder {
/// Border colour to use when high-contrast mode is *off* — the
/// site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color,
}
impl HighContrastBorder {
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
Self { default_color }
}
}
/// Marker for entities whose [`BackgroundColor`] should swap to
/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on.
/// Parallel to [`HighContrastBorder`] but for sites that paint their
/// shape via `BackgroundColor` rather than `BorderColor` —
/// `bevy::ui` 1 px decorative strips, tick marks, fine separators
/// often render as tiny full-bleed `Node`s, not as borders, so the
/// border-marker pattern doesn't apply.
///
/// `default_color` records the off-state colour; `hc_color` the on-
/// state colour. [`with_default`] fills `hc_color` with
/// [`BORDER_SUBTLE_HC`] so the 90 % of sites that just need the
/// standard subtle-border bump can continue using a one-argument
/// constructor. [`with_hc`] overrides the HC colour for the rare
/// site (currently only the WIN MOVE scrub-bar marker) that needs a
/// domain-specific HC variant (`STATE_SUCCESS_HC` instead of a gray).
///
/// [`with_default`]: HighContrastBackground::with_default
/// [`with_hc`]: HighContrastBackground::with_hc
/// [`BackgroundColor`]: bevy::prelude::BackgroundColor
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
pub struct HighContrastBackground {
/// Background colour to use when high-contrast mode is *off* —
/// the site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color,
/// Background colour to use when high-contrast mode is *on*.
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
///
/// [`with_default`]: HighContrastBackground::with_default
pub hc_color: bevy::prelude::Color,
}
impl HighContrastBackground {
/// Convenience constructor — HC colour defaults to
/// [`BORDER_SUBTLE_HC`].
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
Self { default_color, hc_color: BORDER_SUBTLE_HC }
}
/// Constructor for sites whose HC colour differs from the standard
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray.
pub const fn with_hc(
default_color: bevy::prelude::Color,
hc_color: bevy::prelude::Color,
) -> Self {
Self { default_color, hc_color }
}
}
/// Strong border — hover outline, focused button, active popover. /// Strong border — hover outline, focused button, active popover.
/// `outline` from the design system. `#505050`. /// `outline` from the design system. `#505050`.
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0); pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
+3 -2
View File
@@ -36,8 +36,8 @@ use bevy::ui::{ComputedNode, UiGlobalTransform};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM,
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP, TEXT_PRIMARY, TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -189,6 +189,7 @@ fn spawn_tooltip_overlay(
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
Visibility::Hidden, Visibility::Hidden,
// Pin above the focus ring so a tooltip on a focused element // Pin above the focus ring so a tooltip on a focused element
// is never occluded by the focus outline. // is never occluded by the focus outline.
+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(),
} }
} }