Commit Graph

589 Commits

Author SHA1 Message Date
funman300 002d96f2c8 fix(android): add type annotation for hotkey None in spawn_modal_button
The Android aarch64 compiler cannot infer the type of `let hotkey = None` inside
the `#[cfg(target_os = "android")]` block — it needs to know the Option's inner
type to resolve the rebinding downstream. Added `: Option<&'static str>` to match
the parameter type and match the non-Android path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:09:02 -07:00
funman300 cc161cc37f fix(android): correct physical→logical px conversion for safe-area insets
`WindowInsets.getInsets(systemBars())` returns physical pixels (e.g. 84 px
on a 2.625× Pixel 7) but both Bevy's `Val::Px` (UI layer) and the world-
space layout coordinate system use logical pixels. Dividing by
`window.scale_factor()` before applying gives the correct 32 dp offset.

- `safe_area.rs::apply_safe_area_anchors`: query `Window`, divide `insets.top`
  by `scale_factor()` before writing `Val::Px(base_top + top_logical)`.
- `layout.rs::compute_layout`: new `safe_area_top: f32` parameter (logical px)
  subtracts from the vertical budget (`card_width_height_based`) and from
  `top_y` so both card sizing and pile positioning honour the status-bar band.
- `table_plugin.rs`: `setup_table` and `on_window_resized` now read
  `SafeAreaInsets` and divide by scale before passing `safe_area_top` to
  `compute_layout`. New `on_safe_area_changed` system fires a synthetic
  `WindowResized` when insets arrive (~frame 2-3 on Android) so the full
  resize pipeline (layout → pile markers → card snap) re-runs automatically.
- All test call-sites updated with `, 0.0` safe_area_top (desktop/no inset).
- Two regression tests added: shift amount equals `safe_area_top` exactly;
  horizontal layout is unaffected by vertical inset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:59:27 -07:00
funman300 8a3e30bd16 fix(android): P3 keyboard-hint sweep + clipboard JNI verified
Suppress all remaining keyboard-accelerator chips/labels on Android:
- spawn_modal_button (ui_modal.rs): single cfg gate covers every modal
  across all 13+ callers (onboarding, pause, confirm, game-over, restore,
  play-by-seed, home, help, profile, stats, leaderboard, settings, achievement)
- home_plugin.rs: mode-card hotkey chips (N/C/Z/X/T) gated off
- replay_overlay.rs: [SPACE]/[ESC]/[←→] footer hint text gated off;
  mode-indicator text kept
- help_plugin.rs: kbd chip containers gated off; description text kept

Clipboard JNI verified on Pixel 7 AVD (Android 14): added temporary
KEYCODE_C test hook, logcat confirmed "clipboard JNI OK", hook reverted.
Both JNI bridges (keystore + clipboard) are now confirmed working on device.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:22:40 -07:00
funman300 2a206b994c fix(android): wrap sync HTTP tasks in per-call Tokio runtime
reqwest/hyper-util's GaiResolver calls tokio::runtime::Handle::current()
which panics with "no reactor running" when driven by Bevy's
AsyncComputeTaskPool (async-executor, not Tokio).  Fixed all three spawn
sites in sync_plugin.rs (start_pull, handle_manual_sync_request,
push_replay_on_win) and the push_on_exit fallback by wrapping each HTTP
future in tokio::runtime::Builder::new_current_thread().enable_all().

Also fixes a clippy type_complexity warning in hud_plugin.rs by
extracting HudScoreFont / HudMovesFont / HudTimeFont type aliases for
the update_hud_typography query parameters.

Closes P4 AVD JNI bridge test: keystore JNI verified working on
Android 14 x86_64 AVD (load_access_token returned NotFound correctly);
clipboard JNI compiled and linked, runtime test deferred to a real-device
session with a won game and active sync server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:55:20 -07:00
funman300 ae7c6c97f1 fix(android): P3 icon density buckets + P4 B0004 investigation
P3 — App-icon density buckets:
- Created solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/
  ic_launcher.png from assets/icon/ (48→mdpi, 64→hdpi, 128→xhdpi,
  256→xxhdpi+xxxhdpi). aapt downscales oversized buckets; no quality loss.
- Added resources = "res" to [package.metadata.android] so cargo-apk/aapt
  packages the mipmap tree into the APK.
- Added icon = "@mipmap/ic_launcher" to [package.metadata.android.application]
  so the launcher references the density-bucketed icon instead of the
  default grey system icon.

P3 — Density-aware card scaling: investigated, no code change required.
  WindowResized fires with logical pixels; 256×384 card textures are
  downscaled on all current phone targets (40dp logical → 120px physical
  at 3× DPI). Upscaling only occurs on tablets wider than ~765dp at 3× DPI.

P4 — B0004 hierarchy warnings: investigated, no fix required.
  .despawn() is recursive in Bevy 0.18; warnings are startup timing
  artifacts (UI components propagating before parent initialises), not
  gameplay bugs. No crashes or defects in 2+ min AVD runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:53:38 -07:00
