Compare commits

..

64 Commits

Author SHA1 Message Date
funman300 08f74d1e25 docs(handoff): mark E/F/G complete; update HEAD + origin state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:55:30 -07:00
funman300 6e6f3ef1ff feat(server): per-user rate limiting on protected sync endpoints
Adds a UserIdKeyExtractor that decodes the Authorization JWT to rate-limit
each user individually (falls back to client IP for unauthenticated
requests). Protected routes now throttle at 10-request burst / 1 token
per 10 s steady-state (6/min), matching the surface attack area of the
1 MB sync/push endpoint.

Also adds an integration test: sync_push_rate_limit_returns_429_on_11th_request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:55:07 -07:00
funman300 549a817bb1 refactor(sync): remove mirror_achievement from SyncProvider trait
The method had a no-op default, was never overridden in
SolitaireServerClient, and was never called by any engine system.
Achievements are already synced via the full SyncPayload push, so
the method provided no additional value and was a dead maintenance trap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:49:36 -07:00
funman300 613bbf8799 feat(settings): add theme import scan button
Adds "Scan for new themes" button to the Settings Appearance section.
The button fires ScanThemesRequestEvent, handled by a separate
handle_scan_themes system that walks user_theme_dir() for unrecognised
.zip archives, calls import_theme() on each, refreshes ThemeRegistry,
and fires InfoToastEvent messages reporting per-file results.

The import path (label) is shown above the button so players know where
to drop theme archives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:46:35 -07:00
funman300 b129664344 feat(auth): refresh token rotation via jti tracking
Adds a `refresh_tokens` table (migration 003) with one row per live
refresh token, keyed by UUID jti. On every POST /api/auth/refresh the
old jti row is deleted and a new token pair is issued and stored. Using
a consumed token returns 401. Expired rows are pruned inline on each
successful rotation.

Server: Claims gains an optional `jti` field; make_refresh_token now
returns (jwt, jti); register/login insert the jti row; RefreshResponse
now carries both tokens. Client: stores the rotated refresh token from
the response. ARCHITECTURE.md: API table + Security Model updated.
Three new integration tests cover rotation, consumed-token rejection,
and chained rotations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:34:42 -07:00
funman300 7d7c83ab28 docs(architecture): update to v1.3 — all Phase 8 gaps closed
Adds solitaire_wasm crate (§2/§3), replay API endpoints (§9), web
replay player routes, SyncProvider 7 optional methods, ThemePlugin +
SyncSetupPlugin in plugin table (§5), Settings new fields (§8), and
DB migration 002 replays table (§7). Also fixes missing [0.23.0]
section header in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:25:58 -07:00
funman300 bd388fef26 docs(changelog): document Phase 8 sync UI (432061c–272d31f)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:02:39 -07:00
funman300 272d31f851 feat(sync): account deletion flow + handle_sync_buttons refactor
Adds a two-step account-deletion UX: "Delete Account" button in the
Settings sync section (visible only when server backend is configured)
fires DeleteAccountRequestEvent → SyncSetupPlugin opens a confirmation
modal. "Delete Forever" spawns an async delete_account task; on success
SyncLogoutRequestEvent clears local credentials and resets the backend.
Errors surface via InfoToast.

Also splits handle_settings_buttons into handle_settings_buttons +
handle_sync_buttons to stay within Bevy's 16-parameter system limit.
Sync buttons (Sync Now, Connect, Disconnect, Delete Account) are now
handled in the dedicated handle_sync_buttons system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:53:32 -07:00
funman300 6ce55646d8 feat(sync): re-auth prompt on expired session + server deployment artifacts
On auth failure during pull (access + refresh both expired), sync_plugin now
fires SyncConfigureRequestEvent so the Connect modal reopens automatically
instead of leaving the player with a silent error status. The modal's existing
double-open guard keeps repeated failures idempotent.

Also removes the unused SyncAuthResultEvent (results handled inline in
SyncSetupPlugin via PendingAuthTask polling) and adds server deployment
artifacts: Dockerfile (multi-stage, SQLX_OFFLINE), docker-compose.yml (SQLite
volume, health-check), and .env.example for local development setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:45:08 -07:00
funman300 432061c3ec feat(sync): Phase 8 sync setup UI — login/register modal + Connect/Disconnect
Adds SyncSetupPlugin: a three-field (URL / Username / Password) modal
that handles both login and register flows via an async task on
AsyncComputeTaskPool wrapped in a Tokio single-thread runtime (same
pattern as the existing sync push/pull). On success, tokens are stored
to the OS keychain / Android Keystore and SyncProviderResource is
hot-swapped so subsequent pull/push use the new credentials immediately.