funman300 016fb7214d fix(android): responsive HUD typography + portrait orientation lock
Closes the final two P2 Android playability items:

1. HUD typography — new `update_hud_typography` system fires on
   `WindowResized` and adjusts Tier-1 font sizes: below 480 logical px
   Score drops HEADLINE(26)→BODY_LG(18) and Moves/Timer drop
   BODY_LG(18)→CAPTION(11), so all three fit in the 180dp HUD column
   on a 360dp phone without wrapping.

2. Orientation lock — `[package.metadata.android.application.activity]`
   with `orientation = "portrait"` in solitaire_app/Cargo.toml; cargo-apk
   maps this to `android:screenOrientation="portrait"` in the generated
   AndroidManifest.xml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:44:26 -07:00
funman300 948864e653 feat(android): long-press opens radial menu as right-click alternative
Touch screens have no right mouse button, so right-click radial was
inaccessible on Android. New system radial_open_on_long_press counts
up while a touch is held on a face-up card without crossing the drag
threshold; after 0.5 s it transitions RightClickRadialState to Active,
which the existing visual overlay and destination-ring infrastructure
then renders unchanged.

Three supporting changes to wire up the touch-driven confirm path:

- radial_track_cursor: falls back to the first active Touches position
  when cursor_world returns None, so the hover ring tracks a sliding
  held finger on Android.

- radial_handle_release_or_cancel: confirms on Touches::iter_just_released
  (finger lift) in addition to right-mouse release. Cancels on
  Touches::iter_just_canceled. No new event reader — uses the Touches
  resource which is already in scope after the track_cursor addition.

- handle_double_tap: skips when the radial is active. Guards the
  narrow edge case where the finger lifts on the exact same frame
  as the 0.5 s long-press threshold fires; prevents a spurious
  double-tap move from racing with the radial confirm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:23:24 -07:00
funman300 76a754d8e5 fix(android): improve touch drag responsiveness
Two improvements to drag responsiveness on Android:

1. Guard start_drag against touch-simulated mouse presses.
   start_drag (mouse path) now bails when Touches::iter_just_pressed()
   finds an active touch, so touch_start_drag always owns drag state on
   touch-screen devices. Without the guard, Bevy/Winit versions that
   synthesise MouseButton::Left from the primary touch would have the
   mouse drag path claim drag state first (start_drag runs before
   touch_start_drag in the system chain), leaving the card tracked via
   cursor_world instead of the Touches resource.

2. Lower mobile drag commit threshold 10 px → 8 px.
   Matches Android ViewConfiguration.getScaledTouchSlop() exactly.
   Smaller threshold reduces the snap-to-finger displacement at commit
   and makes drag feel more immediate.

Hardware confirmation (verify no stutter, tune if needed) remains a
manual step recorded in PLAYABILITY_TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:16:27 -07:00
funman300 9fb59c7d47 fix(android): lime flash on double-tap auto-move confirmation
When handle_double_tap recognises a double-tap and fires MoveRequestEvent,
the moved card(s) are immediately tinted STATE_SUCCESS (lime #acc267) with
a 0.35 s HintHighlight so the player sees visual confirmation before the
card animation begins.

- Priority 1 (single top card): flashes that card only.
- Priority 2 (whole face-up stack): flashes every card in drag.cards.

Reuses the existing tick_hint_highlight cleanup path (restores sprite
to WHITE when timer expires) so no new system or component is needed.
The flash duration (0.35 s) slightly outlasts a typical card animation
(~0.3 s), giving the tint a brief moment at the destination before clearing.

Marks P1 "Double-tap auto-move visible feedback" as closed in
PLAYABILITY_TODO (hardware trigger-verification still manual).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:10:38 -07:00
funman300 d714a11cfb fix(android): adaptive tableau fan fraction fills portrait viewport
On a 360 dp portrait phone the card width is set by the 9-column
horizontal packing (360/9 = 40 dp); the fixed 0.25 fan fraction then
places the worst-case 13-card column in the top ~44 % of the screen,
leaving the bottom 56 % empty black.

`compute_layout` now solves for the fan fraction that exactly uses the
available vertical space below the tableau row:

    ideal = avail / (12 * card_height)

On height-limited (desktop) windows ideal ≈ 0.25 and the clamp to the
minimum keeps existing behaviour. On width-limited (portrait phone)
windows the fan expands — ≈ 0.84 at 360 × 800 dp — stretching the
tableau to fill the screen.

Both `tableau_fan_frac` and `tableau_facedown_fan_frac` (scaled
proportionally) are stored on the `Layout` struct. `card_plugin` and
`input_plugin` read from the struct so rendering and hit-testing stay
in sync at every viewport size.

Three new regression tests:
- portrait phone expands fan_frac beyond desktop minimum
- expanded fan fits inside phone viewport (no overflow)
- desktop fan_frac stays at minimum 0.25

Closes P1 "Portrait-first card spacing" in PLAYABILITY_TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:05:17 -07:00
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>
v0.22.4
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>
v0.22.3
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>
v0.22.0 v0.22.1 v0.22.2
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>
v0.21.8
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>
v0.21.7
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