Settings sync section now shows Connect (when Local) or Sync Now +
Disconnect + username label (when SolitaireServer). SyncAuthResultEvent
stub registered for future re-auth prompt wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:40:29 -07:00
funman300 22303c62ff fix(android): replace non-FiraMono HUD glyphs with safe Unicode alternatives
⏸ (U+23F8), ★ (U+2605), ⚙ (U+2699) are absent from FiraMono and rendered
as boxes on device. Replace with ← ‖ → ▾ which all fall within FiraMono's
covered blocks (Basic Latin + Arrows + General Punctuation + Geometric Shapes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:00:58 -07:00
funman300 b1731fe68a fix(android): visual polish — green fallback, A-markers, wider fan, compact HUD
- camera clear colour → TABLE_COLOUR green so the background reads as
  felt even before bg_0.png finishes loading (async on Android)
- foundation empty markers now show "A" child text (same pattern as the
  "K" on tableau markers) — no suit letter since any Ace claims any slot
- HUD_BAND_HEIGHT = 128 on Android to accommodate the two-row button
  wrap on narrow phones; card grid reserves this space so buttons no
  longer overlap the top card row
- TABLEAU_FACEDOWN_FAN_FRAC 0.12 → 0.20 (layout.rs + card_plugin.rs):
  face-down stacks show ~67% more back strip per card on fresh deal,
  bringing the deepest column from ~27% to ~40% of available screen height
- update_tableau_fan_frac: return early when max face-up depth ≤ 1
  instead of overwriting the layout-computed adaptive value with the
  desktop minimum (0.25); fixes a regression where the portrait-phone
  adaptive fan_frac was silently snapped to 0.25 on every new deal
- update_tableau_fan_frac: also propagate facedown_fan_frac updates in
  the mid-game path (previously computed but immediately discarded)
- Android HUD buttons: compact Unicode icon labels (≡ ↩ ? ⏸ ⚙▾ +) with
  tighter padding (4 dp) and min-size (44 dp), max-width 90% — all 7
  buttons fit in a single 44 dp row on a 411 dp phone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:36:07 -07:00
funman300 2b01f741b4 feat(engine): Android polish sweep + hint button + watch replay
Draw-Three waste fan: slot.saturating_sub(1) was a constant shift that
hid slot-0 even when the pile had fewer cards than visible. Fixed to
slot.saturating_sub(rendered_len.saturating_sub(visible)) so small piles
fan correctly and only a genuine buffer card gets hidden. New regression
test covers the small-pile case.

Android toast: game-over "press D / N" message now shows touch-friendly
copy ("Tap the stock...") on Android via cfg gate.

Onboarding: SLIDE_COUNT drops from 3 to 2 on Android so first-time
users skip the keyboard-shortcuts slide (irrelevant on touchscreen).
spawn_slide dispatch is gated identically.

Hint button: added HintButton to the HUD action bar (order 4, between
Help and Modes). Clicking it triggers the async solver hint — same path
as the H key — via optional resources so headless tests stay clean.
All button-order and tooltip tests updated for the new 7-button bar.

Watch Replay: win-summary modal now shows a "Watch Replay" secondary
button alongside "Play Again". It loads the most recent entry from
ReplayHistoryResource and hands it to start_replay_playback, dismissing
the modal. Falls back to an info toast when the replay or playback
plugin is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 20:28:20 -07:00
funman300 3110702c74 chore: remove CI/CD workflow files
Workflows are not needed for this Gitea instance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 20:07:24 -07:00
funman300 33fb9627a8 fix(engine): correct has_legal_moves + waste flash on draw
CI / Test & Lint (push) Failing after 16s
CI / Release Build (push) Has been skipped
has_legal_moves: was only checking the top face-up card of each tableau
column as a move source. In Klondike any face-up card can anchor a
movable run, so mid-column cards were missed, causing premature game-over
declarations. Now iterates all face-up cards in each column.

Also tightened the source set: stock (face-down) cards were included
as placement sources producing false positives; waste now only considers
its top card (the one actually reachable by the player).

Waste flash: card_positions rendered exactly `visible` waste cards, so
the card sliding off-pile was despawned the same frame the draw tween
started, causing a one-frame flash. Now renders `visible + 1` cards;
the extra card sits at x=0 (hidden under the stack) and disappears
naturally once the tween positions the new top card over it.

Adds regression test: non-top face-up tableau card as only legal move.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:59:44 -07:00
funman300 4398403418 feat(engine): Android UX sweep — tap-to-move, safe area, HUD polish
CI / Test & Lint (push) Failing after 58s
CI / Release Build (push) Has been skipped
Single-tap auto-move (input_plugin):
- Remove 0.5 s double-tap window; any uncommitted TouchPhase::Ended on
  a face-up card now fires MoveRequestEvent immediately.

Bottom safe-area inset (layout, table_plugin):
- compute_layout gains safe_area_bottom param; height budget and bottom
  margin both respect the navigation bar reservation.

Card back contrast (card_plugin):
- CardBackFrame child sprite (gray, card_size + 3 px, local z=-0.01)
  spawned behind every face-down card so the dark back_0.png reads as
  a distinct rectangle against the dark felt.

HUD action bar compactness (hud_plugin):
- max_width 50% → 65% on the action button row; 6 buttons now wrap to
  2 rows instead of 3 on a 360 dp phone.

Dynamic tableau fan fraction (layout, card_plugin):
- Layout gains available_tableau_height field.
- update_tableau_fan_frac system (after GameMutation, before
  sync_cards_on_change) grows face-up fan from 0.25 to the window max
  as revealed column depth increases. Face-down fan is left at the
  window-adaptive value so stacks stay visible.

ModesPopover + MenuPopover light-dismiss (hud_plugin):
- Fullscreen transparent Button backdrop spawned at Z_HUD+4 behind each
  popover; tapping outside the panel despawns both panel and backdrop.

Stock badge legibility (card_plugin):
- Badge font TYPE_CAPTION (11 pt) → TYPE_BODY (14 pt); background
  sprite 28×16 → 34×20 world units.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:37:46 -07:00
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>
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
67 changed files with 7134 additions and 919 deletions
-88
View File
@@ -1,88 +0,0 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install Linux audio/display dependencies
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 and build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Clippy (all crates, zero warnings)
run: cargo clippy --workspace -- -D warnings
- name: Test (headless crates only — no display required)
run: |
cargo test -p solitaire_core
cargo test -p solitaire_sync
cargo test -p solitaire_data
cargo test -p solitaire_server
build:
name: Release Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install Linux audio/display dependencies
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 and build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-release-
- name: Build release binaries
run: cargo build --workspace --release
+8
View File
@@ -7,3 +7,11 @@
*.tmp
data/
.claude/
# IDE project files
.idea/
# Android signing keystores — never commit
*.jks
*.jks.bak
*.keystore
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT jti FROM refresh_tokens WHERE jti = ?",
"describe": {
"columns": [
{
"name": "jti",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "893c45c27854ba15ea611e8254ef980ced21dc64e6ca95fde56af513f86ffa46"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "c9ee5c64ca547f0c730379919a642bd649cbf81fc1804159101a70efabf08b33"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
}
+81 -5
View File
@@ -1,9 +1,9 @@
# Solitaire Quest — Architecture Document
> **Version:** 1.1
> **Version:** 1.3
> **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable)
> **Last Updated:** 2026-04-29
> **Last Updated:** 2026-05-12
---
@@ -86,6 +86,7 @@ solitaire_quest/
├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
└── solitaire_app/ # Main binary entry point
```
@@ -160,6 +161,20 @@ Owns:
- Daily challenge seed generation
- Leaderboard management
### `solitaire_wasm`
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
Owns:
- `ReplayPlayer` — WASM-exported state machine; steps through a replay's `Vec<ReplayMove>` against a live `GameState`
- `StateSnapshot` — JS-facing pile snapshot returned by each `step()` call
- `ReplayMove` / `Replay` mirrors — same serde shape as `solitaire_data` v2 equivalents
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
### `solitaire_app`
**Dependencies:** `bevy`, `solitaire_engine`.
@@ -261,6 +276,8 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
| `ThemePlugin` | — | Owns `ActiveTheme` resource; registers the `CardTheme` SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting `Image`; handles the embedded default theme and user themes from `user_theme_dir()` |
| `SyncSetupPlugin` | — | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when `SyncError::Auth` is returned by a pull |
| `LeaderboardPlugin` | L | Leaderboard overlay |
| `HelpPlugin` | H | Help / controls overlay |
| `PausePlugin` | Esc | Pause and resume |
@@ -365,10 +382,22 @@ All sync backends implement a single trait in `solitaire_data`. The `SyncPlugin`
```rust
#[async_trait]
pub trait SyncProvider: Send + Sync {
// Required — must be implemented by every backend:
async fn pull(&self) -> Result<SyncPayload, SyncError>;
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
fn backend_name(&self) -> &'static str;
fn is_authenticated(&self) -> bool;
// Optional — all have default no-op / empty implementations:
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError>;
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError>;
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError>;
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError>;
async fn opt_out_leaderboard(&self) -> Result<(), SyncError>;
async fn delete_account(&self) -> Result<(), SyncError>;
// Returns the shareable web URL on success; defaults to Err(UnsupportedPlatform)
// so LocalOnlyProvider silently no-ops the push-on-win path.
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError>;
}
```
@@ -454,6 +483,24 @@ CREATE TABLE leaderboard (
recorded_at TEXT NOT NULL,
PRIMARY KEY (user_id)
);
-- migrations/002_replays.sql
CREATE TABLE IF NOT EXISTS replays (
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
seed INTEGER NOT NULL,
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
time_seconds INTEGER NOT NULL,
final_score INTEGER NOT NULL,
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
replay_json TEXT NOT NULL -- full Replay serialisation
);
CREATE INDEX IF NOT EXISTS replays_received_at_idx ON replays(received_at DESC);
CREATE INDEX IF NOT EXISTS replays_user_id_idx ON replays(user_id);
```
### Request Lifecycle
@@ -579,12 +626,25 @@ pub struct AchievementRecord {
pub struct Settings {
pub draw_mode: DrawMode,
pub sfx_volume: f32, // 0.01.0
pub sfx_volume: f32, // 0.01.0
pub music_volume: f32,
pub animation_speed: AnimSpeed,
pub theme: Theme,
pub sync_backend: SyncBackend, // Local | SolitaireServer
pub sync_backend: SyncBackend, // Local | SolitaireServer
pub selected_card_back: usize, // index into PlayerProgress::unlocked_card_backs
pub selected_background: usize, // index into PlayerProgress::unlocked_backgrounds
pub first_run_complete: bool,
pub color_blind_mode: bool, // blue tint on red suits
pub high_contrast_mode: bool, // boosted luminance for low-vision users
pub reduce_motion_mode: bool, // WCAG reduce-motion — snaps instead of slides
pub window_geometry: Option<WindowGeometry>, // persisted size + position; None on first run
}
pub struct WindowGeometry {
pub width: u32, // logical pixels
pub height: u32,
pub x: i32, // physical pixels, top-left corner
pub y: i32,
}
```
@@ -600,7 +660,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|---|---|---|---|---|
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token}` |
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token, refresh_token}` (rotated) |
### Sync
@@ -617,6 +677,21 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
### Replays
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | `/api/replays` | Bearer JWT | Replay JSON | `{id, share_url}` |
| GET | `/api/replays/recent` | None | — (`?limit=N`) | `Vec<ReplaySummary>` |
| GET | `/api/replays/:id` | None | — | Full Replay JSON |
### Web Replay Player
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
### Account Management
| Method | Path | Auth | Body | Response |
@@ -945,6 +1020,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
| Token expiry | Access: 24h, Refresh: 30d |
| Refresh token rotation | Each `/api/auth/refresh` call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
| Payload abuse | 1MB max request body, enforced by Axum middleware |
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
+285 -2
View File
@@ -6,8 +6,291 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
No threads in flight. v0.21.6 cut on 2026-05-08; CHANGELOG accumulates
the next cycle here.
---
## [0.23.0] — 2026-05-12
Phase 8 sync UI: the self-hosted-server connection flow is now fully
playable end-to-end. Players can open a Connect modal from Settings,
enter a server URL + credentials, log in or register, and see the
sync-status section update live. Token expiry auto-reopens the modal.
Account deletion ships a two-click destroy flow. Server deployment
artifacts (Dockerfile + docker-compose) let self-hosters spin up in one
command.
### Added
- **Sync setup modal — Connect / Disconnect flow** (`432061c`).
New `SyncSetupPlugin` (`solitaire_engine/src/sync_setup_plugin.rs`)
provides the full server-connection UI. Three tab-stopped text fields
(URL, Username, Password) handle keyboard input via `MessageReader<KeyboardInput>`
with focus cycling on Tab. "Log In" and "Register" buttons each spawn an
async `AsyncComputeTaskPool` task that calls the new
`SolitaireServerClient::login()` / `::register()` methods; `poll_auth_task`
harvests the result, stores tokens via `store_tokens()`, hot-swaps
`SyncProviderResource` to the new server backend, fires
`ManualSyncRequestEvent` to pull immediately, and closes the modal.
An inline `SyncAuthError` label displays credential errors without a
toast. The modal is idempotent (`existing.is_empty()` guard) — safe
to open programmatically.
- **`SyncConfigureRequestEvent`, `SyncLogoutRequestEvent`,
`DeleteAccountRequestEvent`** (`432061c`). Three new engine events
wire the Settings buttons → plugin handlers. `SyncConfigureRequestEvent`
opens the setup modal; `SyncLogoutRequestEvent` disconnects and resets
`SyncProviderResource` to `LocalOnlyProvider`; `DeleteAccountRequestEvent`
opens the deletion confirmation modal.
- **Settings sync section — dynamic backend UI** (`432061c`).
`sync_row()` in `SettingsPlugin` now takes `backend: &SyncBackend` and
renders conditionally: `Local` → "Connect" button; `SolitaireServer`
username label + "Sync Now" + "Disconnect" + "Delete Account". Three new
`SettingsButton` discriminants (`ConnectSync` tab 91, `DisconnectSync`
tab 92, `DeleteAccount` tab 93) feed into a new `handle_sync_buttons`
system extracted from `handle_settings_buttons` to stay within Bevy's
16-parameter system limit.
- **`SolitaireServerClient::login()` and `::register()`** (`432061c`).
Both POST to `/api/auth/login` and `/api/auth/register` respectively.
Private helper `extract_auth_tokens` parses `{ access_token, refresh_token }`.
409 CONFLICT → "username already taken"; 401/403 → "invalid credentials";
400 → server message echoed to the player.
- **Re-auth prompt on token expiry** (`6ce5564`).
`poll_pull_result` in `SyncPlugin` now fires `InfoToastEvent("Session
expired — please reconnect")` + `SyncConfigureRequestEvent` when the
pull task resolves to `SyncError::Auth(_)`. Because the modal is
idempotent the re-open is safe to trigger from any system path.
- **Server deployment artifacts** (`6ce5564`).
`solitaire_server/Dockerfile`: multi-stage build (`rust:1.95-slim`
`debian:bookworm-slim`); copies `.sqlx` offline cache so `SQLX_OFFLINE=true`
succeeds without a live database at build time; exposes port 8080.
`solitaire_server/docker-compose.yml`: single-service compose file;
`db-data` volume at `/app/data`; `DATABASE_URL` and `JWT_SECRET` from
environment; HTTP health-check via `wget`. `solitaire_server/.env.example`:
documents all required variables with generation hint (`openssl rand -hex 32`).
- **Account deletion flow** (`272d31f`).
"Delete Account" in Settings fires `DeleteAccountRequestEvent`
`SyncSetupPlugin::open_delete_confirm_modal` spawns a danger-red
confirmation modal with "Cancel" and "Delete Forever" buttons.
"Delete Forever" submits an async `PendingDeleteTask` that calls
`SyncProvider::delete_account()`; `poll_delete_task` on Ok fires
`SyncLogoutRequestEvent` + a success toast; on Err shows an error toast
and leaves the modal open. Two-click destroy pattern — no accidental
account deletion possible.
### Removed
- **`SyncAuthResultEvent`** (`432061c`). Defined but never emitted or
consumed; removed as dead code.
### Stats
- Tests: **1300+ passing** / 0 failing
- Clippy: clean
- Crates touched: `solitaire_data` (sync_client), `solitaire_engine`
(events, settings_plugin, sync_plugin, sync_setup_plugin [new], lib),
`solitaire_app` (lib.rs), `solitaire_server` (Dockerfile,
docker-compose.yml, .env.example [new])
## [0.22.0] — 2026-05-08
Adds difficulty-tier game selection, Android JNI bridges for keystore and
clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens.
Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
### Added
- **Difficulty-tier game mode** (this release).
`DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster /
Random`) added to `solitaire_core::game_state` alongside a new
`GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed
catalogs (40 seeds each, 200 total) are generated by the new
`gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog
contains seeds proven winnable at progressively larger solver budgets
(1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent`
→ catalog seed → `NewGameRequestEvent`; the `Random` tier uses a
system-time seed and intentionally bypasses the winnable-only filter.
The home overlay gains an expandable `▶ Difficulty` section between the
Draw Mode row and the mode-card grid; the last-played tier is persisted
in `Settings::last_difficulty` and pre-expands/highlights on re-open.
Difficulty wins pool into Classic stats (no separate buckets).
- **Prev/Next replay selector in the Stats overlay** (`a449f60`).
`ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and
`ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen`
as a flex row of two bordered chips flanking a `"Replay N / M"`
caption, with a detail line below showing the selected replay's
duration + date and an optional `"· Shareable"` badge. Both chips
carry `ModalButton(Secondary)` so the existing `repaint_modal_buttons`
paint loop gives them hover/press feedback at zero extra cost.
`repaint_replay_selector_detail` is wired into the existing
`.chain()` alongside `handle_replay_selector_buttons` and
`repaint_replay_selector_caption`. The click handler and repaint
systems have been registered (and dormant) since v0.19.0; this
commit is purely the missing spawn site.
- **6 new selector unit tests** (`a449f60`). Covers: spawn-site
presence (Prev, Next, Caption, Detail all spawn with the screen),
caption initial text ("Replay 1 / 1"), detail initial text
("{dur} win on {date}"), Shareable badge when `share_url` is set,
empty-history "No replays" caption, and ordinal wrapping.
`make_test_replay(time_seconds, share_url)` helper encapsulates
`Replay::new(...)` + `chrono::NaiveDate`.
### Fixed
- **`const { assert!() }` for dim-layer z-order test** (`a449f60`).
Converted `assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)` in
`replay_overlay` tests to `const { assert!(…) }` to satisfy
`clippy::assertions_on_constants` (constant-fold at compile time
rather than a runtime no-op).
### Added (post-cut, same pending release)
- **Double-tap auto-move on touch screens** (`395a322`).
`handle_double_tap` fires `MoveRequestEvent` (single card to
foundation/tableau, or a whole face-up stack via
`best_tableau_destination_for_stack`) when two `TouchPhase::Ended`
events on the same card arrive within `DOUBLE_TAP_WINDOW` (0.5 s,
slightly wider than the mouse `DOUBLE_CLICK_WINDOW` to account for
touch latency). If no legal destination exists, fires
`MoveRejectedEvent` (audio + visual rejection feedback). The system
is inserted into the touch drag chain immediately before
`touch_end_drag` so `DragState.active_touch_id` and `committed` are
still readable; the tap timestamp is tracked in a `Local<HashMap<u32,
f32>>` keyed by card ID.
- **Play-by-Seed dialog** (`0cb1587`).
`PlayBySeedPlugin` adds a numeric-input modal that accepts a decimal
seed, runs a solver preview in the background (debounced 500 ms via
`AsyncComputeTaskPool`), and shows a win/no-win verdict before
dealing. A new `HomeMode::PlayBySeed` card in the home overlay fires
`StartPlayBySeedRequestEvent`; the handler in `PlayBySeedPlugin`
spawns the dialog. Digit, Backspace, Enter (confirm), and Escape
(cancel) are handled via `ButtonInput<KeyCode>`. Five unit tests
cover spawn, digit append, buffer read, confirm, and cancel paths.
- **75 new challenge seeds** (`2062bd0`).
New `gen_seeds` binary in `solitaire_assetgen` brute-searches seeds
in the `0xCAFEBABE…` namespace and filters for hands solvable in
≤250 moves via the core solver. The 75 confirmed-win seeds are
appended to `CHALLENGE_SEEDS` in `solitaire_data::challenge`.
### Fixed (post-cut, same pending release)
- **Gate `handle_fullscreen` to non-Android** (`45436d0`).
F11 fullscreen toggle makes no sense on Android (the OS owns window
sizing); the fn and its `MonitorSelection`/`WindowMode` imports are
now `#[cfg(not(target_os = "android"))]`-gated. The `add_systems`
call is extracted as a separate statement so `#[cfg]` can annotate it
(attributes cannot appear mid-chain in Rust).
- **Android APK launch: export `android_main`** (`202a64d`).
`NativeActivity` dlopen-s `libsolitaire_app.so` and calls
`android_main` as its entry point. Without the symbol the app
crashed immediately with `UnsatisfiedLinkError`. The new function
sets `bevy::android::ANDROID_APP` (required by `WinitPlugin`) then
delegates to `run()` — equivalent to what `#[bevy_main]` would
generate, but usable on an arbitrary entry point name.
- **Android APK launch: gate `resize_constraints` to non-Android**
(`202a64d`). On Android `max_width/max_height` default to `0.0`;
Bevy's clamp panicked with `min=800 > max=0`.
- **Android APK launch: gate `apply_smart_default_window_size` to
non-Android** (`202a64d`). The system calls `.clamp(800.0,
logical_w)` which panics when the emulator reports zero window
dimensions during early Android lifecycle events. The OS controls
window size on Android; the system is irrelevant there.
- **Ignore `.idea/` IDE project files** (`16242e6`). Android Studio
created `.idea/` when the project was opened during APK
verification; added to `.gitignore` and removed the accidentally-
committed files.
### Android verification result
APK boots on `x86_64-linux-android` in a Pixel_7 AVD (Android 14 /
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
Bevy renderer initialises, splash screen loads. This is the first
confirmed end-to-end device run.
### Stats
- Tests: **1300+ passing** / 0 failing
- Clippy: clean
- Crates touched: `solitaire_core` (game_state), `solitaire_data`
(settings, stats, difficulty_seeds, challenge), `solitaire_engine`
(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin,
input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs),
`solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries)
## [0.21.8] — 2026-05-08
Patch release for replay-overlay polish. Through-line:
**notch-label centering + WIN MOVE HC legibility + HC system extension**.
All three items were "optional polish" flagged in the v0.21.7 handoff;
all three ship in two commits.
### Added
- **`STATE_SUCCESS_HC` constant** (`c50eaf8`). Brighter lime
(`#c8e862`, L≈0.73) in `ui_theme` for use wherever the
standard `STATE_SUCCESS` (`#acc267`, L≈0.51) needs extra
luminance under HC mode. Sits above the bumped notch ticks
(`BORDER_SUBTLE_HC` gray, L≈0.60) so a WIN MOVE marker at
this colour is unambiguous.
- **`HighContrastBackground::with_hc(default, hc)` constructor**
(`c50eaf8`). Extends `HighContrastBackground` with an
`hc_color: Color` field (default = `BORDER_SUBTLE_HC` via
`with_default()`). `update_high_contrast_backgrounds` now
reads `marker.hc_color` instead of the hardcoded constant —
backwards-compatible; all existing `with_default()` usages
continue to bump to gray.
- **WIN MOVE scrub-bar marker HC bump** (`c50eaf8`). Marker
now carries `HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC)` so the lime stays lime under HC (brighter
lime rather than gray). Pin test locks both the default and
HC colour fields on the spawned entity.
### Fixed
- **Scrub-bar notch-label centering** (`b44d277`). Middle
three labels ("25%", "50%", "75%") previously had their
left edge at the notch; now their text centre coincides
with the notch tick. Implemented using the CSS
`translateX(-50%)` pattern for Bevy 0.18 UI: a fixed
`SCRUB_LABEL_CENTER_WIDTH = 36 px` container with
`margin.left = -18 px` is placed at `left: Percent(pct)`,
and `Justify::Center` centres the text within it. Endpoint
labels ("0%", "100%") keep their flush-left / flush-right
anchoring. `with_default()` remains one-argument.
### Stats
- Tests: 1276 passing / 0 failing (engine: 831)
- Clippy: clean
- Crates touched: `solitaire_engine` (replay_overlay.rs,
ui_theme.rs, settings_plugin.rs)
## [0.21.7] — 2026-05-08
Patch release closing the last major B-2 sub-piece. Through-line:
**mini-tableau preview dim layer**. The mockup's "Game Peek Band at
50 % opacity" is now implemented as a full-screen UI scrim that darkens
the card world during replay so the chrome (banner + move-log panel)
reads clearly against the scene.
### Added
- **Full-screen tableau dim layer** (`da3e542`). Spawns a
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
black) at `Z_REPLAY_DIM = Z_REPLAY_OVERLAY 1 = 54` whenever
a replay starts; despawned alongside the banner and move-log
panel when the replay ends. Bevy's UI/world compositor means
no changes to `card_plugin` are needed — UI nodes always
render above world-space sprites regardless of `Transform.z`.
The dim layer carries no `Interaction` component (purely
visual; pointer events pass through). Adds `Z_REPLAY_DIM`
and `TABLEAU_DIM_ALPHA` constants plus two new tests:
lifecycle (spawn/despawn mirrors the floating-chip pattern)
and z-ordering invariant (`Z_REPLAY_DIM < Z_REPLAY_OVERLAY`
pinned). 1275 tests pass / 0 failing.
### Stats
- Tests: 1275 passing / 0 failing
- Clippy: clean
- Crates touched: `solitaire_engine` (replay_overlay.rs)
## [0.21.6] — 2026-05-08
Generated
+5
View File
@@ -6967,6 +6967,8 @@ version = "0.1.0"
dependencies = [
"ab_glyph",
"png 0.17.16",
"solitaire_core",
"solitaire_data",
]
[[package]]
@@ -6984,8 +6986,10 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"bevy",
"chrono",
"dirs",
"jni 0.21.1",
"jsonwebtoken",
"keyring-core",
"reqwest",
@@ -7009,6 +7013,7 @@ dependencies = [
"bevy",
"chrono",
"dirs",
"jni 0.21.1",
"kira",
"resvg",
"ron",
+1
View File
@@ -31,6 +31,7 @@ keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
arboard = { version = "3", default-features = false }
jni = { version = "0.21", default-features = false }
solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
+122 -330
View File
@@ -1,366 +1,158 @@
# Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08**v0.21.6 cut and tagged at
`f63db76`**, working tree clean, all post-tag work pushed to
origin.
**Last updated:** 2026-05-12Sync rate limiting + mirror_achievement removal + theme import scan shipped (`6e6f3ef`). HEAD locally: `6e6f3ef`. Push pending.
v0.21.6 is a patch release with 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.
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
modal, re-auth on token expiry, account deletion flow, server deployment
artifacts (Dockerfile + docker-compose), replay upload on win, web replay
player (WASM + HTML/CSS/JS served by the server), leaderboard opt-in/out,
and full server integration tests.
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.
---
Six commits on the B-2 replay screen-takeover redesign arc land
here, bringing the post-v0.21.4 total to 12. The remaining B-2
piece — mini-tableau preview that dims the gameplay tableau
during replay — is the only major sub-piece still open.
## Current state
Full v0.21.6 detail lives in `CHANGELOG.md` § [0.21.6]. This
file from here on focuses on what's *open* post-cut and how to
resume.
- **HEAD locally:** `6e6f3ef` (feat: sync rate limiting).
- **HEAD on origin:** `b129664` (pushed — 4 commits ahead).
- **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **1300+ passing / 0 failing** across the workspace.
- **Tags on origin:** `v0.9.0` through `v0.22.0`.
## Status at pause
---
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
`f63db76`; any post-cut docs edits ride on top of that.
- **HEAD on origin:** matches local. v0.21.6 is fully on origin.
- **Working tree:** clean. No WIP outstanding.
- **`artwork/` directory:** still untracked. Intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean.
- **Tests:** **1273 passing / 0 failing** across the workspace.
Detail in `CHANGELOG.md` § [0.21.6] § Stats.
- **Tags on origin:** `v0.9.0` through `v0.21.6`. v0.21.6 is on
`f63db76`; v0.21.5 stays on `a2432df`; v0.21.4 stays on
`23ff62c`; v0.21.3 stays on `3d92a91`; v0.21.2 stays on
`f23df3b`; v0.21.1 stays on `daa655a`; v0.21.0 stays on
`04f9bf9`; v0.20.0 stays on `41a009a`.
## What shipped in Phase 8 (432061c bd388fe)
## Since the v0.21.6 cut
| Commit | Summary |
|--------|---------|
| `432061c` | Sync setup modal (login/register/connect/disconnect) |
| `6ce5564` | Re-auth on expired session + server deployment artifacts |
| `272d31f` | Account deletion flow + `handle_sync_buttons` refactor |
| `bd388fe` | CHANGELOG v0.23.0 documentation |
No threads in flight. Working tree clean as of 2026-05-08. New
work since the cut would land here as commit narratives; for
the v0.21.6 contents themselves, see `CHANGELOG.md` § [0.21.6].
Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
- `solitaire_wasm` crate: WASM ReplayPlayer bindings for browser-side replay playback
- Server replay API: `POST /api/replays`, `GET /api/replays/recent`, `GET /api/replays/:id`
- Server web UI: `/replays/:id` HTML route + `ServeDir /web` static assets
- DB migration 002: `replays` table + two indexes
- Full server integration tests for replay endpoints
- `push_replay` in `sync_plugin` (uploads on win, writes share URL into replay history)
- Stats panel "Copy Share Link" button reads `share_url` from replay history
Open next-step menu (Move Log + scrub-UX + keyboard accelerator
coverage + accessibility are all complete):
1. **Mini-tableau preview** — the only remaining major B-2
sub-piece. Mockup shows a 240 px-tall band at 50 % opacity
showing the gameplay tableau peeking through the replay
chrome. Implementation needs to add a settings-aware dim
overlay or alpha modulation on the tableau cards during
replay. Architectural — touches `card_plugin` rendering.
Multi-session.
2. **Move Log auto-scroll** — only relevant if the panel's
row count grows beyond the current 5-row fixed window.
Currently the prev-2 / active / next-2 layout fits all
visible content, so auto-scroll is unneeded. Becomes
relevant if a future commit expands the panel's row
capacity (e.g. 10-row scrolling list).
3. **Polish: notch label centering.** Bevy 0.18 lacks a
clean `translate-x: -50%` primitive so middle three
labels sit slightly right-of-notch. Could use a child
Text wrapper with computed left-margin compensation.
Tiny commit, requires visual review.
4. **Polish: WIN MOVE marker HC bump.** Currently uses
`STATE_SUCCESS` lime which stays visible under HC, but a
contrast bump under HC would make it even more legible
alongside the bumped notches. Optional.
---
Recommended order: option 1 (mini-tableau preview) is the
big remaining piece that closes B-2 — best tackled in a
fresh session because it crosses into `card_plugin`. Options
3 and 4 are visual polish that benefit from user review.
## Open punch list (ordered by priority)
## Open punch list
### 1. Documentation debt (no code)
- [x] CHANGELOG [Unreleased] → v0.23.0 — done this session
- [x] ARCHITECTURE.md update — all 8 gaps closed, bumped to v1.3
- [x] SESSION_HANDOFF.md update — this file
### Phase Android (build + persistence shipped; runtime gaps remain)
### 2. Leaderboard wiring gaps
- **Best-score auto-post missing.** `POST /api/sync/push` merges stats/achievements/
progress but never touches the `leaderboard` table. Players who opt in never
have their `best_time_secs` / `best_score` updated automatically. Fix: update
the leaderboard row inside the server's sync push handler (or on `GameWonEvent`
via a new async task in `sync_plugin`).
- **Display name = username.** `handle_opt_in_button` uses the `SyncBackend`
username as the leaderboard display name. Consider adding
`leaderboard_display_name: Option<String>` to `Settings` for players who
want a different public identity.
- **APK launch verification on AVD / device.** `adb install` then
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
The build works and persistence is wired, but no end-to-end
device run has been logged. Shakes out runtime bugs the build +
unit tests can't catch.
- **JNI ClipboardManager bridge.** Replaces the Android stub for
the Stats "Copy share link" toast. `arboard` doesn't ship an
Android backend; small custom JNI call.
- **Android Keystore for credentials.** `keyring` is target-gated
to a stub returning `KeychainUnavailable`; replace with Android
Keystore via JNI when sync auth ships on mobile.
- **Google Play Games (gpgs) integration.** Listed as a
Phase-Android target since Phase 1; now unblocked by the build
target.
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
panic doesn't affect the APK on disk but produces noisy stderr.
Either upstream a cargo-apk fix or document `--lib` as
canonical in the runbook.
### 3. Security hardening
- [x] **Refresh token rotation.** Done (`b129664`): `refresh_tokens` table
(migration 003); jti embedded in JWT; rotate-on-use pattern; 3 integration
tests.
- [x] **Sync endpoint rate limiting.** Done (`6e6f3ef`): `UserIdKeyExtractor`
decodes JWT for per-user identity; falls back to IP; burst 10 / 6 min
steady-state; integration test passes.
### Visual-identity follow-ups (post-v0.21.0)
### 4. Android validation
- **Android Keystore functional test** — JNI AES-GCM code ships (`f281425`) but
no AVD round-trip test has been run. Required before Phase 8 sync goes live on
Android.
- **JNI clipboard functional test** — same status (`2c822ba`). Note: `adb tap`
doesn't work in headless AVD (see memory); requires a touch-gesture path.
- **`cargo apk build --lib` noisy stderr** — post-sign panic doesn't affect the
APK but pollutes CI output. Document `--lib` as canonical or upstream a fix.
The visual-identity arc is effectively complete: token system,
chrome migration, splash boot screen, replay-overlay banner,
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
### 5. Feature completeness
- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in
Settings Appearance section. Shows import path label, scans user_theme_dir()
for .zip archives, fires InfoToastEvent per file, refreshes ThemeRegistry.
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
default never overridden and never called; achievements already sync via
`SyncPayload` push. Deleted from trait and blanket impl.
- **WASM build script.** `web/pkg/` contains compiled WASM committed to git.
Need a `build_wasm.sh` or Makefile target documenting the `wasm-pack build`
invocation to regenerate it.
- **Server password reset.** No admin endpoint or CLI tool for resetting a
user's password. Self-hosters have no recovery path short of direct SQLite
edits.
- **Replay-overlay screen-takeover redesign.** The full mockup
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a
mini-tableau preview, playback controls, move-log scroll, and
a WIN MOVE marker on the scrub bar. Banner-local pieces all
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
`e080b49`); the floating MOVE chip above the focused card
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
(UI). Playback controls (pause / resume / step + Space
accelerator) shipped post-v0.21.3 in `fbe48ac`. v0.21.5
bundled six more commits under "replay-overlay scrubbing
affordances + accessibility" (scrub notches + labels +
keybind footer + ESC and ← / → accelerators + HC border).
v0.21.6 bundled six more under "Move Log panel + scrub-UX
polish" — bottom-edge Move Log panel with prev/active/next
rows + active highlight, HC-mode coverage for the scrub
track + notches, continuous scrub on key-held arrows. Banner
height grew 60 → 76 → 92 px across two layout-changing
commits in v0.21.5; Move Log panel grew 56 → 84 → 112 px
across the v0.21.6 move-log commits. Per-commit detail in
`CHANGELOG.md` § [0.21.5] and § [0.21.6]. The only major
B-2 piece left is the mini-tableau preview — the mockup's
"Game Peek Band" at 50 % opacity. Architectural; touches
`card_plugin` rendering.
Multi-session.
- *Floating `MOVE N/M` chip above the focused card during
playback — closed 2026-05-08 by `2fb2d63`.* World-space
`Text2d` entity sibling to the banner overlay; uses the same
`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
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
dynamic-paint rollout (`c153363`).* Card text rendering plus
8 static-border chrome surfaces (modal scaffold, tooltip,
onboarding key chips, help panel key chips, stats panel
cells, home Level/XP/Score row, home mode buttons, home
mode-hotkey chips, 4 settings panel surfaces) all boost
borders to `BORDER_SUBTLE_HC` under HC via the
`HighContrastBorder` marker. The previously-carved-out
dynamic-paint sites are now also covered: HUD action buttons
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).
### 6. Testing gaps
- **Server 401 → refresh → retry path** — the `pull`/`push` retry logic in
`SolitaireServerClient` has no integration test.
- **WASM winning-replay step-through** — current tests cover 2 stock clicks;
a test stepping through a full winning sequence would catch
`GameState`/`ReplayMove` compatibility regressions.
### Carried forward from v0.19.0
---
- *App icon round — closed 2026-05-08 by `3eb3a26` + `716a025`.*
Runtime `Window::icon` wired (Linux/macOS/Windows); 9-size
PNG hierarchy at `assets/icon/icon_<size>.png` covers Linux
hicolor + downstream `.icns`/`.ico` packaging needs. The
`.ico` and `.icns` bundle-format files themselves are *not*
generated — both would need new crate deps (`ico` and
`icns` respectively) and only matter at app-bundle time
(cargo-bundle / packaging), not at `cargo run`. Open if the
project later ships as a packaged macOS / Windows app.
## ARCHITECTURE.md gaps (for the update pass)
### Other small candidates
Items missing from the doc:
1. `solitaire_wasm` crate (§2 workspace + §3 responsibilities)
2. Replay API endpoints (§9 API Reference — 3 new routes)
3. Web replay player route (`/replays/:id` + `ServeDir /web`)
4. `SyncProvider` trait: 6 added methods
5. Theme system in Bevy plugin table (§5)
6. `Settings` new fields: `color_blind_mode`, `high_contrast_mode`,
`reduce_motion_mode`, `window_geometry`, `selected_card_back`,
`selected_background`
7. DB migration 002 (§7)
8. Update "Last Updated" date
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
noted Prev/Next markers exist in `stats_plugin` but no spawn
site renders them today — the Shareable badge therefore lands
on the single-replay caption. If/when Prev/Next is plumbed,
the badge will need to follow.
- **Toast queue / immediate unification.** The two toast paths
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
for fire-and-forget) now share visual treatment but remain
separate functions because they serve different temporal
needs (sequential vs. parallel). If overlap becomes a UX
issue, merge into one queue with priority lanes.
---
### Process notes
## Process notes
- **The desktop-adaptation spec is the canonical reference for
geometry decisions** when porting any future plugin. Read
`docs/ui-mockups/desktop-adaptation.md` first; apply the
universal rules to every surface; consult the per-screen
table for the priority surfaces. The 9 missing-plugin screens
(splash now ported; eight remaining) inherit the universal
rules without dedicated guidance.
- **Stitch `generate_variants` is unreliable for layout-only
adaptation prompts** as of 2026-05-07. The first call timed
out and no variant ever landed in `list_screens`. If a future
session wants visual desktop mockups, prefer
`generate_screen_from_text` with a fresh narrow prompt per
screen rather than `generate_variants` against existing
mobile screens.
- **Token-port pattern.** v0.20.0's chrome-migration commits
set a reusable shape for "centralised design system applied
across N plugins":
1. Constants module (`ui_theme.rs`) is the source of truth.
2. Const sites that can't call `Alpha::with_alpha` (not yet
`const` on stable) use a literal RGB matching the token,
with a unit test pinning the RGB to the token (e.g.
`MARKER_VALID`, `HINT_PILE_HIGHLIGHT_COLOUR`,
`RIGHT_CLICK_HIGHLIGHT_COLOUR`).
3. Cross-plugin duplication (e.g. `MARKER_DEFAULT`
`PILE_MARKER_DEFAULT_COLOUR`) collapses to a single
promoted const re-exported from one plugin and imported
by the other — replaces "kept in sync" doc comments with a
compile-time invariant.
4. Domain colours (suit pips, card faces, lerp helpers) stay
as literals with a comment naming the rationale; only UI
chrome routes through tokens.
- **`SplashFadable` scaffolding pattern** (introduced in
`cacb19c`). Any future overlay that needs to fade `N >> 3`
elements together should follow the same shape: one tiny
marker carrying the full-alpha base colour, one global query
that lerps every marker's alpha each frame, no per-element
query plumbing. Cleanly outscales the `Without<X>, Without<Y>`
query exclusion pattern that the old splash was hitting at
three siblings.
- **Commit attribution:** use `funman300` as git user. Co-author line:
`Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
- **Commit format:** `type(scope): description` per CLAUDE.md §7.
- **Never commit without:** `cargo test --workspace` passing + clippy clean.
- **Sub-agents** stage/verify only; orchestrator commits.
- **`CARD_PLAN.md`** referenced in `theme/` module comments but not present in
repo. Clean up references or commit the file.
- **Token-port pattern** (v0.20.0): when migrating tokens, walk every concrete
artifact downstream — PNGs, SVGs, literals, comments. Three "walked past this"
follow-ups in v0.21.0 all had this shape.
### Canonical remote
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
Always push there. As of v0.21.0 origin matches local; the next
push happens when post-cut work accumulates and is ready to roll
into a v0.21.1 / v0.22.0 cut.
### Design direction (Terminal — base16-eighties)
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
monospaced-forward typography (JetBrains Mono / FiraMono), tight
16 px edge margins, 8 px card radius.
- **Palette:** near-black surface ramp (`#151515` / `#202020` /
`#2a2a2a` / `#353535`), brick-red primary CTA (`#a54242`
swapped from cyan `#6fc2ef` in v0.21.0 commit `a292a7e`), lime
success (`#acc267`), gold warning (`#ddb26f`), pink error /
suit-red (`#fb9fb1`), lavender celebration (`#e1a3ee`), teal
info (`#12cfc0`).
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`.
Outlined glyphs for diamonds & clubs are *always on*; the
Settings "color-blind mode" toggle swaps red → lime `#acc267`
(was red → cyan pre-v0.21.0; lime is the next-best non-red
base16-eighties accent now that the primary itself is red).
- **Card glyphs render upright in both corners** — no 180°
inverted-corner-indicator rotation. Single-orientation
digital play doesn't benefit from the traditional flip-
readback convention. `design-system.md` § Game Cards
documents this deliberate deviation.
---
## Resume prompt
```
You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine>.
Branch: master. v0.21.6 is tagged at f63db76 (cut 2026-05-08, a
patch release rolling up Move Log panel + scrub-UX polish:
brand-new bottom-edge Move Log panel with prev / active / next
row context + active-row highlight, plus HC-mode coverage for
scrub track + notches and continuous scrub on key-held arrow
keys). v0.21.5 stays at a2432df, v0.21.4 at 23ff62c, v0.21.3
at 3d92a91, v0.21.2 at f23df3b, v0.21.1 at daa655a, v0.21.0 at
04f9bf9. Working tree clean. See CHANGELOG.md § [0.21.6] for
full detail.
Working directory: <Rusty_Solitaire clone path>.
Branch: master. v0.23.0 is the current version (HEAD locally: bd388fe).
Phase 8 sync is fully shipped. ARCHITECTURE.md is now v1.3 (all Phase 8 gaps closed).
Push to origin pending (bd388fe + ARCHITECTURE.md + SESSION_HANDOFF.md commits).
State: HEAD locally — see `git rev-parse HEAD`. The cut commit
is f63db76; any post-cut docs edits ride on top of that.
Workspace tests: 1273 passing / 0 failing. Clippy clean.
READ FIRST (in order, before doing anything):
READ FIRST (in order):
1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.21.6] section is the most recent cut
2. CHANGELOG.md — [0.23.0] section has the full Phase 8 detail
3. CLAUDE.md — unified-3.0 rule set
4. CLAUDE_SPEC.md — formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow
6. docs/ui-mockups/ — design system + 24-mockup library +
desktop-adaptation.md (the rules-based
companion to the mockups; read this
before any plugin port)
7. docs/android/* — Android setup + build runbook
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
— saved feedback / project context
(machine-local; may be missing on a
fresh machine)
4. ARCHITECTURE.md — v1.3, fully up to date
5. docs/ui-mockups/ — design system + mockup library
6. docs/android/ — Android setup + build runbook
7. ~/.claude/projects/<this-project>/memory/MEMORY.md
DECISION TO ASK THE PLAYER FIRST:
A. APK launch verification on AVD / device — `adb install` +
`adb logcat` to shake out runtime bugs the build / unit
tests can't catch. Likely surfaces JNI ClipboardManager
and Android Keystore stubs that need real bridges. Larger
scope; needs an Android device or emulator running.
B. Replay-overlay screen-takeover redesign — nearly complete
after 12 commits across v0.21.4-6. Scrub bar with notches
+ labels + WIN MOVE marker, pause / resume / step / stop
buttons, Space + Esc + ← / → keyboard accelerators with
continuous scrub on hold, keybind-hint footer, full HC-mode
coverage on the banner pieces, and a brand-new bottom-edge
Move Log panel with a 5-row prev/active/next window all
ship. The only remaining major B-2 sub-piece is the
**mini-tableau preview** — the mockup's "Game Peek Band"
at 50 % opacity showing the tableau through the replay
chrome. Implementation needs a settings-aware dim overlay
or alpha modulation on the tableau cards during replay.
Architectural — touches `card_plugin` rendering. Best
tackled in a fresh session because it crosses into a
plugin the recent B-2 work hasn't touched. Mockup at
`docs/ui-mockups/replay-overlay-mobile.html`.
C. Phase 8 (sync) — local storage scaffolding, self-hosted
Axum server, `SolitaireServerClient` impl, GPGS stub
wired into Settings. The biggest open arc by scope; rolls
up several Phase Android dependencies (Keystore,
ClipboardManager).
OPEN WORK (in priority order):
D. Android AVD functional tests (Keystore + clipboard)
E. Theme importer UI button in Settings
F. mirror_achievement: decide + implement or remove from trait
G. Sync endpoint rate limiting (POST /api/sync/push has no per-user throttle)
WORKFLOW NOTES:
- Use the system git config (already correct).
- When attributing playtester feedback in commits/docs, use
"Quat" not "Rhys" (saved feedback memory).
- Sub-agents stage + verify only; orchestrator commits.
- Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — gh auth setup-git wired on
primary dev box; verify on laptop before first push.
- Token-port pattern: when migrating tokens, walk every
concrete artifact downstream of the token (PNG textures,
embedded SVGs, hardcoded literals, comment color names),
not just the token name. v0.21.0 surfaced three "the
migration walked past this" follow-ups that all matched
this shape — codified here so future similar work can
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 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.
Ask which to start. All are independent; any is a valid next arc.
```
+245
View File
@@ -0,0 +1,245 @@
# 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. Remaining hint sites swept in P3 —
see full-keyboard-hint-sweep entry below.
- [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.
- [x] **Portrait-first card spacing.** *Closed 2026-05-11.*
`compute_layout` now derives an adaptive `tableau_fan_frac` from the
available vertical space below the tableau row. On height-limited
(desktop) windows the formula returns ≈ 0.25 and the clamp keeps the
existing behaviour. On width-limited (portrait phone) windows — where
card size is constrained by the 9-column horizontal packing — the fan
fraction expands to fill the viewport (≈ 0.84 at 360 × 800 dp).
`tableau_facedown_fan_frac` scales proportionally. Both values live in
the `Layout` struct; `card_plugin::card_positions` and
`input_plugin::card_position` / `pile_drop_rect` read from the struct
so rendering and hit-testing stay in sync across viewport sizes.
- [x] **Double-tap auto-move visible feedback.** *Closed 2026-05-11.*
On a recognised double-tap (priority 1 single-card or priority 2
stack move), the moved card(s) receive a 0.35 s lime flash
(`STATE_SUCCESS` tint + `HintHighlight { remaining: 0.35 }`) before
the move request is written. The flash persists through the card
animation and is cleaned up by the existing `tick_hint_highlight`
system. Hardware trigger-verification remains a manual step — connect
AVD or device and confirm two rapid `TouchPhase::Ended` events within
0.5 s produce the lime flash.
## P2 — Polish
- [x] **Drag responsiveness on touch.** *Closed 2026-05-11.*
Two code-side improvements shipped; final feel confirmation still needs
hardware:
1. `start_drag` (mouse path) now bails out when a touch is just-pressed
(`Touches::iter_just_pressed()`), ensuring `touch_start_drag` always
owns the drag state on touch-screen devices — including Bevy/Winit
versions that simulate `MouseButton::Left` from the primary touch.
2. Mobile drag commit threshold lowered 10 px → 8 px, matching Android's
`ViewConfiguration.getScaledTouchSlop()` spec. Smaller threshold →
smaller snap-on-commit and faster perceived response.
**Remaining:** connect AVD or device and verify drag feels responsive
with no stutter; tune threshold further if needed.
- [x] **Long-press menu.** *Closed 2026-05-11.* New system
`radial_open_on_long_press` in `radial_menu.rs` counts up while a
touch is held (`drag.active_touch_id.is_some() && !drag.committed`)
and opens `RightClickRadialState::Active` after 0.5 s — the same
state the right-click path uses. Existing radial infrastructure
then handles everything:
- `radial_track_cursor` extended to fall back to the first active
touch when no cursor position is available, so sliding the held
finger moves the hover ring.
- `radial_handle_release_or_cancel` extended to confirm/cancel on
`Touches::iter_just_released()` in addition to right-mouse release.
- `handle_double_tap` skips when the radial is active (guards a
narrow edge case where the finger lifts at exactly the same frame
the 0.5 s threshold fires).
Hardware verification needed: confirm the 0.5 s hold feel, verify
sliding to a destination and lifting confirms the move.
- [x] **HUD typography.** *Closed 2026-05-11.* New system
`update_hud_typography` fires on `WindowResized` and adjusts Tier-1
font sizes based on viewport width. Below 480 logical px: Score
`TYPE_HEADLINE` (26) → `TYPE_BODY_LG` (18), Moves/Timer
`TYPE_BODY_LG` (18) → `TYPE_CAPTION` (11), so all three items fit
in the 180 dp HUD column on a 360 dp phone. At ≥ 480 px the
original sizes are restored — desktop/tablet layout unchanged.
`add_message::<WindowResized>()` added defensively to `HudPlugin`
so the system works under `MinimalPlugins` in tests.
- [x] **Orientation lock.** *Closed 2026-05-11.* Added
`[package.metadata.android.application.activity]` section to
`solitaire_app/Cargo.toml` with `orientation = "portrait"`.
cargo-apk/ndk-build maps this to `android:screenOrientation="portrait"`
in the generated `AndroidManifest.xml`. Remove (or add a landscape
layout) before enabling auto-rotate.
## P3 — Asset density
- [x] **Density-aware card scaling.** *Closed 2026-05-11 — no code change
required.* `WindowResized` fires with **logical** pixels; sprites are
sized in world units (1 world unit = 1 logical pixel); Bevy's renderer
maps logical → physical via `scale_factor` internally. On a 360 dp
3×-DPI phone, cards are 40 logical dp = 120 physical px. The 256 × 384 px
card textures are **downscaled** to fit (256 → 120 px) — quality is fine.
Upscaling only occurs if `card_width × scale_factor > 256`, i.e. a
tablet with a logical width > 765 dp at 3× DPI — no current target
device falls in that range. Revisit if the game ships on large-screen
high-DPI tablets.
- [x] **App-icon density buckets.** *Closed 2026-05-11.* Created
`solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png`
from the existing `assets/icon/` PNGs (48→mdpi, 64→hdpi, 128→xhdpi,
256→xxhdpi+xxxhdpi). Added `resources = "res"` to
`[package.metadata.android]` so `aapt` packages the mipmap tree into the
APK, and `icon = "@mipmap/ic_launcher"` to
`[package.metadata.android.application]` so the launcher references it.
- [x] **Full keyboard-hint sweep.** *Closed 2026-05-11.* Extended the
P1 suppression to cover all remaining hint sites:
- `ui_modal.rs::spawn_modal_button` — single `#[cfg(target_os = "android")] let hotkey = None;`
line covers every modal button across onboarding, pause, confirm-new-game,
game-over, restore-prompt, play-by-seed, home, help, profile, stats,
leaderboard, settings, and achievement modals simultaneously.
- `home_plugin.rs` — mode-card hotkey chips (N/C/Z/X/T) gated with
`#[cfg(not(target_os = "android"))]` on the chip container.
- `replay_overlay.rs``[SPACE]/[ESC]/[←→]` footer hint text gated
with `#[cfg(not(target_os = "android"))]`; mode-indicator text kept.
- `help_plugin.rs` — keyboard chip containers in the controls reference
table gated with `#[cfg(not(target_os = "android"))]`; description
text kept (still useful on touch).
## P4 — Stability / runtime
- [x] **B0004 ECS hierarchy warnings.** *Investigated 2026-05-11 — no
fix required.* B0004 fires via Bevy's `validate_parent_has_component<C>`
hook when a child entity has UI component `C` (e.g. `Node`,
`InheritedVisibility`) but its parent doesn't yet. In Bevy 0.18,
`.despawn()` is recursive (docs: "When a parent is despawned, all
children will also be despawned"), so all `.despawn()` calls in the
engine are safe. The warnings seen on the Pixel 7 AVD during startup
are a component-propagation timing artifact — UI children reach the
hook before the parent's inherited components finish initialising —
not a gameplay defect. `despawn_related::<Children>()` in
`card_plugin.rs` is explicit child-only teardown (parent kept alive)
and is correct. No gameplay bugs attributed to these warnings over 2+
min AVD runtime.
- [x] **AVD functional tests for JNI bridges.** *Closed 2026-05-11.*
Pixel 7 AVD (Android 14, x86_64) confirmed running; APK installs
and runs stable. Key findings:
**Keystore JNI — verified working.** Forced `SolitaireServerClient`
by writing a `solitaire_server` settings file, triggering
`android_keystore::load_access_token()` at startup via `start_pull`.
Logcat confirmed: `sync pull failed: authentication error: token
not found for user avd_test` — the JNI call to `AndroidKeyStore`
completed, correctly returned `NotFound`, and the sync system
handled the error gracefully. No panic, no crash from the JNI layer.
**Clipboard JNI — verified working.** Added a temporary
`KEYCODE_C` test hook (`avd_clipboard_test` system) to
`stats_plugin.rs`, rebuilt the APK, pressed C on the AVD.
Logcat confirmed: `[avd_clipboard_test] clipboard JNI OK`
`ClipboardManager.setPrimaryClip()` succeeded on Android 14.
Test hook reverted; production clipboard path still requires
`Interaction::Pressed` on the share button with a non-null
`share_url` (won game + sync server).
**Side-finding fixed:** `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 in `sync_plugin.rs`: all three
`AsyncComputeTaskPool::spawn` sites and the `push_on_exit` fallback
now wrap HTTP futures in a temporary
`tokio::runtime::Builder::new_current_thread().enable_all()` runtime.
**Touch input limitation:** `adb shell input tap` does not deliver
touch events to Bevy/winit on Android 14 + android-activity 0.6.1
in headless AVD mode. Keyboard events (`KEYCODE_*`) work normally.
---
## 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.
+17
View File
@@ -60,6 +60,15 @@ package = "com.solitairequest.app"
apk_name = "solitaire-quest"
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
assets = "../assets"
# Density-bucketed launcher icons. `aapt` processes `res/mipmap-*/` and
# packages them into the APK; the launcher selects the best-fit bucket
# for the device screen density. Sizes used:
# mdpi (1×, 48 dp) → 48 px (exact)
# hdpi (1.5×, 72 dp) → 64 px (88 %, aapt scales up slightly)
# xhdpi (2×, 96 dp) → 128 px (133 %, aapt scales down)
# xxhdpi (3×, 144 dp) → 256 px (178 %, aapt scales down)
# xxxhdpi (4×, 192 dp) → 256 px (133 %, aapt scales down)
resources = "res"
# No `runtime_libs` — we don't ship any precompiled .so files,
# the entire app is pure Rust + Bevy. cargo-apk would try to
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
@@ -79,6 +88,14 @@ name = "android.permission.INTERNET"
[package.metadata.android.application]
label = "Solitaire Quest"
# Launcher icon — references the density-bucketed mipmap resource above.
icon = "@mipmap/ic_launcher"
# `debuggable` defaults to false on release builds; cargo-apk flips it
# automatically for debug profiles. Leaving the field unset keeps the
# default behaviour.
[package.metadata.android.application.activity]
# Lock to portrait — the current layout has only been designed and tested
# in portrait orientation. Remove (or add a landscape layout) before
# enabling auto-rotate.
orientation = "portrait"
Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+56 -13
View File
@@ -18,21 +18,23 @@ use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
};
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
#[cfg(not(target_os = "android"))]
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
#[cfg(not(target_os = "android"))]
use bevy::winit::WinitWindows;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
SelectionPlugin, SettingsPlugin,
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
};
/// App entry point — builds and runs the Bevy app.
@@ -76,6 +78,7 @@ pub fn run() {
// primary monitor) — `apply_smart_default_window_size` will resize
// up to a monitor-relative target on the first frame so HiDPI / 4K
// sessions don't end up with a comparatively tiny window.
#[cfg(not(target_os = "android"))]
let had_saved_geometry = settings.window_geometry.is_some();
let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => (
@@ -116,6 +119,9 @@ pub fn run() {
// small enough that a few stray dropped frames from
// disabling vsync are imperceptible.
present_mode: PresentMode::AutoNoVsync,
// Android windows always fill the screen; max_width/max_height
// default to 0.0, which panics Bevy's clamp when min > max.
#[cfg(not(target_os = "android"))]
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
@@ -126,11 +132,20 @@ pub fn run() {
..default()
})
// The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
// Point one level up so `cargo run -p solitaire_app` finds
// card faces, backs, backgrounds, and the UI font.
// on desktop Bevy resolves `AssetPlugin::file_path` relative
// to the binary package's `CARGO_MANIFEST_DIR`
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
// miss the workspace-root `assets/` without a `../` prefix.
//
// On Android cargo-apk packages the same directory into the
// APK at `assets/` (via `[package.metadata.android].assets`
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
// is already rooted there, so any `file_path` other than the
// default makes it walk *out* of the APK's assets root and
// all loads fail silently — which is what produced the
// solid-red card-back fallback in the v0.22.3 screenshot.
.set(bevy::asset::AssetPlugin {
#[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(),
..default()
}),
@@ -142,6 +157,13 @@ pub fn run() {
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
// Cursor-icon feedback is desktop-only; Android has no pointer cursor.
// The drop-target highlight systems (update_drop_highlights,
// update_drop_target_overlays) live in CursorPlugin but ARE useful
// on Android — they've been left running because their Bevy system
// params compile and function on Android; only the CursorIcon insert
// is inert. Gate the whole plugin if the cursor APIs ever cause
// Android linker issues; for now it's harmless to leave it registered.
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin)
@@ -158,7 +180,10 @@ pub fn run() {
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(SafeAreaInsetsPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
@@ -168,6 +193,7 @@ pub fn run() {
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
@@ -195,6 +221,8 @@ pub fn run() {
// every fresh launch can flip `disable_smart_default_size` in
// Settings to opt out. The flag is checked once at startup; a
// mid-session change applies on the next launch.
// Android windows are always full-screen; the OS controls sizing.
#[cfg(not(target_os = "android"))]
if !had_saved_geometry && !settings.disable_smart_default_size {
app.add_systems(Update, apply_smart_default_window_size);
}
@@ -215,6 +243,7 @@ pub fn run() {
/// a dedicated resource. The Update tick is necessary because Bevy
/// populates the `Monitor` entities asynchronously after winit's
/// Resumed event fires; they may not exist on the first Startup pass.
#[cfg(not(target_os = "android"))]
fn apply_smart_default_window_size(
mut applied: Local<bool>,
monitors: Query<&Monitor, With<PrimaryMonitor>>,
@@ -335,6 +364,20 @@ fn set_window_icon(
*applied = true;
}
/// Android entry point called by NativeActivity after dlopen-ing the `.so`.
/// Sets the `AndroidApp` handle that Bevy's winit backend reads before
/// constructing the event loop, then delegates to [`run`].
///
/// The `#[bevy_main]` proc-macro would generate the same code but only
/// works on a function named `main`; our shared entry point is `run`, so
/// we emit the equivalent expansion manually.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let _ = bevy::android::ANDROID_APP.set(android_app);
run();
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
+10
View File
@@ -12,6 +12,8 @@ publish = false
[dependencies]
png = "0.17"
ab_glyph = "0.2"
solitaire_core = { path = "../solitaire_core" }
solitaire_data = { path = "../solitaire_data" }
[[bin]]
name = "gen_sfx"
@@ -20,3 +22,11 @@ path = "src/bin/gen_sfx.rs"
[[bin]]
name = "gen_art"
path = "src/bin/gen_art.rs"
[[bin]]
name = "gen_seeds"
path = "src/bin/gen_seeds.rs"
[[bin]]
name = "gen_difficulty_seeds"
path = "src/bin/gen_difficulty_seeds.rs"
@@ -0,0 +1,195 @@
//! Generate difficulty-stratified seed catalogs for `EASY_SEEDS`, `MEDIUM_SEEDS`,
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
//! `solitaire_data/src/difficulty_seeds.rs`.
//!
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
//! provably-winnable seeds).
//!
//! # Usage
//!
//! ```bash
//! cargo run -p solitaire_assetgen --bin gen_difficulty_seeds --release -- \
//! --start 0xD1FF0000_00000000 --per-tier 40
//! ```
//!
//! Flags:
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xD1FF000000000000)
//! --per-tier Seeds to emit per tier (default 40)
//! --help Print this message
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
// Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable.
const BUDGETS: &[(&str, u64, usize)] = &[
("Easy", 1_000, 1_000),
("Medium", 5_000, 5_000),
("Hard", 25_000, 25_000),
("Expert", 100_000, 100_000),
("Grandmaster", 200_000, 200_000),
];
fn main() {
let mut args = std::env::args().skip(1).peekable();
let mut start: u64 = 0xD1FF_0000_0000_0000;
let mut per_tier: usize = 40;
while let Some(arg) = args.next() {
match arg.as_str() {
"--start" => {
let val = args.next().unwrap_or_else(|| {
eprintln!("error: --start requires a value");
std::process::exit(1);
});
start = parse_u64(&val);
}
"--per-tier" => {
let val = args.next().unwrap_or_else(|| {
eprintln!("error: --per-tier requires a value");
std::process::exit(1);
});
per_tier = val.parse().unwrap_or_else(|_| {
eprintln!("error: --per-tier must be a positive integer");
std::process::exit(1);
});
}
"--help" | "-h" => {
eprintln!("gen_difficulty_seeds: generate tiered seed catalogs");
eprintln!(" --start <seed> starting seed (hex or decimal)");
eprintln!(" --per-tier <n> seeds per tier (default 40)");
return;
}
other => {
eprintln!("error: unknown argument: {other}");
std::process::exit(1);
}
}
}
if per_tier == 0 {
eprintln!("error: --per-tier must be > 0");
std::process::exit(1);
}
let draw_mode = DrawMode::DrawOne;
let num_tiers = BUDGETS.len();
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
let mut tried: u64 = 0;
let mut seed = start;
eprintln!(
"gen_difficulty_seeds: finding {} seeds per tier from 0x{start:016X} (DrawOne) …",
per_tier
);
eprintln!(
" Tiers: {}",
BUDGETS.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")
);
while buckets.iter().any(|b| b.len() < per_tier) {
tried += 1;
'tier: for (i, &(name, move_budget, state_budget)) in BUDGETS.iter().enumerate() {
if buckets[i].len() >= per_tier {
continue;
}
let cfg = SolverConfig { move_budget, state_budget };
match try_solve(seed, draw_mode.clone(), &cfg) {
SolverResult::Winnable => {
buckets[i].push(seed);
eprintln!(
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
buckets[i].len(),
per_tier
);
break 'tier; // assign to the cheapest tier that proves it winnable
}
SolverResult::Unwinnable => {
// Definitely unsolvable — skip all remaining tiers.
break 'tier;
}
SolverResult::Inconclusive => {
// Budget exhausted without proof — try the next larger tier.
// If this is the last tier, the seed is discarded (Inconclusive
// at max budget means "probably but not provably winnable").
if i == num_tiers - 1 {
break 'tier;
}
}
}
}
seed = seed.wrapping_add(1);
}
eprintln!("\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n");
let date = current_date();
for (i, (tier_name, _, _)) in BUDGETS.iter().enumerate() {
println!(
" // Generated by solitaire_assetgen::gen_difficulty_seeds \
(tier={tier_name}, date={date})"
);
for chunk in buckets[i].chunks(5) {
for s in chunk {
println!(
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
(s >> 48) & 0xFFFF,
(s >> 32) & 0xFFFF,
(s >> 16) & 0xFFFF,
s & 0xFFFF,
);
}
}
println!();
}
}
fn parse_u64(s: &str) -> u64 {
let cleaned = s.replace('_', "");
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
eprintln!("error: could not parse '{s}' as a hex u64");
std::process::exit(1);
})
} else {
cleaned.parse().unwrap_or_else(|_| {
eprintln!("error: could not parse '{s}' as a decimal u64");
std::process::exit(1);
})
}
}
fn current_date() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86400;
let mut y = 1970u64;
let mut d = days;
loop {
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
let days_in_year = if leap { 366 } else { 365 };
if d < days_in_year {
break;
}
d -= days_in_year;
y += 1;
}
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
let month_days: [u64; 12] = [
31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
];
let mut m = 0usize;
for &md in &month_days {
if d < md {
break;
}
d -= md;
m += 1;
}
format!("{y}-{:02}-{:02}", m + 1, d + 1)
}
+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,
}
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
/// catalog is drawn from. `Random` skips verification entirely and uses a
/// system-time seed — deals may or may not be winnable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum DifficultyLevel {
#[default]
Easy,
Medium,
Hard,
Expert,
Grandmaster,
/// Unverified system-time seed — may or may not be winnable.
Random,
}
impl DifficultyLevel {
/// Short human-readable label shown in the HUD and win summary.
pub fn label(self) -> &'static str {
match self {
Self::Easy => "Easy",
Self::Medium => "Medium",
Self::Hard => "Hard",
Self::Expert => "Expert",
Self::Grandmaster => "Grandmaster",
Self::Random => "Random",
}
}
}
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
///
/// - `Classic`: standard Klondike scoring, undo allowed.
@@ -59,6 +88,8 @@ pub enum DrawMode {
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
/// countdown around the session and auto-deals a fresh game on every win
/// (see `solitaire_engine::TimeAttackPlugin`).
/// - `Difficulty(DifficultyLevel)`: seed drawn from a pre-verified per-tier catalog
/// (or system-time for `Random`). Rules identical to Classic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode {
#[default]
@@ -70,6 +101,8 @@ pub enum GameMode {
Challenge,
/// Play as many games as possible within 10 minutes.
TimeAttack,
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
Difficulty(DifficultyLevel),
}
/// Snapshot of game state used for undo.
+7
View File
@@ -26,6 +26,13 @@ tokio = { workspace = true }
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring-core = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
# process-wide JavaVM handle for JNI. Must be listed here so the
# symbol resolves when cross-compiling for Android targets.
bevy = { workspace = true }
[dev-dependencies]
solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { workspace = true }
+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.
// Lets `sync_client::*` compile unchanged on Android; the runtime
// effect is "session login required every launch", same as a Linux
// box without Secret Service.
// Android — delegate to the JNI Keystore bridge in android_keystore.
// -------------------------------------------------------------------
#[cfg(target_os = "android")]
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
#[cfg(target_os = "android")]
pub fn store_tokens(
_username: &str,
_access_token: &str,
_refresh_token: &str,
username: &str,
access_token: &str,
refresh_token: &str,
) -> Result<(), TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
crate::android_keystore::store_tokens(username, access_token, refresh_token)
}
#[cfg(target_os = "android")]
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
crate::android_keystore::load_access_token(username)
}
#[cfg(target_os = "android")]
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
crate::android_keystore::load_refresh_token(username)
}
#[cfg(target_os = "android")]
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
crate::android_keystore::delete_tokens(username)
}
+76
View File
@@ -40,6 +40,82 @@ pub const CHALLENGE_SEEDS: &[u64] = &[
0xDDDD_EEEE_FFFF_0000,
0x0101_0101_0101_0101,
0xA1B2_C3D4_E5F6_0718,
// Generated by solitaire_assetgen::gen_seeds (start=0xCAFEBABE00000000, count=75, date=2026-05-09)
0xCAFE_BABE_0000_0000,
0xCAFE_BABE_0000_0002,
0xCAFE_BABE_0000_0004,
0xCAFE_BABE_0000_0008,
0xCAFE_BABE_0000_000B,
0xCAFE_BABE_0000_000D,
0xCAFE_BABE_0000_000E,
0xCAFE_BABE_0000_0010,
0xCAFE_BABE_0000_0011,
0xCAFE_BABE_0000_0014,
0xCAFE_BABE_0000_0016,
0xCAFE_BABE_0000_0019,
0xCAFE_BABE_0000_001A,
0xCAFE_BABE_0000_001F,
0xCAFE_BABE_0000_0020,
0xCAFE_BABE_0000_0021,
0xCAFE_BABE_0000_0024,
0xCAFE_BABE_0000_0025,
0xCAFE_BABE_0000_0027,
0xCAFE_BABE_0000_002B,
0xCAFE_BABE_0000_002D,
0xCAFE_BABE_0000_0030,
0xCAFE_BABE_0000_0034,
0xCAFE_BABE_0000_0036,
0xCAFE_BABE_0000_003A,
0xCAFE_BABE_0000_003B,
0xCAFE_BABE_0000_003D,
0xCAFE_BABE_0000_0042,
0xCAFE_BABE_0000_0043,
0xCAFE_BABE_0000_0044,
0xCAFE_BABE_0000_004C,
0xCAFE_BABE_0000_004D,
0xCAFE_BABE_0000_004F,
0xCAFE_BABE_0000_0050,
0xCAFE_BABE_0000_0051,
0xCAFE_BABE_0000_0054,
0xCAFE_BABE_0000_0055,
0xCAFE_BABE_0000_0056,
0xCAFE_BABE_0000_0059,
0xCAFE_BABE_0000_005B,
0xCAFE_BABE_0000_005C,
0xCAFE_BABE_0000_005E,
0xCAFE_BABE_0000_0060,
0xCAFE_BABE_0000_0062,
0xCAFE_BABE_0000_0064,
0xCAFE_BABE_0000_0067,
0xCAFE_BABE_0000_0069,
0xCAFE_BABE_0000_006A,
0xCAFE_BABE_0000_006B,
0xCAFE_BABE_0000_006C,
0xCAFE_BABE_0000_006D,
0xCAFE_BABE_0000_006E,
0xCAFE_BABE_0000_006F,
0xCAFE_BABE_0000_0072,
0xCAFE_BABE_0000_0073,
0xCAFE_BABE_0000_0074,
0xCAFE_BABE_0000_0079,
0xCAFE_BABE_0000_007A,
0xCAFE_BABE_0000_007D,
0xCAFE_BABE_0000_007E,
0xCAFE_BABE_0000_007F,
0xCAFE_BABE_0000_0082,
0xCAFE_BABE_0000_0083,
0xCAFE_BABE_0000_0084,
0xCAFE_BABE_0000_0085,
0xCAFE_BABE_0000_0089,
0xCAFE_BABE_0000_008A,
0xCAFE_BABE_0000_008D,
0xCAFE_BABE_0000_008E,
0xCAFE_BABE_0000_0090,
0xCAFE_BABE_0000_0094,
0xCAFE_BABE_0000_0095,
0xCAFE_BABE_0000_0098,
0xCAFE_BABE_0000_0099,
0xCAFE_BABE_0000_009F,
];
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
+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 -7
View File
@@ -27,10 +27,6 @@ pub trait SyncProvider: Send + Sync {
fn backend_name(&self) -> &'static str;
/// Returns true if the user is currently authenticated with this backend.
fn is_authenticated(&self) -> bool;
/// Mirror an achievement unlock to this backend (no-op for most backends).
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
Ok(())
}
/// Fetch the global leaderboard from this backend. Returns an empty list
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
@@ -83,9 +79,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
fn is_authenticated(&self) -> bool {
(**self).is_authenticated()
}
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
(**self).mirror_achievement(id).await
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
(**self).fetch_leaderboard().await
}
@@ -138,6 +131,9 @@ pub use weekly::{
pub mod challenge;
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod difficulty_seeds;
pub use difficulty_seeds::{seeds_for, DifficultySeeds};
pub mod settings;
pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
@@ -147,6 +143,9 @@ pub use settings::{
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
};
#[cfg(target_os = "android")]
mod android_keystore;
pub mod auth_tokens;
pub use auth_tokens::{
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
+9 -1
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::DrawMode;
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
const APP_DIR_NAME: &str = "solitaire_quest";
const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -224,6 +224,13 @@ pub struct Settings {
/// `#[serde(default = "default_replay_move_interval_secs")]`.
#[serde(default = "default_replay_move_interval_secs")]
pub replay_move_interval_secs: f32,
/// Last difficulty tier the player selected. `None` means the player has
/// never used the difficulty picker. When `Some`, the difficulty section in
/// the home overlay opens pre-expanded and highlights this tier. Older
/// `settings.json` files written before this field existed deserialize
/// cleanly to `None` via `#[serde(default)]`.
#[serde(default)]
pub last_difficulty: Option<DifficultyLevel>,
}
fn default_draw_mode() -> DrawMode {
@@ -342,6 +349,7 @@ impl Default for Settings {
winnable_deals_only: false,
disable_smart_default_size: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None,
}
}
}
+7
View File
@@ -104,6 +104,13 @@ impl StatsExt for StatsSnapshot {
// Time Attack uses its own session-level scoring; a per-game best
// wouldn't compose with the other modes' single-game numbers.
GameMode::TimeAttack => {}
// Difficulty games pool into the Classic best-score/time buckets per
// the user's stats preference.
GameMode::Difficulty(_) => {
self.classic_best_score = self.classic_best_score.max(score_u32);
self.classic_fastest_win_seconds =
min_ignore_zero(self.classic_fastest_win_seconds, time_seconds);
}
}
self.last_modified = Utc::now();
}
+87 -7
View File
@@ -83,18 +83,96 @@ impl SolitaireServerClient {
}
}
/// Authenticate with a username + password and return `(access_token, refresh_token)`.
///
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
/// The client's `username` field is used as the credential — the caller must
/// construct the client with the correct username before calling this.
pub async fn login(&self, password: &str) -> Result<(String, String), SyncError> {
let resp = self
.client
.post(format!("{}/api/auth/login", self.base_url))
.json(&serde_json::json!({
"username": self.username,
"password": password,
}))
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
Self::extract_auth_tokens(resp).await
}
/// Register a new account with a username + password and return `(access_token, refresh_token)`.
///
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
pub async fn register(&self, password: &str) -> Result<(String, String), SyncError> {
let resp = self
.client
.post(format!("{}/api/auth/register", self.base_url))
.json(&serde_json::json!({
"username": self.username,
"password": password,
}))
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
Self::extract_auth_tokens(resp).await
}
/// Parse `{ "access_token": "...", "refresh_token": "..." }` from an auth response.
async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> {
let status = resp.status();
if !status.is_success() {
let body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::json!({}));
let msg = body["error"]
.as_str()
.or_else(|| body["message"].as_str())
.unwrap_or("authentication failed");
return Err(if status == reqwest::StatusCode::CONFLICT {
SyncError::Auth("username already taken".into())
} else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth("invalid credentials".into())
} else if status == reqwest::StatusCode::BAD_REQUEST {
SyncError::Auth(msg.to_string())
} else {
SyncError::Network(format!("server returned {status}"))
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let access = body["access_token"]
.as_str()
.ok_or_else(|| SyncError::Serialization("missing access_token".into()))?
.to_string();
let refresh = body["refresh_token"]
.as_str()
.ok_or_else(|| SyncError::Serialization("missing refresh_token".into()))?
.to_string();
Ok((access, refresh))
}
/// Attempt to refresh the access token using the stored refresh token.
///
/// On success the new access token is persisted to the OS keychain,
/// replacing the previous one. The refresh token itself is unchanged.
/// The server rotates refresh tokens on each call: the response includes a
/// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success.
async fn refresh_token(&self) -> Result<(), SyncError> {
let refresh = load_refresh_token(&self.username)
let old_refresh = load_refresh_token(&self.username)
.map_err(|e| SyncError::Auth(e.to_string()))?;
let resp = self
.client
.post(format!("{}/api/auth/refresh", self.base_url))
.json(&serde_json::json!({ "refresh_token": refresh }))
.json(&serde_json::json!({ "refresh_token": old_refresh }))
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
@@ -112,9 +190,11 @@ impl SolitaireServerClient {
.as_str()
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
// store_tokens replaces both access and refresh; we keep the old
// refresh token unchanged so its 30-day TTL is preserved.
store_tokens(&self.username, new_access, &refresh)
// Server rotates refresh tokens — store the new one.
// Fall back to the old token if the field is absent (pre-rotation server).
let new_refresh = body["refresh_token"].as_str().unwrap_or(&old_refresh);
store_tokens(&self.username, new_access, new_refresh)
.map_err(|e| SyncError::Auth(e.to_string()))
}
+3
View File
@@ -32,6 +32,9 @@ zip = { workspace = true }
[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
tempfile = { workspace = true }
+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}"))
}
@@ -114,7 +114,7 @@ impl AnimationTuning {
platform: InputPlatform::Touch,
duration_scale: 0.75,
overshoot_scale: 0.5,
drag_threshold_px: 10.0,
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
drag_scale: 1.12,
hover_scale: 1.0, // no hover affordance on touch
hover_lerp_speed: 20.0,
+205 -34
View File
@@ -35,13 +35,12 @@ use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TEXT_PRIMARY_HC,
TYPE_CAPTION, Z_STOCK_BADGE,
TYPE_BODY, Z_STOCK_BADGE,
};
/// Fraction of card height used as vertical offset between face-up tableau cards.
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
/// Tighter fan for face-down cards in the tableau — just enough to show the stack.
/// Per-card vertical step for face-down tableau cards, as a fraction of
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
/// don't need their full body shown — only the back-pattern strip is
@@ -49,7 +48,12 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
/// when hit-testing tableau columns; any drift between this and the
/// renderer creates a visible offset between the card face and where
/// clicks land.
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
///
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must
/// stay in sync; the layout constant drives the adaptive LayoutResource value
/// used at runtime, while this one is the minimum floor used by
/// `update_tableau_fan_frac` when computing proportional updates.
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
/// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible. Public so other plugins
@@ -263,6 +267,23 @@ pub struct ShadowEntity;
#[derive(Component, Debug)]
pub struct CardShadow;
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
///
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
/// frame — gives every face-down card a visible perimeter.
#[derive(Component, Debug)]
pub struct CardBackFrame;
/// Fill colour for the face-down card border frame. Medium gray so it reads as
/// a neutral "edge" without competing with the suit colours on face-up cards.
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.38, 0.38, 0.38);
/// Extra width/height (in world units) added to each side of the card to form
/// the visible border. 3 world units ≈ 3 dp on a 1× screen.
const CARD_BACK_FRAME_PADDING: f32 = 3.0;
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
/// shadow given whether its parent card is currently part of the dragged
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
@@ -318,6 +339,21 @@ fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
));
}
/// Spawns a `CardBackFrame` child behind a face-down card entity so the dark
/// back PNG has a visible perimeter against the dark felt.
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
parent.spawn((
CardBackFrame,
Sprite {
color: CARD_BACK_FRAME_COLOR,
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(),
));
}
/// Throttle interval for resize-driven card snap work, in seconds.
///
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
@@ -373,6 +409,9 @@ impl Plugin for CardPlugin {
.add_systems(
Update,
(
update_tableau_fan_frac
.after(GameMutation)
.before(sync_cards_on_change),
sync_cards_on_change.after(GameMutation),
resync_cards_on_settings_change.before(sync_cards_on_change),
start_flip_anim.after(GameMutation),
@@ -649,16 +688,25 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
cards.len().saturating_sub(visible)
// Render one extra card so that the card sliding off the waste
// during a draw animation is still present in the world at z=0
// (hidden under the stack) rather than vanishing mid-tween.
cards.len().saturating_sub(visible + 1)
} else {
0
};
let mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len();
for (slot, card) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) {
// Fan left→right; top card (last slot) is rightmost and playable.
slot as f32 * layout.card_size.x * 0.28
// When len > visible, slot 0 is a hidden buffer card kept at
// x=0 to prevent a flash during the draw tween. When len ≤
// visible (small pile), every card is visible and should fan
// normally — no card is hidden, so the shift is 0.
let visible = 3_usize;
let hidden = rendered_len.saturating_sub(visible);
slot.saturating_sub(hidden) as f32 * layout.card_size.x * 0.28
} else {
0.0
};
@@ -667,9 +715,9 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
out.push((card, pos, z));
if is_tableau {
let step = if card.face_up {
TABLEAU_FAN_FRAC
layout.tableau_fan_frac
} else {
TABLEAU_FACEDOWN_FAN_FRAC
layout.tableau_facedown_fan_frac
};
y_offset -= layout.card_size.y * step;
}
@@ -706,6 +754,13 @@ fn spawn_card_entity(
entity.with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
// Face-down cards get a thin contrasting border frame so the dark back
// PNG reads as a distinct rectangle against the dark felt.
if !card.face_up {
entity.with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
}
// When PNG faces are loaded the rank/suit are baked into the image.
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
if card_images.is_none() {
@@ -781,6 +836,11 @@ fn update_card_entity(
commands.entity(entity).with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
if !card.face_up {
commands.entity(entity).with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
}
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
@@ -1438,8 +1498,8 @@ fn update_stock_empty_indicator(
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
/// Width / height of the badge background sprite, in world pixels. Sized so
/// a 2-digit count (max "24") fits comfortably with `TYPE_CAPTION` text.
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(28.0, 16.0);
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
/// Returns the count of cards currently in the stock pile.
///
@@ -1484,7 +1544,7 @@ fn spawn_stock_count_badge(
};
let text_font = TextFont {
font: font.cloned().unwrap_or_default(),
font_size: TYPE_CAPTION,
font_size: TYPE_BODY,
..default()
};
@@ -1629,13 +1689,20 @@ fn snap_cards_on_window_resize(
card_images: Option<Res<CardImageSet>>,
entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(Without<CardLabel>, Without<CardShadow>),
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
>,
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
shadow_query: Query<&mut Sprite, (With<CardShadow>, Without<CardEntity>, Without<PileMarker>)>,
shadow_query: Query<
&mut Sprite,
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>, Without<CardBackFrame>),
>,
frame_query: Query<
&mut Sprite,
(With<CardBackFrame>, Without<CardEntity>, Without<CardShadow>, Without<PileMarker>),
>,
mut pile_markers: Query<
(Entity, &PileMarker, &mut Sprite),
(Without<CardEntity>, Without<CardShadow>),
(Without<CardEntity>, Without<CardShadow>, Without<CardBackFrame>),
>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
@@ -1665,6 +1732,7 @@ fn snap_cards_on_window_resize(
entities,
label_query,
shadow_query,
frame_query,
);
apply_stock_empty_indicator(
@@ -1691,7 +1759,7 @@ fn snap_cards_on_window_resize(
///
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
/// retargeted relative to the previous card-size's position.
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn resize_cards_in_place(
commands: &mut Commands,
game: &GameState,
@@ -1699,12 +1767,16 @@ fn resize_cards_in_place(
card_images: Option<&CardImageSet>,
mut entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(Without<CardLabel>, Without<CardShadow>),
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
>,
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
mut shadow_query: Query<
&mut Sprite,
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>),
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>, Without<CardBackFrame>),
>,
mut frame_query: Query<
&mut Sprite,
(With<CardBackFrame>, Without<CardEntity>, Without<CardShadow>, Without<PileMarker>),
>,
) {
let positions = card_positions(game, layout);
@@ -1756,6 +1828,62 @@ fn resize_cards_in_place(
font.font_size = new_font_size;
}
}
// Resize every face-down border frame to match the new card size.
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
for mut frame_sprite in frame_query.iter_mut() {
frame_sprite.custom_size = Some(frame_size);
}
}
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
/// expands as the player reveals cards while staying within the window.
///
/// On fresh deal (max face-up depth = 1) the function returns early, leaving
/// both fracs at the window-size-adaptive values that `compute_layout` already
/// computed for the current viewport. Previously it overwrote the adaptive
/// value with the desktop minimum (0.25) — the wrong behaviour on portrait
/// phones where the adaptive value is much larger.
fn update_tableau_fan_frac(
mut events: MessageReader<StateChangedEvent>,
game: Option<Res<GameStateResource>>,
mut layout: Option<ResMut<LayoutResource>>,
) {
if events.read().next().is_none() {
return;
}
let Some(game) = game else { return; };
let Some(layout) = layout.as_mut() else { return; };
let max_depth = (0..7_usize)
.filter_map(|i| game.0.piles.get(&solitaire_core::pile::PileType::Tableau(i)))
.map(|pile| pile.cards.iter().filter(|c| c.face_up).count())
.max()
.unwrap_or(0);
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
// With ≤ 1 face-up card per column (fresh deal, or completely face-down
// piles) the face-up fan fraction has no visible effect. Leave both fracs
// at the adaptive values set by compute_layout rather than snapping them
// to the desktop minimum.
if max_depth <= 1 || card_h <= 0.0 {
return;
}
let ideal = avail / ((max_depth - 1) as f32 * card_h);
let max_frac = if card_h > 0.0 { avail / (12.0 * card_h) } else { TABLEAU_FAN_FRAC };
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, max_frac.max(TABLEAU_FAN_FRAC));
let new_facedown_frac = new_frac * (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC);
if (layout.0.tableau_fan_frac - new_frac).abs() > 1e-4 {
layout.0.tableau_fan_frac = new_frac;
}
if (layout.0.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
layout.0.tableau_facedown_fan_frac = new_facedown_frac;
}
}
#[cfg(test)]
@@ -1862,7 +1990,7 @@ mod tests {
// At game start waste is empty, so all 52 cards are across stock + tableau.
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52);
}
@@ -1882,7 +2010,7 @@ mod tests {
.collect();
assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
// Filter rendered positions to only waste cards (by card ID).
@@ -1890,11 +2018,13 @@ mod tests {
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Draw-One: only 1 waste card should be rendered regardless of pile depth.
assert_eq!(waste_rendered.len(), 1);
// The single rendered card must be the top (last) waste card.
// Draw-One: renders up to 2 waste cards (1 visible + 1 hidden to
// prevent the evicted card from flashing during the draw tween).
assert!(waste_rendered.len() <= 2, "Draw-One renders at most 2 waste cards");
assert!(!waste_rendered.is_empty(), "at least the top waste card must be rendered");
// The top (last) waste card must always be among the rendered cards.
let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id;
assert_eq!(waste_rendered[0].0.id, top_id);
assert!(waste_rendered.iter().any(|(c, _, _)| c.id == top_id), "top waste card must be rendered");
}
#[test]
@@ -1911,32 +2041,73 @@ mod tests {
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Draw-Three: at most 3 waste cards rendered.
assert_eq!(waste_rendered.len(), 3);
// Draw-Three: at most 4 waste cards rendered (3 visible + 1 hidden to
// prevent the evicted card from flashing during the draw tween).
assert!(waste_rendered.len() <= 4, "Draw-Three renders at most 4 waste cards");
assert!(waste_rendered.len() >= 3, "Draw-Three renders at least 3 waste cards when pile is deep enough");
// The three fanned cards must have strictly increasing X coordinates
// (left = oldest visible, right = top/playable).
// The three visible fanned cards (slots 13) must have strictly
// increasing X coordinates. The hidden extra card at slot 0 sits at x=0.
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
for w in waste_rendered.windows(2) {
assert!(w[1].1.x > w[0].1.x, "fanned waste cards must have distinct X positions");
// The top 3 cards (after the hidden one) must be fanned.
let visible = &waste_rendered[waste_rendered.len().saturating_sub(3)..];
for w in visible.windows(2) {
assert!(w[1].1.x >= w[0].1.x, "fanned waste cards must have non-decreasing X positions");
}
// Top card (rightmost) must be the last card in the waste pile.
// Top card (rightmost by x) must be the last card in the waste pile.
let top_id = waste_pile.last().unwrap().id;
assert_eq!(waste_rendered.last().unwrap().0.id, top_id);
}
#[test]
fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
// Regression: slot.saturating_sub(1) always hid slot-0 even when the
// pile was too small to have a buffer card, collapsing 2 visible cards
// onto x=0 instead of fanning them.
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
// Draw exactly once — in Draw-Three mode with a full stock this gives
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
let _ = g.draw();
let waste_pile = &g.piles[&PileType::Waste].cards;
// We need exactly 2 or 3 waste cards to hit the small-pile path.
// One draw in Draw-Three adds up to 3 cards; take the first 2 if needed.
let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// All waste cards should be visible (no hidden buffer when len ≤ visible).
assert_eq!(waste_rendered.len(), count, "all waste cards rendered when pile ≤ visible");
// Cards must be fanned with distinct x positions (or equal for 1-card).
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
if count >= 2 {
let last = waste_rendered.last().unwrap();
let second_last = &waste_rendered[waste_rendered.len() - 2];
assert!(last.1.x > second_last.1.x, "top 2 waste cards must fan to distinct x positions");
}
}
#[test]
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
// Collect positions for Tableau(6) (should have 7 cards).
@@ -2248,7 +2419,7 @@ mod tests {
#[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
@@ -2409,7 +2580,7 @@ mod tests {
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the
// refreshed Layout.
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0));
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!(
(after - expected).abs() < 1e-3,
+2 -2
View File
@@ -604,7 +604,7 @@ mod tests {
use crate::layout::compute_layout;
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
}
@@ -624,7 +624,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(GameStateResource(game))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0))))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0)))
.insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays);
app
+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"
);
}
}
+43
View File
@@ -129,6 +129,23 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord);
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ManualSyncRequestEvent;
/// Request to open the sync-server setup modal (Connect flow).
/// Fired by the "Connect" button in the Settings sync section.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct SyncConfigureRequestEvent;
/// Request to disconnect from the current sync backend, clear stored
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
/// button in the Settings sync section.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct SyncLogoutRequestEvent;
/// Request to open the account-deletion confirmation modal. Fired by the
/// "Delete Account" button in the Settings sync section (visible only when
/// a server backend is configured). Consumed by `SyncSetupPlugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct DeleteAccountRequestEvent;
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
/// the same toggle path runs whether the player presses `Esc` or clicks.
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
@@ -172,6 +189,23 @@ pub struct StartTimeAttackRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StartDailyChallengeRequestEvent;
/// Request to open the Play-by-Seed dialog. Fired by the Home overlay
/// "Play by Seed" mode card. The handler in `play_by_seed_plugin` spawns
/// a numeric-input modal where the player types a decimal seed and
/// optionally sees a solver-verified verdict before dealing.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StartPlayBySeedRequestEvent;
/// Request to start a game at a specific difficulty tier. Fired by the
/// difficulty section in the home overlay. The handler in `difficulty_plugin`
/// picks a seed from the corresponding pre-verified catalog (or generates a
/// random system-time seed for `DifficultyLevel::Random`) and writes a
/// `NewGameRequestEvent`.
#[derive(Message, Debug, Clone, Copy)]
pub struct StartDifficultyRequestEvent {
pub level: solitaire_core::game_state::DifficultyLevel,
}
/// Request to toggle the Stats overlay. Fired by the HUD Menu-popover
/// "Stats" row alongside the existing `S` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
@@ -249,6 +283,15 @@ pub struct ForfeitEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ForfeitRequestEvent;
/// Fired when the player clicks "Scan for new themes" in Settings.
///
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
/// fires [`InfoToastEvent`] messages to report results.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ScanThemesRequestEvent;
/// Fired when the player requests a hint (H key). Carries the source card ID
/// and destination pile for visual highlighting.
///
+55 -11
View File
@@ -990,18 +990,26 @@ pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
let mut sources: Vec<Card> = Vec::new();
for ty in [PileType::Stock, PileType::Waste] {
if let Some(p) = game.piles.get(&ty) {
sources.extend(p.cards.iter().cloned());
}
// Only the top waste card is playable.
if let Some(p) = game.piles.get(&PileType::Waste)
&& let Some(top) = p.cards.last()
{
sources.push(top.clone());
}
// Any face-up card in a tableau column can be the base of a movable run.
for i in 0..7_usize {
if let Some(t) = game.piles.get(&PileType::Tableau(i))
&& let Some(top) = t.cards.last().filter(|c| c.face_up)
{
sources.push(top.clone());
if let Some(t) = game.piles.get(&PileType::Tableau(i)) {
for card in t.cards.iter().filter(|c| c.face_up) {
sources.push(card.clone());
}
}
}
// Stock cards are face-down and cannot be placed directly; drawing is
// only useful if the drawn card can subsequently be placed, which the
// waste-card check above already covers for the currently visible card.
// Including all stock cards would produce false positives for unplayable
// face-down cards (the test has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards
// explicitly guards this case).
for card in &sources {
for slot in 0..4_u8 {
@@ -1064,9 +1072,11 @@ fn check_no_moves(
}
if !moves_ok && !*already_fired {
toast.write(InfoToastEvent(
"No moves available \u{2014} press D to draw or N for a new game".to_string(),
));
#[cfg(target_os = "android")]
let no_moves_msg = "No moves available \u{2014} tap the stock to draw or start a new game";
#[cfg(not(target_os = "android"))]
let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game";
toast.write(InfoToastEvent(no_moves_msg.to_string()));
*already_fired = true;
// Only spawn the overlay if one does not already exist.
if game_over_screens.is_empty() {
@@ -1730,6 +1740,40 @@ mod tests {
assert!(!has_legal_moves(&game), "Two of Clubs with empty board has no legal move");
}
#[test]
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
// Regression: the bug only checked t.cards.last() (top face-up card).
// If the only legal move involves a face-up card that is NOT the top
// card of its column the previous code would return false (softlock)
// even though the player can still move that run.
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Tableau 0: face-up Queen of Spades (non-top) + face-up Jack of Hearts on top.
// King of Diamonds is on Tableau 1 (empty otherwise), so Queen→King is the
// only legal tableau move, and that move targets the Queen which is non-top.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 10, suit: Suit::Spades, rank: Rank::Queen, face_up: true });
t0.cards.push(Card { id: 11, suit: Suit::Hearts, rank: Rank::Jack, face_up: true });
let t1 = game.piles.get_mut(&PileType::Tableau(1)).unwrap();
t1.cards.push(Card { id: 12, suit: Suit::Diamonds, rank: Rank::King, face_up: true });
assert!(
has_legal_moves(&game),
"Queen (non-top face-up) should be detected as a valid move source onto King",
);
}
// -----------------------------------------------------------------------
// Task #57 — Confirm-new-game dialog tests
// -----------------------------------------------------------------------
+2 -3
View File
@@ -250,9 +250,8 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default()
})
.with_children(|line| {
// The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of
// the description text.
// Keyboard chip — suppressed on Android (no keyboard).
#[cfg(not(target_os = "android"))]
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
+255 -15
View File
@@ -16,15 +16,15 @@
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::game_state::DrawMode;
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
ToggleProfileRequestEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
@@ -81,6 +81,27 @@ struct HomeDrawThreeButton;
#[derive(Component, Debug)]
struct HomeScrollable;
/// Marker on the "▶ Difficulty" / "▼ Difficulty" toggle button that
/// expands / collapses the difficulty tier chip row.
#[derive(Component, Debug)]
struct HomeDifficultyToggle;
/// Marker on each difficulty tier chip inside the expanded difficulty
/// section. The wrapped `DifficultyLevel` identifies which tier was
/// clicked so the handler can fire `StartDifficultyRequestEvent`.
#[derive(Component, Debug)]
struct HomeDifficultyChip(DifficultyLevel);
/// Whether the difficulty section is currently expanded. Toggled by
/// `handle_home_difficulty_toggle` and checked by `spawn_home_screen`
/// to determine initial render state.
///
/// Initialised at plugin startup; `spawn_home_on_launch` upgrades it
/// to `true` when `settings.last_difficulty` is already set so
/// returning players see their tier pre-expanded.
#[derive(Resource, Default, Debug)]
pub struct DifficultyExpanded(pub bool);
// ---------------------------------------------------------------------------
// Private mode-card data shape
// ---------------------------------------------------------------------------
@@ -96,6 +117,7 @@ enum HomeMode {
Zen,
Challenge,
TimeAttack,
PlayBySeed,
}
impl HomeMode {
@@ -107,6 +129,7 @@ impl HomeMode {
HomeMode::Zen => "Zen Mode",
HomeMode::Challenge => "Challenge",
HomeMode::TimeAttack => "Time Attack",
HomeMode::PlayBySeed => "Play by Seed",
}
}
@@ -118,6 +141,7 @@ impl HomeMode {
HomeMode::Zen => "No timer, no score. Just the cards.",
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
HomeMode::PlayBySeed => "Enter any number to play a specific deal.",
}
}
@@ -150,6 +174,9 @@ impl HomeMode {
// ships ▲ (up triangle) but evidently not the sideways
// siblings.
HomeMode::TimeAttack => "\u{2192}",
// Number sign — ASCII, universally available. Reads as
// "a specific number / seed ID".
HomeMode::PlayBySeed => "#",
}
}
@@ -162,6 +189,7 @@ impl HomeMode {
HomeMode::Zen => "Z",
HomeMode::Challenge => "X",
HomeMode::TimeAttack => "T",
HomeMode::PlayBySeed => "6",
}
}
@@ -233,11 +261,14 @@ impl Plugin for HomePlugin {
// Pre-mark the auto-show as already done in headless mode so the
// gating system is a permanent no-op for tests.
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
.init_resource::<DifficultyExpanded>()
.add_message::<NewGameRequestEvent>()
.add_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<StartPlayBySeedRequestEvent>()
.add_message::<StartDifficultyRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>()
@@ -245,13 +276,10 @@ impl Plugin for HomePlugin {
// runs cleanly under MinimalPlugins headless tests too.
.add_message::<MouseWheel>()
// `.chain()` because several systems (M-toggle, card click,
// cancel button, digit-key shortcut) all read the
// `HomeScreen` entity and may queue a despawn on it in the
// same tick. Bevy's parallel scheduler would otherwise let
// two of them run simultaneously and double-despawn the
// entity, panicking when the second command buffer is
// applied. Chaining serialises these systems and keeps the
// despawn deterministic.
// cancel button, digit-key shortcut, difficulty handlers)
// all read the `HomeScreen` entity and may queue a despawn
// on it in the same tick. Chaining serialises these systems
// and keeps the despawn deterministic.
.add_systems(
Update,
(
@@ -262,6 +290,8 @@ impl Plugin for HomePlugin {
handle_home_cancel_button,
handle_home_profile_chip,
handle_home_draw_mode_buttons,
handle_home_difficulty_toggle,
handle_home_difficulty_chip_click,
handle_home_digit_keys,
)
.chain(),
@@ -306,6 +336,7 @@ fn spawn_home_on_launch(
settings: Option<Res<SettingsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut diff_expanded: ResMut<DifficultyExpanded>,
) {
if shown.0
|| !splash.is_empty()
@@ -316,6 +347,11 @@ fn spawn_home_on_launch(
return;
}
// Pre-expand the difficulty section when the player has a saved preference.
if settings.as_ref().is_some_and(|s| s.0.last_difficulty.is_some()) {
diff_expanded.0 = true;
}
spawn_home_screen(
&mut commands,
build_home_context(
@@ -324,6 +360,7 @@ fn spawn_home_on_launch(
settings.as_deref(),
daily.as_deref(),
font_res.as_deref(),
diff_expanded.0,
),
);
shown.0 = true;
@@ -343,6 +380,7 @@ fn toggle_home_screen(
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>,
diff_expanded: Res<DifficultyExpanded>,
) {
if !keys.just_pressed(KeyCode::KeyM) {
return;
@@ -358,6 +396,7 @@ fn toggle_home_screen(
settings.as_deref(),
daily.as_deref(),
font_res.as_deref(),
diff_expanded.0,
),
);
}
@@ -373,6 +412,7 @@ fn build_home_context<'a>(
settings: Option<&SettingsResource>,
daily: Option<&DailyChallengeResource>,
font_res: Option<&'a FontResource>,
difficulty_expanded: bool,
) -> HomeContext<'a> {
let daily_today = daily.map(|d| {
let completed_today = progress
@@ -398,6 +438,8 @@ fn build_home_context<'a>(
.map(|s| s.0.draw_mode.clone())
.unwrap_or(DrawMode::DrawOne),
font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
}
}
@@ -423,6 +465,7 @@ fn handle_home_card_click(
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
let level = progress.as_ref().map_or(0, |p| p.0.level);
@@ -457,6 +500,9 @@ fn handle_home_card_click(
HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
HomeMode::PlayBySeed => {
play_by_seed.write(StartPlayBySeedRequestEvent);
}
}
// Close the modal after dispatching the launch event.
@@ -557,6 +603,7 @@ fn handle_home_draw_mode_buttons(
stats: Option<Res<StatsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
diff_expanded: Res<DifficultyExpanded>,
) {
if screens.is_empty() {
return;
@@ -600,10 +647,92 @@ fn handle_home_draw_mode_buttons(
Some(settings),
daily.as_deref(),
font_res.as_deref(),
diff_expanded.0,
),
);
}
// ---------------------------------------------------------------------------
// Difficulty section handlers
// ---------------------------------------------------------------------------
/// Click on the "▶/▼ Difficulty" header — toggle `DifficultyExpanded` and
/// repaint the Home modal so the chevron and chip row update. Mirrors
/// `handle_home_draw_mode_buttons`: despawn + respawn keeps all styling in
/// `spawn_difficulty_section` rather than scattered across mutation helpers.
#[allow(clippy::too_many_arguments)]
fn handle_home_difficulty_toggle(
mut commands: Commands,
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
mut diff_expanded: ResMut<DifficultyExpanded>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
) {
if screens.is_empty() {
return;
}
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
return;
}
diff_expanded.0 = !diff_expanded.0;
for entity in &screens {
commands.entity(entity).despawn();
}
spawn_home_screen(
&mut commands,
build_home_context(
progress.as_deref(),
stats.as_deref(),
settings.as_deref(),
daily.as_deref(),
font_res.as_deref(),
diff_expanded.0,
),
);
}
/// Click on a difficulty tier chip — persist `last_difficulty`, fire
/// `StartDifficultyRequestEvent`, and close the Home modal.
#[allow(clippy::too_many_arguments)]
fn handle_home_difficulty_chip_click(
mut commands: Commands,
chips: Query<(&Interaction, &HomeDifficultyChip), Changed<Interaction>>,
screens: Query<Entity, With<HomeScreen>>,
mut difficulty_ev: MessageWriter<StartDifficultyRequestEvent>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
if screens.is_empty() {
return;
}
let Some((_, chip)) = chips.iter().find(|(i, _)| **i == Interaction::Pressed) else {
return;
};
let level = chip.0;
if let Some(s) = settings.as_mut() {
s.0.last_difficulty = Some(level);
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, &s.0)
{
warn!("home: failed to persist last_difficulty: {e}");
}
changed.write(SettingsChangedEvent(s.0.clone()));
}
difficulty_ev.write(StartDifficultyRequestEvent { level });
for entity in &screens {
commands.entity(entity).despawn();
}
}
// ---------------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped
// ---------------------------------------------------------------------------
@@ -619,6 +748,7 @@ fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
KeyCode::Digit3 => Some(HomeMode::Zen),
KeyCode::Digit4 => Some(HomeMode::Challenge),
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
KeyCode::Digit6 => Some(HomeMode::PlayBySeed),
_ => None,
}
}
@@ -646,6 +776,7 @@ fn handle_home_digit_keys(
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
) {
// Modal-scoped: do nothing when the Mode Launcher isn't open.
if screens.is_empty() {
@@ -658,6 +789,7 @@ fn handle_home_digit_keys(
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
]
.into_iter()
.find(|k| keys.just_pressed(*k))
@@ -687,6 +819,9 @@ fn handle_home_digit_keys(
HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
HomeMode::PlayBySeed => {
play_by_seed.write(StartPlayBySeedRequestEvent);
}
}
// Close the modal after dispatching the launch event — same shape as
@@ -717,6 +852,11 @@ struct HomeContext<'a> {
daily_today: Option<DailyToday>,
draw_mode: DrawMode,
font_res: Option<&'a FontResource>,
/// Whether the difficulty section header is currently expanded.
difficulty_expanded: bool,
/// The last difficulty tier the player selected (persisted in Settings).
/// When `Some`, that tier's chip is highlighted.
last_difficulty: Option<DifficultyLevel>,
}
/// Today's daily-challenge metadata as the Home picker needs it. Only
@@ -784,10 +924,13 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
HomeMode::PlayBySeed,
] {
spawn_mode_card(grid, mode, &ctx);
}
});
spawn_difficulty_section(body, &ctx);
});
spawn_modal_actions(card, |actions| {
@@ -951,6 +1094,101 @@ fn spawn_draw_mode_chip<M: Component>(
});
}
/// Collapsible difficulty-tier section injected below the mode tile grid.
///
/// Structure:
/// ```text
/// ▶ Difficulty ← HomeDifficultyToggle (Button, row)
/// [Easy] [Medium] [Hard] [Expert] [GM] [Random] ← visible only when expanded
/// ```
///
/// The toggle header despawns + respawns the home screen (same pattern as
/// the draw-mode toggle) so the chevron direction and chip row visibility
/// update without Visibility component surgery.
fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
let chevron = if ctx.difficulty_expanded { "" } else { "" };
// Header row — click to toggle expand/collapse.
parent
.spawn((
HomeDifficultyToggle,
Button,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
padding: UiRect::axes(Val::Px(0.0), VAL_SPACE_1),
..default()
},
))
.with_children(|row| {
row.spawn((
Text::new(chevron),
font_label.clone(),
TextColor(TEXT_SECONDARY),
));
row.spawn((
Text::new("Difficulty"),
font_label.clone(),
TextColor(TEXT_SECONDARY),
));
});
// Tier chips — only rendered when expanded.
if ctx.difficulty_expanded {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_2,
column_gap: VAL_SPACE_2,
width: Val::Percent(100.0),
..default()
})
.with_children(|row| {
for level in [
DifficultyLevel::Easy,
DifficultyLevel::Medium,
DifficultyLevel::Hard,
DifficultyLevel::Expert,
DifficultyLevel::Grandmaster,
DifficultyLevel::Random,
] {
let active = ctx.last_difficulty == Some(level);
let (bg, fg) = if active {
(ACCENT_PRIMARY, BG_ELEVATED)
} else {
(BG_ELEVATED_HI, TEXT_PRIMARY)
};
row.spawn((
HomeDifficultyChip(level),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|c| {
c.spawn((
Text::new(level.label()),
font_chip.clone(),
TextColor(fg),
));
});
}
});
}
}
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
/// otherwise the raw number with thousands separators. Keeps chip text
/// short enough to fit a 3-up header strip without wrapping.
@@ -999,6 +1237,7 @@ fn home_mode_focus_order(mode: HomeMode) -> i32 {
HomeMode::Zen => 2,
HomeMode::Challenge => 3,
HomeMode::TimeAttack => 4,
HomeMode::PlayBySeed => 5,
}
}
@@ -1146,8 +1385,8 @@ fn spawn_mode_card(
));
if unlocked {
// Hotkey chip — same look as the kbd-chip rows used
// elsewhere so accelerators read consistently.
// Hotkey chip — suppressed on Android (touch builds have no keyboard).
#[cfg(not(target_os = "android"))]
row.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
@@ -1402,13 +1641,14 @@ mod tests {
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
HomeMode::PlayBySeed,
] {
assert!(
modes.contains(&expected),
"missing card for {expected:?}; found {modes:?}"
);
}
assert_eq!(modes.len(), 5, "exactly five cards expected");
assert_eq!(modes.len(), 6, "exactly six cards expected");
}
#[test]
@@ -1600,7 +1840,7 @@ mod tests {
.map(|(c, f)| (c.0, *f))
.collect();
assert_eq!(cards.len(), 5, "all five cards must carry a Focusable");
assert_eq!(cards.len(), 6, "all six cards must carry a Focusable");
for (mode, focusable) in &cards {
assert_eq!(
focusable.group,
@@ -1626,7 +1866,7 @@ mod tests {
for (mode, disabled) in states {
match mode {
HomeMode::Classic | HomeMode::Daily => assert!(
HomeMode::Classic | HomeMode::Daily | HomeMode::PlayBySeed => assert!(
!disabled,
"{mode:?} must not be Disabled at level 0 (it's never locked)"
),
+305 -92
View File
@@ -7,6 +7,7 @@
//! without a separate tick system.
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
@@ -17,6 +18,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
@@ -239,6 +242,11 @@ pub struct PauseButton;
#[derive(Component, Debug)]
pub struct HelpButton;
/// Marker on the "Hint" action button. Click spawns an async solver task
/// (same as the `H` keyboard accelerator) and highlights the suggested card.
#[derive(Component, Debug)]
pub struct HintButton;
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
/// (a small dropdown panel) below the action bar. Each popover row starts
/// the corresponding game mode.
@@ -273,6 +281,16 @@ pub struct MenuButton;
#[derive(Component, Debug)]
pub struct MenuPopover;
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
#[derive(Component, Debug)]
struct MenuPopoverBackdrop;
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
#[derive(Component, Debug)]
struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which
/// `Toggle*RequestEvent` the click handler fires.
#[derive(Component, Debug, Clone, Copy)]
@@ -322,11 +340,15 @@ impl Plugin for HudPlugin {
.add_message::<WinStreakMilestoneEvent>()
.init_resource::<PreviousScore>()
.init_resource::<HudActionFade>()
// WindowResized is registered by table_plugin; re-register
// defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
.add_systems(Update, update_hud.after(GameMutation))
.add_systems(Update, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud)
.add_systems(Update, update_hud_typography)
.add_systems(
Update,
(
@@ -350,10 +372,13 @@ impl Plugin for HudPlugin {
handle_undo_button,
handle_pause_button,
handle_help_button,
handle_hint_button,
handle_modes_button,
handle_mode_option_click,
handle_modes_backdrop_click,
handle_menu_button,
handle_menu_option_click,
handle_menu_backdrop_click,
paint_action_buttons,
),
)
@@ -376,11 +401,13 @@ impl Plugin for HudPlugin {
/// bottom edge lines up exactly with the top edge of the highest
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
/// alpha, so the green felt reads through subtly.
fn spawn_hud_band(mut commands: Commands) {
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
const BASE_TOP: f32 = 0.0;
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
commands.spawn((
Node {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
top: Val::Px(BASE_TOP + top_inset),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(HUD_BAND_HEIGHT),
@@ -391,6 +418,7 @@ fn spawn_hud_band(mut commands: Commands) {
// paint on top, but above the card sprites (which are 2D-world
// entities and rendered behind UI regardless).
ZIndex(Z_HUD - 1),
SafeAreaAnchoredTop { base_top: BASE_TOP },
));
}
@@ -413,7 +441,12 @@ fn spawn_hud_band(mut commands: Commands) {
/// player's #1 complaint. This restructure groups by purpose, lets
/// transient items disappear cleanly, and uses the typography scale to
/// make Score the visual protagonist.
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
fn spawn_hud(
font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont {
font: font_handle.clone(),
@@ -434,6 +467,16 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let row_node = || Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3,
// On a narrow viewport the four tier rows (Score/Moves/Timer,
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
// Auto-complete, selection chip) can collectively be wider than
// the available space and overflow into the action-button column
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
// a second line; on a desktop window the rows stay single-line
// because the parent column has no width cap and the row never
// exceeds the natural line width.
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_1,
align_items: AlignItems::Baseline,
..default()
};
@@ -443,12 +486,21 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
Node {
position_type: PositionType::Absolute,
left: VAL_SPACE_3,
top: VAL_SPACE_2,
top: Val::Px(SPACE_2 + top_inset),
flex_direction: FlexDirection::Column,
// Cap the column at 50% of viewport so on narrow
// (mobile) widths the inner tier rows have a bounded
// width to wrap against, and the column can't bleed
// into the right-anchored action button row (also
// capped at 50%). On desktop 50% of 1920 = 960 px,
// wider than any tier row's natural width, so the
// visible layout is unaffected.
max_width: Val::Percent(50.0),
row_gap: VAL_SPACE_1,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
))
.with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
@@ -568,94 +620,83 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
/// because it's the most consequential action; the destructive button sits
/// on its own visual edge.
fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Commands) {
fn spawn_action_buttons(
font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
#[cfg(not(target_os = "android"))]
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
// top-bar-overlap fix. Aligns with the rest of `hud_plugin`'s
// text (which already routes through the `TYPE_*` tokens) and
// reclaims horizontal space so the action button row doesn't
// collide with the left-anchored HUD column at narrow window
// widths.
font_size: TYPE_BODY,
..default()
};
// Android labels use only FiraMono-safe glyphs (≡ ← ‖ → ▾), so the same
// embedded font works — no system font fallback required.
#[cfg(target_os = "android")]
let font = TextFont { font_size: TYPE_BODY, ..default() };
// On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on
// a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7
// fit in a single row (7×44 + 6×4 = 332 dp, well within a 90%-wide band
// of 370 dp). On desktop, keep the descriptive text labels.
#[cfg(target_os = "android")]
let (max_width, col_gap, row_gap_val) =
(Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0));
#[cfg(not(target_os = "android"))]
let (max_width, col_gap, row_gap_val) =
(Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2);
#[cfg(target_os = "android")]
let labels = (
/* menu */ "\u{2261}", // ≡ identical-to (hamburger look-alike, in FiraMono)
/* undo */ "\u{2190}", // ← leftwards arrow (in FiraMono)
/* pause */ "\u{2016}", // ‖ double vertical line (in FiraMono general-punct)
/* help */ "?",
/* hint */ "\u{2192}", // → rightwards arrow (in FiraMono)
/* modes */ "\u{25BE}", // ▾ small down-pointing triangle (in FiraMono)
/* new */ "+",
);
#[cfg(not(target_os = "android"))]
let labels = (
"Menu \u{25BE}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{25BE}",
"New Game",
);
commands
.spawn((
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
top: VAL_SPACE_2,
top: Val::Px(SPACE_2 + top_inset),
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_2,
max_width,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::FlexEnd,
column_gap: col_gap,
row_gap: row_gap_val,
align_items: AlignItems::Center,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
))
.with_children(|row| {
// Menu and Modes don't have a single hotkey accelerator
// (each row inside their popover has its own); their button
// labels carry the dropdown chevron in lieu of a key chip.
//
// The trailing `order` argument is the per-button index in
// visual reading order (left → right). It feeds
// `Focusable { group: Hud, order }` so Tab cycles the action
// bar in the same order the eye scans it.
spawn_action_button(
row,
MenuButton,
"Menu \u{25BE}",
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
&font,
0,
);
spawn_action_button(
row,
UndoButton,
"Undo",
Some("U"),
"Take back your last move. Costs points and blocks No Undo.",
&font,
1,
);
spawn_action_button(
row,
PauseButton,
"Pause",
Some("Esc"),
"Pause the game and freeze the timer.",
&font,
2,
);
spawn_action_button(
row,
HelpButton,
"Help",
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
&font,
3,
);
spawn_action_button(
row,
ModesButton,
"Modes \u{25BE}",
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font,
4,
);
spawn_action_button(
row,
NewGameButton,
"New Game",
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
&font,
5,
);
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0);
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1);
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3);
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5);
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6);
});
}
@@ -681,32 +722,42 @@ fn spawn_action_button<M: Component>(
font: &TextFont,
order: i32,
) {
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
// touch device — the button itself is the affordance — and they
// visibly clutter the narrow-viewport action row. Force the hint
// off on Android; the chevrons on Menu/Modes remain because they
// indicate dropdown behaviour and still apply on touch.
#[cfg(target_os = "android")]
let hotkey: Option<&'static str> = None;
let hotkey_font = TextFont {
font: font.font.clone(),
font_size: TYPE_CAPTION,
..default()
};
// On Android, use tighter padding and a slightly smaller min-size so all
// 7 icon-label buttons fit in one row on a ~411 dp phone. 44 dp ≥
// Apple's minimum touch target; padding of 4 dp each side keeps the icon
// centred with room to breathe. On desktop, keep the comfortable 48 dp
// floor and 8 dp side padding.
#[cfg(target_os = "android")]
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0));
#[cfg(not(target_os = "android"))]
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
row.spawn((
marker,
ActionButton,
Button,
Tooltip::new(tooltip),
// Joins the `Hud` focus group at the supplied order so Tab
// cycles HUD buttons left-to-right under Phase 2. The HUD focus
// ring still only engages when a HUD button is hovered (or in
// future phases, when the player explicitly switches groups);
// the marker just declares membership.
Focusable {
group: FocusGroup::Hud,
order,
},
Node {
// Horizontal padding stepped down from VAL_SPACE_3 to
// VAL_SPACE_2 to reclaim ~96px across the 6-button row at
// narrow window widths (see top-bar-overlap fix in the
// companion commit). Vertical padding stays at VAL_SPACE_2
// so button height tracks the rest of the chrome band.
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
padding: pad,
min_width: min_w,
min_height: min_h,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
@@ -777,12 +828,43 @@ fn handle_help_button(
}
}
fn handle_hint_button(
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
paused: Option<Res<crate::PausedResource>>,
game: Option<Res<GameStateResource>>,
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
for interaction in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
if paused.as_ref().is_some_and(|p| p.0) {
return;
}
let Some(ref g) = game else { return };
if g.0.is_won {
#[cfg(target_os = "android")]
let won_msg = "Game won! Tap New Game to play again";
#[cfg(not(target_os = "android"))]
let won_msg = "Game won! Press N for a new game";
info_toast.write(InfoToastEvent(won_msg.to_string()));
return;
}
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
hint.spawn(g.0.clone(), cfg.0);
}
}
}
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
/// second click. Mode rows are populated per the player's current level so
/// only unlocked options appear.
fn handle_modes_button(
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
@@ -796,6 +878,9 @@ fn handle_modes_button(
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
} else {
spawn_modes_popover(
&mut commands,
@@ -896,6 +981,23 @@ fn spawn_modes_popover(
});
}
});
// Fullscreen transparent backdrop at Z_HUD+4 (below the popover at
// Z_HUD+5) so tapping outside the panel light-dismisses it.
commands.spawn((
ModesPopoverBackdrop,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::NONE),
ZIndex(Z_HUD + 4),
));
}
/// Dispatches the click on a popover row to the matching request event,
@@ -909,6 +1011,7 @@ fn spawn_modes_popover(
fn handle_mode_option_click(
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
@@ -941,9 +1044,13 @@ fn handle_mode_option_click(
}
}
if clicked_any
&& let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
&& let Ok(entity) = popovers.single()
{
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
}
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
@@ -952,6 +1059,7 @@ fn handle_mode_option_click(
fn handle_menu_button(
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
@@ -963,6 +1071,9 @@ fn handle_menu_button(
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
} else {
spawn_menu_popover(&mut commands, font_res.as_deref());
}
@@ -1050,6 +1161,23 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
});
}
});
// Transparent fullscreen backdrop behind the popover — tapping anywhere
// outside the panel light-dismisses it via handle_menu_backdrop_click.
commands.spawn((
MenuPopoverBackdrop,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::NONE),
ZIndex(Z_HUD + 4),
));
}
/// Dispatches the click on a menu row to the matching toggle event,
@@ -1058,6 +1186,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
fn handle_menu_option_click(
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>,
@@ -1092,9 +1221,46 @@ fn handle_menu_option_click(
if clicked_any
&& let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
}
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
/// anywhere outside the panel.
fn handle_modes_backdrop_click(
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
fn handle_menu_backdrop_click(
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Auto-fade state for the action button bar. The bar fades out when
/// the cursor is in the play area (below the HUD band) and back in when
/// the cursor approaches the top of the window — same UX as a video
@@ -1741,6 +1907,7 @@ fn update_hud(
GameMode::Zen => "ZEN".to_string(),
GameMode::Challenge => "CHALLENGE".to_string(),
GameMode::TimeAttack => "TIME ATTACK".to_string(),
GameMode::Difficulty(level) => level.label().to_uppercase(),
};
}
@@ -1937,6 +2104,46 @@ pub fn challenge_time_color(remaining: u64) -> Color {
}
}
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
///
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
/// all three items remain on one row inside the 50 %-wide HUD column
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
/// restored so desktop/tablet layouts are unaffected.
type HudScoreFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
type HudMovesFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
type HudTimeFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
fn update_hud_typography(
mut events: MessageReader<WindowResized>,
mut score_q: HudScoreFont,
mut moves_q: HudMovesFont,
mut time_q: HudTimeFont,
) {
let Some(ev) = events.read().last() else {
return;
};
let (score_size, secondary_size) = if ev.width < 480.0 {
(TYPE_BODY_LG, TYPE_CAPTION)
} else {
(TYPE_HEADLINE, TYPE_BODY_LG)
};
for mut font in &mut score_q {
font.font_size = score_size;
}
for mut font in &mut moves_q {
font.font_size = secondary_size;
}
for mut font in &mut time_q {
font.font_size = secondary_size;
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2442,6 +2649,7 @@ mod tests {
focusable_for::<UndoButton>(&mut app),
focusable_for::<PauseButton>(&mut app),
focusable_for::<HelpButton>(&mut app),
focusable_for::<HintButton>(&mut app),
focusable_for::<ModesButton>(&mut app),
focusable_for::<NewGameButton>(&mut app),
] {
@@ -2550,6 +2758,10 @@ mod tests {
tooltip_for::<HelpButton>(&mut app),
"Show controls, rules, and keyboard shortcuts."
);
assert_eq!(
tooltip_for::<HintButton>(&mut app),
"Highlight a suggested move. Cycles through alternatives on repeat taps."
);
assert_eq!(
tooltip_for::<ModesButton>(&mut app),
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
@@ -2669,14 +2881,15 @@ mod tests {
fn hud_button_order_matches_spawn_order() {
let mut app = headless_app();
// Visual reading order (left → right): Menu, Undo, Pause, Help,
// Modes, New Game. Their `order` fields must be 0..=5 in that
// order so Tab cycles them as the player reads them.
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
// that order so Tab cycles them as the player reads them.
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 4);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 5);
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
}
#[test]
+160 -28
View File
@@ -23,7 +23,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches};
use bevy::input::ButtonInput;
use bevy::math::{Vec2, Vec3};
use bevy::prelude::*;
use bevy::window::{MonitorSelection, PrimaryWindow, WindowMode};
use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::card::{Card, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
@@ -31,11 +33,9 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
TABLEAU_FAN_FRAC,
};
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING};
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
use crate::radial_menu::RightClickRadialState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
@@ -105,12 +105,16 @@ impl Plugin for InputPlugin {
// Touch drag pipeline (parallel path through DragState).
touch_start_drag,
touch_follow_drag,
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
touch_end_drag.before(GameMutation),
)
.chain(),
)
.add_systems(Update, handle_fullscreen)
.add_systems(Update, reset_hint_cycle_on_state_change)
.add_systems(Update, reset_hint_cycle_on_state_change);
// F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
#[cfg(not(target_os = "android"))]
app.add_systems(Update, handle_fullscreen);
app
// Async hint pipeline: state-change drop runs before the
// poll system so a move applied this frame cancels any
// in-flight task before its result can be surfaced.
@@ -423,6 +427,7 @@ fn reset_hint_cycle_on_state_change(
/// `F11` toggles between borderless-fullscreen and windowed mode.
/// Not gated by the pause flag — the player can always resize the window.
#[cfg(not(target_os = "android"))]
fn handle_fullscreen(
keys: Res<ButtonInput<KeyCode>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
@@ -515,8 +520,10 @@ fn handle_touch_stock_tap(
/// Begins a mouse drag: records the press position and the cards that would be
/// dragged. Cards are **not** elevated yet — that happens in [`follow_drag`]
/// once the drag threshold is crossed.
#[allow(clippy::too_many_arguments)]
fn start_drag(
buttons: Res<ButtonInput<MouseButton>>,
touches: Option<Res<Touches>>,
paused: Option<Res<PausedResource>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
@@ -531,6 +538,15 @@ fn start_drag(
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
return;
}
// On platforms where Winit simulates a MouseButton::Left press from the
// first touch, this guard ensures touch_start_drag (which runs after this
// system) claims the drag state instead of the mouse path. Without it the
// card is tracked via cursor_world (updated from the simulated mouse
// position) rather than the Touches resource, which can be one frame
// behind the actual finger position on Android.
if touches.as_ref().is_some_and(|t| t.iter_just_pressed().next().is_some()) {
return;
}
let Some(layout) = layout else { return };
let Some(world) = cursor_world(&windows, &cameras) else { return };
@@ -607,7 +623,7 @@ fn follow_drag(
// Move cards to the cursor.
let bottom_pos = world + drag.cursor_offset;
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, &id) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) =
@@ -868,7 +884,7 @@ fn touch_follow_drag(
}
let bottom_pos = world + drag.cursor_offset;
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, &id) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) =
@@ -1040,8 +1056,8 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
/// Where a card at `stack_index` in pile `pile` would be rendered.
///
/// For tableau columns the per-card fan step depends on the face-up state of
/// every preceding card — face-down cards step by `TABLEAU_FACEDOWN_FAN_FRAC`,
/// face-up cards by `TABLEAU_FAN_FRAC`. Mirrors `card_plugin::card_positions`
/// every preceding card — face-down cards step by `layout.tableau_facedown_fan_frac`,
/// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions`
/// exactly; any drift creates an offset between the visible card face and
/// where clicks land.
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
@@ -1051,9 +1067,9 @@ fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index
if let Some(pile_cards) = game.piles.get(pile) {
for card in pile_cards.cards.iter().take(stack_index) {
let step = if card.face_up {
TABLEAU_FAN_FRAC
layout.tableau_fan_frac
} else {
TABLEAU_FACEDOWN_FAN_FRAC
layout.tableau_facedown_fan_frac
};
y_offset -= layout.card_size.y * step;
}
@@ -1188,7 +1204,7 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
if matches!(pile, PileType::Tableau(_)) {
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
if card_count > 1 {
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
let fan = -layout.card_size.y * layout.tableau_fan_frac;
let bottom_card_center_y = center.y + fan * (card_count - 1) as f32;
let top_edge = center.y + layout.card_size.y / 2.0;
let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0;
@@ -1204,12 +1220,17 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
}
// ---------------------------------------------------------------------------
// Task #27 — Double-click to auto-move
// Task #27 — Double-click / double-tap to auto-move
// ---------------------------------------------------------------------------
/// Maximum seconds between two clicks to count as a double-click.
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
/// Duration of the lime flash applied to moved cards when a tap
/// auto-move succeeds. Short enough not to linger, long enough to register
/// during the card animation (~0.3 s).
const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
/// Find the best legal destination for `card` — Foundation first, then Tableau.
///
/// Returns `None` if no legal move exists from the card's current location.
@@ -1363,6 +1384,116 @@ fn handle_double_click(
}
}
// ---------------------------------------------------------------------------
// Tap-to-move (touch equivalent of mouse auto-move)
// ---------------------------------------------------------------------------
/// Fires `MoveRequestEvent` when the player taps a face-up card without
/// dragging — the touch equivalent of the mouse auto-move flow.
///
/// 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.
///
/// Move priority:
/// 1. Single top card to its best foundation (or tableau).
/// 2. Whole face-up run to best tableau column when no single-card move exists.
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
#[allow(clippy::too_many_arguments)]
fn handle_double_tap(
mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>,
radial: Option<Res<RightClickRadialState>>,
drag: Res<DragState>,
game: Res<GameStateResource>,
mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>,
mut commands: Commands,
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Long-press opened the radial — let radial_handle_release_or_cancel own
// the finger-lift event.
if radial.is_some_and(|r| r.is_active()) {
return;
}
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 || event.phase != 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;
}
// Priority 1: move single top card.
if let Some(dest) = best_destination(top_card, &game.0) {
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if ce.card_id == top_card_id {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight { remaining: DOUBLE_TAP_FLASH_SECS });
break;
}
}
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(),
)
{
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if drag.cards.contains(&ce.card_id) {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight { remaining: DOUBLE_TAP_FLASH_SECS });
}
}
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count,
});
return;
}
}
rejected.write(MoveRejectedEvent {
from: pile.clone(),
to: pile.clone(),
count: drag.cards.len(),
});
}
}
// ---------------------------------------------------------------------------
// Task #28 — Hint system helpers
// ---------------------------------------------------------------------------
@@ -1501,7 +1632,7 @@ mod tests {
#[test]
fn find_draggable_picks_top_of_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// In tableau 6, the visually topmost card is the last (face-up) one.
// Its position: base.y + fan * 6.
@@ -1515,7 +1646,7 @@ mod tests {
#[test]
fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
// the bottom (index 6). Click at the topmost face-down card's
@@ -1536,7 +1667,7 @@ mod tests {
// face-up bottom card, clicking the visible card face missed the
// hit-test box and only the bottom strip of the card responded.
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
@@ -1575,7 +1706,7 @@ mod tests {
face_up: true,
});
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// The Queen's geometric center (index 1) is inside the Jack's bounding box
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top
@@ -1607,7 +1738,7 @@ mod tests {
face_up: true,
});
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Both cards in waste sit at the same (x, y). Clicking should pick
// the visually top card (id 201), with count = 1.
let pos = card_position(&game, &layout, &PileType::Waste, 0);
@@ -1620,7 +1751,7 @@ mod tests {
#[test]
fn find_drop_target_hits_empty_tableau_pile_marker() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Move all cards out of tableau 0 so its marker is the only drop area.
let mut game = game;
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
@@ -1632,7 +1763,7 @@ mod tests {
#[test]
fn find_drop_target_returns_none_for_origin() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let pos = layout.pile_positions[&PileType::Tableau(3)];
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
assert_eq!(target, None);
@@ -1641,7 +1772,7 @@ mod tests {
#[test]
fn pile_drop_rect_extends_for_tableau_with_cards() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
@@ -1666,7 +1797,7 @@ mod tests {
waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true });
waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true });
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let waste_base = layout.pile_positions[&PileType::Waste];
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
@@ -1682,7 +1813,7 @@ mod tests {
#[test]
fn find_draggable_returns_none_for_click_on_empty_pile() {
let mut game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Clear tableau 0 so it's an empty slot.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let pos = layout.pile_positions[&PileType::Tableau(0)];
@@ -1693,7 +1824,7 @@ mod tests {
#[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
for pile in [
PileType::Waste,
PileType::Foundation(2),
@@ -2194,7 +2325,7 @@ mod tests {
app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource(
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0),
));
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
app.add_systems(Update, handle_keyboard_hint);
@@ -2215,5 +2346,6 @@ mod tests {
"pressing H must spawn an async hint task",
);
}
}
+266 -30
View File
@@ -21,9 +21,25 @@ pub enum LayoutSystem {
UpdateOnResize,
}
/// Minimum supported window dimensions. Layout is still computed below this
/// size but cards will be small.
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
/// Minimum window dimensions used as a layout floor.
///
/// `compute_layout` runs `window.max(MIN_WINDOW)` so a window smaller than this
/// on either axis is laid out as if it were at least this size. The floor
/// exists to guard against degenerate / divide-by-zero layouts on very small
/// surfaces (Bevy can briefly report 0-size windows during startup or after
/// minimisation on some compositors); it is not a "minimum supported playable
/// size" — desktop builds enforce that via `WindowResizeConstraints` set in
/// `solitaire_app::lib`.
///
/// The previous floor of 800×600 was set with desktop in mind and produced
/// the wrong behaviour on Android: a 360 dp phone got laid out as if it were
/// 800-wide, pushing the leftmost foundation past `-180` and the rightmost
/// tableau pile past `+180`, which clipped both at the visible viewport
/// edges (visible in the v0.22.3 hardware screenshot). 320×400 is below the
/// smallest reasonable phone (≈ 360×640) so every real device flows through
/// without clamping, while still being large enough that the layout math
/// produces non-degenerate card sizes.
pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
/// Aspect ratio (height / width) of a standard playing card.
///
@@ -36,11 +52,22 @@ const CARD_ASPECT: f32 = 1.4523;
/// the tableau row.
const VERTICAL_GAP_FRAC: f32 = 0.2;
/// Fraction of card height contributed by each additional face-up tableau card
/// when fanned. Mirrors `card_plugin::TABLEAU_FAN_FRAC` so layout sizing can
/// solve for a worst-case column without depending on `card_plugin`.
/// Minimum fraction of card height used as vertical offset between face-up
/// tableau cards. Used for the height-based sizing candidate (worst-case
/// column must fit at this fraction). On desktop (height-limited) windows the
/// adaptive computation returns this value exactly; on portrait phones it
/// expands to fill available vertical space.
const TABLEAU_FAN_FRAC: f32 = 0.25;
/// Minimum fraction for face-down tableau cards. Scales proportionally with
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
///
/// Raised from 0.12 to 0.20 so face-down stacks on portrait phones show
/// enough of each card back to read as a meaningful stack rather than a
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
/// the adaptive scaling in `compute_layout`.
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
/// after every face-down card has flipped on column 7. Layout sizing must keep
/// this column inside the visible window.
@@ -50,10 +77,15 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
/// below this band so the HUD doesn't bleed into the play surface.
///
/// 64 px comfortably fits the action button bar (~32 px tall) plus the
/// Score/Moves text line plus padding, with a few pixels of breathing room.
/// The matching translucent background is painted by `hud_plugin::spawn_hud_band`.
/// Desktop: 64 px fits the single-row action bar plus the Score/Moves line.
/// Android: 128 px accommodates the two-row button wrap on narrow phones
/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two
/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of
/// buttons overlaps the top card row.
#[cfg(not(target_os = "android"))]
pub const HUD_BAND_HEIGHT: f32 = 64.0;
#[cfg(target_os = "android")]
pub const HUD_BAND_HEIGHT: f32 = 128.0;
/// Table background colour (dark green felt).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -72,9 +104,33 @@ pub struct Layout {
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
/// entry. The map always contains exactly 13 entries after `compute_layout`.
pub pile_positions: HashMap<PileType, Vec2>,
/// Per-step vertical offset fraction for face-up tableau cards, as a
/// fraction of `card_size.y`. On height-limited (desktop) windows this
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone)
/// windows it expands to fill the available vertical space so the tableau
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
/// and hit testing (`input_plugin`) both read from this field so they
/// stay in sync.
pub tableau_fan_frac: f32,
/// Per-step vertical offset fraction for face-down tableau cards, as a
/// fraction of `card_size.y`. Scales proportionally with `tableau_fan_frac`
/// (ratio preserved from `TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC`).
pub tableau_facedown_fan_frac: f32,
/// Vertical pixel budget available for tableau fan steps — the distance
/// from the top edge of the first tableau card to the bottom margin, in
/// logical pixels. Used by `card_plugin::update_tableau_fan_frac` to
/// recompute `tableau_fan_frac` dynamically based on the actual max
/// face-up column depth after each game state change.
pub available_tableau_height: f32,
}
/// Compute the board layout from a window size.
/// Compute the board layout from a window size and safe-area insets.
///
/// `safe_area_top` and `safe_area_bottom` are the **logical-pixel** heights of
/// the OS-reserved regions at the top and bottom of the screen (status bar and
/// gesture / navigation bar on Android). Pass `0.0` on desktop or when the
/// inset is unknown. Android's `WindowInsets` API returns **physical** pixels;
/// callers must divide by `window.scale_factor()` before passing values here.
///
/// # Geometry
/// - `card_width` is the smaller of:
@@ -90,7 +146,7 @@ pub struct Layout {
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
/// waste/stock cluster from the foundations.
pub fn compute_layout(window: Vec2) -> Layout {
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) -> Layout {
let window = window.max(MIN_WINDOW);
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
@@ -113,7 +169,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
let card_width_height_based = (window.y - HUD_BAND_HEIGHT).max(0.0) / height_denom;
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - HUD_BAND_HEIGHT).max(0.0) / height_denom;
let card_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT;
@@ -133,7 +189,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
};
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
let top_y = window.y / 2.0 - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
let top_y = window.y / 2.0 - safe_area_top - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
let tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
@@ -153,9 +209,36 @@ pub fn compute_layout(window: Vec2) -> Layout {
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
// height-based sizing already ensures a worst-case 13-card column fits at
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
// keeps it there — no change from prior behaviour. On width-limited
// (portrait phone) windows card_size is small and lots of vertical space
// is unused; we solve for the fraction that exactly fills the available
// space to the bottom margin.
//
// avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps.
let avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + h_gap) - card_height / 2.0).max(0.0);
let ideal_fan_frac = if card_height > 0.0 {
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
} else {
TABLEAU_FAN_FRAC
};
// Never go below the desktop minimum — avoids shrinking the fan on
// degenerate near-square windows where the formula might undershoot.
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
// Scale the face-down fraction proportionally so rendering and hit-testing
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
Layout {
card_size,
pile_positions,
tableau_fan_frac,
tableau_facedown_fan_frac,
available_tableau_height: avail,
}
}
@@ -187,15 +270,15 @@ mod tests {
#[test]
fn layout_has_all_thirteen_piles() {
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0)));
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0)));
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0)));
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0));
}
#[test]
fn card_size_scales_with_window_width() {
let small = compute_layout(Vec2::new(800.0, 600.0));
let large = compute_layout(Vec2::new(1920.0, 1080.0));
let small = compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0);
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0);
assert!(large.card_size.x > small.card_size.x);
assert!(
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
@@ -205,14 +288,42 @@ mod tests {
#[test]
fn layout_below_minimum_clamps_to_minimum() {
let below = compute_layout(Vec2::new(400.0, 300.0));
let at_min = compute_layout(MIN_WINDOW);
// 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, 0.0, 0.0).
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0);
let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0);
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, 0.0, 0.0);
let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions {
assert!(
pos.x - half_card >= -half_w - 1e-3,
"{:?} overflows left at portrait phone window {:?}",
pile,
window
);
assert!(
pos.x + half_card <= half_w + 1e-3,
"{:?} overflows right at portrait phone window {:?}",
pile,
window
);
}
}
#[test]
fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
for i in 0..6 {
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
@@ -222,7 +333,7 @@ mod tests {
#[test]
fn top_row_is_above_tableau_row() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
assert!(stock_y > tableau_y);
@@ -235,7 +346,7 @@ mod tests {
#[test]
fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
@@ -247,7 +358,7 @@ mod tests {
#[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let stock_x = layout.pile_positions[&PileType::Stock].x;
let waste_x = layout.pile_positions[&PileType::Waste].x;
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
@@ -258,7 +369,7 @@ mod tests {
#[test]
fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
for slot in 0..4_u8 {
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
@@ -277,7 +388,7 @@ mod tests {
// keep a worst-case 13-card column inside the window. (Most desktop
// monitors fall into this regime — e.g. 1280x800, 1920x1080.)
let window = Vec2::new(2560.0, 1080.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let width_based = window.x / 9.0;
assert!(
layout.card_size.x < width_based,
@@ -293,7 +404,7 @@ mod tests {
// the bottleneck and card_width matches the legacy window.x / 9
// derivation exactly.
let window = Vec2::new(900.0, 1600.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let width_based = window.x / 9.0;
assert!(
(layout.card_size.x - width_based).abs() < 1e-3,
@@ -307,7 +418,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_default_resolution() {
// Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card.
@@ -326,7 +437,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_full_hd() {
// The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
@@ -338,6 +449,50 @@ mod tests {
);
}
/// Portrait phone (width-limited) should expand the fan fraction beyond
/// the desktop minimum so the tableau fills the available vertical space.
#[test]
fn portrait_phone_expands_tableau_fan_frac() {
let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0);
assert!(
phone.tableau_fan_frac > desktop.tableau_fan_frac,
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
phone.tableau_fan_frac,
desktop.tableau_fan_frac,
);
}
/// The expanded fan on a portrait phone must not overflow the visible
/// window — the worst-case 13-card column must stay above the bottom margin.
#[test]
fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0;
// Bottom of the 13th (worst-case) fanned face-up card.
let bottom = tableau_y - 12.0 * layout.tableau_fan_frac * card_h - card_h / 2.0;
let margin = -window.y / 2.0 + h_gap;
assert!(
bottom >= margin - 1e-3,
"worst-case fan overflows phone viewport: bottom={bottom:.1} < margin={margin:.1}",
);
}
/// Desktop (height-limited) must keep the minimum fan fraction so the
/// existing worst-case-fits-vertically invariant is preserved.
#[test]
fn desktop_tableau_fan_frac_is_minimum() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
assert!(
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}",
layout.tableau_fan_frac,
);
}
#[test]
fn all_piles_fit_inside_window_horizontally() {
for window in [
@@ -345,7 +500,7 @@ mod tests {
Vec2::new(1280.0, 800.0),
Vec2::new(1920.0, 1080.0),
] {
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions {
@@ -364,4 +519,85 @@ mod tests {
}
}
}
/// A non-zero `safe_area_top` must shift both the top row and the tableau
/// downward by the same amount — so the first card row stays below the
/// status-bar band and the tableau tracks it proportionally.
#[test]
fn safe_area_top_shifts_top_row_downward() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 32.0, 0.0);
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
assert!(
stock_with_inset < stock_no_inset,
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
stock_no_inset,
stock_with_inset,
);
assert!(
(stock_no_inset - stock_with_inset - 32.0).abs() < 1e-3,
"stock pile must shift by exactly safe_area_top (32 dp): delta was {:.3}",
stock_no_inset - stock_with_inset,
);
}
/// With a safe-area inset the card grid must still fit horizontally —
/// safe_area_top only affects the vertical budget.
#[test]
fn safe_area_top_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 32.0, 0.0);
for pile in [
PileType::Stock,
PileType::Waste,
PileType::Tableau(0),
PileType::Tableau(6),
] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_top",
);
}
}
/// A bottom safe-area inset must shrink the tableau fan so the worst-case
/// column stays above the gesture bar.
#[test]
fn safe_area_bottom_reduces_tableau_fan() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 0.0, 48.0);
assert!(
with_inset.tableau_fan_frac <= without.tableau_fan_frac,
"safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}",
without.tableau_fan_frac,
with_inset.tableau_fan_frac,
);
let card_h = with_inset.card_size.y;
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
let h_gap = with_inset.card_size.x / 4.0;
let margin = -window.y / 2.0 + 48.0 + h_gap;
assert!(
bottom_edge >= margin - 1e-3,
"worst-case tableau bottom {bottom_edge:.2} overflows gesture-bar margin {margin:.2}",
);
}
/// safe_area_bottom must not affect horizontal positions.
#[test]
fn safe_area_bottom_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 0.0, 48.0);
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_bottom",
);
}
}
}
+15 -4
View File
@@ -1,5 +1,7 @@
//! Bevy integration layer for Solitaire Quest.
#[cfg(target_os = "android")]
pub mod android_clipboard;
pub mod assets;
pub mod card_animation;
pub mod achievement_plugin;
@@ -12,6 +14,7 @@ pub mod feedback_anim_plugin;
pub mod challenge_plugin;
pub mod cursor_plugin;
pub mod daily_challenge_plugin;
pub mod difficulty_plugin;
pub mod diagnostics_hud;
pub mod events;
pub mod game_plugin;
@@ -24,6 +27,7 @@ pub mod layout;
pub mod onboarding_plugin;
pub mod pause_plugin;
pub mod pending_hint;
pub mod play_by_seed_plugin;
pub mod profile_plugin;
pub mod radial_menu;
pub mod replay_overlay;
@@ -31,10 +35,12 @@ pub mod replay_playback;
pub mod settings_plugin;
pub mod progress_plugin;
pub mod resources;
pub mod safe_area;
pub mod selection_plugin;
pub mod splash_plugin;
pub mod stats_plugin;
pub mod sync_plugin;
pub mod sync_setup_plugin;
pub mod table_plugin;
pub mod theme;
pub mod time_attack_plugin;
@@ -92,11 +98,14 @@ pub use events::{
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent,
WinStreakMilestoneEvent, XpAwardedEvent,
};
pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin};
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
pub use game_plugin::{
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
ReplayPath,
@@ -131,6 +140,7 @@ pub use settings_plugin::{
};
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
};
@@ -141,6 +151,7 @@ pub use stats_plugin::{
StatsScreen, StatsUpdate, WatchReplayButton,
};
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
pub use sync_setup_plugin::SyncSetupPlugin;
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
+16 -1
View File
@@ -41,7 +41,13 @@ use crate::ui_theme::{
// ---------------------------------------------------------------------------
/// Total number of onboarding slides (0-based index goes 0..SLIDE_COUNT-1).
///
/// Android omits the keyboard-shortcuts slide (index 2) because there is no
/// physical keyboard on a touchscreen device, dropping the count to 2.
#[cfg(not(target_os = "android"))]
const SLIDE_COUNT: u8 = 3;
#[cfg(target_os = "android")]
const SLIDE_COUNT: u8 = 2;
// ---------------------------------------------------------------------------
// Components (private — never re-exported)
@@ -276,6 +282,8 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc
match index {
0 => spawn_slide_welcome(commands, font_res),
1 => spawn_slide_how_to_play(commands, font_res),
// Slide 2 (keyboard shortcuts) is desktop-only; Android has no keyboard.
#[cfg(not(target_os = "android"))]
2 => spawn_slide_hotkeys(commands, font_res),
_ => spawn_slide_welcome(commands, font_res),
}
@@ -664,8 +672,15 @@ mod tests {
// -----------------------------------------------------------------------
#[test]
#[cfg(not(target_os = "android"))]
fn slide_count_constant_is_three() {
assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3");
assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3 on desktop");
}
#[test]
#[cfg(target_os = "android")]
fn slide_count_constant_is_two_on_android() {
assert_eq!(SLIDE_COUNT, 2, "SLIDE_COUNT must be 2 on Android (no keyboard slide)");
}
#[test]
+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");
}
}
+97 -15
View File
@@ -42,6 +42,7 @@
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
//! neither.
use bevy::input::touch::Touches;
use bevy::input::ButtonInput;
use bevy::math::Vec2;
use bevy::prelude::*;
@@ -59,6 +60,11 @@ use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
/// Seconds a finger must be held on a face-up card (without crossing the
/// drag threshold) before the radial menu opens. Matches Android's long-press
/// gesture recogniser default.
const LONG_PRESS_SECS: f32 = 0.5;
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
///
/// One rung above [`crate::ui_theme::Z_DROP_OVERLAY`] (`50.0`) so the radial icons render
@@ -181,6 +187,7 @@ impl Plugin for RadialMenuPlugin {
Update,
(
radial_open_on_right_click,
radial_open_on_long_press,
radial_track_cursor,
radial_handle_release_or_cancel,
radial_redraw_overlay,
@@ -446,6 +453,68 @@ fn radial_open_on_right_click(
};
}
/// Opens the radial menu after a sustained touch hold on a face-up card.
///
/// Counts up while the touch is down, the drag threshold has not been
/// crossed, and the radial is not yet active. Fires after
/// [`LONG_PRESS_SECS`] (0.5 s). The timer resets whenever these
/// conditions are not met, so lifting, committing a drag, or the radial
/// already being open all clear it cleanly.
#[allow(clippy::too_many_arguments)]
fn radial_open_on_long_press(
time: Res<Time>,
mut hold_timer: Local<f32>,
drag: Res<DragState>,
paused: Option<Res<PausedResource>>,
touches: Option<Res<Touches>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>,
mut state: ResMut<RightClickRadialState>,
) {
// Guard: only count while a touch is down, uncommitted, and radial is idle.
let active_id = drag.active_touch_id;
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
*hold_timer = 0.0;
return;
}
*hold_timer += time.delta_secs();
if *hold_timer < LONG_PRESS_SECS {
return;
}
*hold_timer = 0.0;
// Resolve current touch world position.
let Some(touches) = touches else { return };
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else {
return;
};
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
let Some(world) = camera.viewport_to_world_2d(cam_xf, touch.position()).ok() else {
return;
};
let Some(layout) = layout else { return };
let Some(game) = game else { return };
let Some((source_pile, card)) = find_top_face_up_card_at(world, &game.0, &layout.0) else {
return;
};
let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
if dests.is_empty() {
return;
}
let legal_destinations = build_radial_destinations(world, dests);
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card.id],
legal_destinations,
centre: world,
hovered_index: None,
};
}
/// Each frame while `Active`, updates `hovered_index` based on the
/// current cursor position. Cheap — just re-runs hit-testing against
/// the precomputed anchors. The overlay redraw system reads this index
@@ -454,6 +523,7 @@ fn radial_track_cursor(
cursor_override: Option<Res<RadialCursorOverride>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
touches: Option<Res<Touches>>,
mut state: ResMut<RightClickRadialState>,
) {
let RightClickRadialState::Active {
@@ -464,21 +534,28 @@ fn radial_track_cursor(
else {
return;
};
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
return;
};
// Cursor first (mouse / test override); fall back to first active touch
// so the player can slide their held finger over radial icons on Android.
let world = cursor_world(cursor_override.as_ref(), &windows, &cameras).or_else(|| {
let (camera, cam_xf) = cameras.single().ok()?;
let touch_pos = touches.as_ref()?.iter().next()?.position();
camera.viewport_to_world_2d(cam_xf, touch_pos).ok()
});
let Some(world) = world else { return };
let anchors: Vec<Vec2> = legal_destinations.iter().map(|(_, a)| *a).collect();
*hovered_index = radial_hovered_index(world, &anchors);
}
/// Handles three exit conditions while `Active`:
/// Handles exit conditions while `Active`:
/// 1. Right-mouse release → confirm if hovering, otherwise cancel.
/// 2. `Escape` → cancel.
/// 3. Left-mouse press → cancel (keeps the existing drag pipeline clean).
/// 2. Touch lift (`Touches::iter_just_released`) → confirm if hovering, cancel otherwise.
/// 3. `Escape` → cancel.
/// 4. Left-mouse press → cancel (keeps the existing drag pipeline clean).
#[allow(clippy::too_many_arguments)]
fn radial_handle_release_or_cancel(
buttons: Option<Res<ButtonInput<MouseButton>>>,
keys: Option<Res<ButtonInput<KeyCode>>>,
touches: Option<Res<Touches>>,
mut state: ResMut<RightClickRadialState>,
mut moves: MessageWriter<MoveRequestEvent>,
) {
@@ -495,13 +572,18 @@ fn radial_handle_release_or_cancel(
let left_pressed = buttons
.as_ref()
.is_some_and(|b| b.just_pressed(MouseButton::Left));
// Finger lift: any touch that ended or was cancelled this frame.
let touch_ended = touches.as_ref().is_some_and(|t| {
t.iter_just_released().next().is_some() || t.iter_just_canceled().next().is_some()
});
if !escape_pressed && !right_released && !left_pressed {
if !escape_pressed && !right_released && !left_pressed && !touch_ended {
return;
}
// On confirm, fire a MoveRequestEvent. On any other exit, just clear.
if right_released
// On confirm (right-release or touch-lift while hovering), fire a move.
let confirm = right_released || touch_ended;
if confirm
&& let RightClickRadialState::Active {
source_pile,
count,
@@ -719,7 +801,7 @@ mod tests {
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
app.insert_resource(GameStateResource(state));
app.insert_resource(LayoutResource(compute_layout(layout_window)));
app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0)));
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
}
@@ -831,7 +913,7 @@ mod tests {
fn right_click_press_on_face_up_card_opens_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -868,7 +950,7 @@ mod tests {
fn right_click_release_over_destination_fires_move_request() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -907,7 +989,7 @@ mod tests {
fn right_click_release_outside_any_destination_cancels() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -934,7 +1016,7 @@ mod tests {
fn escape_cancels_active_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -957,7 +1039,7 @@ mod tests {
fn right_click_on_face_down_card_does_not_open_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
+106 -32
View File
@@ -38,8 +38,8 @@ use solitaire_data::ReplayMove;
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
STATE_SUCCESS, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION,
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
};
// ---------------------------------------------------------------------------
@@ -105,6 +105,21 @@ const SCRUB_LABEL_ROW_HEIGHT: f32 = 16.0;
/// (12 px) + 4 px breathing room.
const KEYBIND_FOOTER_HEIGHT: f32 = 16.0;
/// Fixed pixel width of the centred scrub-bar notch-label container.
/// Wide enough to hold the widest label ("100%" at 4 chars) while
/// narrower than the 25 % gap between adjacent notches (≈ banner_w
/// × 0.25; on a 320 px banner that's 80 px). A 36 px container
/// leaves ≥ 44 px of clearance on each side at the narrowest common
/// screen width.
///
/// Container width drives the `margin.left = -width / 2` centering
/// trick: the container's left edge is placed at `left: Percent(pct)`
/// and then shifted left by half its own width, so the container's
/// centre coincides with the notch line. `Justify::Center` then
/// renders the text centred within the container. This is the
/// CSS `translateX(-50%)` pattern adapted for Bevy 0.18 UI.
const SCRUB_LABEL_CENTER_WIDTH: f32 = 36.0;
/// How long a held arrow key waits before firing the next repeat
/// step. 100 ms = 10 steps/sec — fast enough to scrub through a
/// hundred-move replay in ~10 seconds while held, slow enough that
@@ -764,6 +779,11 @@ fn spawn_overlay(
..default()
},
BackgroundColor(STATE_SUCCESS),
// HC bump: lime → brighter lime so the win
// marker reads clearly above the bumped
// notch ticks (BORDER_SUBTLE_HC gray) under
// high-contrast mode.
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
));
}
// Fixed quarter-mark notches: five 1px vertical
@@ -822,45 +842,63 @@ fn spawn_overlay(
labels.iter().zip(positions.iter()).enumerate()
{
// Endpoints flush to the row's edges; middle
// three labels anchor at their percentage.
// `i == 0` → flush left (`left: 0`), so the
// "0%" caption doesn't get clipped at the
// left edge. `i == last` → flush right
// (`right: 0`) so "100%" doesn't overflow
// the banner. Bevy 0.18 UI has no clean
// CSS-style `translate-x: -50%` centering,
// so the middle three labels sit slightly
// right-of-notch — visually subtle at this
// font size; explicit polish target if
// anyone notices.
let mut node = Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
..default()
};
if i == 0 {
node.left = Val::Px(0.0);
// three labels use the `translateX(-50%)`
// pattern for Bevy 0.18 UI: a fixed-width
// container is placed at `left: Percent(pct)`
// then shifted left by half its own width via
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
// `Justify::Center` renders the text centred
// within the container so the text's visual
// centre coincides with the notch line.
let (node, justify) = if i == 0 {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Px(0.0),
..default()
},
Justify::Left,
)
} else if i == labels.len() - 1 {
node.right = Val::Px(0.0);
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
right: Val::Px(0.0),
..default()
},
Justify::Right,
)
} else {
node.left = Val::Percent(*pct);
}
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Percent(*pct),
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
margin: UiRect {
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
..default()
},
..default()
},
Justify::Center,
)
};
row.spawn((
ReplayOverlayScrubNotchLabel,
node,
Text::new(*label),
TextLayout::new_with_justify(justify),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
// The mockup's `text-outline` (BORDER_SUBTLE)
// would match the notches but reads as too
// low-contrast against `BG_ELEVATED_HI` for
// the labels to actually be legible at 12 px.
// TEXT_SECONDARY keeps the subdued visual
// hierarchy (caption, not headline) while
// staying readable.
// staying readable against BG_ELEVATED_HI.
TextColor(TEXT_SECONDARY),
));
}
@@ -906,6 +944,7 @@ fn spawn_overlay(
},
TextColor(TEXT_SECONDARY),
));
#[cfg(not(target_os = "android"))]
footer.spawn((
Text::new(keybind_footer_hint_text()),
TextFont {
@@ -2316,6 +2355,44 @@ mod tests {
);
}
/// The WIN MOVE marker carries `HighContrastBackground::with_hc(
/// STATE_SUCCESS, STATE_SUCCESS_HC)` so the lime bumps to brighter
/// lime under HC mode rather than to a neutral gray. Pin the
/// presence of the marker so a future refactor can't accidentally
/// drop it and silently regress HC legibility.
#[test]
fn win_move_marker_carries_hc_background_marker() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
let mut q = app
.world_mut()
.query_filtered::<&HighContrastBackground, With<ReplayOverlayWinMoveMarker>>();
let marker = q
.iter(app.world())
.next()
.expect("WIN MOVE marker must carry HighContrastBackground");
assert_eq!(
marker.default_color,
STATE_SUCCESS,
"default colour must be STATE_SUCCESS"
);
assert_eq!(
marker.hc_color,
STATE_SUCCESS_HC,
"HC colour must be STATE_SUCCESS_HC (brighter lime, not gray)"
);
}
// -----------------------------------------------------------------------
// scrub_notch_positions + ReplayOverlayScrubNotch spawn behaviour
// -----------------------------------------------------------------------
@@ -3910,9 +3987,6 @@ mod tests {
/// silently flip the intended stacking.
#[test]
fn dim_layer_z_is_below_replay_chrome() {
assert!(
Z_REPLAY_DIM < Z_REPLAY_OVERLAY,
"dim layer (z={Z_REPLAY_DIM}) must be below replay chrome (z={Z_REPLAY_OVERLAY})",
);
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
}
}
+248
View File
@@ -0,0 +1,248 @@
//! 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>,
windows: Query<&Window>,
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) {
if !insets.is_changed() {
return;
}
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
// expects logical pixels (≈ dp). Divide by the window scale factor so
// the HUD band shifts by the correct number of dp on high-DPI devices.
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let top_logical = insets.top / scale;
for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + top_logical);
}
}
#[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());
}
}
+338 -52
View File
@@ -22,11 +22,17 @@ use solitaire_data::{
TOOLTIP_DELAY_STEP_SECS,
};
use crate::events::{InfoToastEvent, ManualSyncRequestEvent, ToggleSettingsRequestEvent};
use solitaire_data::settings::SyncBackend;
use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
use crate::assets::user_theme_dir;
use crate::theme::{import_theme, ImportError, ThemeThumbnailCache, ThemeThumbnailPair, refresh_registry};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
@@ -230,7 +236,15 @@ enum SettingsButton {
/// flag only affects launches without saved geometry — the
/// player's last window size always wins.
ToggleSmartDefaultSize,
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
ScanThemes,
SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync,
/// Disconnect from the sync server (shown when backend = SolitaireServer).
DisconnectSync,
/// Open the account-deletion confirmation modal.
DeleteAccount,
Done,
/// Select a specific card-back by index from the picker row.
SelectCardBack(usize),
@@ -282,8 +296,12 @@ impl SettingsButton {
SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80,
SettingsButton::SelectTheme(_) => 85,
SettingsButton::ScanThemes => 86,
// Sync section
SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91,
SettingsButton::DisconnectSync => 92,
SettingsButton::DeleteAccount => 93,
// Done is tagged by `attach_focusable_to_modal_buttons` and
// never reaches `attach_focusable_to_settings_buttons`; the
// value here is only a fallback for completeness.
@@ -333,6 +351,9 @@ impl Plugin for SettingsPlugin {
.init_resource::<PendingWindowGeometry>()
.add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>()
.add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<bevy::input::mouse::MouseWheel>()
@@ -359,6 +380,8 @@ impl Plugin for SettingsPlugin {
(
sync_settings_panel_visibility,
handle_settings_buttons,
handle_sync_buttons,
handle_scan_themes,
update_sync_status_text,
update_card_back_text,
update_background_text,
@@ -701,7 +724,7 @@ pub(crate) fn update_high_contrast_backgrounds(
let high_contrast = settings.0.high_contrast_mode;
for (marker, mut bg) in backgrounds.iter_mut() {
let target = if high_contrast {
BORDER_SUBTLE_HC
marker.hc_color
} else {
marker.default_color
};
@@ -840,7 +863,6 @@ fn handle_settings_buttons(
mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>,
mut changed: MessageWriter<SettingsChangedEvent>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
@@ -1053,8 +1075,14 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
SettingsButton::SyncNow => {
manual_sync.write(ManualSyncRequestEvent);
SettingsButton::ScanThemes => {
// Handled by `handle_scan_themes`.
}
SettingsButton::SyncNow
| SettingsButton::ConnectSync
| SettingsButton::DisconnectSync
| SettingsButton::DeleteAccount => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => {
screen.0 = false;
@@ -1063,6 +1091,30 @@ fn handle_settings_buttons(
}
}
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// and Delete Account. Split from `handle_settings_buttons` to stay within
/// Bevy's 16-parameter system limit.
fn handle_sync_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
match button {
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); }
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
_ => {}
}
}
}
fn draw_mode_label(mode: &DrawMode) -> String {
match mode {
DrawMode::DrawOne => "Draw 1".into(),
@@ -1593,10 +1645,11 @@ fn spawn_settings_panel(
font_res,
);
}
import_themes_row(body, font_res);
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
});
// Done is the only action — primary so the player always knows
@@ -2208,8 +2261,14 @@ fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) {
));
}
/// Status text + manual "Sync Now" button.
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
/// Sync section row — shows different controls depending on whether a server
/// backend is configured.
fn sync_row(
parent: &mut ChildSpawnerCommands,
status_text: &str,
backend: &SyncBackend,
font_res: Option<&FontResource>,
) {
let status_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY,
@@ -2220,45 +2279,105 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
font_size: TYPE_CAPTION,
..default()
};
// Helper closure to spawn a small settings-style pill button.
let small_button = |row: &mut ChildSpawnerCommands,
marker: SettingsButton,
label: &str,
tooltip: String,
font: TextFont| {
row.spawn((
marker,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::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(label.to_string()),
font,
TextColor(TEXT_PRIMARY),
));
});
};
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|row| {
row.spawn((
SyncStatusText,
Text::new(status_text.to_string()),
status_font,
TextColor(TEXT_SECONDARY),
));
// ManualSyncRequestEvent is always registered, so this
// button is safe to show even when SyncPlugin is absent.
row.spawn((
SettingsButton::SyncNow,
Button,
Tooltip::new(
"Push and pull stats now. Runs automatically on launch and exit.",
),
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::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("Sync Now"),
button_font,
TextColor(TEXT_PRIMARY),
.with_children(|col| {
// Status line + inline action buttons.
col.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|row| {
row.spawn((
SyncStatusText,
Text::new(status_text.to_string()),
status_font,
TextColor(TEXT_SECONDARY),
));
match backend {
SyncBackend::Local => {
small_button(
row,
SettingsButton::ConnectSync,
"Connect",
"Connect to a self-hosted Solitaire Quest sync server.".to_string(),
button_font,
);
}
SyncBackend::SolitaireServer { username, .. } => {
// Show the logged-in username as a secondary label.
row.spawn((
Text::new(format!("({username})")),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
small_button(
row,
SettingsButton::SyncNow,
"Sync Now",
"Push and pull stats now. Runs automatically on launch and exit.".to_string(),
button_font.clone(),
);
small_button(
row,
SettingsButton::DisconnectSync,
"Disconnect",
"Unlink this device from the sync server.".to_string(),
button_font.clone(),
);
small_button(
row,
SettingsButton::DeleteAccount,
"Delete Account",
"Permanently delete your account and all server data. Cannot be undone.".to_string(),
button_font,
);
}
}
});
});
}
@@ -2284,6 +2403,172 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
/// Settings icon button ships with one because the glyph alone (`+`, ``,
/// `⇄`) does not name what it adjusts; the tooltip carries that meaning.
/// Scans `user_theme_dir()` for `.zip` files and calls [`import_theme`] on
/// each one. On success, [`ThemeRegistry`] is refreshed in place and an
/// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
/// already installed) are silently skipped; all other errors produce a warning
/// toast. A final toast tells the player to reopen Settings to see new themes.
fn handle_scan_themes(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut toast: MessageWriter<InfoToastEvent>,
mut registry: Option<ResMut<crate::theme::ThemeRegistry>>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
if !matches!(button, SettingsButton::ScanThemes) {
continue;
}
let themes_dir = user_theme_dir();
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) {
Ok(entries) => entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "zip"))
.collect(),
Err(_) => {
toast.write(InfoToastEvent(
"Themes folder not found — drop .zip files there first.".to_string(),
));
return;
}
};
if zips.is_empty() {
toast.write(InfoToastEvent(
"No .zip files found in themes folder.".to_string(),
));
return;
}
let mut imported = 0u32;
let mut errors = 0u32;
for zip_path in &zips {
match import_theme(zip_path) {
Ok(theme_id) => {
toast.write(InfoToastEvent(format!(
"Imported theme '{}'.",
theme_id.as_str()
)));
imported += 1;
}
Err(ImportError::IdCollision { .. }) => {
// Already installed — silent skip.
}
Err(e) => {
let name = zip_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
toast.write(InfoToastEvent(format!("Import failed ({name}): {e}")));
errors += 1;
}
}
}
if imported == 0 && errors == 0 {
toast.write(InfoToastEvent("All themes already installed.".to_string()));
return;
}
if imported > 0 {
if let Some(reg) = &mut registry {
refresh_registry(reg, &themes_dir);
}
toast.write(InfoToastEvent(
"Reopen Settings to see new themes in the picker.".to_string(),
));
}
}
}
/// A small pill-shaped settings button, matching the style used in `sync_row`.
fn pill_button(
parent: &mut ChildSpawnerCommands,
marker: SettingsButton,
label: &str,
tooltip: &'static str,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
marker,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::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(label.to_string()), font, TextColor(TEXT_PRIMARY)));
});
}
/// "Import Theme" row: folder-path label + "Scan for new themes" button.
///
/// The player drops `.zip` theme archives into the themes folder shown here,
/// then presses the button. [`handle_scan_themes`] picks them up, validates,
/// and installs them. Reopen Settings to see newly imported themes in the
/// card-theme picker.
fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
},
))
.with_children(|col| {
// Folder path hint.
let path_str = user_theme_dir().to_string_lossy().into_owned();
col.spawn((
Text::new(format!("Drop .zip files into: {path_str}")),
caption_font,
TextColor(TEXT_SECONDARY),
));
// Scan button.
col.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
..default()
})
.with_children(|row| {
pill_button(
row,
SettingsButton::ScanThemes,
"Scan for new themes",
"Scan the themes folder for .zip archives and install any that are new.",
font_res,
);
});
});
}
fn icon_button(
parent: &mut ChildSpawnerCommands,
label: &str,
@@ -2620,19 +2905,20 @@ mod tests {
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
);
// Spot-check: the Sync Now button's tooltip text is the
// canonical microcopy. We find it via the `SettingsButton`
// discriminant — there is exactly one Sync Now entity per panel.
let sync_tip = app
// Spot-check: with default (Local) settings the Connect button
// spawns. We verify its tooltip carries the canonical microcopy.
let connect_tip = app
.world_mut()
.query::<(&SettingsButton, &Tooltip)>()
.iter(app.world())
.find_map(|(btn, tip)| matches!(btn, SettingsButton::SyncNow).then(|| tip.0.clone()))
.expect("Sync Now button should spawn with a Tooltip");
.find_map(|(btn, tip)| {
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
})
.expect("Connect button should spawn with a Tooltip when backend is Local");
assert_eq!(
sync_tip.as_ref(),
"Push and pull stats now. Runs automatically on launch and exit.",
"Sync Now tooltip must use the canonical microcopy"
connect_tip.as_ref(),
"Connect to a self-hosted Solitaire Quest sync server.",
"ConnectSync tooltip must use the canonical microcopy"
);
}
+273 -35
View File
@@ -29,12 +29,13 @@ use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
ModalButton, ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING,
STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL,
};
/// Bevy resource wrapping the current stats.
@@ -121,6 +122,13 @@ pub struct ReplayNextButton;
#[derive(Component, Debug)]
pub struct ReplaySelectorCaption;
/// Marker on the detail text node that shows the selected replay's
/// `"{duration} win on {date}"` + optional `"· Shareable"` badge.
/// Repainted by `repaint_replay_selector_detail` whenever the
/// selection or history changes.
#[derive(Component, Debug)]
pub struct ReplaySelectorDetail;
/// Marker component on each per-mode bests row in the stats overlay.
///
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
@@ -223,7 +231,12 @@ impl Plugin for StatsPlugin {
.add_systems(Update, handle_copy_share_link_button)
.add_systems(
Update,
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
(
handle_replay_selector_buttons,
repaint_replay_selector_caption,
repaint_replay_selector_detail,
)
.chain(),
)
.add_systems(Update, scroll_stats_panel);
}
@@ -348,9 +361,13 @@ fn handle_copy_share_link_button(
}
#[cfg(target_os = "android")]
{
toast.write(InfoToastEvent(format!(
"Share link: {url}"
)));
match crate::android_clipboard::set_text(&url) {
Ok(()) => { toast.write(InfoToastEvent(format!("Copied: {url}"))); }
Err(e) => {
warn!("android clipboard failed: {e}");
toast.write(InfoToastEvent(format!("Share link: {url}")));
}
}
}
}
@@ -439,6 +456,39 @@ fn repaint_replay_selector_caption(
}
}
/// Repaints the `ReplaySelectorDetail` text node whenever the
/// selection or history changes. Shows `"{duration} win on {date}"` for
/// the selected replay, with a `"· Shareable"` badge when the replay
/// carries a sync-uploaded share URL. Empty when the history is empty.
fn repaint_replay_selector_detail(
history: Res<ReplayHistoryResource>,
selected: Res<SelectedReplayIndex>,
mut q: Query<&mut Text, With<ReplaySelectorDetail>>,
) {
if !history.is_changed() && !selected.is_changed() {
return;
}
let label = replay_selector_detail(&history.0.replays, selected.0);
for mut text in &mut q {
**text = label.clone();
}
}
/// Pure helper: render the detail line for the selected replay. Returns
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
/// when a share URL is present. Empty when the history slice is empty.
pub fn replay_selector_detail(replays: &[solitaire_data::Replay], index: usize) -> String {
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
return String::new();
};
let base = format_replay_caption(r);
if r.share_url.is_some() {
format!("{base} \u{2022} Shareable") // ·
} else {
base
}
}
/// Pure helper: render the selector caption shown next to the Prev /
/// Next chips. Returns `"No replays"` when the history is empty,
/// otherwise `"Replay {1-based index} / {total}"`.
@@ -618,14 +668,14 @@ fn toggle_stats_screen(
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
let selected = latest_replay.0.replays.get(selected_index.0);
spawn_stats_screen(
&mut commands,
&stats.0,
progress.as_deref().map(|p| &p.0),
time_attack.as_deref(),
font_res.as_deref(),
selected,
&latest_replay.0.replays,
selected_index.0,
);
}
}
@@ -651,7 +701,8 @@ fn spawn_stats_screen(
progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>,
latest_replay: Option<&Replay>,
replays: &[Replay],
selected_index: usize,
) {
// --- primary stat cells ---
// First-launch zero-state: when no games have been played yet, render
@@ -859,31 +910,84 @@ fn spawn_stats_screen(
));
}
// --- Latest replay caption ---
// Surfaces the most recent winning game so the player can spot
// whether their last victory has been recorded. The Watch
// Replay action below is what the player clicks to revisit it.
//
// When the displayed replay carries a `share_url` (uploaded
// to a sync server, persisted by v0.19.0's share-link
// contract), append a "Shareable" badge so the player can
// tell at a glance whether the Copy share link button below
// will produce a URL — without it the button surfaces a
// toast explaining why nothing was copied, which is more
// friction than necessary when a quick visual cue suffices.
let replay_caption = match latest_replay {
Some(r) => {
let base = format!("Latest win: {}", format_replay_caption(r));
if r.share_url.is_some() {
format!("{base} \u{2022} Shareable")
} else {
base
}
}
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
};
// --- Replay selector ---
// Prev / Next chips step through the full replay history;
// `repaint_replay_selector_caption` and
// `repaint_replay_selector_detail` keep both text nodes
// live as the selection changes. Using `ModalButton` on
// the chips plugs them into the existing modal-button
// hover/press paint loop at no extra cost.
body.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
// ← Prev chip
row.spawn((
ReplayPrevButton,
ModalButton(ButtonVariant::Secondary),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((
Text::new("\u{2190}"),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
});
// "Replay N / M" caption — rewritten live by
// `repaint_replay_selector_caption`.
row.spawn((
ReplaySelectorCaption,
Text::new(replay_selector_caption(selected_index, replays.len())),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
// → Next chip
row.spawn((
ReplayNextButton,
ModalButton(ButtonVariant::Secondary),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((
Text::new("\u{2192}"),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
});
});
// Detail line: rewritten live by `repaint_replay_selector_detail`.
body.spawn((
Text::new(replay_caption),
ReplaySelectorDetail,
Text::new(replay_selector_detail(replays, selected_index)),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
@@ -1670,6 +1774,140 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// Prev/Next replay selector spawn-site tests
// -----------------------------------------------------------------------
#[test]
fn selector_row_spawns_when_stats_screen_opens() {
let mut app = headless_app();
// Pre-populate a replay so the selector has something to show.
{
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(90, None));
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let prev = app
.world_mut()
.query::<&ReplayPrevButton>()
.iter(app.world())
.count();
let next = app
.world_mut()
.query::<&ReplayNextButton>()
.iter(app.world())
.count();
let caption = app
.world_mut()
.query::<&ReplaySelectorCaption>()
.iter(app.world())
.count();
let detail = app
.world_mut()
.query::<&ReplaySelectorDetail>()
.iter(app.world())
.count();
assert_eq!(prev, 1, "expected one ReplayPrevButton");
assert_eq!(next, 1, "expected one ReplayNextButton");
assert_eq!(caption, 1, "expected one ReplaySelectorCaption");
assert_eq!(detail, 1, "expected one ReplaySelectorDetail");
}
#[test]
fn selector_caption_initial_text_is_replay_one_of_one() {
let mut app = headless_app();
{
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(120, None));
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplaySelectorCaption>>();
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
assert_eq!(texts.len(), 1);
assert_eq!(
texts[0],
"Replay 1 / 1",
"caption must show '1 / 1' for a single-replay history"
);
}
#[test]
fn selector_detail_initial_text_matches_replay_caption() {
let mut app = headless_app();
{
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplaySelectorDetail>>();
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
assert_eq!(texts.len(), 1);
assert_eq!(
texts[0], "1:05 win on 2026-05-08",
"detail must show formatted replay caption for the selected replay"
);
}
#[test]
fn selector_detail_appends_shareable_badge_when_url_present() {
// `replay_selector_detail` is pure — no app setup needed.
let replays = vec![make_test_replay(
90,
Some("https://example.com/r/abc".to_string()),
)];
let label = replay_selector_detail(&replays, 0);
assert!(
label.contains("Shareable"),
"detail must include 'Shareable' badge when share_url is set, got: {label:?}"
);
}
#[test]
fn selector_caption_shows_no_replays_when_history_is_empty() {
assert_eq!(replay_selector_caption(0, 0), "No replays");
}
#[test]
fn selector_caption_wraps_ordinal_correctly() {
// index 2 (0-based) in a 3-replay history → "Replay 3 / 3"
assert_eq!(replay_selector_caption(2, 3), "Replay 3 / 3");
}
/// Build a minimal [`Replay`] for use in stats-plugin unit tests.
///
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
/// `time_seconds` and `share_url` are the only varying fields across tests.
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
let mut r = solitaire_data::Replay::new(
1,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
time_seconds,
0,
date,
vec![],
);
r.share_url = share_url;
r
}
/// Integration: pre-set streak to 10, fire a win that bumps it to 11.
/// Past the highest threshold, no event must fire — the flourish
/// is reserved for the threshold crossing itself.
+48 -9
View File
@@ -25,7 +25,10 @@ use solitaire_data::{
use solitaire_sync::{merge, SyncPayload, SyncResponse};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent};
use crate::events::{
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
SyncConfigureRequestEvent,
};
use crate::game_plugin::RecordingReplay;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
@@ -104,6 +107,8 @@ impl Plugin for SyncPlugin {
.init_resource::<PendingReplayUpload>()
.add_message::<ManualSyncRequestEvent>()
.add_message::<SyncCompleteEvent>()
.add_message::<SyncConfigureRequestEvent>()
.add_message::<InfoToastEvent>()
.add_systems(Startup, start_pull)
.add_systems(
Update,
@@ -130,7 +135,14 @@ fn start_pull(
) {
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider.pull().await
// Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but
// reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide
// a short-lived single-threaded runtime for this network round-trip.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.pull())
});
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
@@ -153,7 +165,11 @@ fn handle_manual_sync_request(
}
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider.pull().await
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.pull())
});
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
@@ -180,6 +196,8 @@ fn poll_pull_result(
mut progress: ResMut<ProgressResource>,
progress_path: Res<ProgressStoragePath>,
mut complete_writer: MessageWriter<SyncCompleteEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else {
return;
@@ -229,10 +247,19 @@ fn poll_pull_result(
warn!("sync pull failed: {e}");
let msg = match &e {
SyncError::Network(_) => "Can't reach server — check your connection".to_string(),
SyncError::Auth(_) => "Login expired — tap Sync Now after re-logging in".to_string(),
SyncError::Auth(_) => "Session expired — please reconnect".to_string(),
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
SyncError::UnsupportedPlatform => unreachable!("handled above"),
};
// On auth failure, reopen the Connect modal so the player can
// re-enter credentials without having to navigate through Settings.
// `open_sync_setup_modal` is idempotent — it ignores the event when
// the modal is already on screen, so repeated pull failures don't
// stack multiple modals.
if matches!(e, SyncError::Auth(_)) {
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
configure_sync.write(SyncConfigureRequestEvent);
}
status.0 = SyncStatus::Error(msg.clone());
complete_writer.write(SyncCompleteEvent(Err(msg)));
}
@@ -259,11 +286,18 @@ fn push_on_exit(
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone();
// Prefer an existing tokio runtime; fall back to futures_lite block_on
// for environments (e.g. tests) that don't have one.
// Prefer an existing tokio runtime; fall back to a temporary one for
// environments (e.g. tests, Android's non-Tokio async executor) where
// reqwest/hyper would otherwise panic with "no reactor running".
let result = match tokio::runtime::Handle::try_current() {
Ok(handle) => handle.block_on(provider.push(&payload)),
Err(_) => future::block_on(provider.push(&payload)),
Err(_) => match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt.block_on(provider.push(&payload)),
Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))),
},
};
match result {
Ok(_) => {}
@@ -314,8 +348,13 @@ fn push_replay_on_win(
recording.moves.clone(),
);
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.push_replay(&replay).await });
let task = AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.push_replay(&replay))
});
// If a previous upload is still in flight, drop it — the most
// recent win is the one whose share link the player will care
// about. Bevy's `Task` Drop cancels cooperatively.
+876
View File
@@ -0,0 +1,876 @@
//! Sync-server configuration UI: login / register modal, provider hot-swap,
//! and disconnect handler.
//!
//! # Flow (connect)
//!
//! 1. Player clicks "Connect" in the Settings sync section.
//! 2. `SyncConfigureRequestEvent` → `open_sync_setup_modal` spawns the form.
//! 3. Player fills URL / Username / Password; Tab cycles fields.
//! 4. "Log In" or "Register" → `handle_auth_button` → async task on
//! `AsyncComputeTaskPool` calling `SolitaireServerClient::login` or
//! `::register`.
//! 5. `poll_auth_task` harvests the result:
//! - **Ok**: store tokens → update `SettingsResource` → swap
//! `SyncProviderResource` → fire `ManualSyncRequestEvent` → toast + close.
//! - **Err**: display error inline; form stays open.
//!
//! # Flow (disconnect)
//!
//! `SyncLogoutRequestEvent` → `handle_logout` clears tokens, resets
//! `SyncBackend::Local`, swaps provider, closes settings, shows toast.
//!
//! # Flow (delete account)
//!
//! 1. Player clicks "Delete Account" in Settings.
//! 2. `DeleteAccountRequestEvent` → `open_delete_confirm_modal` spawns a
//! two-button confirmation modal.
//! 3. "Cancel" → despawn modal.
//! 4. "Delete Forever" → `handle_delete_confirm` → async task on
//! `AsyncComputeTaskPool` calling `SyncProvider::delete_account`.
//! 5. `poll_delete_task` harvests the result:
//! - **Ok**: fire `SyncLogoutRequestEvent` (clears tokens + resets backend)
//! + toast.
//! - **Err**: display error in a toast; modal is already closed.
use std::sync::Arc;
use bevy::input::ButtonState;
use bevy::input::keyboard::KeyboardInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::{
auth_tokens::{delete_tokens, store_tokens},
settings::SyncBackend,
save_settings_to,
sync_client::{LocalOnlyProvider, SolitaireServerClient},
SyncError,
};
use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::spawn_modal;
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI,
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/// Marker on the sync-setup modal scrim (despawn root).
#[derive(Component, Debug)]
pub struct SyncSetupScreen;
/// Discriminant attached to each input-field container and inner text entity.
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
enum SyncFieldKind {
Url,
Username,
Password,
}
/// Per-field raw-text buffer, stored on the inner text entity.
#[derive(Component, Default, Debug)]
struct SyncFieldBuffer(String);
/// Marker on the error-message text node.
#[derive(Component, Debug)]
struct SyncAuthError;
/// Marks the "Log In" button.
#[derive(Component, Debug)]
struct SyncLoginButton;
/// Marks the "Register" button.
#[derive(Component, Debug)]
struct SyncRegisterButton;
/// Marks the "Cancel" button.
#[derive(Component, Debug)]
struct SyncCancelButton;
/// Marks the spinner / busy overlay node shown while the auth task is running.
#[derive(Component, Debug)]
struct SyncBusyOverlay;
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Which field in the sync-setup modal currently has keyboard focus.
#[derive(Resource, Default, Clone, Copy, Debug, PartialEq, Eq)]
enum SyncFocusedField {
#[default]
Url,
Username,
Password,
}
impl SyncFocusedField {
fn next(self) -> Self {
match self {
Self::Url => Self::Username,
Self::Username => Self::Password,
Self::Password => Self::Url,
}
}
fn kind(self) -> SyncFieldKind {
match self {
Self::Url => SyncFieldKind::Url,
Self::Username => SyncFieldKind::Username,
Self::Password => SyncFieldKind::Password,
}
}
}
/// In-flight login/register task. `url` and `username` are preserved so the
/// poll system can update settings and provider on success without re-reading
/// the (already-despawned or cleared) form fields.
#[derive(Resource, Default)]
struct PendingAuthTask {
task: Option<Task<Result<(String, String), SyncError>>>,
url: String,
username: String,
}
/// Marker on the account-deletion confirmation modal root.
#[derive(Component, Debug)]
struct DeleteConfirmScreen;
/// Marks the "Delete Forever" confirmation button.
#[derive(Component, Debug)]
struct DeleteConfirmButton;
/// Marks the cancel button inside the delete-confirm modal.
#[derive(Component, Debug)]
struct DeleteCancelButton;
/// In-flight account-deletion task.
#[derive(Resource, Default)]
struct PendingDeleteTask(Option<Task<Result<(), SyncError>>>);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers the sync configuration UI systems and resources.
pub struct SyncSetupPlugin;
impl Plugin for SyncSetupPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SyncFocusedField>()
.init_resource::<PendingAuthTask>()
.init_resource::<PendingDeleteTask>()
.add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ManualSyncRequestEvent>()
.add_message::<InfoToastEvent>()
.add_systems(
Update,
(
open_sync_setup_modal,
handle_text_input,
update_field_borders,
handle_auth_button,
poll_auth_task,
handle_cancel,
handle_logout,
open_delete_confirm_modal,
handle_delete_cancel,
handle_delete_confirm,
poll_delete_task,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
fn open_sync_setup_modal(
mut events: MessageReader<SyncConfigureRequestEvent>,
existing: Query<(), With<SyncSetupScreen>>,
mut commands: Commands,
mut focused: ResMut<SyncFocusedField>,
font_res: Option<Res<FontResource>>,
) {
if events.is_empty() {
return;
}
events.clear();
if !existing.is_empty() {
return; // Already open.
}
*focused = SyncFocusedField::Url;
spawn_sync_setup_modal(&mut commands, font_res.as_deref());
}
/// Routes keyboard input to the focused field while the modal is open.
fn handle_text_input(
screen: Query<(), With<SyncSetupScreen>>,
mut key_events: MessageReader<KeyboardInput>,
mut focused: ResMut<SyncFocusedField>,
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer, &mut Text, &mut TextColor)>,
pending: Res<PendingAuthTask>,
) {
if screen.is_empty() || pending.task.is_some() {
// Swallow events while modal is closed or auth is in flight.
key_events.clear();
return;
}
for ev in key_events.read() {
if ev.state != ButtonState::Pressed {
continue;
}
// Tab / Shift-Tab cycle focus.
if ev.key_code == KeyCode::Tab {
let shift = ev.logical_key == bevy::input::keyboard::Key::Tab; // no-shift
let _ = shift; // handled below via modifier check
// Bevy doesn't give us the shift modifier state on KeyboardInput directly,
// so we check key_code == Tab and trust that shift produces a separate event.
// Use ButtonInput<KeyCode> alternative: we check Tab key here and rely on
// the SyncFocusedField cycling being called per press.
*focused = focused.next();
continue;
}
if ev.key_code == KeyCode::Backspace {
for (kind, mut buf, mut text, _) in &mut fields {
if *kind == focused.kind() {
buf.0.pop();
text.0 = display_text(&buf.0, *kind);
}
}
continue;
}
// Printable character — append to focused buffer.
if let Some(ch) = ev.text.as_deref().and_then(printable_char) {
for (kind, mut buf, mut text, mut color) in &mut fields {
if *kind == focused.kind() {
if buf.0.len() < 256 {
buf.0.push(ch);
}
text.0 = display_text(&buf.0, *kind);
color.0 = TEXT_PRIMARY;
}
}
}
}
}
/// Updates the border colour of each input field based on which field is focused.
fn update_field_borders(
screen: Query<(), With<SyncSetupScreen>>,
focused: Res<SyncFocusedField>,
mut borders: Query<(&SyncFieldKind, &mut BorderColor), Without<SyncFieldBuffer>>,
) {
if screen.is_empty() || !focused.is_changed() {
return;
}
for (kind, mut border) in &mut borders {
*border = BorderColor::all(if *kind == focused.kind() {
ACCENT_PRIMARY
} else {
BORDER_SUBTLE
});
}
}
/// Fires an async auth task when Login or Register is clicked.
fn handle_auth_button(
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
mut pending: ResMut<PendingAuthTask>,
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
) {
let login_clicked = login_q
.iter()
.any(|i| *i == Interaction::Pressed);
let register_clicked = register_q
.iter()
.any(|i| *i == Interaction::Pressed);
if !login_clicked && !register_clicked {
return;
}
if pending.task.is_some() {
return; // Already in flight.
}
// Collect field values.
let mut url = String::new();
let mut username = String::new();
let mut password = String::new();
for (kind, buf) in &fields {
match kind {
SyncFieldKind::Url => url = buf.0.trim().to_string(),
SyncFieldKind::Username => username = buf.0.trim().to_string(),
SyncFieldKind::Password => password = buf.0.clone(),
}
}
// Basic validation before hitting the network.
let validation_error = if url.is_empty() {
Some("Server URL is required")
} else if !url.starts_with("http://") && !url.starts_with("https://") {
Some("URL must start with http:// or https://")
} else if username.is_empty() {
Some("Username is required")
} else if password.is_empty() {
Some("Password is required")
} else {
None
};
if let Some(msg) = validation_error {
for (mut text, mut color) in &mut error_nodes {
text.0 = msg.to_string();
color.0 = STATE_DANGER;
}
return;
}
// Clear error and show busy indicator.
for (mut text, _) in &mut error_nodes {
text.0 = "Connecting…".to_string();
}
for mut vis in &mut busy_nodes {
*vis = Visibility::Visible;
}
let is_register = register_clicked;
let client = SolitaireServerClient::new(url.clone(), username.clone());
let pw = password.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(async {
if is_register {
client.register(&pw).await
} else {
client.login(&pw).await
}
})
});
pending.task = Some(task);
pending.url = url;
pending.username = username;
}
/// Polls the in-flight auth task. On success updates settings + provider.
#[allow(clippy::too_many_arguments)]
fn poll_auth_task(
mut pending: ResMut<PendingAuthTask>,
mut settings: ResMut<SettingsResource>,
settings_path: Res<SettingsStoragePath>,
mut provider: ResMut<SyncProviderResource>,
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
screen: Query<Entity, With<SyncSetupScreen>>,
mut settings_screen: ResMut<SettingsScreen>,
mut commands: Commands,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = pending.task.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
pending.task = None;
for mut vis in &mut busy_nodes {
*vis = Visibility::Hidden;
}
match result {
Ok((access_token, refresh_token)) => {
let url = pending.url.clone();
let username = pending.username.clone();
// Persist tokens to the OS keychain / Android Keystore.
if let Err(e) = store_tokens(&username, &access_token, &refresh_token) {
for (mut text, mut color) in &mut error_nodes {
text.0 = format!("Token storage failed: {e}");
color.0 = STATE_DANGER;
}
return;
}
// Update settings and persist.
settings.0.sync_backend = SyncBackend::SolitaireServer {
url: url.clone(),
username: username.clone(),
};
if let Some(path) = &settings_path.0
&& let Err(e) = save_settings_to(path, &settings.0)
{
warn!("sync setup: failed to persist settings: {e}");
}
// Hot-swap the provider so pull/push use the new credentials.
provider.0 = Arc::new(SolitaireServerClient::new(url, username.clone()));
// Kick off an immediate pull with the new provider.
manual_sync.write(ManualSyncRequestEvent);
// Close both the setup modal and the settings panel.
for entity in &screen {
commands.entity(entity).despawn();
}
settings_screen.0 = false;
toast.write(InfoToastEvent(format!("Connected as {username}")));
}
Err(e) => {
let msg = match e {
SyncError::Auth(m) => m,
SyncError::Network(m) => format!("Network error: {m}"),
SyncError::Serialization(m) => format!("Unexpected response: {m}"),
SyncError::UnsupportedPlatform => "Unsupported platform".into(),
};
for (mut text, mut color) in &mut error_nodes {
text.0 = msg.clone();
color.0 = STATE_DANGER;
}
}
}
}
/// Dismisses the sync-setup modal on Cancel click or Escape.
fn handle_cancel(
cancel_q: Query<&Interaction, (Changed<Interaction>, With<SyncCancelButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screen: Query<Entity, With<SyncSetupScreen>>,
mut commands: Commands,
) {
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|| keys.just_pressed(KeyCode::Escape);
if !cancelled || screen.is_empty() {
return;
}
for entity in &screen {
commands.entity(entity).despawn();
}
}
/// Clears stored tokens, resets the backend to `Local`, and hot-swaps the
/// provider. Triggered by "Disconnect" in the settings sync section.
fn handle_logout(
mut events: MessageReader<SyncLogoutRequestEvent>,
mut settings: ResMut<SettingsResource>,
settings_path: Res<SettingsStoragePath>,
mut provider: ResMut<SyncProviderResource>,
mut settings_screen: ResMut<SettingsScreen>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if events.is_empty() {
return;
}
events.clear();
// Extract username before resetting so we can clear the right keychain key.
let username = match &settings.0.sync_backend {
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
SyncBackend::Local => None,
};
if let Some(u) = username
&& let Err(e) = delete_tokens(&u)
{
warn!("sync logout: failed to clear tokens: {e}");
}
settings.0.sync_backend = SyncBackend::Local;
if let Some(path) = &settings_path.0
&& let Err(e) = save_settings_to(path, &settings.0)
{
warn!("sync logout: failed to persist settings: {e}");
}
provider.0 = Arc::new(LocalOnlyProvider);
settings_screen.0 = false;
toast.write(InfoToastEvent("Disconnected from sync server".to_string()));
}
/// Opens the account-deletion confirmation modal when `DeleteAccountRequestEvent` fires.
fn open_delete_confirm_modal(
mut events: MessageReader<DeleteAccountRequestEvent>,
existing: Query<(), With<DeleteConfirmScreen>>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
if events.is_empty() {
return;
}
events.clear();
if !existing.is_empty() {
return;
}
spawn_delete_confirm_modal(&mut commands, font_res.as_deref());
}
/// Despawns the delete-confirm modal on the cancel button or Escape.
fn handle_delete_cancel(
cancel_q: Query<&Interaction, (Changed<Interaction>, With<DeleteCancelButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screen: Query<Entity, With<DeleteConfirmScreen>>,
mut commands: Commands,
) {
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|| keys.just_pressed(KeyCode::Escape);
if !cancelled || screen.is_empty() {
return;
}
for entity in &screen {
commands.entity(entity).despawn();
}
}
/// Spawns the async delete-account task when "Delete Forever" is clicked.
fn handle_delete_confirm(
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
provider: Res<SyncProviderResource>,
mut pending: ResMut<PendingDeleteTask>,
screen: Query<Entity, With<DeleteConfirmScreen>>,
mut commands: Commands,
) {
if !confirm_q.iter().any(|i| *i == Interaction::Pressed) || pending.0.is_some() {
return;
}
// Despawn the confirmation modal immediately so the player can't double-click.
for entity in &screen {
commands.entity(entity).despawn();
}
let provider = provider.0.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.delete_account())
}));
}
/// Polls the in-flight delete-account task. On success fires `SyncLogoutRequestEvent`.
fn poll_delete_task(
mut pending: ResMut<PendingDeleteTask>,
mut logout: MessageWriter<SyncLogoutRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = pending.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
pending.0 = None;
match result {
Ok(()) => {
logout.write(SyncLogoutRequestEvent);
toast.write(InfoToastEvent("Account deleted".to_string()));
}
Err(e) => {
let msg = match e {
SyncError::Auth(_) => "Not authorised — try reconnecting first".to_string(),
SyncError::Network(m) => format!("Network error: {m}"),
other => format!("Delete failed: {other}"),
};
toast.write(InfoToastEvent(msg));
}
}
}
// ---------------------------------------------------------------------------
// UI construction
// ---------------------------------------------------------------------------
fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, SyncSetupScreen, Z_MODAL_PANEL + 1, |card| {
// Header.
card.spawn(Node {
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_3, VAL_SPACE_2),
..default()
})
.with_children(|h| {
h.spawn((
Text::new("Connect to Server"),
make_font(font_res, TYPE_BODY_LG),
TextColor(TEXT_PRIMARY),
));
});
// Scrollable body — three labeled input fields + error line.
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_3,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
flex_grow: 1.0,
..default()
})
.with_children(|body| {
spawn_field(
body,
SyncFieldKind::Url,
"Server URL",
"https://your-server.example.com",
true, // focused initially
font_res,
);
spawn_field(
body,
SyncFieldKind::Username,
"Username",
"your-username",
false,
font_res,
);
spawn_field(
body,
SyncFieldKind::Password,
"Password",
"••••••••",
false,
font_res,
);
// Error / status line.
body.spawn(Node {
min_height: Val::Px(18.0),
..default()
})
.with_children(|row| {
row.spawn((
SyncAuthError,
SyncBusyOverlay,
Text::new(String::new()),
make_font(font_res, TYPE_CAPTION),
TextColor(TEXT_SECONDARY),
Visibility::Hidden,
));
});
// Tab hint.
body.spawn((
Text::new("Tab = next field"),
make_font(font_res, TYPE_CAPTION),
TextColor(TEXT_DISABLED),
));
});
// Action row.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::FlexEnd,
column_gap: VAL_SPACE_2,
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_2, VAL_SPACE_3),
..default()
})
.with_children(|actions| {
spawn_action_button(actions, SyncCancelButton, "Cancel", false, font_res);
spawn_action_button(actions, SyncRegisterButton, "Register", false, font_res);
spawn_action_button(actions, SyncLoginButton, "Log In", true, font_res);
});
});
}
fn spawn_field(
parent: &mut ChildSpawnerCommands,
kind: SyncFieldKind,
label: &str,
placeholder: &str,
focused: bool,
font_res: Option<&FontResource>,
) {
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(4.0),
..default()
})
.with_children(|col| {
// Label.
col.spawn((
Text::new(label.to_string()),
make_font(font_res, TYPE_CAPTION),
TextColor(TEXT_SECONDARY),
));
// Input border container — carries kind for the border-update system.
col.spawn((
kind,
Node {
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
padding: UiRect::axes(VAL_SPACE_2, Val::Px(6.0)),
min_height: Val::Px(32.0),
..default()
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(if focused { ACCENT_PRIMARY } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|border| {
// Inner text / buffer entity.
border.spawn((
kind,
SyncFieldBuffer(String::new()),
Text::new(placeholder.to_string()),
make_font(font_res, TYPE_BODY),
TextColor(TEXT_DISABLED),
));
});
});
}
fn spawn_action_button<M: Component>(
parent: &mut ChildSpawnerCommands,
marker: M,
label: &str,
primary: bool,
font_res: Option<&FontResource>,
) {
let bg = if primary { ACCENT_PRIMARY } else { BG_ELEVATED_HI };
let fg = TEXT_PRIMARY;
parent
.spawn((
marker,
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(bg),
BorderColor::all(if primary { ACCENT_PRIMARY } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(label.to_string()),
make_font(font_res, TYPE_BODY),
TextColor(fg),
));
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_font(font_res: Option<&FontResource>, size: f32) -> TextFont {
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: size,
..default()
}
}
fn spawn_delete_confirm_modal(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, DeleteConfirmScreen, Z_MODAL_PANEL + 2, |card| {
// Header.
card.spawn(Node {
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_3, VAL_SPACE_2),
..default()
})
.with_children(|h| {
h.spawn((
Text::new("Delete Account"),
make_font(font_res, TYPE_BODY_LG),
TextColor(STATE_DANGER),
));
});
// Body.
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
..default()
})
.with_children(|body| {
body.spawn((
Text::new(
"This permanently deletes your account and all server data.\n\
Local progress is kept. This cannot be undone.",
),
make_font(font_res, TYPE_BODY),
TextColor(TEXT_SECONDARY),
));
});
// Actions.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::FlexEnd,
column_gap: VAL_SPACE_2,
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_2, VAL_SPACE_3),
..default()
})
.with_children(|actions| {
spawn_action_button(actions, DeleteCancelButton, "Cancel", false, font_res);
// "Delete Forever" button — danger styling (STATE_DANGER background).
actions
.spawn((
DeleteConfirmButton,
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(STATE_DANGER),
BorderColor::all(STATE_DANGER),
))
.with_children(|b| {
b.spawn((
Text::new("Delete Forever"),
make_font(font_res, TYPE_BODY),
TextColor(TEXT_PRIMARY),
));
});
});
});
}
/// Returns the display string for a field — password fields show bullets.
fn display_text(raw: &str, kind: SyncFieldKind) -> String {
if kind == SyncFieldKind::Password {
"".repeat(raw.len())
} else {
raw.to_string()
}
}
/// Extracts a printable ASCII character from a SmolStr keypress text.
fn printable_char(text: &str) -> Option<char> {
let ch = text.chars().next()?;
// Accept printable ASCII: 0x20 (space) through 0x7e (~).
(' '..='~').contains(&ch).then_some(ch)
}
+88 -22
View File
@@ -11,6 +11,7 @@ use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
use crate::safe_area::SafeAreaInsets;
use crate::resources::GameStateResource;
#[cfg(test)]
use crate::layout::TABLE_COLOUR;
@@ -82,6 +83,7 @@ impl Plugin for TablePlugin {
.add_systems(
Update,
(
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
apply_theme_on_settings_change,
apply_hint_pile_highlight,
@@ -146,18 +148,38 @@ fn setup_table(
existing_camera: Query<(), With<Camera>>,
settings: Option<Res<SettingsResource>>,
bg_images: Option<Res<BackgroundImageSet>>,
safe_area: Option<Res<SafeAreaInsets>>,
) {
// Only spawn a camera if one does not already exist (e.g. a parent app
// may have added one in tests).
// may have added one in tests). Use the felt-green clear colour so the
// background reads as green even before the background PNG finishes
// loading (which is asynchronous and can lag by several frames on
// Android).
if existing_camera.is_empty() {
commands.spawn(Camera2d);
commands.spawn((
Camera2d,
Camera {
clear_color: ClearColorConfig::Custom(Color::srgb(
crate::layout::TABLE_COLOUR[0],
crate::layout::TABLE_COLOUR[1],
crate::layout::TABLE_COLOUR[2],
)),
..default()
},
));
}
let window_size = windows
.iter()
.next()
.map_or(Vec2::new(1280.0, 800.0), default_window_size);
let layout = compute_layout(window_size);
let (window_size, scale) = windows.iter().next().map_or(
(Vec2::new(1280.0, 800.0), 1.0f32),
|w| (default_window_size(w), w.scale_factor()),
);
// Safe-area insets arrive from JNI asynchronously; they are almost always
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
// arrive and issues a synthetic WindowResized to re-snap all game objects.
let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let layout = compute_layout(window_size, safe_area_top, safe_area_bottom);
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
@@ -258,20 +280,31 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
PileMarker(pile.clone()),
));
// Foundation slots no longer carry a suit letter — any Ace can claim
// any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
// foundation markers render as plain translucent rectangles.
// Task #43 — King indicator on empty tableau placeholders.
if let PileType::Tableau(_) = &pile {
entity.with_children(|b| {
b.spawn((
Text2d::new("K"),
TextFont { font_size, ..default() },
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
// Tableau markers show "K" (only a King may start an empty column).
// Foundation markers show "A" (only an Ace may claim an empty slot).
// Neither label carries a suit because any suit may start any slot.
match &pile {
PileType::Tableau(_) => {
entity.with_children(|b| {
b.spawn((
Text2d::new("K"),
TextFont { font_size, ..default() },
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
PileType::Foundation(_) => {
entity.with_children(|b| {
b.spawn((
Text2d::new("A"),
TextFont { font_size, ..default() },
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
_ => {}
}
}
}
@@ -279,6 +312,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
#[allow(clippy::type_complexity)]
fn on_window_resized(
mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
@@ -290,7 +325,11 @@ fn on_window_resized(
return;
};
let window_size = Vec2::new(ev.width, ev.height);
let new_layout = compute_layout(window_size);
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom);
if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone();
@@ -318,6 +357,33 @@ fn on_window_resized(
// and forth" jitter).
}
/// Bridges the asynchronous safe-area inset update into the synchronous
/// window-resize pipeline. When Android's JNI delivers the real inset values
/// (typically frame 2-3 of a fresh launch), this system writes a synthetic
/// `WindowResized` event carrying the current window size. `on_window_resized`
/// (which runs in `LayoutSystem::UpdateOnResize`) will then recompute the
/// layout with the correct `safe_area_top`, update `LayoutResource` and the
/// pile markers, and `snap_cards_on_window_resize` (running after the set)
/// will snap card sprites to the corrected positions.
fn on_safe_area_changed(
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
let Some(safe_area) = safe_area else { return; };
if !safe_area.is_changed() {
return;
}
let Some((entity, window)) = windows.iter().next() else {
return;
};
resize_events.write(WindowResized {
window: entity,
width: window.resolution.width(),
height: window.resolution.height(),
});
}
// ---------------------------------------------------------------------------
// Task #6 — Hint pile-marker highlight
// ---------------------------------------------------------------------------
+2
View File
@@ -328,6 +328,8 @@ pub fn spawn_modal_button<M: Component>(
variant: ButtonVariant,
font_res: Option<&FontResource>,
) {
#[cfg(target_os = "android")]
let hotkey: Option<&'static str> = None;
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont {
font: font_handle.clone(),
+35 -7
View File
@@ -93,6 +93,13 @@ pub const ACCENT_SECONDARY: Color = Color::srgb(0.882, 0.639, 0.933);
/// from base16-eighties. `#acc267`.
pub const STATE_SUCCESS: Color = Color::srgb(0.675, 0.761, 0.404);
/// High-contrast variant of [`STATE_SUCCESS`] — `#c8e862`. Brighter
/// lime that maintains the success hue while lifting luminance from
/// ~0.51 → ~0.73 so the WIN MOVE scrub-bar marker stands out from
/// the bumped notch ticks (`BORDER_SUBTLE_HC` `#a0a0a0`, L≈0.60) in
/// high-contrast mode.
pub const STATE_SUCCESS_HC: Color = Color::srgb(0.784, 0.910, 0.384);
/// Warning — penalty signal, daily-seed expiry countdown, sync-pending
/// status. Gold from base16-eighties. **Both** Undo and Recycle
/// counters use this when non-zero. `#ddb26f`.
@@ -260,24 +267,45 @@ impl HighContrastBorder {
/// 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 the entity was
/// spawned with so the system can revert when HC is toggled back
/// off. The accompanying paint system is
/// [`update_high_contrast_backgrounds`](crate::settings_plugin::update_high_contrast_backgrounds).
/// `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 —
/// `HighContrastBackground::with_default(BORDER_SUBTLE)`.
/// Convenience constructor — HC colour defaults to
/// [`BORDER_SUBTLE_HC`].
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
Self { default_color }
Self { default_color, hc_color: BORDER_SUBTLE_HC }
}
/// Constructor for sites whose HC colour differs from the standard
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray.
pub const fn with_hc(
default_color: bevy::prelude::Color,
hc_color: bevy::prelude::Color,
) -> Self {
Self { default_color, hc_color }
}
}
+93 -31
View File
@@ -167,10 +167,11 @@ pub struct SessionAchievements {
#[derive(Component, Debug)]
pub struct WinSummaryOverlay;
/// Marker on the "Play Again" button inside the win-summary modal.
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
#[derive(Component, Debug)]
enum WinSummaryButton {
PlayAgain,
WatchReplay,
}
/// Marker for one row of the win-modal score-breakdown reveal.
@@ -352,7 +353,7 @@ impl ScoreBreakdown {
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
let multiplier = match mode {
GameMode::Zen => 0.0,
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack => 1.0,
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0,
};
Self {
base,
@@ -423,6 +424,7 @@ fn mode_display_name(mode: GameMode) -> &'static str {
GameMode::Zen => "Zen",
GameMode::Challenge => "Challenge",
GameMode::TimeAttack => "Time Attack",
GameMode::Difficulty(level) => level.label(),
}
}
@@ -601,26 +603,58 @@ fn spawn_win_summary_after_delay(
}
}
/// Despawns the win-summary modal and fires `NewGameRequestEvent` when
/// the player presses "Play Again".
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
let pressed: Vec<&WinSummaryButton> = interaction_query
.iter()
.filter(|(i, _)| **i == Interaction::Pressed)
.map(|(_, b)| b)
.collect();
for button in pressed {
match button {
WinSummaryButton::PlayAgain => {
// Despawn the modal.
for entity in &overlays {
commands.entity(entity).despawn();
}
new_game.write(NewGameRequestEvent::default());
}
WinSummaryButton::WatchReplay => {
let latest = history
.as_ref()
.and_then(|h| h.0.replays.last())
.cloned();
match (latest, playback.as_mut()) {
(Some(replay), Some(pb)) => {
for entity in &overlays {
commands.entity(entity).despawn();
}
crate::replay_playback::start_replay_playback(
&mut commands,
pb,
replay,
);
}
(Some(_), None) => {
toast.write(InfoToastEvent(
"Replay playback not available".to_string(),
));
}
(None, _) => {
toast.write(InfoToastEvent("No replay saved yet".to_string()));
}
}
}
}
}
}
@@ -810,28 +844,56 @@ fn spawn_overlay(
spawn_achievements_section(card, &session.names);
}
// Play Again button
card.spawn((
WinSummaryButton::PlayAgain,
Button,
Node {
padding: UiRect::axes(Val::Px(28.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
margin: UiRect::top(VAL_SPACE_2),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
// Append the Enter / Return glyph so keyboard players see
// the accelerator on the button itself — mirrors the
// chip-style hints on every modal button helper.
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextColor(BG_BASE),
));
// Button row: Watch Replay + Play Again side by side.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
column_gap: VAL_SPACE_3,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|row| {
// Watch Replay (secondary style)
row.spawn((
WinSummaryButton::WatchReplay,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Watch Replay"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextColor(ACCENT_PRIMARY),
));
});
// Play Again (primary style)
row.spawn((
WinSummaryButton::PlayAgain,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextColor(BG_BASE),
));
});
});
});
});
+13
View File
@@ -0,0 +1,13 @@
# Copy this file to .env and fill in the values.
# The server reads these on startup via dotenvy.
# SQLite database path. For local dev use a file path; for Docker use the
# volume-mounted path (see docker-compose.yml).
DATABASE_URL=sqlite://sol.db
# HS256 signing secret for JWT tokens. Use at least 32 random characters.
# Generate one with: openssl rand -hex 32
JWT_SECRET=change-me-use-openssl-rand-hex-32
# TCP port to listen on (optional, default 8080).
# SERVER_PORT=8080
+57
View File
@@ -0,0 +1,57 @@
# --- Build stage ---
FROM rust:1.95-slim AS builder
WORKDIR /build
# Install musl tools for a fully static binary and sqlx-cli for compile-time
# query checking (SQLX_OFFLINE=true skips the live-DB check at build time).
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy only the files needed to build the server crate.
# Layer order: workspace manifests first so dependency fetches are cached.
COPY Cargo.toml Cargo.lock ./
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
# Stub every crate source so `cargo fetch` succeeds without full source.
RUN mkdir -p solitaire_sync/src solitaire_server/src solitaire_core/src && \
echo "pub fn _stub() {}" > solitaire_sync/src/lib.rs && \
echo "pub fn _stub() {}" > solitaire_core/src/lib.rs && \
echo "pub fn _stub() {}" > solitaire_server/src/lib.rs && \
echo "fn main() {}" > solitaire_server/src/main.rs
RUN cargo fetch --locked
# Now copy real source and build in release mode.
COPY solitaire_core/src ./solitaire_core/src
COPY solitaire_sync/src ./solitaire_sync/src
COPY solitaire_server/src ./solitaire_server/src
COPY solitaire_server/migrations ./solitaire_server/migrations
# sqlx offline query cache — required when SQLX_OFFLINE=true so the
# compile-time macros don't need a live database.
COPY .sqlx ./.sqlx
ENV SQLX_OFFLINE=true
RUN cargo build --release --locked -p solitaire_server --bin solitaire_server
# --- Runtime stage ---
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/release/solitaire_server ./solitaire_server
# Migrations are embedded via sqlx::migrate!("./migrations") relative to the
# crate root at compile time — they do not need to be copied here.
ENV SERVER_PORT=8080
EXPOSE 8080
ENTRYPOINT ["./solitaire_server"]
+25
View File
@@ -0,0 +1,25 @@
services:
server:
build:
context: ..
dockerfile: solitaire_server/Dockerfile
image: solitaire-quest-server:latest
restart: unless-stopped
ports:
- "${SERVER_PORT:-8080}:8080"
volumes:
# SQLite database persisted outside the container.
- db-data:/app/data
environment:
DATABASE_URL: sqlite:///app/data/sol.db
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: 8080
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
db-data:
@@ -0,0 +1,17 @@
-- Migration 003: refresh token rotation table
--
-- One row per live refresh token. Issued at login/register and rotated
-- (old row deleted, new row inserted) on every POST /api/auth/refresh call.
-- Cascade on user deletion means no manual cleanup is needed when an
-- account is removed.
CREATE TABLE IF NOT EXISTS refresh_tokens (
jti TEXT PRIMARY KEY, -- UUID v4 embedded in the JWT
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL -- ISO 8601, mirrors the JWT exp claim
);
-- Expired-row pruning (done inline in the refresh handler) uses this index
-- to avoid a full table scan on every refresh call.
CREATE INDEX IF NOT EXISTS refresh_tokens_expires_at_idx
ON refresh_tokens(expires_at);
+112 -20
View File
@@ -37,10 +37,13 @@ pub struct AuthResponse {
pub refresh_token: String,
}
/// Successful refresh response — contains only the new access token.
/// Successful refresh response — contains the new access token and the rotated
/// refresh token. The refresh token is always rotated: the client must store
/// the new value and discard the old one.
#[derive(Debug, Serialize)]
pub struct RefreshResponse {
pub access_token: String,
pub refresh_token: String,
}
// ---------------------------------------------------------------------------
@@ -73,21 +76,47 @@ pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError
sub: user_id.to_string(),
exp,
kind: "access".to_string(),
jti: None,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| AppError::Internal(e.to_string()))
}
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<String, AppError> {
///
/// Returns `(jwt_string, jti)`. The caller must insert the jti into
/// `refresh_tokens` before returning the JWT to the client.
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<(String, String), AppError> {
let jti = Uuid::new_v4().to_string();
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
let claims = Claims {
sub: user_id.to_string(),
exp,
kind: "refresh".to_string(),
jti: Some(jti.clone()),
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| AppError::Internal(e.to_string()))
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok((token, jti))
}
/// Insert a jti row into `refresh_tokens`. Must be called immediately after
/// [`make_refresh_token`] and before the token is sent to the client.
async fn store_refresh_jti(
pool: &sqlx::SqlitePool,
jti: &str,
user_id: &str,
) -> Result<(), AppError> {
let expires_at = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
sqlx::query!(
"INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
jti,
user_id,
expires_at
)
.execute(pool)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
@@ -160,9 +189,13 @@ pub async fn register(
.execute(&state.pool)
.await?;
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
store_refresh_jti(&state.pool, &refresh_jti, &user_id).await?;
Ok(Json(AuthResponse {
access_token: make_access_token(&user_id, &state.jwt_secret)?,
refresh_token: make_refresh_token(&user_id, &state.jwt_secret)?,
access_token,
refresh_token,
}))
}
@@ -190,27 +223,74 @@ pub async fn login(
return Err(AppError::InvalidCredentials);
}
let access_token = make_access_token(&row_id, &state.jwt_secret)?;
let (refresh_token, refresh_jti) = make_refresh_token(&row_id, &state.jwt_secret)?;
store_refresh_jti(&state.pool, &refresh_jti, &row_id).await?;
Ok(Json(AuthResponse {
access_token: make_access_token(&row_id, &state.jwt_secret)?,
refresh_token: make_refresh_token(&row_id, &state.jwt_secret)?,
access_token,
refresh_token,
}))
}
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
/// `POST /api/auth/refresh` — exchange a valid refresh token for a new token pair.
///
/// The incoming refresh token is consumed (its jti row is deleted) and a new
/// refresh token is issued. Using a consumed token returns 401. Tokens issued
/// before rotation was enabled (no `jti` claim) are also rejected with 401 —
/// the player must re-login once after upgrading the server.
///
/// Expired rows from other sessions are pruned on each successful call.
pub async fn refresh(
State(state): State<AppState>,
Json(body): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, AppError> {
let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?;
// Tokens without jti predate rotation — require re-login.
let jti = claims.jti.ok_or(AppError::Unauthorized)?;
// Verify this jti is still live (not yet consumed or from a deleted account).
// SQLite TEXT columns are always nullable in sqlx; flatten the double-Option.
let exists: Option<String> = sqlx::query_scalar!(
"SELECT jti FROM refresh_tokens WHERE jti = ?",
jti
)
.fetch_optional(&state.pool)
.await?
.flatten();
if exists.is_none() {
return Err(AppError::Unauthorized);
}
// Consume the old token before issuing new ones. If the insert below
// fails, the user loses this session (must re-login) — safe by design.
sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
.execute(&state.pool)
.await?;
let new_access = make_access_token(&claims.sub, &state.jwt_secret)?;
let (new_refresh, new_jti) = make_refresh_token(&claims.sub, &state.jwt_secret)?;
store_refresh_jti(&state.pool, &new_jti, &claims.sub).await?;
// Prune expired rows from all sessions on each successful rotation.
// The expires_at index makes this a cheap index-backed scan.
let now = Utc::now().to_rfc3339();
sqlx::query!("DELETE FROM refresh_tokens WHERE expires_at < ?", now)
.execute(&state.pool)
.await?;
Ok(Json(RefreshResponse {
access_token: make_access_token(&claims.sub, &state.jwt_secret)?,
access_token: new_access,
refresh_token: new_refresh,
}))
}
/// `DELETE /api/account` — permanently delete the authenticated user's account.
///
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
/// All related rows (sync_state, refresh_tokens, leaderboard) are removed
/// via `ON DELETE CASCADE` in the schema.
pub async fn delete_account(
State(state): State<AppState>,
user: AuthenticatedUser,
@@ -229,7 +309,7 @@ mod tests {
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
fn decode_token(token: &str) -> Claims {
fn decode_claims(token: &str) -> Claims {
let mut validation = Validation::default();
validation.leeway = 60;
decode::<Claims>(
@@ -244,27 +324,39 @@ mod tests {
#[test]
fn make_access_token_decodes_with_correct_claims() {
let token = make_access_token("user-123", TEST_SECRET).unwrap();
let claims = decode_token(&token);
let claims = decode_claims(&token);
assert_eq!(claims.sub, "user-123");
assert_eq!(claims.kind, "access");
assert!(claims.jti.is_none(), "access token must not carry a jti");
let now = Utc::now().timestamp() as usize;
// expiry should be roughly 24 hours in the future (allow ±60s for test execution)
assert!(claims.exp > now + 86_400 - 60);
assert!(claims.exp < now + 86_400 + 60);
}
#[test]
fn make_refresh_token_decodes_with_correct_claims() {
let token = make_refresh_token("user-456", TEST_SECRET).unwrap();
let claims = decode_token(&token);
let (token, jti) = make_refresh_token("user-456", TEST_SECRET).unwrap();
let claims = decode_claims(&token);
assert_eq!(claims.sub, "user-456");
assert_eq!(claims.kind, "refresh");
assert_eq!(
claims.jti.as_deref(),
Some(jti.as_str()),
"jti in JWT must match returned jti"
);
assert!(!jti.is_empty(), "jti must be non-empty");
let now = Utc::now().timestamp() as usize;
// expiry should be roughly 30 days in the future (allow ±60s for test execution)
assert!(claims.exp > now + 30 * 86_400 - 60);
assert!(claims.exp < now + 30 * 86_400 + 60);
}
#[test]
fn make_refresh_token_generates_unique_jtis() {
let (_, jti1) = make_refresh_token("u", TEST_SECRET).unwrap();
let (_, jti2) = make_refresh_token("u", TEST_SECRET).unwrap();
assert_ne!(jti1, jti2, "each call must produce a unique jti");
}
#[test]
fn make_access_token_wrong_secret_fails_decode() {
let token = make_access_token("user-789", TEST_SECRET).unwrap();
@@ -279,9 +371,9 @@ mod tests {
#[test]
fn access_and_refresh_tokens_have_different_kinds() {
let access = make_access_token("u", TEST_SECRET).unwrap();
let refresh = make_refresh_token("u", TEST_SECRET).unwrap();
let a_claims = decode_token(&access);
let r_claims = decode_token(&refresh);
let (refresh, _jti) = make_refresh_token("u", TEST_SECRET).unwrap();
let a_claims = decode_claims(&access);
let r_claims = decode_claims(&refresh);
assert_ne!(a_claims.kind, r_claims.kind);
}
+67 -2
View File
@@ -19,15 +19,61 @@ use axum::{
routing::{delete, get, post},
Router,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use sqlx::SqlitePool;
use std::sync::Arc;
use tower_governor::{
errors::GovernorError,
governor::GovernorConfigBuilder,
key_extractor::SmartIpKeyExtractor,
key_extractor::{KeyExtractor, SmartIpKeyExtractor},
GovernorLayer,
};
use tower_http::services::ServeDir;
/// Rate-limiting key extractor for authenticated endpoints.
///
/// Extracts the authenticated user's UUID from the `Authorization: Bearer` JWT
/// so each user gets their own bucket. Falls back to the client IP address when
/// the header is absent or the token fails signature verification — this
/// protects the server from unauthenticated request floods while ensuring
/// legitimate users are always identified by identity rather than IP.
///
/// Expiry is intentionally **not** checked here: `require_auth` validates the
/// full token (including `exp`) and returns 401. Counting an expired token
/// against the user's bucket is harmless and avoids returning 500 (the
/// `UnableToExtractKey` outcome) for a request that would get 401 anyway.
#[derive(Clone)]
struct UserIdKeyExtractor {
jwt_secret: String,
}
impl KeyExtractor for UserIdKeyExtractor {
type Key = String;
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> {
if let Some(user_id) = self.try_extract_user_id(req.headers()) {
return Ok(user_id);
}
// Fall back to IP so unauthenticated bursts don't bypass throttling.
SmartIpKeyExtractor
.extract(req)
.map(|ip| ip.to_string())
}
}
impl UserIdKeyExtractor {
fn try_extract_user_id(&self, headers: &axum::http::HeaderMap) -> Option<String> {
let value = headers.get("Authorization")?.to_str().ok()?;
let token = value.strip_prefix("Bearer ")?;
let key = DecodingKey::from_secret(self.jwt_secret.as_bytes());
let mut validation = Validation::default();
validation.validate_exp = false;
decode::<middleware::Claims>(token, &key, &validation)
.ok()
.map(|d| d.claims.sub)
}
}
/// Shared application state injected into every Axum handler via [`axum::extract::State`].
///
/// Loaded once at startup so a missing `JWT_SECRET` causes an immediate startup
@@ -64,7 +110,7 @@ pub fn build_test_router(pool: SqlitePool) -> Router {
fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
// Protected routes require a valid JWT (injected by require_auth middleware).
let protected = Router::new()
let protected_base = Router::new()
.route("/api/sync/pull", get(sync::pull))
.route("/api/sync/push", post(sync::push))
.route("/api/replays", post(replays::upload))
@@ -77,6 +123,25 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
middleware::require_auth,
));
// Per-user rate limit on protected endpoints: 10-request burst, then 1
// token replenished every 10 seconds (6/min steady-state). This prevents
// a single compromised account from hammering the 1 MB sync/push endpoint.
let protected = if rate_limit {
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(UserIdKeyExtractor {
jwt_secret: state.jwt_secret.clone(),
})
.per_second(10)
.burst_size(10)
.finish()
.expect("invalid sync governor config"),
);
protected_base.layer(GovernorLayer::new(governor_conf))
} else {
protected_base
};
// Auth endpoints — rate-limited in production, unrestricted in tests.
let auth_routes = Router::new()
.route("/api/auth/register", post(auth::register))
+6 -1
View File
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::{error::AppError, AppState};
/// The claims encoded in our JWT access tokens.
/// The claims encoded in our JWTs.
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
/// Subject — the user's UUID string.
@@ -24,6 +24,10 @@ pub struct Claims {
pub exp: usize,
/// Token kind: `"access"` or `"refresh"`.
pub kind: String,
/// JWT ID — UUID v4 embedded in refresh tokens for rotation tracking.
/// Access tokens omit this field (`None`).
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
}
/// The authenticated user identity injected into request extensions after
@@ -135,6 +139,7 @@ mod tests {
sub: user_id.to_string(),
exp,
kind: kind.to_string(),
jti: None,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_bytes())).unwrap()
}
+139 -2
View File
@@ -347,9 +347,10 @@ async fn login_with_unknown_username_returns_401() {
);
}
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with a new access token.
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with both
/// a new access token and a rotated refresh token.
#[tokio::test]
async fn refresh_returns_new_access_token() {
async fn refresh_returns_new_access_and_refresh_tokens() {
let app = build_test_router(test_pool().await);
@@ -368,6 +369,80 @@ async fn refresh_returns_new_access_token() {
body["access_token"].is_string(),
"refresh must return a new access_token"
);
assert!(
body["refresh_token"].is_string(),
"refresh must return a rotated refresh_token"
);
let rotated = body["refresh_token"].as_str().unwrap();
assert_ne!(
rotated, refresh,
"rotated refresh token must differ from the original"
);
}
/// After a successful rotation, the old refresh token must be rejected (consumed).
#[tokio::test]
async fn consumed_refresh_token_is_rejected() {
let app = build_test_router(test_pool().await);
let (_access, original_refresh) =
register_user(app.clone(), "grace_rot", "rotation_pass").await;
// First refresh — consumes original_refresh, returns a new one.
let resp1 = post_json(
app.clone(),
"/api/auth/refresh",
serde_json::json!({ "refresh_token": original_refresh }),
)
.await;
assert_eq!(resp1.status(), StatusCode::OK, "first rotation must succeed");
// Second attempt with the now-consumed original token must fail.
let resp2 = post_json(
app,
"/api/auth/refresh",
serde_json::json!({ "refresh_token": original_refresh }),
)
.await;
assert_eq!(
resp2.status(),
StatusCode::UNAUTHORIZED,
"consumed refresh token must return 401"
);
}
/// The rotated refresh token must be usable for a subsequent refresh.
#[tokio::test]
async fn rotated_refresh_token_can_be_used_again() {
let app = build_test_router(test_pool().await);
let (_access, refresh) = register_user(app.clone(), "helen_rot", "pass_word_1").await;
// First rotation.
let resp1 = post_json(
app.clone(),
"/api/auth/refresh",
serde_json::json!({ "refresh_token": refresh }),
)
.await;
assert_eq!(resp1.status(), StatusCode::OK);
let rotated = body_json(resp1).await;
let second_refresh = rotated["refresh_token"].as_str().unwrap().to_string();
// Second rotation using the first rotated token.
let resp2 = post_json(
app,
"/api/auth/refresh",
serde_json::json!({ "refresh_token": second_refresh }),
)
.await;
assert_eq!(
resp2.status(),
StatusCode::OK,
"rotated token must work for a second rotation"
);
let body2 = body_json(resp2).await;
assert!(body2["access_token"].is_string());
}
/// Supplying an access token to `POST /api/auth/refresh` must be rejected because
@@ -1448,6 +1523,68 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
);
}
/// The 11th `POST /api/sync/push` from the same authenticated user within the
/// rate-limit window must return 429 Too Many Requests.
///
/// Uses [`solitaire_server::build_router`] (rate limiting ON) so the
/// GovernorLayer is applied. We register a fresh account, then send 10 pushes
/// (consuming the burst allowance), and assert the 11th is throttled.
///
/// Note: the push body deliberately omits valid `SyncPayload` structure —
/// that would return 422, but the rate limiter fires before deserialization,
/// so the response code for the first 10 is 422 and for the 11th is 429.
/// The test only asserts `!= 429` for requests 110 and `== 429` for request 11.
#[tokio::test]
async fn sync_push_rate_limit_returns_429_on_11th_request() {
let state = solitaire_server::AppState {
pool: test_pool().await,
jwt_secret: TEST_SECRET.to_string(),
};
let app = solitaire_server::build_router(state);
// Register a user to obtain a valid JWT for the UserIdKeyExtractor.
let (token, _) = register_user(app.clone(), "sync_ratelimit_user", "p4ssword!").await;
let stub_body = serde_json::to_vec(&serde_json::json!({})).unwrap();
// First 10 requests consume the burst allowance (burst_size = 10).
// The body is intentionally invalid — the rate limiter fires before
// deserialization, so we get 422 rather than 200. We only assert != 429.
for i in 0..10 {
let req = Request::builder()
.method("POST")
.uri("/api/sync/push")
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {token}"))
.header("x-forwarded-for", TEST_CLIENT_IP)
.body(Body::from(stub_body.clone()))
.expect("failed to build request");
let resp = app.clone().oneshot(req).await.expect("oneshot failed");
assert_ne!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"request {} of 10 must not be rate-limited",
i + 1
);
}
// The 11th request must be throttled.
let req = Request::builder()
.method("POST")
.uri("/api/sync/push")
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {token}"))
.header("x-forwarded-for", TEST_CLIENT_IP)
.body(Body::from(stub_body))
.expect("failed to build 11th request");
let resp = app.clone().oneshot(req).await.expect("oneshot failed");
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"11th sync push must be rate-limited with 429"
);
}
// ---------------------------------------------------------------------------
// Replay endpoints
//