Compare commits

..

111 Commits

Author SHA1 Message Date
funman300 77df2d2aef fix(web): auto-complete now works with cards remaining in waste
Build and Deploy / build-and-push (push) Failing after 3m55s
check_auto_complete no longer requires the waste pile to be empty —
only the stock must be exhausted and all tableau cards face-up.
next_auto_complete_move checks the waste top card before scanning
tableau, and auto_complete_step falls back to draw() when no direct
foundation move is available so the waste drains automatically.

Fixes the end-game state where the player could see a clear win but
the auto-complete interval never fired because the waste was non-empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:30:46 -07:00
Gitea CI 11bfb4f1c8 chore(deploy): bump image to 3e006a1e [skip ci] 2026-05-14 04:14:55 +00:00
funman300 3e006a1e94 feat(analytics): replace custom pipeline with Matomo
Build and Deploy / build-and-push (push) Successful in 4m36s
Removes the hand-rolled analytics endpoint and SQLite event table in favour
of Matomo — a self-hosted, full-featured analytics platform.

k8s:
- Deploy MariaDB 11 + Bitnami Matomo 5 in the solitaire namespace
- Route analytics.aleshym.co ingress to the Matomo service
- Remove Datasette sidecar and its BasicAuth middleware/secret
- Remove the analytics port from the solitaire-server Service

Rust:
- Replace AnalyticsClient (custom HTTP endpoint) with MatomoClient (Matomo
  HTTP Tracking API bulk endpoint); maps game events to Matomo categories
- Add matomo_url + matomo_site_id fields to Settings (serde default → None/1)
- Privacy toggle in Settings now activates when matomo_url is set (not tied
  to SyncBackend::SolitaireServer)
- Remove POST /api/analytics route from solitaire_server

Web:
- Add Matomo JS tracking snippet to game.html (/play page)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:10:15 -07:00
funman300 18ed1549e0 feat(deploy): Datasette analytics sidecar + analytics.aleshym.co ingress
Adds a Datasette container alongside the existing server in the same pod so
it can read the SQLite PVC without a second ReadWriteOnce mount. Protected
by a Traefik BasicAuth middleware at analytics.aleshym.co.

Also fixes the ArgoCD repoURL to point to the migrated Gitea hostname
(git.aleshym.co) instead of the old bare IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:17:20 -07:00
Gitea CI 0fffce9a29 chore(deploy): bump image to 3cec200a [skip ci] 2026-05-14 03:10:52 +00:00
funman300 3cec200ac0 feat(analytics): opt-in usage analytics with server ingest and settings toggle
Build and Deploy / build-and-push (push) Successful in 4m17s
- Server: POST /api/analytics endpoint with per-IP rate limit (5/min),
  batch validation (≤50 events, event_type regex, UUID dedup, clock check),
  INSERT OR IGNORE for idempotency, and migration 004_analytics.sql
- Client (solitaire_data): AnalyticsClient with in-memory Mutex buffer,
  UUID session_id per launch, async flush via background task
- Engine: AnalyticsPlugin records game_won, game_forfeit, game_start,
  achievement_unlocked; flushes immediately on game-end, every 60 s otherwise
- Settings UI: Privacy section with ON/OFF toggle, hidden in local-only mode
- Default: analytics_enabled = false (explicit opt-in required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:06:34 -07:00
Gitea CI ec7e2b7c08 chore(deploy): bump image to 09fcd209 [skip ci] 2026-05-14 02:43:38 +00:00
funman300 9e04b389af fix(server): add CSP/security headers middleware, gitignore jks.bak*
Build and Deploy / build-and-push (push) Failing after 3m52s
Content-Security-Policy, X-Content-Type-Options, and X-Frame-Options are
now injected by a single Axum middleware on the web router subtree, so
all HTML pages get consistent headers without touching each file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:41:50 -07:00
funman300 09fcd2097e fix(server): XSS, missing score submission, leaderboard never updated, no LIMIT
Build and Deploy / build-and-push (push) Successful in 4m14s
- leaderboard.html, replays.html: escape user-supplied display_name and
  username before inserting into innerHTML to prevent stored XSS
- game.js: call POST /api/replays on win so browser-game completions are
  recorded; scores were never submitted before this fix
- replays.rs: after replay insert, upsert leaderboard best_score /
  best_time_secs for opted-in users when the new score beats their current
  best (classic mode only); scores were never updated before this fix
- leaderboard.rs: add LIMIT 100 to GET /api/leaderboard to prevent
  unbounded query growth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:32:14 -07:00
funman300 f0b9536e09 chore: verify gitea migration 2026-05-13 19:13:47 -07:00
Gitea CI a09ec48097 chore(deploy): bump image to d5c95f9a [skip ci] 2026-05-14 00:21:16 +00:00
funman300 d5c95f9a0f fix(web): preload card images to prevent white-flash on flip
Build and Deploy / build-and-push (push) Successful in 3m43s
When a card flipped face-up, the browser fetched the PNG on demand,
showing the cream fallback colour until the image arrived. Preloading
all 52 faces and the back at module load ensures they are cached before
any flip can occur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:17:33 -07:00
Gitea CI 494bd8b8ca chore(deploy): bump image to b0478117 [skip ci] 2026-05-14 00:14:00 +00:00
funman300 b04781178e feat(web): account page with sign in / sign up tabs
Build and Deploy / build-and-push (push) Successful in 4m12s
- Add account.html: tabbed form for login and registration, signed-in
  state with sign-out, links to leaderboard and replays
- Wire /account route in build_router_inner
- Add Account card to landing page
- Link leaderboard login prompt to /account for new users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:09:57 -07:00
Gitea CI 4af19c4d62 chore(deploy): bump image to e6c67d03 [skip ci] 2026-05-14 00:09:08 +00:00
funman300 e6c67d03c2 chore: rename app from Solitaire Quest to Ferrous Solitaire
Build and Deploy / build-and-push (push) Successful in 4m55s
Replace all display-name occurrences across web pages, Rust source,
docs, and Cargo metadata. Update localStorage token key from sq_token
to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:04:45 -07:00
Gitea CI 484db22208 chore(deploy): bump image to 4315c0ae [skip ci] 2026-05-13 23:54:33 +00:00
funman300 4315c0ae70 feat(web): leaderboard and replays pages with nav from landing
Build and Deploy / build-and-push (push) Successful in 3m38s
- Add leaderboard.html: JWT login form + localStorage token + table
- Add replays.html: public listing of recent replays, row click to viewer
- Wire /leaderboard and /replays routes in build_router_inner
- Fix home.html Recent Replays link from /api/replays/recent to /replays

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:50:54 -07:00
Gitea CI f417177858 chore(deploy): bump image to 31d0a1b6 [skip ci] 2026-05-13 23:43:30 +00:00
funman300 31d0a1b6e3 feat(web): add home arrow link to game page header
Build and Deploy / build-and-push (push) Successful in 4m32s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:38:58 -07:00
Gitea CI 6fa1b28902 chore(deploy): bump image to 56dbc3ff [skip ci] 2026-05-13 23:37:19 +00:00
funman300 56dbc3ff2c fix(ci): rebase before kustomization push to handle concurrent runs
Build and Deploy / build-and-push (push) Successful in 40s
Two runs for the same SHA racing to push the kustomization update
caused the second to fail with "failed to push some refs".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:36:42 -07:00
Gitea CI 19ba065154 chore(deploy): bump image to 3e98872f [skip ci] 2026-05-13 23:33:23 +00:00
funman300 3e98872f15 ci: add Docker BuildKit registry cache to speed up Rust builds
Build and Deploy / build-and-push (push) Failing after 42s
Caches compiled dependency layers in the Gitea registry under
:buildcache. Subsequent builds that only touch solitaire_server/src/
skip recompiling the full workspace dependency tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:28:10 -07:00
Gitea CI 6cee4e9a2b chore(deploy): bump image to 98f9933e [skip ci]
Build and Deploy / build-and-push (push) Successful in 5m18s
2026-05-13 23:28:10 +00:00
funman300 98f9933ed0 fix(web): apply Terminal palette and UX fixes to game page
Build and Deploy / build-and-push (push) Successful in 1m19s
Aligns /play with the landing page and app color scheme — same
bg, panel, accent, and felt tokens from ui_theme.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:26:51 -07:00
Gitea CI 0ef75a0c9a chore(deploy): bump image to a6030f4b [skip ci] 2026-05-13 23:24:43 +00:00
funman300 a6030f4b7b fix(web): align replay and landing page to Terminal (base16-eighties) palette
Build and Deploy / build-and-push (push) Successful in 1m28s
Replay viewer was using the old midnight-purple palette. Both pages now
use the exact color tokens from ui_theme.rs — matching the desktop and
Android app exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:23:16 -07:00
funman300 28b1d38951 feat(web): add landing page at / with links to play, leaderboard, replays
Build and Deploy / build-and-push (push) Failing after 1m37s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:21:38 -07:00
Gitea CI efe930af1e chore(deploy): bump image to 022a749f [skip ci] 2026-05-13 22:45:42 +00:00
funman300 022a749f5f fix(server): create SQLite database file if missing on first start
Build and Deploy / build-and-push (push) Successful in 1m15s
SqlitePool::connect defaults create_if_missing=false in SQLx 0.8, causing
SQLITE_CANTOPEN (error 14) when the PVC is empty on first deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:44:22 -07:00
Gitea CI 6e3ce8ea59 chore(deploy): bump image to 0c673e3b [skip ci] 2026-05-13 22:32:46 +00:00
funman300 0c673e3bb6 fix(docker): add libsqlite3-0 to runtime image to fix SQLite CANTOPEN error
Build and Deploy / build-and-push (push) Successful in 32s
The server binary dynamically links against libsqlite3.so.0, which is not
present in debian:bookworm-slim by default, causing SQLite error code 14
at startup when connecting to the database.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:32:09 -07:00
Gitea CI f3b28a1b9d chore(deploy): bump image to 597aba20 [skip ci] 2026-05-13 15:04:01 -07:00
funman300 597aba200a fix(docker): rename binary to ./server to avoid collision with solitaire_server/web dir
Build and Deploy / build-and-push (push) Successful in 15s
2026-05-13 15:03:45 -07:00
funman300 8396f0f067 ci: trigger with dockerfile change for debug
Build and Deploy / build-and-push (push) Failing after 8s
2026-05-13 14:46:09 -07:00
funman300 9f8e32db36 ci: debug push trigger 2026-05-13 14:42:20 -07:00
funman300 7f333443dd fix(docker): copy web/ to builder stage for include_str! macros
Build and Deploy / build-and-push (push) Failing after 1m8s
2026-05-13 14:18:05 -07:00
funman300 29b8c33d3f fix(docker): stub all workspace crates for cargo fetch in CI
Build and Deploy / build-and-push (push) Failing after 2m16s
2026-05-13 14:15:24 -07:00
funman300 edf2013ab1 ci: retrigger after fixing runner instance URL
Build and Deploy / build-and-push (push) Failing after 2m27s
2026-05-13 14:11:54 -07:00
funman300 e3864c60a0 ci: retrigger build after enabling Actions
Build and Deploy / build-and-push (push) Failing after 4m18s
2026-05-13 14:05:23 -07:00
funman300 44493a2200 ci: trigger first docker build 2026-05-13 14:03:23 -07:00
funman300 9983b873f9 feat(ops): add k3s + ArgoCD GitOps pipeline
- Dockerfile: copy web/ and assets/ to runtime stage so ServeDir routes work
- .gitea/workflows/docker-build.yml: build/push image on master push, pin SHA
  tag back into deploy/kustomization.yaml so ArgoCD sees a real manifest change
- deploy/: Kustomize manifests — Namespace, PVC, Deployment (Recreate for
  SQLite), Service, Traefik Ingress at klondike.aleshym.co
- argocd/application.yaml: auto-sync Application watching deploy/ on Gitea

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:53:09 -07:00
funman300 079349dc0f fix(web): explicit top/left on .slot and .recycle-label
Without top:0;left:0, Firefox and other non-Chrome engines place
absolute elements at the content edge (padding offset = 20px) before
the JS transform is applied, shifting slots 20px below/right of cards.
Cards already had explicit top:0;left:0; slots now match.

.recycle-label also had top:50%;left:50% which combined with the JS
inline transform would place the ↺ symbol halfway across the board.
Changed to top:0;left:0 so JS transform is the sole position source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:20:56 -07:00
funman300 8f82b9fcb5 fix(web): sticky header, correct bottom-corner suit glyphs, main min-width
- header: position sticky so HUD/controls never scroll off screen
- .card .corner.bottom: remove rotate(180deg) — ♠ rotated looks like ♥,
  causing players to misread suit on the bottom corner
- main: add min-width:0 so flex container doesn't push board off-edge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:06:15 -07:00
funman300 0ebe87a411 fix(web): browser game UX pass — shake feedback, timer, stock count, HUD
- game.js fully rewritten: correct coordinate system (PAD baked into
  PILE_ORIGIN), undo driven by undo_stack_len, flashIllegal shake with
  --card-tx CSS variable, game timer, stock count HUD, URL seed persist,
  foundation suit hints, auto-complete step loop
- game.html: adds hud-timer, hud-stock, win-time elements
- game.css: @keyframes illegal-shake, .slot-hint, overflow-x on main
- solitaire_wasm: adds undo_stack_len to GameSnapshot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:27:05 -07:00
funman300 1e6d153cd0 feat(wasm): playable browser game at /play
Add `SolitaireGame` WASM binding to `solitaire_wasm` exposing draw(),
move_cards(), undo(), auto_complete_step(), and state() — all backed by
the real solitaire_core rules engine.

Add /play route to solitaire_server serving a full vanilla-JS
interactive Klondike game (game.html / game.css / game.js). Features:
drag-and-drop card moves (mouse + touch via PointerEvents), click stock
to draw, double-click card to auto-move to foundation, undo, draw-1/3
toggle, new game, auto-complete animation, win overlay, seed display.
Rebuild solitaire_wasm.js + solitaire_wasm_bg.wasm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:42:56 -07:00
funman300 af5ac68947 feat(core): take-from-foundation house rule
Add `GameState::take_from_foundation` flag (default false). When off,
Foundation→Tableau moves are blocked at the core rule layer. When on,
the top card of a foundation pile may be moved back to a compatible
tableau column (one card at a time).

Wire the matching `Settings::take_from_foundation` field through
`handle_new_game` so the player's preference applies to every new deal.
Four targeted tests cover: blocked-by-default, allowed-when-enabled,
illegal-tableau-placement, and count>1 rejection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:16:54 -07:00
funman300 859b69b3c5 fix(android): A2/A3/A4 — APK build doc, dead refs, modal hit targets
A2: docs/ANDROID.md — remove stale "permanent fix to come" note;
    clarify --lib is the canonical command; root-cause the upstream
    cargo-apk bug. SESSION_HANDOFF.md closes the open item.

A3: Remove dead CARD_PLAN.md references from four source module
    doc comments (theme/importer.rs, theme/plugin.rs, assets/mod.rs,
    assets/svg_loader.rs). Also fix stale "future picker UI" language
    in plugin.rs (picker shipped in Phase 7).

A4: ui_modal.rs spawn_modal_button — add min_height: Val::Px(48.0)
    so every modal action button meets Material's 48 dp touch target
    minimum. Modal button height was 42 px (2×SPACE_3 + TYPE_BODY_LG);
    now clamped to 48 px minimum. Cards at 40 dp on 360 dp phones are
    layout-constrained (7 columns) and cannot be widened.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:55:30 -07:00
funman300 24ab25b0b7 feat(android): tap-to-toggle HUD visibility (A1)
On Android, a short tap on the empty game area (not on a card) toggles
the HUD band, info column, and action bar between Visible and Hidden.
Layout recomputes with band_h=0 when hidden so cards fill the full
screen. Any modal open restores the HUD to Visible automatically.

- hud_plugin: HudVisibility resource, HudBand/HudColumn/HudActionBar
  markers, apply_hud_visibility (fires synthetic WindowResized),
  restore_hud_on_modal, and Android-only toggle_hud_on_tap +
  HudTapTracker (15 px slop, skips card taps via DragState.is_idle())
- layout: compute_layout gains hud_visible: bool; passes band_h=0.0
  when hidden; all callers updated
- input_plugin: TouchDragSet (AfterStartDrag / BeforeEndDrag) public
  system-set anchors for cross-plugin ordering
- table_plugin: setup_table + on_window_resized read HudVisibility and
  pass hud_visible to compute_layout
- Desktop behaviour is unchanged (HudVisibility always Visible, tap
  system is #[cfg(target_os = "android")] gated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:46:36 -07:00
funman300 918d83420b docs: update all project docs to reflect Phase 8 + Android work
- CLAUDE.md unified-3.1 → unified-4.0: narrowed error policy, relaxed
  ECS/embed/API rules, added Android pitfalls, modal conventions (§14),
  Android build guide (§15), context injection system (§16), auto-hide
  HUD chrome exception in UI-first rule
- ARCHITECTURE.md: Android → Active platform; add Android to sync table;
  add SafeAreaInsets + HudVisibility to Key Resources; add solitaire_wasm
- CLAUDE_SPEC.md: add solitaire_wasm crate; communication: events → events and resources
- CLAUDE_PROMPT_PACK.md: fix §8 typo; narrow dep rule to core/sync only
- SESSION_HANDOFF.md: add §5b Android UX punch list; resume prompt unified-4.0
- docs/android/PLAYABILITY_TODO.md: add P5 section (UX-1/UX-5b/UX-7/BUG-3)
- docs/SESSION_HANDOFF.md: mark as archived (Phase 2 era)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:21:01 -07:00
funman300 a381a42f21 fix(android): UX-1/UX-5b/UX-7/BUG-3 — safe-area modals, glyph, help wrap, modal guard
- UX-1 (safe_area.rs): apply_safe_area_to_modal_scrims pads ModalScrim
  bottom by insets.bottom / scale_factor so Done buttons clear the
  gesture bar; fires on inset change + Added<ModalScrim>
- UX-5b (home_plugin.rs): replace Geometric Shapes (U+25xx, missing
  from FiraMono) with card suits U+2660/2665/2666
- UX-7 (help_plugin.rs): shorten Android ≡ button description to
  "Open menu (Stats, Settings, Profile...)" — fits one line at 360 dp
- BUG-3 (hud_plugin.rs): guard spawn_menu_popover with
  scrims.is_empty() so tapping ≡ while a modal is open is a no-op

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:20:07 -07:00
funman300 04f3dab563 fix(android): UX pass — pause stacking, timer, help content, achievement glyphs
BUG-1: pause_plugin — auto_resume_on_overlay system despawns PauseScreen
whenever any other ModalScrim becomes live; fixes Pause modal stacking on
top of Stats / Settings / Help / Achievements / Profile overlays opened
from the HUD menu while paused.

BUG-2: game_plugin — tick_elapsed_time skips the first delta_secs after
AppLifecycle::WillSuspend/Suspended so the Android post-resume frame spike
(equal to the full suspension duration) no longer inflates the in-game timer.

UX-2: help_plugin — Android build gets a touch-specific CONTROL_SECTIONS
(Tap / New Game / HUD buttons); desktop sections (Mouse, Keyboard drag,
Mode Launcher, Overlays) remain on non-Android builds only.

UX-3: achievement_plugin — replace \u{25CB} (○) and \u{2713} (✓) prefixes
with ASCII "- " / "+ "; both Geometric Shapes codepoints are absent from
FiraMono and rendered as the fallback letter "o".

Phase 8 work from previous session (already compiled, not yet committed):
hud_plugin — HUD visual hierarchy (Undo/Pause bright, nav buttons dim);
  menu popover — Help + Game Modes entries added (7 items total).
card_plugin — stock badge drops "·" prefix, shows plain count.
pause_plugin — Draw Mode segmented control (Draw 1 / Draw 3 explicit buttons).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:02:39 -07:00
funman300 d204662415 fix(android): close HUD popovers on Escape instead of opening Pause
When the Menu or Modes popover was open, pressing Escape (Android back)
fired the Pause system instead of closing the popover, because both
systems listened to the same key with no coordination.

Fix:
- Add HudPopoverOpen marker to both popover entities on spawn.
- Add close_menu/modes_popover_on_escape systems in HudPlugin that
  despawn the popover + backdrop when Escape is pressed.
- Guard toggle_pause with an open_hud_popovers query: bail if any
  HudPopoverOpen entity exists, preventing Pause from stacking behind
  the closing popover.
- Init ButtonInput<KeyCode> in HudPlugin::build() so the new systems
  work under MinimalPlugins in tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 19:10:27 -07:00
funman300 4f0080dfbc fix(android): replace broken HUD glyphs and restore FiraMono font
‖ (U+2016) and ▾ (U+25BE) are absent from FiraMono and rendered as
boxes on device. Replace with || (ASCII) and ↓ (U+2193, Arrows block)
which are confirmed FiraMono-safe alongside the existing ≡ ← →.

Also removes the erroneous Android-only TextFont split introduced in
22303c6: that split accidentally used Bevy's built-in ASCII-only bitmap
font instead of FiraMono on Android, causing ALL non-ASCII HUD glyphs
to render as boxes. Now both platforms use the same FiraMono handle.

Separately, suppress the "Tab = next field" hint in the sync login
modal on Android (no Tab key on mobile).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:58:07 -07:00
funman300 46c3bf4bb2 fix(engine): profile achievement count derived from ALL_ACHIEVEMENTS
Hardcoded 18 in the profile summary line diverged from the actual count
of 19. Use ALL_ACHIEVEMENTS.len() so the count stays in sync when new
achievements are added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:54:59 -07:00
funman300 6beb9f68ac fix(engine): help panel scrollable via touch on Android
Register touch_scroll_panel::<HelpScrollable> so the Controls overlay
can be scrolled by swipe on Android. Without it, the Mode Launcher and
Overlays sections (rows 2–19) were unreachable via touch.

Also add 96px bottom padding to HelpScrollable — same fix applied to
settings_plugin — so the last row clears the scroll-container edge.
Register TouchInput message so existing headless tests continue to pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:49:40 -07:00
funman300 a0081a251c fix(engine): settings sync section scrollable + flaky midnight test
Add 96px bottom padding to SettingsPanelScrollable so the Sync section
is fully reachable by scrolling on Android (was clipped at container edge).

Fix check_system_fires_warning_event_only_once_per_day flakiness: Bevy
0.18 Messages<T> keeps events visible for two frames, so tests running
near UTC midnight saw a stale WarningToastEvent from headless_app()'s
initial update. Clear the buffer with .clear() before each assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:11:34 -07:00
funman300 7411468e10 fix(engine): extend touch scroll to achievements and stats panels via generic helper
Extracts touch_scroll_panel<M: Component> into ui_modal.rs and wires it
to SettingsPanelScrollable, AchievementsScrollable, and StatsScrollable
so all three panels respond to finger swipe on Android.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:03:20 -07:00
funman300 9af4046ac3 fix(engine): modal action buttons wrap to next row on narrow screens
On high-DPI Android (Pixel 7: 420 DPI → ~411 dp logical width), the
modal card fits at ~363 dp wide. The stats modal's three-button row
("Watch replay" + "Copy share link" + "Done") totals ~455 dp, causing
text to wrap inside each button (2–3 lines per button label).

Added flex_wrap: FlexWrap::Wrap + row_gap: VAL_SPACE_2 to
spawn_modal_actions so buttons that don't fit flow onto a second line
as whole units instead of wrapping text inside them. Affects all modals
uniformly; desktop (wide modal) is unaffected since buttons fit in one
line with room to spare.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:55:49 -07:00
funman300 d06af28aef fix(engine): settings panel scrollable via touch on Android
scroll_settings_panel only read MouseWheel, which is generated by desktop
scroll wheels and two-finger OS-level scroll gestures. On Android, a
single-finger swipe generates TouchInput, not MouseWheel, leaving the
settings panel unscrollable on real touchscreen devices.

Added touch_scroll_settings_panel: tracks touch start Y, applies the
vertical delta from each Moved event to ScrollPosition, resets on lift.
Registered TouchInput messages in SettingsPlugin::build so tests that use
MinimalPlugins (which omit InputPlugin) don't fail with "Message not
initialized".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:49:17 -07:00
funman300 27b58a5b71 fix(engine): pause game timer while onboarding modal is visible
tick_elapsed_time already stopped the clock for PausedResource and
HomeScreen, but not for the first-run onboarding modal. A new player
reading the three welcome slides would see their first-game time inflated
by however long they spent on the tutorial. Added OnboardingScreen to the
early-return guard using the same pattern as HomeScreen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:29:33 -07:00
funman300 3b6c8d2aab fix(engine): has_legal_moves treats non-empty stock/waste as always-legal
Drawing from a non-empty stock and recycling a non-empty waste are always
legal moves in standard Klondike (unlimited recycles). The old implementation
only scanned face-up tableau cards and the waste top for valid placements,
returning false for any fresh deal where the initial 7 face-up cards had no
immediate destination — causing a spurious "No more moves" game-over dialog
at Moves: 0. The correct stuck condition is stock=0 AND waste=0 AND no
visible card can be placed.

Updated the "false when stock unplayable" test to assert true instead, since
a non-empty stock means drawing is always legal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:17:55 -07:00
funman300 51fc8f65b1 docs(handoff): mark Android AVD tests done; Phase 8 punch list fully closed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:46:31 -07:00
funman300 65cb41461f docs(handoff): mark best-score auto-post done (303c78a); only AVD tests remain
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:44:41 -07:00
funman300 24f5d140df docs(handoff): mark display name done; update HEAD to 03be4fc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:44:04 -07:00
funman300 03be4fcc67 feat(leaderboard): add custom public display name
Adds `leaderboard_display_name: Option<String>` to `Settings` (serde
default = None, backwards-compatible). When set, this name is submitted
to the server on opt-in instead of the player's username, giving players
a separate public identity on the leaderboard.

Engine changes:
- `handle_opt_in_button` prefers `leaderboard_display_name` over username
- Leaderboard panel shows "Public name: X" row with "Set Name" button
- "Set Name" opens a modal with a single text-input field (32-char max)
- Save/Cancel buttons write to SettingsResource and persist to disk

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:38:53 -07:00
funman300 9564f54fc0 docs(handoff): mark WASM winning-sequence test complete; update HEAD state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:21:51 -07:00
funman300 b4ada2a07e test(wasm): add full winning-sequence step-through test
Adds `replay_player_completes_full_winning_sequence` to `solitaire_wasm`.
A greedy solver runs over seeds 1–200 to find the first deterministically
winnable DrawOne Classic game, serialises the move list as a Replay JSON,
and feeds it to `ReplayPlayer::from_json`. Every move is stepped with
`step_native`; the test asserts `is_won = true` on the final snapshot.

Regression target: any change to `GameState` move semantics or `ReplayMove`
serialisation that breaks a historically valid replay will fail this test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:21:29 -07:00
funman300 d44cedbea0 docs(handoff): mark password reset complete; update HEAD state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:12:34 -07:00
funman300 75146847f6 feat(server): add --reset-password admin subcommand
Self-hosters can now run:
  ./solitaire_server --reset-password <username>
to update a player's password and invalidate all their refresh tokens
(forcing re-login on every device). Password is read from stdin so it
can be piped from scripts or a password manager without appearing in
shell history.

Implementation:
- reset_password() in auth.rs: validates length, bcrypt-hashes new
  password, updates users.password_hash, deletes all refresh_tokens
  rows for the user.
- main.rs: --reset-password dispatch before HTTP server startup;
  JWT_SECRET not required for this path.
- 4 integration tests covering: login works after reset, old password
  rejected, refresh tokens invalidated, unknown user → NotFound,
  short password → BadRequest.
- README_SERVER.md: admin password-reset section with examples.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:10:13 -07:00
funman300 566b112d9e docs(handoff): mark WASM script + 401-retry test complete; update HEAD state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:04:49 -07:00
funman300 198df75f94 test(data): add push retry-on-401 integration test + server test pool helper
Adds push_retries_after_401_on_expired_access_token to sync_round_trip.rs,
closing the push-side coverage gap alongside the existing pull test
(jwt_refresh_on_401_succeeds). Both tests use an expired-but-validly-signed
access token to trigger the 401 → refresh → retry path in
SolitaireServerClient.

Also exposes build_test_pool() from solitaire_server so downstream crates
can boot a test server without duplicating the migration boilerplate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:04:26 -07:00
funman300 40d07122ba docs(wasm): add build_wasm.sh to document wasm-pack invocation
Captures the exact wasm-pack build command needed to regenerate
solitaire_server/web/pkg/. Removes wasm-pack's package.json and
.gitignore artifacts from the output dir since we manage it directly.
Includes a dependency guard and install instructions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:00:13 -07:00
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
127 changed files with 8829 additions and 1313 deletions
+73
View File
@@ -0,0 +1,73 @@
name: Build and Deploy
on:
push:
branches: [master]
# Only run when server code changes, not when CI itself updates deploy/.
paths-ignore:
- 'deploy/**'
- 'argocd/**'
- '**.md'
env:
REGISTRY: git.aleshym.co
IMAGE: git.aleshym.co/funman300/solitaire-server
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Need full history so we can push the tag-update commit back.
fetch-depth: 0
token: ${{ secrets.CI_TOKEN }}
- name: Set image tag
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.CI_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: solitaire_server/Dockerfile
push: true
tags: |
${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
${{ env.IMAGE }}:latest
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
- name: Install kustomize
run: |
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
sudo mv kustomize /usr/local/bin/kustomize
- name: Pin image tag in deploy manifests
run: |
cd deploy
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
- name: Commit and push updated kustomization
run: |
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
git add deploy/kustomization.yaml
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true
git pull --rebase origin master
git push
-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
-168
View File
@@ -1,168 +0,0 @@
name: Release
# Triggered by pushing a version tag, e.g. `git tag v0.22.0 && git push origin v0.22.0`.
# Builds a Linux x86_64 tarball and a signed Android APK, then publishes
# both as assets on a GitHub Release. Obtainium can track this repo's
# releases and download the APK automatically.
#
# Required repository secrets (Settings → Secrets and variables → Actions):
# ANDROID_KEYSTORE_BASE64 base64-encoded .jks file (see README for gen command)
# ANDROID_KEYSTORE_PASSWORD password used with -storepass when creating the keystore
# ANDROID_KEY_ALIAS alias used with -alias when creating the keystore
# ANDROID_KEY_PASSWORD password used with -keypass when creating the keystore
on:
push:
tags:
- 'v*'
permissions:
contents: write # gh release create needs write access
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# ---------------------------------------------------------------------------
# Job 1: Linux x86_64 binary + assets tarball
# ---------------------------------------------------------------------------
jobs:
build-linux:
name: Build · Linux x86_64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install system deps
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Cache cargo registry + build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: linux-release-
- name: Build release binary
run: cargo build --release -p solitaire_app
- name: Package tarball
run: |
mkdir solitaire-quest
cp target/release/solitaire_app solitaire-quest/
cp -r assets solitaire-quest/
tar -czf solitaire-quest-linux-x86_64.tar.gz solitaire-quest
- uses: actions/upload-artifact@v4
with:
name: linux
path: solitaire-quest-linux-x86_64.tar.gz
# ---------------------------------------------------------------------------
# Job 2: Android APK (multi-arch) — release-built and signed via cargo-apk
# ---------------------------------------------------------------------------
build-android:
name: Build · Android APK
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable + Android targets
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Expose NDK root to cargo-apk
# ANDROID_NDK_LATEST_HOME is set by the GitHub-hosted runner.
# cargo-apk reads ANDROID_NDK_ROOT; write it to GITHUB_ENV so
# all subsequent steps in this job inherit it.
run: echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
- name: Cache cargo registry + cargo-apk binary + build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
target
key: android-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: android-release-
- name: Install cargo-apk
# --locked: use the dependency versions cargo-apk was tested with.
# cargo install is a no-op when the cached binary is already current.
run: cargo install --locked cargo-apk
- name: Inject release signing config
# cargo-apk --release requires [package.metadata.android.signing.release]
# in solitaire_app/Cargo.toml. Appended at CI time so secrets never
# live in the repo. printf keeps every line inside the YAML run block,
# avoiding the YAML parse error a heredoc with column-0 content causes.
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > release.keystore
{
printf '\n[package.metadata.android.signing.release]\n'
printf 'path = "%s"\n' "${GITHUB_WORKSPACE}/release.keystore"
printf 'keystore_password = "%s"\n' "$ANDROID_KEYSTORE_PASSWORD"
printf 'key_alias = "%s"\n' "$ANDROID_KEY_ALIAS"
printf 'key_password = "%s"\n' "$ANDROID_KEY_PASSWORD"
} >> solitaire_app/Cargo.toml
- name: Build and sign APK (release profile)
run: cargo apk build -p solitaire_app --release
- name: Stage APK for upload
run: |
cp target/release/apk/solitaire-quest.apk \
"solitaire-quest-${{ github.ref_name }}.apk"
rm release.keystore
- uses: actions/upload-artifact@v4
with:
name: android
path: solitaire-quest-${{ github.ref_name }}.apk
# ---------------------------------------------------------------------------
# Job 3: Create the GitHub Release once both builds succeed
# ---------------------------------------------------------------------------
release:
name: Publish GitHub Release
runs-on: ubuntu-latest
needs: [build-linux, build-android]
steps:
- uses: actions/download-artifact@v4
with:
name: linux
- uses: actions/download-artifact@v4
with:
name: android
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--repo "${{ github.repository }}" \
--title "Solitaire Quest ${{ github.ref_name }}" \
--generate-notes \
"solitaire-quest-linux-x86_64.tar.gz" \
"solitaire-quest-${{ github.ref_name }}.apk"
+6
View File
@@ -10,3 +10,9 @@ data/
# IDE project files
.idea/
# Android signing keystores — never commit
*.jks
*.jks.bak
*.jks.bak*
*.keystore
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE leaderboard\n SET best_score = ?,\n best_time_secs = ?,\n recorded_at = ?\n WHERE user_id = ?\n AND (\n best_score IS NULL\n OR ? > best_score\n OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "0e199cafab7e71b0c7f28ede85a622e38649d2fe5a73a5c715f2319f5450f729"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC\n LIMIT 100",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112"
"hash": "2b814989a6632ca930ae1e895f97a7fc3389c91d1d2abf6900a21fb0d6e94ef3"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0"
}
@@ -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": "UPDATE users SET password_hash = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3"
}
+91 -8
View File
@@ -1,9 +1,9 @@
# Solitaire Quest — Architecture Document
# Ferrous Solitaire — 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
---
@@ -34,7 +34,7 @@
## 1. Project Overview
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
Ferrous Solitaire is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
### Sync Backend by Platform
@@ -43,6 +43,7 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
| macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set |
| Linux | Self-hosted server | Full feature set |
| Android | Self-hosted server | Touch input; safe-area insets via JNI; `cargo-apk` build |
### Design Principles
@@ -86,6 +87,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 +162,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 +277,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 |
@@ -305,6 +323,12 @@ struct FontResource(Handle<Font>);
struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 04 match selected_background setting
}
// OS-reserved edge insets (physical px); zero on desktop
struct SafeAreaInsets { top: f32, bottom: f32, left: f32, right: f32 }
// Whether the HUD band is visible (auto-hide chrome feature)
enum HudVisibility { Visible, Hidden }
```
### Key Bevy Events
@@ -365,10 +389,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 +490,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 +633,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 +667,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 +684,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 |
@@ -825,7 +907,7 @@ All sound effect WAV files are embedded at compile time via `include_bytes!()` i
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
| Android | Active | Self-hosted server | `cargo-apk`; touch + long-press + double-tap; safe-area JNI; portrait layout |
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
@@ -945,6 +1027,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` |
+105 -1
View File
@@ -1,11 +1,115 @@
# Changelog
All notable changes to Solitaire Quest are documented here. The format is
All notable changes to Ferrous Solitaire are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Fixed
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
now checks `scrims.is_empty()` — a `Query<(), With<ModalScrim>>` guard —
before calling `spawn_menu_popover`. Tapping ≡ while any modal (Stats,
Settings, Profile, Help) is open is now a no-op. Previously Stats + Profile
could be open simultaneously.
- **UX-7: Help text single-line overflow** (`help_plugin.rs`). The HUD menu
button description "Menu: Stats, Settings, Profile, Achievements" wrapped to
two lines on Android. Shortened to "Open menu (Stats, Settings, Profile...)"
which fits on one line. Verified on device.
- **UX-5b: Home mode glyph corruption** (`home_plugin.rs`). Mode selector icons
were using Geometric Shapes block (U+25xx) absent from the bundled FiraMono
font — rendered as missing-glyph rectangles on Android. Replaced with card
suits (U+26602666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge.
- **UX-1: Modal Done button in gesture zone** (`safe_area.rs`). New
`apply_safe_area_to_modal_scrims` Bevy system pads every `ModalScrim` bottom
by `SafeAreaInsets.bottom / scale_factor`. Modal cards are now centred over
the safe area, not the full physical screen. The Settings / Help / Stats Done
buttons are reachable on gesture-nav Android devices. Verified on device.
---
## [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
+148 -26
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
version: unified-3.0
version: unified-4.0
---
@@ -29,8 +29,9 @@ solitaire_sync/ # Shared API + merge logic
solitaire_data/ # Persistence + sync client
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer)
solitaire_wasm/ # WASM bindings for browser-side replay player
solitaire_app/ # Entry binary
assets/ # Runtime assets (except audio)
assets/ # Runtime assets (except audio + default theme)
```
---
@@ -72,12 +73,16 @@ These override all other instructions.
* NO `unwrap()`
* NO `panic!()` in runtime/game logic
* All state transitions:
* Core game state mutations MUST return:
```rust id="err_model"
Result<T, MoveError>
```
* Engine / UI state changes follow ECS patterns (Resources, Events) —
they do not return `MoveError`
* Use `thiserror`-derived types for any new error enums outside `solitaire_core`
---
## 2.4 Threading Rules
@@ -126,10 +131,15 @@ trait SyncProvider
## 3.1 ECS Design
* systems = single responsibility
* communication = Events only
* shared state = Resources only
* cross-system communication = Events (fire-and-forget triggers)
* persistent shared state = Resources (polled every frame or on change)
* per-entity state = Components only
Events and Resources are both valid communication paths — use Events when
the receiver needs to react once; use Resources when the receiver polls
or when multiple systems read the same value (e.g. `SafeAreaInsets`,
`HudVisibility`, `LayoutResource`).
---
## 3.2 Game State Authority
@@ -149,11 +159,22 @@ Every player action MUST:
Keyboard shortcuts are:
→ optional accelerators only
**Exception — UI chrome gestures:**
Tap-to-toggle visibility of UI chrome (e.g. auto-hiding HUD band) is
permitted without a visible button. The gesture MUST:
* affect only chrome visibility, never game state
* restore chrome automatically when any modal opens
* be purely additive (game remains fully playable with chrome always visible)
---
## 3.4 Layout System
* recompute on `WindowResized`
* recompute on `SafeAreaInsets` changed
* recompute on `HudVisibility` changed
* `compute_layout` MUST accept `hud_visible: bool`; pass `HUD_BAND_HEIGHT`
when `true`, `0.0` when `false`
* no fixed resolution assumptions
---
@@ -178,11 +199,18 @@ Includes:
## 4.2 Embedded Assets
Only audio:
Embed via `include_bytes!()` only when ALL of the following are true:
```text id="audio_rule"
include_bytes!()
```
* the asset is small (< 500 KB uncompressed)
* it changes rarely (not user-customisable)
* a missing file would be a hard crash, not a graceful degradation
Currently embedded:
* **Audio** — all `.wav` files in `audio_plugin.rs`
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
Do NOT embed card face PNGs, background images, or user fonts —
these are loaded via `AssetServer` so art can be swapped without recompile.
---
@@ -210,7 +238,9 @@ Must degrade gracefully under `MinimalPlugins`.
## 5.2 Public API Rules
* prefer `Into<T>` over concrete types
* all public items require doc comments
* publicly exported functions, traits, and non-trivial types require doc comments
* simple marker components, newtype wrappers, and internal `pub` items
used only within the same crate are exempt from doc comment requirements
---
@@ -276,11 +306,13 @@ NEVER commit otherwise
Claude must request confirmation before:
* adding dependencies
* modifying `solitaire_sync`
* changing DB schema
* adding dependencies to `solitaire_core` or `solitaire_sync`
(engine/server crates may add deps without confirmation)
* modifying `solitaire_sync` types or the `SyncProvider` trait
* changing DB schema (migrations are append-only)
* introducing `unsafe`
* changing merge strategy
* changing the merge strategy in `solitaire_sync::merge`
* changing the `SyncPayload` wire format (breaking change for existing servers)
---
@@ -304,10 +336,29 @@ Core is always the source of truth.
Must always be handled explicitly:
**All platforms**
* Bevy `Time` uses `f32`
* `sqlx::migrate!()` path is crate-relative
* `dirs::data_dir()` may return `None`
* Linux may lack keyring backend
* Linux may lack keyring backend — handle `keyring::Error` gracefully
**Android (active target — not stretch)**
* Safe-area insets arrive in frames 13 via JNI polling, not at startup;
UI that depends on them must handle the zero-inset initial state
* Physical pixels ≠ logical pixels: `SafeAreaInsets` values are physical
(from `WindowInsets` API); divide by `window.scale_factor()` before
passing to Bevy `Val::Px`
* `adb shell input tap` uses physical pixel coordinates
* FiraMono (bundled font) covers: ASCII, card suits U+26602666,
Arrows U+219021FF. It does NOT cover Geometric Shapes (U+25xx) —
those render as missing-glyph rectangles on Android
* The gesture/navigation bar at the bottom (≈132px physical on common
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
avoid placing interactive elements in that zone
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
layout constants are `#[cfg(target_os = "android")]` gated
* JNI calls must use `attach_current_thread_permanently` — not
`attach_current_thread` — to avoid detach-on-drop panics
---
@@ -318,6 +369,12 @@ Must always be handled explicitly:
* blocking async calls in ECS
* insecure credential storage
* bypassing core logic layer
* hardcoded pixel coordinates in layout — always derive from `compute_layout`
* Unicode Geometric Shapes block (U+25xx) in UI text — not in FiraMono
* spawning a second `ModalScrim` while one already exists without first
dismissing the existing one (use `scrims.is_empty()` guard)
* reading `SafeAreaInsets` physical values directly into `Val::Px` without
dividing by `window.scale_factor()`
---
@@ -345,9 +402,74 @@ If unclear:
| Both combined | full system understanding |
---
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
# 14. Modal System Conventions
## 14.1 Purpose
All full-screen overlay panels MUST use the `spawn_modal` / `ModalScrim` pattern
from `solitaire_engine::ui_modal`.
## 14.1 Spawn pattern
```rust
let scrim = spawn_modal(commands, MyScreenMarker, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Title", font_res);
// ... body nodes ...
spawn_modal_actions(card, |actions| {
spawn_modal_button(actions, MyCloseButton, "Done", None,
ButtonVariant::Primary, font_res);
});
});
// Optional: allow clicking the scrim outside the card to dismiss
commands.entity(scrim).insert(ScrimDismissible);
```
## 14.2 Guard rule
Before spawning a new modal, check `scrims: Query<(), With<ModalScrim>>`
and return early if `!scrims.is_empty()` — unless the new modal is
explicitly replacing the current one (despawn first, then spawn).
## 14.3 Safe area
Every `ModalScrim` automatically receives `padding.bottom` equal to the
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
## 14.4 Z-ordering
Use `Z_MODAL_PANEL` from `ui_theme` for all modal scrims. Do not use
raw `z_index` values — they drift and cause ordering bugs.
---
# 15. Android Build & Verification
## 15.1 Build command
```bash
cargo apk build --package solitaire_app --lib
adb install -r target/debug/apk/solitaire-quest.apk
```
## 15.2 Coordinate system reminder
Device physical: 1080×2400. Bevy logical: 900×2000. Scale factor: 1.20.
`adb shell input tap X Y` takes PHYSICAL coordinates.
To convert from what you see on screen (logical): multiply by 1.20.
## 15.3 Android-specific test checklist
Before shipping any Android build:
- [ ] Safe area insets arrive and shift HUD correctly (check after 3s)
- [ ] All modal Done buttons are above the gesture bar
- [ ] No Geometric Shapes glyphs in UI text
- [ ] HUD band does not overlap the top status bar
- [ ] Touch drag-and-drop works on all pile types
---
# 16. Context Injection System (AUTOMATIC SCOPE FILTER)
## 16.1 Purpose
Before generating any response, Claude MUST construct a **minimal relevant context set**.
@@ -360,7 +482,7 @@ This prevents:
---
## 14.2 Input Classification Step (MANDATORY)
## 16.2 Input Classification Step (MANDATORY)
Every request MUST be classified into exactly one task type:
@@ -381,13 +503,13 @@ If uncertain → ask clarification.
---
## 14.3 Context Selection Engine
## 16.3 Context Selection Engine
After classification, Claude MUST include ONLY the relevant sections below.
---
## 14.4 Context Map (CORE RULESET)
## 16.4 Context Map (CORE RULESET)
### feature
@@ -495,7 +617,7 @@ Include:
---
## 14.5 Context Compression Rules
## 16.5 Context Compression Rules
Claude MUST obey:
@@ -506,7 +628,7 @@ Claude MUST obey:
---
## 14.6 Context Priority Order
## 16.6 Context Priority Order
When space is limited:
@@ -517,7 +639,7 @@ When space is limited:
---
## 14.7 “No Context Pollution” Rule
## 16.7 “No Context Pollution” Rule
Claude must NOT include:
@@ -529,7 +651,7 @@ Claude must NOT include:
---
## 14.8 Self-Check Before Execution
## 16.8 Self-Check Before Execution
Before writing code, Claude MUST verify:
@@ -542,7 +664,7 @@ If any fail → revise context selection.
---
## 14.9 Injection Output Format (Internal Model)
## 16.9 Injection Output Format (Internal Model)
Claude should behave as if it constructed:
@@ -560,7 +682,7 @@ Claude should behave as if it constructed:
---
## 14.10 Relationship to ARCHITECTURE.md
## 16.10 Relationship to ARCHITECTURE.md
* ARCHITECTURE.md = source of truth
* CLAUDE.md = execution constraints
+2 -2
View File
@@ -12,7 +12,7 @@ You must follow CLAUDE_SPEC.md strictly.
Rules:
- Do not expand scope beyond what is defined
- Do not refactor unrelated code
- Do not introduce new dependencies
- Do not introduce new dependencies to solitaire_core or solitaire_sync without confirmation
- Prefer minimal, surgical changes
- Use existing patterns in the codebase
- Return minimal diffs or changed functions only
@@ -360,7 +360,7 @@ notes:
target:
"<what is slow>"
constraints:CLAUDE_WORKFLOW.md
constraints:
- no behavior change
- no architecture change
- minimal code changes
+5 -1
View File
@@ -41,6 +41,10 @@ solitaire_server:
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
role: "backend"
solitaire_wasm:
depends_on: [solitaire_core, wasm-bindgen, serde-wasm-bindgen]
role: "wasm_replay_player"
solitaire_app:
depends_on: [solitaire_engine]
role: "entrypoint"
@@ -180,7 +184,7 @@ threading:
plugins:
pattern: "feature_isolation"
communication: "events"
communication: "events and resources"
---
+3 -3
View File
@@ -1,6 +1,6 @@
# Credits
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
Ferrous Solitaire is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
the work of many open-source projects and a small handful of third-party
assets. This file lists every component that ships in the binary or in the
`assets/` directory.
@@ -43,7 +43,7 @@ copyleft code is statically linked into the game binary.
| File(s) | Source | License |
|---|---|---|
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) |
| `solitaire_engine/assets/themes/default/back.svg` | Original — Ferrous Solitaire | MIT (this project) |
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
| `assets/cards/backs/back_0.png` `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
@@ -107,6 +107,6 @@ Audio files are MIT-licensed alongside the rest of this project.
backs, every audio file) are original work covered by this project's MIT
license.
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
If you redistribute Ferrous Solitaire, you must ship this `CREDITS.md` and the
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
and OFL (FiraMono) notices remain visible to end users.
Generated
+2
View File
@@ -6986,6 +6986,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"bevy",
"chrono",
"dirs",
"jni 0.21.1",
@@ -7017,6 +7018,7 @@ dependencies = [
"resvg",
"ron",
"serde",
"serde_json",
"solitaire_core",
"solitaire_data",
"solitaire_sync",
+1 -1
View File
@@ -1,4 +1,4 @@
# Solitaire Quest
# Ferrous Solitaire
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and
+27 -1
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Self-Hosting Guide
# Ferrous Solitaire — Self-Hosting Guide
## Prerequisites
@@ -42,3 +42,29 @@ git pull
docker compose build
docker compose up -d
```
## Admin — Password Reset
If a player loses access to their account, the server binary includes a
built-in password reset command. Run it on the host (or inside the container)
with `DATABASE_URL` pointing at your database:
```bash
# Interactive (prompts for the new password):
DATABASE_URL=sqlite://./data/solitaire.db \
./solitaire_server --reset-password <username>
# Non-interactive (piped from a script or password manager):
echo "new_password" | \
DATABASE_URL=sqlite://./data/solitaire.db \
./solitaire_server --reset-password <username>
# Inside a running Docker container:
docker compose exec server sh -c \
'echo "new_password" | ./solitaire_server --reset-password alice'
```
On success the user's `password_hash` is updated and **all active refresh
tokens are deleted**, so every open session must log in again with the new
password. `JWT_SECRET` does not need to be set for this command.
+142 -303
View File
@@ -1,338 +1,177 @@
# Solitaire Quest — Session Handoff
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-08**v0.21.8 tagged at `c50eaf8`**;
nine post-cut commits on master. Push pending.
**Last updated:** 2026-05-12Leaderboard display name shipped (`03be4fc`). All commits pushed to origin.
v0.21.8 closes the last optional polish items in the B-2
replay screen-takeover arc: **notch-label centering** (middle
three scrub-bar labels now centred on their notch ticks via the
CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
MOVE HC legibility** (lime stays lime under HC mode via the
extended `HighContrastBackground::with_hc` constructor and a
new `STATE_SUCCESS_HC` brighter-lime constant). The replay
overlay arc is now fully closed with no known open items.
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.
Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
file from here on focuses on what's *open* post-cut and how to
resume.
---
## Status at pause
## Current state
- **HEAD locally:** `f281425` (Android Keystore JNI).
Docs ride on top; push pending.
- **HEAD on origin:** `395a322` (double-tap commit — last pushed).
- **Working tree:** clean (docs uncommitted). No WIP outstanding.
- **`artwork/` directory:** still untracked. Intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean.
- **Tests:** **1292 passing / 0 failing** across the workspace.
- **Tags on origin:** `v0.9.0` through `v0.21.8`.
- **Android:** APK verified booting on Pixel_7 AVD (Android 14,
x86_64). All desktop-only systems (handle_fullscreen) now gated.
See Phase Android punch list for remaining work.
- **HEAD locally:** `03be4fc` (feat: leaderboard custom display name).
- **HEAD on origin:** `03be4fc` (fully pushed).
- **Working tree:** clean (only `solitaire-release.jks.bak2` untracked — intentional).
- **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`.
## Since the v0.21.8 cut
---
Seven commits since the v0.21.8 tag:
- `a449f60` — Stats Prev/Next selector spawn site
- `202a64d` — Android launch fixes (android_main, resize_constraints,
apply_smart_default_window_size) — **closes APK launch verification**
- `16242e6` — Ignore .idea/ IDE files
- `395a322` — double-tap auto-move for touch input
- `0cb1587` — Play-by-Seed dialog + HomeMode card
- `2062bd0` — 75 new challenge seeds + gen_seeds binary
- `45436d0` — gate handle_fullscreen to non-Android
- `2c822ba` — JNI clipboard bridge for Android Stats share-link
- `f281425` — Android Keystore AES-GCM token storage via JNI
## What shipped in Phase 8 (432061c bd388fe)
CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
| 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 |
Open next-step menu:
1. **Phase 8 (sync)** — the biggest open arc. Local storage
scaffolding, self-hosted Axum server, GPGS stub.
2. **Android follow-ups** — JNI ClipboardManager, Android Keystore,
GPGS. Launch verification and double-tap both closed; these
are the remaining Phase Android items.
3. **Move Log auto-scroll** — only relevant if the panel
row count grows beyond the current 5-row fixed window.
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 punch list
---
### Phase Android (build + persistence shipped; runtime gaps remain)
## Open punch list (ordered by priority)
- *APK launch verification — closed 2026-05-08 by `202a64d`.*
Three fixes shipped: `android_main` export (missing NativeActivity
entry point), `resize_constraints` gated to non-Android (max=0
panic), `apply_smart_default_window_size` gated to non-Android
(clamp panic on zero-dimension window event). Verified booting on
Pixel_7 AVD (Android 14, x86_64, SwiftShader Vulkan), 2+ min
runtime without crash. B0004 ECS hierarchy warnings remain
(non-fatal; entity parent/child component mismatch); investigate
if they surface gameplay bugs.
- *Double-tap auto-move — closed 2026-05-08 by `395a322`.*
`handle_double_tap` fires `MoveRequestEvent` on two rapid
`TouchPhase::Ended` events within 0.5 s. Prefers foundation;
falls back to tableau stack move. Fires `MoveRejectedEvent` when
no legal destination exists. System runs before `touch_end_drag`
in the chain so drag state is readable.
- *F11 fullscreen gate — closed 2026-05-08 by `45436d0`.*
`handle_fullscreen` and its `MonitorSelection`/`WindowMode`
imports are `#[cfg(not(target_os = "android"))]`-gated. The
`add_systems` call is a separate statement (not mid-chain).
- *JNI ClipboardManager bridge — closed 2026-05-08 by `2c822ba`.*
`android_clipboard::set_text(url)` calls `ClipboardManager` via
JNI. Stats share-link button now writes to the clipboard with a
"Copied: {url}" toast; falls back to "Share link: {url}" on JNI
error. Requires AVD functional test (see verification steps in
the approved plan).
- *Android Keystore for credentials — closed 2026-05-08 by `f281425`.*
`android_keystore` module: AES-256/GCM/NoPadding device-bound key,
tokens serialised to JSON and stored atomically at
`{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+tag]`.
`auth_tokens.rs` Android stubs now delegate to it. Key
invalidation (biometric reset) → `TokenError::KeychainUnavailable`.
Requires AVD functional test before Phase 8 sync goes live on
Android.
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
panic doesn't affect the APK on disk but produces noisy stderr.
Either upstream a cargo-apk fix or document `--lib` as
canonical in the runbook.
### 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
### Visual-identity follow-ups (post-v0.21.0)
### 2. Leaderboard wiring gaps
- [x] **Best-score auto-post.** Done (`303c78a`): `update_leaderboard_if_opted_in`
called from both first-push and merge paths in `sync.rs`; uses SQLite `MIN`/`MAX`
in the UPDATE so scores never regress on stale data.
- [x] **Display name = username.** Done (`03be4fc`): `leaderboard_display_name:
Option<String>` added to `Settings`; editor modal in leaderboard panel; persists
to `settings.json`; `handle_opt_in_button` prefers custom name over username.
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:
### 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.
- *Replay-overlay screen-takeover redesign — closed 2026-05-08
across 13 commits (v0.21.4v0.21.7).* The full mockup
(`docs/ui-mockups/replay-overlay-mobile.html`) has shipped:
banner chrome (v0.21.0), floating MOVE chip (v0.21.2), WIN
MOVE scrub-bar marker (post-v0.21.3), playback controls /
Space accelerator (post-v0.21.3), scrub notches + labels +
keybind footer + ESC / ← / → accelerators + HC border
(v0.21.5), Move Log panel + HC scrub track + continuous
scrub (v0.21.6), and full-screen 50 % opacity dim layer
(v0.21.7). Every major B-2 sub-piece is now closed. The
only remaining items are minor polish: notch-label centering
and WIN MOVE HC contrast bump (see Open next-step menu).*
- *Floating `MOVE N/M` chip above the focused card during
playback — closed 2026-05-08 by `2fb2d63`.* World-space
`Text2d` entity sibling to the banner overlay; uses the same
`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).
### 4. Android validation
- [x] **Android Keystore functional test.** Done (2026-05-11, Pixel 7 AVD,
Android 14): `load_access_token()` exercised via `start_pull`; logcat confirmed
`NotFound` returned cleanly — no JNI panic. See `docs/android/PLAYABILITY_TODO.md` P4.
- [x] **JNI clipboard functional test.** Done (2026-05-11): temporary `KEYCODE_C`
hook confirmed `ClipboardManager.setPrimaryClip()` succeeds on Android 14.
Hook reverted. Production path requires Interaction::Pressed + non-null `share_url`.
Note: `adb shell input tap` doesn't deliver touch events on headless AVD (documented).
- [x] **`cargo apk build --lib` noisy stderr** — upstream cargo-apk bug; `--lib`
is the canonical command (CLAUDE.md §15.1, docs/ANDROID.md). No in-repo fix possible.
### Carried forward from v0.19.0
### 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.
- [x] **WASM build script.** Done (`40d0712`): `build_wasm.sh` at repo root
documents `wasm-pack build --target web`, cleans up pkg metadata files,
includes dependency guard + install instructions.
- [x] **Server password reset.** Done (`7514684`): `--reset-password <username>`
subcommand reads new password from stdin, bcrypt-hashes it, invalidates all
active sessions for the user.
- *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.
### 5b. Android UX polish (2026-05-12)
### Other small candidates
- [x] **UX-1 — Modal Done button in gesture zone.** `apply_safe_area_to_modal_scrims` system
added to `SafeAreaInsetsPlugin` (`safe_area.rs`). Pads every `ModalScrim` bottom by
`insets.bottom / scale`. Fires on resource change + `Added<ModalScrim>`. Verified on device.
- [x] **UX-5b — Home mode glyph corruption.** Geometric Shapes (U+25xx, absent from FiraMono)
replaced with card suits U+26602666 in `home_plugin.rs`. Affects Zen/Challenge/Daily mode
selector buttons at level 5+.
- [x] **UX-7 — Help text wrap.** Android HUD entry shortened to
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs` — fits one line.
- [x] **BUG-3 — Multi-modal stacking.** `handle_menu_button` now checks
`scrims: Query<(), With<ModalScrim>>` and guards `spawn_menu_popover` with `scrims.is_empty()`.
Verified on device: ≡ tap while Stats open does nothing.
- *Play-by-Seed dialog — closed 2026-05-08 by `0cb1587`.*
`PlayBySeedPlugin` adds a numeric-input modal with async solver
preview (debounced 500 ms). `HomeMode::PlayBySeed` card fires
`StartPlayBySeedRequestEvent`. 5 unit tests. 75 new verified-win
seeds (`2062bd0`) expand `CHALLENGE_SEEDS` via the new
`solitaire_assetgen::gen_seeds` binary.
- *Prev/Next selector chips spawn site — closed 2026-05-08 by
`a449f60`.* `ReplayPrevButton` / `ReplayNextButton` /
`ReplaySelectorCaption` / `ReplaySelectorDetail` now spawn in
`spawn_stats_screen` as a compact chip row above the Watch
Replay action. The Shareable badge is in the detail line.
The click handler and repaint systems were already live since
v0.19.0; this was purely the missing spawn site.
- **Toast queue / immediate unification.** The two toast paths
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
for fire-and-forget) now share visual treatment but remain
separate functions because they serve different temporal
needs (sequential vs. parallel). If overlap becomes a UX
issue, merge into one queue with priority lanes.
**Note:** These 4 fixes are implemented and verified but not yet committed.
### Process notes
### 6. Testing gaps
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
`jwt_refresh_on_401_succeeds` (pull) and
`push_retries_after_401_on_expired_access_token` (push) in
`solitaire_data/tests/sync_round_trip.rs`.
- [x] **WASM winning-replay step-through.** Done (`b4ada2a`): greedy solver
searches seeds 1200 at test time; steps every move through `ReplayPlayer`;
asserts `is_won = true` on the final `StateSnapshot`.
- **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.
---
### Canonical remote
## ARCHITECTURE.md gaps (for the update pass)
`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.
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
### 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.
## Process notes
- **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.
---
## 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.8 is tagged at c50eaf8 (cut 2026-05-08,
replay-overlay polish). Seven post-cut commits are on master (see
"Since the v0.21.8 cut" above); push of the last four pending.
v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
pending. See CHANGELOG.md § [0.21.9] for full detail.
You are a senior Rust + Bevy developer working on Ferrous Solitaire.
Working directory: <Rusty_Solitaire clone path>.
Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed.
State: HEAD locally — see `git rev-parse HEAD`. Workspace
tests: 1292 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.9] section has the pending-cut items
3. CLAUDE.md — unified-3.0 rule set
4. CLAUDE_SPEC.md — formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow
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)
2. CHANGELOG.md — [0.23.0] section has full Phase 8 detail
3. CLAUDE.md — unified-4.0 rule set
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. Android follow-ups — JNI ClipboardManager bridge (arboard
has no Android backend), Android Keystore (blocked on Phase 8).
Launch verification + double-tap are closed.
B. Phase 8 (sync) — local storage scaffolding, self-hosted
Axum server, `SolitaireServerClient` impl. The biggest open
arc by scope; rolls up Android dependencies (Keystore,
ClipboardManager).
C. Play-by-Seed polish — the dialog is functional but has no
visual preview of the solver verdict in the UI yet; the
HomeMode card is wired but the dialog spawn site and verdict
display could use a second pass.
OPEN WORK:
Phase 8 punch list is fully closed. All items verified complete.
Remaining nuisance: `cargo apk build --lib` noisy stderr (cosmetic, non-blocking).
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.
4 Android UX fixes are implemented and verified but NOT YET COMMITTED:
- BUG-3 (hud_plugin.rs): multi-modal stacking guard
- UX-7 (help_plugin.rs): help text wrap on Android
- UX-5b (home_plugin.rs): FiraMono glyph corruption in mode selector
- UX-1 (safe_area.rs): modal Done button in gesture zone
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.
Commit those first, then suggest Phase 9 planning.
```
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: solitaire-server
namespace: argocd
spec:
project: default
source:
repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
targetRevision: master
path: deploy
destination:
server: https://kubernetes.default.svc
namespace: solitaire
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Rebuild the solitaire_wasm crate and install the output into
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
#
# Prerequisites:
# cargo install wasm-pack
# rustup target add wasm32-unknown-unknown
#
# Run from the repo root:
# ./build_wasm.sh
#
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
# committed to git so self-hosters who don't touch the WASM crate can
# skip this step. Regenerate after any change to solitaire_wasm/ or
# solitaire_core/.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
if ! command -v wasm-pack &> /dev/null; then
echo "error: wasm-pack not found." >&2
echo " Install with: cargo install wasm-pack" >&2
exit 1
fi
echo "Building solitaire_wasm (target: web)..."
wasm-pack build \
--target web \
--out-dir "$OUT_DIR" \
--no-typescript \
"$REPO_ROOT/solitaire_wasm"
# wasm-pack writes a package.json and .gitignore into the output dir.
# Remove them — we manage the output directory ourselves.
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
echo "Done. Output:"
ls -lh "$OUT_DIR"
+62
View File
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: solitaire-server
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: solitaire-server
# SQLite is single-writer; Recreate avoids two pods owning the PVC at once.
strategy:
type: Recreate
template:
metadata:
labels:
app: solitaire-server
spec:
imagePullSecrets:
- name: gitea-registry
containers:
- name: server
image: solitaire-server
imagePullPolicy: Always
ports:
- containerPort: 8080
env:
- name: DATABASE_URL
value: sqlite:///data/sol.db
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: solitaire-secrets
key: jwt-secret
- name: SERVER_PORT
value: "8080"
volumeMounts:
- name: db-data
mountPath: /data
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: db-data
persistentVolumeClaim:
claimName: solitaire-db
+25
View File
@@ -0,0 +1,25 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: solitaire-analytics
namespace: solitaire
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: analytics.aleshym.co
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: matomo
port:
name: http
tls:
- hosts:
- analytics.aleshym.co
secretName: analytics-tls
+27
View File
@@ -0,0 +1,27 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: solitaire-server
namespace: solitaire
annotations:
# Remove the next two lines if you are not using cert-manager.
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: klondike.aleshym.co
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: solitaire-server
port:
name: http
# Remove the tls block if you are not using cert-manager.
tls:
- hosts:
- klondike.aleshym.co
secretName: solitaire-tls
+24
View File
@@ -0,0 +1,24 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- pvc.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- mariadb-pvc.yaml
- mariadb-deployment.yaml
- mariadb-service.yaml
- matomo-pvc.yaml
- matomo-secret.yaml
- matomo-deployment.yaml
- matomo-service.yaml
- ingress-analytics.yaml
# CI updates this block automatically via `kustomize edit set image`.
# The image name here matches the `image: solitaire-server` stub in deployment.yaml.
images:
- name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server
newTag: 3e006a1e
+72
View File
@@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mariadb
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: mariadb
strategy:
type: Recreate
template:
metadata:
labels:
app: mariadb
spec:
containers:
- name: mariadb
image: mariadb:11
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_ROOT_PASSWORD
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_DATABASE
- name: MYSQL_USER
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_USER
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_PASSWORD
ports:
- containerPort: 3306
volumeMounts:
- name: mariadb-data
mountPath: /var/lib/mysql
livenessProbe:
exec:
command:
- healthcheck.sh
- --connect
- --innodb_initialized
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
exec:
command:
- healthcheck.sh
- --connect
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: mariadb-data
persistentVolumeClaim:
claimName: mariadb-data
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb-data
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: solitaire
spec:
selector:
app: mariadb
ports:
- name: mysql
port: 3306
targetPort: 3306
clusterIP: None
+85
View File
@@ -0,0 +1,85 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: matomo
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: matomo
strategy:
type: Recreate
template:
metadata:
labels:
app: matomo
spec:
containers:
- name: matomo
image: bitnami/matomo:5
env:
- name: MATOMO_DATABASE_HOST
value: mariadb
- name: MATOMO_DATABASE_PORT_NUMBER
value: "3306"
- name: MATOMO_DATABASE_NAME
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_DATABASE
- name: MATOMO_DATABASE_USER
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_USER
- name: MATOMO_DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_PASSWORD
- name: MATOMO_USERNAME
value: admin
- name: MATOMO_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MATOMO_ADMIN_PASSWORD
- name: MATOMO_EMAIL
value: funman300@gmail.com
- name: MATOMO_WEBSITE_NAME
value: "Solitaire Quest"
- name: MATOMO_WEBSITE_HOST
value: "https://klondike.aleshym.co"
- name: MATOMO_HOST
value: analytics.aleshym.co
- name: MATOMO_ENABLE_PROXY_URI_HEADER
value: "yes"
ports:
- containerPort: 8080
volumeMounts:
- name: matomo-data
mountPath: /bitnami/matomo
livenessProbe:
httpGet:
path: /index.php
port: 8080
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /index.php
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: matomo-data
persistentVolumeClaim:
claimName: matomo-data
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: matomo-data
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
+13
View File
@@ -0,0 +1,13 @@
# Credentials for MariaDB and the Matomo admin account.
# Regenerate with: python3 -c "import secrets; print(secrets.token_urlsafe(18))"
apiVersion: v1
kind: Secret
metadata:
name: matomo-secret
namespace: solitaire
stringData:
MYSQL_ROOT_PASSWORD: "jspRn-QU18sZhB55FR-JfrMJ"
MYSQL_DATABASE: matomo
MYSQL_USER: matomo
MYSQL_PASSWORD: "ZxDp648UuL5fsN7eQI23E7ue"
MATOMO_ADMIN_PASSWORD: "J6QUtbroK4Z7zao4Dnl0J7e2"
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: matomo
namespace: solitaire
spec:
selector:
app: matomo
ports:
- name: http
port: 80
targetPort: 8080
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: solitaire
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: solitaire-db
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: solitaire-server
namespace: solitaire
spec:
selector:
app: solitaire-server
ports:
- name: http
port: 80
targetPort: 8080
+8 -6
View File
@@ -143,16 +143,18 @@ After the APK is signed cargo-apk panics with:
thread 'main' panicked: Bin is not compatible with Cdylib
```
This happens AFTER the APK is on disk and signed. cargo-apk is
trying to also wrap the desktop `[[bin]]` target. The APK is still
valid. Work around with `--lib`:
This happens AFTER the APK is on disk and signed. cargo-apk tries to
also wrap the desktop `[[bin]]` target alongside the `[lib]`. The APK
is valid — the panic is cosmetic. **Always use `--lib`**, which is the
canonical build command (see `CLAUDE.md §15.1`):
```bash
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
cargo apk build -p solitaire_app --lib
```
(Permanent fix to come — likely a `[[bin]] required-features = ["desktop"]`
gate so cargo-apk skips the bin target on Android.)
Root cause: upstream cargo-apk bug — it does not skip `[[bin]]` targets
when building for Android. No in-repo fix is possible; `--lib` is the
accepted workaround.
---
+6 -2
View File
@@ -1,4 +1,8 @@
# Solitaire Quest — Session Handoff
# Ferrous Solitaire — Session Handoff (ARCHIVED)
> **This file is from Phase 2 (2026-04-25, 242 tests). It is kept for historical
> reference only. The authoritative session handoff is at the repo root:
> `SESSION_HANDOFF.md`.**
> Last updated: 2026-04-25
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
@@ -20,7 +24,7 @@ All seven Cargo crates created and compiling cleanly:
| `solitaire_engine` | Stub | Bevy ECS systems — all plugins added in Phase 3 |
| `solitaire_server` | Stub | Axum sync server — implemented in Phase 8C |
| `solitaire_gpgs` | Compile-time stub | Google Play Games bridge — Android only, JNI in Phase: Android |
| `solitaire_app` | Working | Opens blank Bevy window titled "Solitaire Quest" at 1280×800 |
| `solitaire_app` | Working | Opens blank Bevy window titled "Ferrous Solitaire" at 1280×800 |
Fast compile profiles, `assets/` directory structure, and `.env.example` are all in place.
+270
View File
@@ -0,0 +1,270 @@
# 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.
---
## P5 — UX polish (2026-05-12)
- [x] **UX-1 — Modal Done button unreachable in gesture zone.** *Closed
2026-05-12.* New `apply_safe_area_to_modal_scrims` system in
`safe_area.rs` pads every `ModalScrim` bottom by `insets.bottom /
window.scale_factor()` (logical pixels). Fires when `SafeAreaInsets`
changes AND when a new `ModalScrim` is spawned (`Added<ModalScrim>`
filter). Verified on device: Settings Done button reachable at physical
y ≈ 18002000 (was y ≈ 2232+, inside gesture zone).
- [x] **UX-5b — Home mode selector glyph corruption.** *Closed
2026-05-12.* `home_plugin.rs` mode glyphs changed from Geometric Shapes
block (U+25xx — absent from FiraMono, renders as rectangles) to card
suits U+2660 ♠ / U+2665 ♥ / U+2666 ♦. Affects Zen, Challenge, and
Daily mode selector buttons shown at level 5+.
- [x] **UX-7 — Help screen HUD button entry wraps to two lines.** *Closed
2026-05-12.* Android `CONTROL_SECTIONS` entry for ≡ button shortened
from `"Menu: Stats, Settings, Profile, Achievements"` to
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs`.
Fits on one line at 360 dp.
- [x] **BUG-3 — Multi-modal stacking (Stats + Profile simultaneously).** *Closed
2026-05-12.* `handle_menu_button` in `hud_plugin.rs` now checks
`scrims: Query<(), With<ModalScrim>>` and only calls
`spawn_menu_popover` when `scrims.is_empty()`. Tapping ≡ while any
modal is open is a no-op. Verified on device.
## 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.
+1 -1
View File
@@ -2,7 +2,7 @@
> **Date:** 2026-04-28
> **Author:** Claude Code
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2
> **Scope:** Feasibility analysis for porting Ferrous Solitaire to Android using cargo-mobile2
---
@@ -1,4 +1,4 @@
# Solitaire Quest — Phase 1 + 2: Workspace & Core Game Engine
# Ferrous Solitaire — Phase 1 + 2: Workspace & Core Game Engine
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
@@ -555,7 +555,7 @@ fn main() {
.add_plugins(
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
title: "Ferrous Solitaire".into(),
resolution: (1280.0, 800.0).into(),
..default()
}),
@@ -571,7 +571,7 @@ fn main() {
```bash
cargo run -p solitaire_app --features bevy/dynamic_linking
```
Expected: A blank Bevy window titled "Solitaire Quest" opens. Press Escape or close the window to exit. No panics or errors in the terminal.
Expected: A blank Bevy window titled "Ferrous Solitaire" opens. Press Escape or close the window to exit. No panics or errors in the terminal.
---
@@ -1210,7 +1210,7 @@ fn main() {
.add_plugins(
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
title: "Ferrous Solitaire".into(),
resolution: (1280.0, 800.0).into(),
..default()
}),
+1 -1
View File
@@ -11,7 +11,7 @@
### Infrastructure
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
- A running Ferrous Solitaire sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
- Verify the server is live before starting:
```bash
+1 -1
View File
@@ -3,7 +3,7 @@
> **Why this exists.** The 24 mockups in this directory are mobile
> (390 × 844 logical, iPhone 14 Pro frame) with one exception
> (`home-menu-desktop.html`). The Stitch project that produced them
> is named "Solitaire Quest *Mobile* Redesign" — the mobile-first
> is named "Ferrous Solitaire *Mobile* Redesign" — the mobile-first
> framing was deliberate when the new Android target opened, but
> desktop is still the primary delivery surface. Porting the mobile
> mockups 1:1 would land a 390-px-wide column floating in the middle
+18 -1
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
@@ -78,7 +87,15 @@ required = true
name = "android.permission.INTERNET"
[package.metadata.android.application]
label = "Solitaire Quest"
label = "Ferrous Solitaire"
# 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

+21 -8
View File
@@ -25,13 +25,14 @@ use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
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,
register_theme_asset_sources, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
SelectionPlugin, SettingsPlugin,
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
};
@@ -105,7 +106,7 @@ pub fn run() {
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
title: "Ferrous Solitaire".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("solitaire-quest".into()),
@@ -131,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()
}),
@@ -173,6 +183,7 @@ pub fn run() {
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(SafeAreaInsetsPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
@@ -182,6 +193,8 @@ pub fn run() {
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
+1 -1
View File
@@ -1,4 +1,4 @@
//! Generates PNG assets for Solitaire Quest.
//! Generates PNG assets for Ferrous Solitaire.
//!
//! Produces:
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
+131 -37
View File
@@ -145,6 +145,10 @@ pub struct GameState {
/// Used by the `comeback` achievement condition.
#[serde(default)]
pub recycle_count: u32,
/// When `true`, the player may move the top card of a foundation pile back
/// onto a compatible tableau column. Off by default — non-standard house rule.
#[serde(default)]
pub take_from_foundation: bool,
/// Save-file schema version. Defaults to `1` for older files that pre-date
/// the field. The loader refuses any value other than
/// [`GAME_STATE_SCHEMA_VERSION`].
@@ -187,6 +191,7 @@ impl GameState {
is_auto_completable: false,
undo_count: 0,
recycle_count: 0,
take_from_foundation: false,
schema_version: GAME_STATE_SCHEMA_VERSION,
undo_stack: VecDeque::new(),
}
@@ -312,6 +317,18 @@ impl GameState {
}
}
PileType::Tableau(_) => {
if matches!(&from, PileType::Foundation(_)) {
if !self.take_from_foundation {
return Err(MoveError::RuleViolation(
"take-from-foundation rule is disabled".into(),
));
}
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can return from foundation at a time".into(),
));
}
}
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
if !can_place_on_tableau(&bottom_card, dest) {
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
@@ -409,12 +426,11 @@ impl GameState {
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
/// At that point the game can be completed without further player input.
pub fn check_auto_complete(&self) -> bool {
// Stock must be empty; waste may still have cards (they are resolved
// by draw() calls inside next_auto_complete_move / auto_complete_step).
if !self.piles[&PileType::Stock].cards.is_empty() {
return false;
}
if !self.piles[&PileType::Waste].cards.is_empty() {
return false;
}
(0..7).all(|i| {
self.piles[&PileType::Tableau(i)]
.cards
@@ -442,42 +458,53 @@ impl GameState {
if !self.is_auto_completable || self.is_won {
return None;
}
// Check waste top first — when stock is exhausted the waste may still
// contain cards that can go directly to a foundation.
let waste = PileType::Waste;
if let Some((card, slot)) = self.piles[&waste].cards.last()
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
{
let _ = card; // borrow ends here
return Some((waste, PileType::Foundation(slot)));
}
for i in 0..7 {
let tableau = PileType::Tableau(i);
if let Some(card) = self.piles[&tableau].cards.last() {
// Prefer the slot that already claims this card's suit so
// Aces don't sometimes land in slot 0 and then leave the
// matching suit-claimed slot empty.
let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 {
let foundation = PileType::Foundation(slot);
let pile = &self.piles[&foundation];
if pile.cards.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
}
} else if pile.claimed_suit() == Some(card.suit) {
candidate = Some(slot);
break;
}
}
let target_slot = candidate.or_else(|| {
// Only fall back to an empty slot if the card is an Ace,
// which is the only rank that can claim an empty slot.
if card.rank.value() == 1 { empty_slot } else { None }
});
if let Some(slot) = target_slot {
let foundation = PileType::Foundation(slot);
if can_place_on_foundation(card, &self.piles[&foundation]) {
return Some((tableau, foundation));
}
}
if let Some(slot) = self.piles[&tableau].cards.last()
.and_then(|c| self.foundation_slot_for(c))
{
return Some((tableau, PileType::Foundation(slot)));
}
}
None
}
/// Return the foundation slot index that `card` can legally move to, or
/// `None` if no such slot exists.
///
/// Prefers the slot already claiming this card's suit so Aces always land
/// in a consistent column. Falls back to an empty slot only for Aces.
fn foundation_slot_for(&self, card: &crate::card::Card) -> Option<u8> {
let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 {
let pile = &self.piles[&PileType::Foundation(slot)];
if pile.cards.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
}
} else if pile.claimed_suit() == Some(card.suit) {
candidate = Some(slot);
break;
}
}
let target = candidate.or_else(|| {
if card.rank.value() == 1 { empty_slot } else { None }
});
target.filter(|&slot| {
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
})
}
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
pub fn compute_time_bonus(&self) -> i32 {
scoring_time_bonus(self.elapsed_seconds)
@@ -1005,24 +1032,24 @@ mod tests {
}
#[test]
fn auto_complete_false_when_waste_not_empty() {
fn auto_complete_true_when_stock_empty_waste_has_cards() {
// Waste no longer blocks auto-complete — draw() drains it during
// auto-complete steps. Only stock-not-empty and face-down tableau
// cards block the flag.
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
// Leave the waste pile untouched (it may be empty after clearing stock,
// so add a card explicitly to ensure the waste guard is exercised).
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: 99,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
// Make all tableau cards face-up so only the waste guard is the blocker.
for i in 0..7 {
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
c.face_up = true;
}
}
assert!(!g.check_auto_complete());
assert!(g.check_auto_complete());
}
#[test]
@@ -1258,4 +1285,71 @@ mod tests {
"must target the Hearts-claimed slot, not the empty slot 0",
);
}
fn setup_take_from_foundation_game() -> GameState {
let mut g = new_game();
// Clear the board so we control the layout exactly.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Foundation slot 0: A♠, 2♠ (top = 2♠)
let f = g.piles.get_mut(&PileType::Foundation(0)).unwrap();
f.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
f.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Two, face_up: true });
// Tableau 0: 3♥ face-up (2♠ can go on 3♥ — different colour, rank-1)
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 3, suit: Suit::Hearts, rank: Rank::Three, face_up: true,
});
g
}
#[test]
fn take_from_foundation_blocked_by_default() {
let mut g = setup_take_from_foundation_game();
assert!(!g.take_from_foundation);
let err = g
.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1)
.unwrap_err();
assert!(
matches!(err, MoveError::RuleViolation(_)),
"expected RuleViolation, got {err:?}",
);
}
#[test]
fn take_from_foundation_allowed_when_enabled() {
let mut g = setup_take_from_foundation_game();
g.take_from_foundation = true;
g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap();
// Foundation slot 0 should now hold only the Ace.
assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1);
assert_eq!(g.piles[&PileType::Foundation(0)].cards[0].rank, Rank::Ace);
// The 2♠ should be on top of tableau 0 above the 3♥.
let t0 = &g.piles[&PileType::Tableau(0)].cards;
assert_eq!(t0.len(), 2);
assert_eq!(t0[1].rank, Rank::Two);
}
#[test]
fn take_from_foundation_rejects_illegal_tableau_placement() {
let mut g = setup_take_from_foundation_game();
g.take_from_foundation = true;
// Tableau 1 is empty — only a King can go there; 2♠ is not a King.
let err = g
.move_cards(PileType::Foundation(0), PileType::Tableau(1), 1)
.unwrap_err();
assert!(matches!(err, MoveError::RuleViolation(_)));
}
#[test]
fn take_from_foundation_rejects_count_gt_1() {
let mut g = setup_take_from_foundation_game();
g.take_from_foundation = true;
let err = g
.move_cards(PileType::Foundation(0), PileType::Tableau(0), 2)
.unwrap_err();
assert!(matches!(err, MoveError::RuleViolation(_)));
}
}
+1
View File
@@ -15,6 +15,7 @@ async-trait = { workspace = true }
dirs = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in
+3 -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
}
@@ -170,5 +163,8 @@ pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
};
pub mod matomo_client;
pub use matomo_client::MatomoClient;
pub mod platform;
pub use platform::data_dir;
+122
View File
@@ -0,0 +1,122 @@
//! Matomo HTTP Tracking API client.
//!
//! Buffers game-play events and flushes them via the Matomo bulk tracking
//! endpoint. Errors are silently discarded — analytics must never affect
//! gameplay or block the UI.
use std::sync::Mutex;
use reqwest::Client;
use uuid::Uuid;
/// Sends game-play events to a self-hosted Matomo instance via the
/// [HTTP Tracking API](https://developer.matomo.org/api-reference/tracking-api).
///
/// Construct once per session and share via `Arc`. `event` is cheap and
/// can be called from the Bevy main thread; `flush` is async and must be
/// called from a background task.
pub struct MatomoClient {
tracking_url: String,
site_id: u32,
/// 16 hex-char visitor ID, stable for the lifetime of this client.
visitor_id: String,
uid: Option<String>,
client: Client,
/// Pre-encoded query strings, one per buffered event.
pending: Mutex<Vec<String>>,
}
impl MatomoClient {
/// Create a new client targeting `base_url` (e.g. `"https://analytics.example.com"`).
pub fn new(base_url: impl AsRef<str>, site_id: u32, uid: Option<String>) -> Self {
let base = base_url.as_ref().trim_end_matches('/');
let tracking_url = format!("{}/matomo.php", base);
// Take the lower 64 bits of a v4 UUID and format as 16 hex chars.
let visitor_id = format!("{:016x}", Uuid::new_v4().as_u128() as u64);
Self {
tracking_url,
site_id,
visitor_id,
uid,
client: Client::new(),
pending: Mutex::new(Vec::new()),
}
}
/// Buffer one Matomo custom event. Never blocks; never fails visibly.
///
/// When the buffer exceeds 100 events the oldest 50 are dropped to
/// prevent unbounded memory growth during extended offline play.
pub fn event(
&self,
category: &str,
action: &str,
name: Option<&str>,
value: Option<f64>,
) {
let Ok(mut guard) = self.pending.lock() else {
return;
};
let mut qs = format!(
"idsite={}&rec=1&apiv=1&send_image=0\
&url=game%3A%2F%2Fsolitaire%2Fevent\
&_id={}&e_c={}&e_a={}",
self.site_id,
self.visitor_id,
url_encode(category),
url_encode(action),
);
if let Some(n) = name {
qs.push_str(&format!("&e_n={}", url_encode(n)));
}
if let Some(v) = value {
qs.push_str(&format!("&e_v={v}"));
}
if let Some(uid) = &self.uid {
qs.push_str(&format!("&uid={}", url_encode(uid)));
}
guard.push(qs);
if guard.len() > 100 {
guard.drain(0..50);
}
}
/// Drain the pending buffer and POST it to Matomo's bulk tracking endpoint.
///
/// The buffer is drained *before* the HTTP call so events recorded during
/// an in-flight flush are not lost. Network errors are silently discarded.
pub async fn flush(&self) {
let pending = {
let Ok(mut guard) = self.pending.lock() else {
return;
};
if guard.is_empty() {
return;
}
std::mem::take(&mut *guard)
};
let requests: Vec<String> = pending.into_iter().map(|qs| format!("?{qs}")).collect();
let body = serde_json::json!({ "requests": requests });
let _ = self
.client
.post(&self.tracking_url)
.json(&body)
.send()
.await;
}
}
fn url_encode(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
vec![c]
}
c => format!("%{:02X}", c as u32).chars().collect(),
})
.collect()
}
+37 -1
View File
@@ -49,7 +49,7 @@ pub enum SyncBackend {
#[default]
#[serde(rename = "local")]
Local,
/// Sync with a self-hosted Solitaire Quest server.
/// Sync with a self-hosted Ferrous Solitaire server.
#[serde(rename = "solitaire_server")]
SolitaireServer {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
@@ -231,6 +231,33 @@ pub struct Settings {
/// cleanly to `None` via `#[serde(default)]`.
#[serde(default)]
pub last_difficulty: Option<DifficultyLevel>,
/// Custom public name displayed on the leaderboard. When `None`, the
/// player's server `username` is used instead. Trimmed to 32 characters
/// before submission. Older `settings.json` files written before this
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
#[serde(default)]
pub leaderboard_display_name: Option<String>,
/// When `true`, the player may drag the top card of a completed foundation
/// pile back onto a compatible tableau column — a non-standard house rule.
/// Off by default. Older `settings.json` files deserialize cleanly to
/// `false` via `#[serde(default)]`.
#[serde(default)]
pub take_from_foundation: bool,
/// When `true`, anonymous game-play events (game start, game won, etc.)
/// are sent to the configured Matomo instance. Opt-in; defaults to `false`.
/// Requires `matomo_url` to be set. Older `settings.json` files deserialize
/// cleanly to `false` via `#[serde(default)]`.
#[serde(default)]
pub analytics_enabled: bool,
/// Base URL of the Matomo instance to send events to, e.g.
/// `"https://analytics.example.com"`. When `None` the analytics toggle has
/// no effect. Older `settings.json` files deserialize cleanly to `None`.
#[serde(default)]
pub matomo_url: Option<String>,
/// Matomo site ID assigned when the tracked site was created in Matomo.
/// Defaults to `1` (the first site created in a fresh Matomo install).
#[serde(default = "default_matomo_site_id")]
pub matomo_site_id: u32,
}
fn default_draw_mode() -> DrawMode {
@@ -299,6 +326,10 @@ fn default_replay_move_interval_secs() -> f32 {
0.45
}
fn default_matomo_site_id() -> u32 {
1
}
/// Lower bound of the player-tunable replay-playback per-move interval,
/// in seconds. Below this the cards barely register visually before
/// the next move fires; the cap keeps the playback legible.
@@ -350,6 +381,11 @@ impl Default for Settings {
disable_smart_default_size: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None,
leaderboard_display_name: None,
take_from_foundation: false,
analytics_enabled: false,
matomo_url: None,
matomo_site_id: default_matomo_site_id(),
}
}
}
+89 -9
View File
@@ -6,7 +6,7 @@
//! | Struct | Backend |
//! |---|---|
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
//! | [`SolitaireServerClient`] | Self-hosted Solitaire Quest server (JWT auth) |
//! | [`SolitaireServerClient`] | Self-hosted Ferrous Solitaire server (JWT auth) |
//!
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
//! without matching on [`SyncBackend`] anywhere else in the codebase.
@@ -55,7 +55,7 @@ impl SyncProvider for LocalOnlyProvider {
// SolitaireServerClient
// ---------------------------------------------------------------------------
/// HTTP sync client for the self-hosted Solitaire Quest server.
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
///
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
/// client automatically attempts a token refresh and retries the request once
@@ -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()))
}
+53
View File
@@ -418,3 +418,56 @@ async fn pull_after_account_deletion_returns_default_or_error() {
let _ = delete_tokens(username);
}
/// **Push retry on 401.**
///
/// Mirrors `jwt_refresh_on_401_succeeds` but for the `push()` path.
/// We install an expired access token so the first push attempt returns 401,
/// the client refreshes, and the retry push succeeds.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn push_retries_after_401_on_expired_access_token() {
ensure_mock_keyring();
let base = spawn_test_server().await;
let username = "rt_push_expiring";
let (_real_access, real_refresh) =
register_user_raw(&base, username, "pushexpirepass1!").await;
let user_id = decode_sub(&_real_access);
#[derive(serde::Serialize)]
struct Claims {
sub: String,
exp: usize,
kind: String,
}
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_access = encode(
&Header::default(),
&Claims {
sub: user_id.clone(),
exp,
kind: "access".into(),
},
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
)
.expect("failed to encode expired access token");
store_tokens(username, &expired_access, &real_refresh)
.expect("storing tokens in mock keyring must succeed");
let client = SolitaireServerClient::new(&base, username);
let payload = make_payload(&user_id, 17);
// Push: server returns 401, client refreshes, retries, succeeds.
let push_resp = client
.push(&payload)
.await
.expect("push must succeed after the client transparently refreshes the access token");
assert_eq!(
push_resp.merged.stats.games_played, 17,
"merged games_played must reflect what was pushed after auto-refresh"
);
let _ = delete_tokens(username);
}
+1
View File
@@ -14,6 +14,7 @@ chrono = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
usvg = { workspace = true }
resvg = { workspace = true }
+4 -2
View File
@@ -116,6 +116,7 @@ impl Plugin for AchievementPlugin {
// achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
// Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee).
@@ -139,6 +140,7 @@ impl Plugin for AchievementPlugin {
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel)
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
@@ -531,9 +533,9 @@ fn spawn_achievements_screen(
}
let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
(ACCENT_PRIMARY, TEXT_PRIMARY, "+ ")
} else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
(TEXT_DISABLED, TEXT_DISABLED, "- ")
};
let tooltip_text = tooltip_for_row(record.unlocked, def);
+188
View File
@@ -0,0 +1,188 @@
//! Matomo analytics plugin — buffers game-play events and flushes them to
//! the configured Matomo instance in the background.
//!
//! Disabled by default (opt-in via Settings → Privacy). Only active when
//! `settings.analytics_enabled` is `true` AND `settings.matomo_url` is set.
use std::sync::Arc;
use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
use solitaire_core::game_state::GameMode;
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
// ---------------------------------------------------------------------------
// Resource
// ---------------------------------------------------------------------------
/// Holds the active Matomo client. `None` when the feature is disabled.
#[derive(Resource)]
pub struct AnalyticsResource {
pub client: Option<Arc<MatomoClient>>,
flush_timer: Timer,
}
impl Default for AnalyticsResource {
fn default() -> Self {
Self {
client: None,
flush_timer: Timer::from_seconds(60.0, TimerMode::Repeating),
}
}
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers analytics systems. Add after `SettingsPlugin` in the app.
pub struct AnalyticsPlugin;
impl Plugin for AnalyticsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AnalyticsResource>()
.add_systems(Startup, init_analytics)
.add_systems(
Update,
(
react_to_settings_change,
on_game_won,
on_forfeit,
on_new_game,
on_achievement_unlocked,
tick_flush_timer,
),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
fn init_analytics(settings: Res<SettingsResource>, mut analytics: ResMut<AnalyticsResource>) {
analytics.client = client_for(&settings.0);
}
fn react_to_settings_change(
mut events: MessageReader<SettingsChangedEvent>,
mut analytics: ResMut<AnalyticsResource>,
) {
for ev in events.read() {
analytics.client = client_for(&ev.0);
}
}
fn on_game_won(
mut wins: MessageReader<GameWonEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in wins.read() {
client.event("Game", "Won", None, Some(ev.score as f64));
fire_flush(client.clone(), &settings.0);
}
}
fn on_forfeit(
mut forfeits: MessageReader<ForfeitEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for _ev in forfeits.read() {
client.event("Game", "Forfeit", None, None);
fire_flush(client.clone(), &settings.0);
}
}
fn on_new_game(
mut requests: MessageReader<NewGameRequestEvent>,
analytics: Res<AnalyticsResource>,
game: Res<GameStateResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in requests.read() {
if !ev.confirmed {
continue;
}
let mode = ev.mode.unwrap_or(game.0.mode);
client.event("Game", "Start", Some(mode_str(mode)), None);
}
}
fn on_achievement_unlocked(
mut achievements: MessageReader<AchievementUnlockedEvent>,
analytics: Res<AnalyticsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in achievements.read() {
client.event("Achievement", "Unlocked", Some(&ev.0.id), None);
}
}
fn tick_flush_timer(
time: Res<Time>,
mut analytics: ResMut<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
analytics.flush_timer.tick(time.delta());
if !analytics.flush_timer.just_finished() {
return;
}
if let Some(client) = analytics.client.clone() {
fire_flush(client, &settings.0);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
if !settings.analytics_enabled {
return None;
}
let url = settings.matomo_url.as_deref()?;
let uid = match &settings.sync_backend {
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
SyncBackend::Local => None,
};
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
}
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
AsyncComputeTaskPool::get()
.spawn(async move {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
rt.block_on(client.flush());
}
})
.detach();
}
fn mode_str(mode: GameMode) -> &'static str {
match mode {
GameMode::Classic => "classic",
GameMode::Zen => "zen",
GameMode::Challenge => "challenge",
GameMode::TimeAttack => "time_attack",
GameMode::Difficulty(_) => "difficulty",
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! SVG builder for the Solitaire Quest application icon.
//! SVG builder for the Ferrous Solitaire application icon.
//!
//! Renders the project's signature `▌RS` Terminal mark (the same
//! cursor-block + monogram pair used on the splash boot-screen and
+2 -4
View File
@@ -1,10 +1,8 @@
//! Asset-loading infrastructure for runtime SVG rasterisation and the
//! per-platform user-themes directory.
//!
//! See `CARD_PLAN.md` for the full multi-phase implementation plan.
//! This module is the entry point for Phases 1 (SVG → `Image`) and 5
//! (user-themes directory). Phase 3 will extend it further with custom
//! `AssetSource` implementations for `embedded://` and `themes://`.
//! Provides the SVG → `Image` loader and the `embedded://` / `themes://`
//! custom `AssetSource` implementations used by the theme system.
pub mod card_face_svg;
pub mod icon_svg;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Bevy `AssetLoader` that rasterises an SVG into `bevy::image::Image`.
//!
//! The card-theme system (see `CARD_PLAN.md`) ships SVG sources both as
//! The card-theme system ships SVG sources both as
//! the embedded default theme and as user-supplied themes. Bevy 0.18 has
//! no built-in SVG support, so this loader bridges `usvg` (parser) +
//! `resvg` (renderer) + `tiny-skia` (CPU pixmap) to produce textures
@@ -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,
+211 -40
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((
@@ -1424,7 +1484,7 @@ fn update_stock_empty_indicator(
// ---------------------------------------------------------------------------
// Stock-pile remaining-count badge
//
// Shows a small "·N" chip pinned to the top-right corner of the stock pile so
// Shows a small "N" chip pinned to the top-right corner of the stock pile so
// the player can see how many cards remain before the next recycle. The
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
// the badge hides itself when the stock has zero cards — the two indicators
@@ -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()
};
@@ -1502,7 +1562,7 @@ fn spawn_stock_count_badge(
.with_children(|b| {
b.spawn((
StockCountBadgeText,
Text2d::new(format!("·{count}")),
Text2d::new(format!("{count}")),
text_font,
TextColor(STOCK_BADGE_FG),
// Slightly above the chip background so the digits aren't
@@ -1564,7 +1624,7 @@ fn update_stock_count_badge(
if let Ok(badge_children) = children.get(entity) {
for child in badge_children.iter() {
if let Ok(mut text) = texts.get_mut(child) {
let new = format!("·{count}");
let new = format!("{count}");
if text.0 != new {
text.0 = new;
}
@@ -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, true);
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, true);
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, true);
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, true);
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, true);
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, true);
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, true);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!(
(after - expected).abs() < 1e-3,
@@ -2640,7 +2811,7 @@ mod tests {
// First update inside `app()` runs the spawn path; run one more to
// confirm the in-place update path is also stable.
app.update();
assert_eq!(stock_badge_text(&mut app), "·24");
assert_eq!(stock_badge_text(&mut app), "24");
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
}
@@ -2666,7 +2837,7 @@ mod tests {
// initial 24) and assert the badge text follows.
let mut app = app();
// Sanity-check the starting count.
assert_eq!(stock_badge_text(&mut app), "·24");
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
@@ -2674,7 +2845,7 @@ mod tests {
}
}
app.update();
assert_eq!(stock_badge_text(&mut app), "·23");
assert_eq!(stock_badge_text(&mut app), "23");
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
}
+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, true);
// 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, true)))
.insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays);
app
@@ -581,6 +581,12 @@ mod tests {
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today);
// Flush any stale events from headless_app()'s initial update (the
// double-buffer keeps them visible for one extra frame).
app.update();
app.world_mut()
.resource_mut::<Messages<WarningToastEvent>>()
.clear();
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
@@ -597,6 +603,9 @@ mod tests {
.resource_mut::<ProgressResource>()
.0
.daily_challenge_last_completed = Some(today);
app.world_mut()
.resource_mut::<Messages<WarningToastEvent>>()
.clear();
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
+26
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 /
@@ -266,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.
///
+104 -40
View File
@@ -11,6 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::window::AppLifecycle;
use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
@@ -200,6 +201,7 @@ impl Plugin for GamePlugin {
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<AppLifecycle>()
.add_systems(
Update,
poll_pending_new_game_seed.before(GameMutation),
@@ -252,20 +254,42 @@ pub fn advance_elapsed(
}
/// Increment `GameState.elapsed_seconds` once per real-world second while
/// the game is in progress (not won), not paused, and the launch /
/// mode-picker Home modal isn't covering the board. Stops counting on
/// win so the final time reflects how long the player took to solve
/// the deal; stops while the pause overlay is open; stops while Home
/// is up so the timer doesn't tick under the picker before the player
/// has actually committed to a deal.
/// the game is in progress (not won), not paused, and no blocking modal
/// (Home picker or first-run onboarding) is covering the board. Stops
/// counting on win so the final time reflects how long the player took;
/// stops while the pause overlay is open; stops while Home is up so the
/// timer doesn't tick before the player commits to a deal; stops while
/// the onboarding modal is visible so a new player's first-game time
/// isn't inflated by reading the tutorial.
///
/// On Android the first frame after the app is resumed from background
/// can carry a very large `delta_secs` equal to the entire suspension
/// period. `skip_next_delta` is set to `true` on `WillSuspend` /
/// `Suspended` so that frame's delta is dropped instead of applied.
#[allow(clippy::too_many_arguments)]
fn tick_elapsed_time(
time: Res<Time>,
mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>,
mut skip_next_delta: Local<bool>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
onboarding_screens: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
mut lifecycle: MessageReader<AppLifecycle>,
) {
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillSuspend | AppLifecycle::Suspended) {
*skip_next_delta = true;
}
}
if paused.is_some_and(|p| p.0)
|| !home_screens.is_empty()
|| !onboarding_screens.is_empty()
{
return;
}
if *skip_next_delta {
*skip_next_delta = false;
return;
}
let is_won = game.0.is_won;
@@ -466,6 +490,9 @@ fn handle_new_game(
let chosen_seed = initial_seed;
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
if let Some(s) = settings.as_ref() {
game.0.take_from_foundation = s.0.take_from_foundation;
}
// Reset the in-flight replay buffer — a fresh deal starts with
// an empty move list. The previously saved replay on disk
// (latest_replay.json) is preserved until the player wins again.
@@ -989,17 +1016,29 @@ pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::pile::PileType;
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());
}
// Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles).
// A game can only be genuinely stuck when both stock AND waste are exhausted.
let stock_empty = game.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty());
let waste_empty = game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty());
if !stock_empty || !waste_empty {
return true;
}
// Stock and waste exhausted — check whether any visible card can be placed.
let mut sources: Vec<Card> = Vec::new();
// Top waste card (waste is empty here, but included for completeness).
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());
}
}
}
@@ -1064,9 +1103,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() {
@@ -1638,19 +1679,18 @@ mod tests {
#[test]
fn has_legal_moves_returns_true_for_fresh_game() {
// A fresh deal always contains at least one playable card —
// typically several tableau→tableau opportunities plus any Aces
// that surface as a tableau column's bottom card.
// A fresh deal always has a non-empty stock (24 cards), so drawing
// is always a legal move regardless of the initial face-up tableau cards.
let game = GameState::new(42, DrawMode::DrawOne);
assert!(has_legal_moves(&game), "fresh deal must contain at least one legal move");
}
#[test]
fn has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards() {
// Reproduces Quat's softlock: stock has cards but no card anywhere
// (stock or otherwise) can land on any pile. The previous heuristic
// returned `true` here because stock was non-empty, so the game
// sat there forever instead of declaring softlock.
fn has_legal_moves_returns_true_when_stock_has_cards_even_if_not_immediately_placeable() {
// Drawing from a non-empty stock is always a legal move in standard
// Klondike (unlimited recycles), even if the drawn card cannot be
// immediately placed. The game is only stuck when both stock AND waste
// are exhausted and no visible card can be moved.
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 {
@@ -1660,25 +1700,15 @@ mod tests {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Fill foundation 0 with Clubs A10, leaving only J/Q/K of Clubs
// as plausible foundation moves; load the stock with cards that
// can't land on the empty tableau (anything but a King) and can't
// extend foundation 0 (anything but Jack of Clubs).
let stock = game.piles.get_mut(&PileType::Stock).unwrap();
stock.cards.clear();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
stock.cards.push(Card { id: 100 + r as u32, suit: Suit::Hearts, rank: r, face_up: false });
}
let foundation_zero = game.piles.get_mut(&PileType::Foundation(0)).unwrap();
for r in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
] {
foundation_zero.cards.push(Card { id: r as u32, suit: Suit::Clubs, rank: r, face_up: true });
}
// Stock is non-empty, so drawing is always a valid move.
assert!(
!has_legal_moves(&game),
"stock cards with no legal landing should count as softlock",
has_legal_moves(&game),
"non-empty stock means drawing is a legal move regardless of placement options",
);
}
@@ -1730,6 +1760,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
// -----------------------------------------------------------------------
+43 -7
View File
@@ -44,13 +44,19 @@ pub struct HelpPlugin;
impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the help-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
// `MouseWheel` and `TouchInput` are emitted by Bevy's input
// plugin under `DefaultPlugins`; register them explicitly so
// scroll systems run cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
.add_systems(
Update,
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
(
toggle_help_screen,
handle_help_close_button,
scroll_help_panel,
crate::ui_modal::touch_scroll_panel::<HelpScrollable>,
),
);
}
}
@@ -129,6 +135,36 @@ struct ControlSection {
rows: &'static [ControlRow],
}
#[cfg(target_os = "android")]
const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Touch",
rows: &[
ControlRow { keys: "Tap stock", description: "Draw from stock" },
ControlRow { keys: "Drag card", description: "Move cards between piles" },
ControlRow { keys: "Tap foundation area", description: "Auto-move top card to foundation" },
],
},
ControlSection {
title: "New Game",
rows: &[
ControlRow { keys: "New+", description: "Start a new Classic game" },
ControlRow { keys: "Modes↓", description: "Pick Daily, Zen, Challenge, or Time Attack" },
],
},
ControlSection {
title: "HUD buttons",
rows: &[
ControlRow { keys: "", description: "Undo last move" },
ControlRow { keys: "||", description: "Pause / resume" },
ControlRow { keys: "?", description: "This help screen" },
ControlRow { keys: "", description: "Show a hint" },
ControlRow { keys: "", description: "Open menu (Stats, Settings, Profile...)" },
],
},
];
#[cfg(not(target_os = "android"))]
const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Gameplay",
@@ -229,6 +265,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
row_gap: VAL_SPACE_2,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
padding: UiRect::bottom(Val::Px(96.0)),
..default()
},
))
@@ -250,9 +287,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),
+16 -24
View File
@@ -150,32 +150,24 @@ impl HomeMode {
/// readability rather than visual fidelity. Swap to `Image` nodes
/// when art lands; the rest of the tile layout doesn't change.
///
/// Picks are constrained to **card suits** (U+2660-2666) and basic
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled
/// FiraMono-Medium face actually covers. Earlier choices in
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as
/// missing-glyph rectangles because FiraMono's coverage there is
/// minimal.
/// Picks are constrained to **card suits** (U+2660-2666), the
/// **Arrows** block (U+2190-21FF), and ASCII — ranges confirmed
/// present in the bundled FiraMono-Medium face. The Geometric
/// Shapes block (U+25xx) is NOT covered by FiraMono; glyphs in
/// that range render as missing-glyph rectangles on Android.
fn glyph(self) -> &'static str {
match self {
// Black club — card suit, the obvious solitaire mark.
// Black club — card suit; the obvious solitaire mark.
HomeMode::Classic => "\u{2663}",
// Black diamond — Geometric Shapes; reads as the day's gem.
HomeMode::Daily => "\u{25C6}",
// White circle — Geometric Shapes; reads as the Zen enso.
HomeMode::Zen => "\u{25CB}",
// Black up-pointing triangle — Geometric Shapes; reads as
// a mountain / a step up in difficulty.
HomeMode::Challenge => "\u{25B2}",
// Rightwards arrow — Arrows block (U+2190-21FF), a core
// range every dev-oriented monospace font (FiraMono
// included) ships. Reads as "go / fast-forward" for the
// timed mode. Earlier ▶ (U+25B6) did not render; FiraMono
// ships ▲ (up triangle) but evidently not the sideways
// siblings.
// Black diamond suit — "gem of the day" reading.
HomeMode::Daily => "\u{2666}",
// Black heart suit — calm/warm; conveys the Zen mood.
HomeMode::Zen => "\u{2665}",
// Black spade suit — sharp/high-stakes; signals difficulty.
HomeMode::Challenge => "\u{2660}",
// Rightwards arrow — "go / fast-forward" for the timed mode.
HomeMode::TimeAttack => "\u{2192}",
// Number sign — ASCII, universally available. Reads as
// "a specific number / seed ID".
// Number sign — ASCII; "a specific seed ID".
HomeMode::PlayBySeed => "#",
}
}
@@ -1385,8 +1377,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),
+525 -98
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,
@@ -33,10 +36,18 @@ use crate::events::{
};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
#[cfg(target_os = "android")]
use crate::input_plugin::TouchDragSet;
use crate::layout::LayoutSystem;
#[cfg(target_os = "android")]
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
#[cfg(target_os = "android")]
use crate::resources::DragState;
use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_tooltip::Tooltip;
/// Marker on the score text node.
@@ -115,6 +126,37 @@ pub struct HudDrawCycle;
#[derive(Component, Debug)]
pub struct HudSelection;
/// Marker on the HUD band background node (the translucent band behind buttons).
#[derive(Component, Debug)]
pub struct HudBand;
/// Marker on the HUD score/info column root node.
#[derive(Component, Debug)]
pub struct HudColumn;
/// Marker on the action button bar root node.
#[derive(Component, Debug)]
pub struct HudActionBar;
/// Controls whether the in-game HUD (band, score column, action buttons) is
/// visible. Toggled on Android by tapping empty board space; always `Visible`
/// on desktop. Resets to `Visible` whenever a modal opens.
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HudVisibility {
#[default]
Visible,
Hidden,
}
#[cfg(target_os = "android")]
#[derive(Resource, Debug, Default)]
struct HudTapTracker {
start_pos: Option<bevy::math::Vec2>,
}
#[cfg(target_os = "android")]
const HUD_TAP_SLOP_PX: f32 = 15.0;
/// Drives the score-readout pulse: scales the [`HudScore`] text from
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
@@ -239,6 +281,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,10 +320,29 @@ pub struct MenuButton;
#[derive(Component, Debug)]
pub struct MenuPopover;
/// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities
/// while they are open. External systems (e.g. `PausePlugin`) query this to
/// determine whether a HUD popover is currently visible without importing the
/// individual popover types.
#[derive(Component, Debug)]
pub struct HudPopoverOpen;
/// 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)]
pub enum MenuOption {
Help,
Modes,
Stats,
Achievements,
Profile,
@@ -322,11 +388,24 @@ impl Plugin for HudPlugin {
.add_message::<WinStreakMilestoneEvent>()
.init_resource::<PreviousScore>()
.init_resource::<HudActionFade>()
.init_resource::<HudVisibility>()
// Escape-close handlers for popovers read this; init defensively
// so HudPlugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
// 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,
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
)
.add_systems(Update, restore_hud_on_modal)
.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 +429,15 @@ 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,
close_modes_popover_on_escape,
handle_menu_button,
handle_menu_option_click,
handle_menu_backdrop_click,
close_menu_popover_on_escape,
paint_action_buttons,
),
)
@@ -363,6 +447,17 @@ impl Plugin for HudPlugin {
// `paint_action_buttons` would clobber the alpha back to 1.0
// mid-fade and produce a visible blip.
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
#[cfg(target_os = "android")]
{
app.init_resource::<HudTapTracker>()
.add_message::<bevy::input::touch::TouchInput>()
.add_systems(
Update,
toggle_hud_on_tap
.after(TouchDragSet::AfterStartDrag)
.in_set(TouchDragSet::BeforeEndDrag),
);
}
}
}
@@ -376,11 +471,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 +488,8 @@ 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 },
HudBand,
));
}
@@ -413,7 +512,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 +538,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 +557,22 @@ 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 },
HudColumn,
))
.with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
@@ -568,94 +692,82 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
/// because it's the most consequential action; the destructive button sits
/// on its own visual edge.
fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Commands) {
fn spawn_action_buttons(
font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
// 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()
};
// 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 (Arrows/Math-Op, confirmed FiraMono)
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
/* help */ "?",
/* hint */ "\u{2192}", // → rightwards arrow (Arrows block, confirmed FiraMono)
/* modes */ "\u{2193}", // ↓ downwards arrow (Arrows block, confirmed FiraMono)
// replaces ▾ (U+25BE) which is absent from 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 },
HudActionBar,
))
.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.
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY);
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY);
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY);
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY);
});
}
@@ -680,33 +792,44 @@ fn spawn_action_button<M: Component>(
tooltip: &'static str,
font: &TextFont,
order: i32,
text_color: Color,
) {
// 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)),
@@ -718,7 +841,7 @@ fn spawn_action_button<M: Component>(
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label —
// keeps the keyboard accelerator discoverable without
@@ -777,12 +900,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 +950,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,
@@ -859,6 +1016,7 @@ fn spawn_modes_popover(
commands
.spawn((
ModesPopover,
HudPopoverOpen,
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
@@ -896,6 +1054,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 +1084,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 +1117,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 +1132,8 @@ 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>>,
scrims: Query<(), With<ModalScrim>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
@@ -963,7 +1145,10 @@ fn handle_menu_button(
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
} else {
for e in &backdrops {
commands.entity(e).despawn();
}
} else if scrims.is_empty() {
spawn_menu_popover(&mut commands, font_res.as_deref());
}
}
@@ -982,7 +1167,17 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what each overlay shows — mirroring
// the tooltips on the action-bar buttons that opened this popover.
let rows: [(MenuOption, &'static str, &'static str); 5] = [
let rows: [(MenuOption, &'static str, &'static str); 7] = [
(
MenuOption::Help,
"Help",
"Show controls, rules, and keyboard shortcuts.",
),
(
MenuOption::Modes,
"Game Modes",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
),
(
MenuOption::Stats,
"Stats",
@@ -1013,6 +1208,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
commands
.spawn((
MenuPopover,
HudPopoverOpen,
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
@@ -1050,6 +1246,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,20 +1271,32 @@ 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>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
MenuOption::Help => {
help.write(HelpRequestEvent);
}
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent);
}
@@ -1092,7 +1317,85 @@ 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();
}
}
if open_modes {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
}
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
fn close_modes_popover_on_escape(
keys: Res<ButtonInput<KeyCode>>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut commands: Commands,
) {
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
/// is pressed while the popover is open.
fn close_menu_popover_on_escape(
keys: Res<ButtonInput<KeyCode>>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut commands: Commands,
) {
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
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
@@ -1938,6 +2241,122 @@ 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;
}
}
#[allow(clippy::type_complexity)]
fn apply_hud_visibility(
hud_vis: Res<HudVisibility>,
mut nodes: Query<
&mut Visibility,
Or<(With<HudBand>, With<HudColumn>, With<HudActionBar>)>,
>,
window_entities: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
if !hud_vis.is_changed() {
return;
}
let v = if *hud_vis == HudVisibility::Visible {
Visibility::Visible
} else {
Visibility::Hidden
};
for mut node_vis in &mut nodes {
*node_vis = v;
}
if let Some((entity, window)) = window_entities.iter().next() {
resize_events.write(WindowResized {
window: entity,
width: window.resolution.width(),
height: window.resolution.height(),
});
}
}
fn restore_hud_on_modal(
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
mut hud_vis: ResMut<HudVisibility>,
) {
if !new_scrims.is_empty() {
*hud_vis = HudVisibility::Visible;
}
}
#[cfg(target_os = "android")]
fn toggle_hud_on_tap(
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
drag: Res<DragState>,
scrims: Query<(), With<ModalScrim>>,
paused: Option<Res<PausedResource>>,
mut tracker: ResMut<HudTapTracker>,
mut hud_vis: ResMut<HudVisibility>,
) {
use bevy::input::touch::TouchPhase;
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
tracker.start_pos = None;
return;
}
for event in touch_events.read() {
match event.phase {
TouchPhase::Started => {
tracker.start_pos = Some(event.position);
}
TouchPhase::Ended if drag.is_idle() => {
if let Some(start) = tracker.start_pos.take() {
if (event.position - start).length() < HUD_TAP_SLOP_PX {
*hud_vis = match *hud_vis {
HudVisibility::Visible => HudVisibility::Hidden,
HudVisibility::Hidden => HudVisibility::Visible,
};
}
}
}
TouchPhase::Canceled | TouchPhase::Moved => {
tracker.start_pos = None;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2443,6 +2862,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),
] {
@@ -2551,6 +2971,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."
@@ -2637,7 +3061,7 @@ mod tests {
);
}
// Same contract for MenuOption rows: five entries, each with a
// Same contract for MenuOption rows: seven entries, each with a
// tooltip, exact strings matching the approved microcopy.
let mut menu_q = app
.world_mut()
@@ -2648,11 +3072,13 @@ mod tests {
.collect();
assert_eq!(
menu_tooltips.len(),
5,
"expected a tooltip on each of the 5 menu rows, got {}",
7,
"expected a tooltip on each of the 7 menu rows, got {}",
menu_tooltips.len()
);
for expected in [
"Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.",
@@ -2670,14 +3096,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]
+105 -101
View File
@@ -33,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::{
@@ -53,6 +51,16 @@ use crate::resources::{DragState, GameStateResource, HintCycleIndex};
use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource;
/// System-set labels used to anchor external systems relative to the touch
/// drag pipeline without duplicating the internal chain ordering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum TouchDragSet {
/// After `touch_start_drag` has run — drag state is populated if a card was touched.
AfterStartDrag,
/// Before `touch_end_drag` runs — drag state has not yet been cleared.
BeforeEndDrag,
}
/// Z-depth used for cards while being dragged — above all resting cards.
const DRAG_Z: f32 = 500.0;
@@ -105,10 +113,10 @@ impl Plugin for InputPlugin {
follow_drag,
end_drag.before(GameMutation),
// Touch drag pipeline (parallel path through DragState).
touch_start_drag,
touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
touch_follow_drag,
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
touch_end_drag.before(GameMutation),
touch_end_drag.after(TouchDragSet::BeforeEndDrag).before(GameMutation),
)
.chain(),
)
@@ -522,8 +530,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)>,
@@ -538,6 +548,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 };
@@ -614,7 +633,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, _)) =
@@ -875,7 +894,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, _)) =
@@ -1047,8 +1066,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 {
@@ -1058,9 +1077,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;
}
@@ -1195,7 +1214,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;
@@ -1217,9 +1236,10 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
/// Maximum seconds between two clicks to count as a double-click.
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
/// Maximum seconds between two taps to count as a double-tap.
/// Slightly wider than the mouse window — touch screens have higher latency.
const DOUBLE_TAP_WINDOW: f32 = 0.5;
/// 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.
///
@@ -1375,63 +1395,51 @@ fn handle_double_click(
}
// ---------------------------------------------------------------------------
// Task #27b — Double-tap to auto-move (touch equivalent of double-click)
// Tap-to-move (touch equivalent of mouse auto-move)
// ---------------------------------------------------------------------------
/// System that detects double-taps on face-up cards and fires `MoveRequestEvent`
/// to the best legal destination — the touch equivalent of [`handle_double_click`].
/// 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.
///
/// A pure tap is identified by `drag.active_touch_id.is_some() &&
/// !drag.committed`: the touch began (so `touch_start_drag` populated
/// `drag`) but the drag threshold was never crossed.
///
/// Move priority matches [`handle_double_click`]:
/// 1. Move the single top card to its best foundation (or tableau).
/// 2. If no single-card move exists and the selection spans multiple
/// face-up cards, move the whole stack to the best tableau column.
/// 3. If both priorities fail, fire `MoveRejectedEvent` for audio + shake
/// feedback.
/// 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>>,
time: Res<Time>,
radial: Option<Res<RightClickRadialState>>,
drag: Res<DragState>,
game: Res<GameStateResource>,
mut last_tap: Local<HashMap<u32, f32>>,
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;
}
// Only active when a touch is tracked and hasn't crossed the drag threshold.
let Some(active_id) = drag.active_touch_id else { return };
if drag.committed {
return;
}
for event in touch_events.read() {
if event.id != active_id {
if event.id != active_id || event.phase != TouchPhase::Ended {
continue;
}
match event.phase {
TouchPhase::Canceled => {
// Cancelled touch — clear any pending tap state for these cards.
for &id in &drag.cards {
last_tap.remove(&id);
}
return;
}
TouchPhase::Ended => {}
_ => continue,
}
// Uncommitted touch ended = pure tap.
let Some(&top_card_id) = drag.cards.last() else { return };
@@ -1445,50 +1453,54 @@ fn handle_double_tap(
return;
}
let now = time.elapsed_secs();
let prev = last_tap.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
// 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;
}
if now - prev <= DOUBLE_TAP_WINDOW {
last_tap.remove(&top_card_id);
// Priority 1: move single top card.
if let Some(dest) = best_destination(top_card, &game.0) {
// 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: 1,
count,
});
return;
}
// Priority 2: move whole face-up stack to best tableau column.
if drag.cards.len() > 1 {
let stack_index = pile_cards.cards.len() - drag.cards.len();
if let Some(bottom_card) = pile_cards.cards.get(stack_index)
&& let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card,
pile,
&game.0,
drag.cards.len(),
)
{
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count,
});
return;
}
}
rejected.write(MoveRejectedEvent {
from: pile.clone(),
to: pile.clone(),
count: drag.cards.len(),
});
} else {
last_tap.insert(top_card_id, now);
}
rejected.write(MoveRejectedEvent {
from: pile.clone(),
to: pile.clone(),
count: drag.cards.len(),
});
}
}
@@ -1630,7 +1642,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, true);
// In tableau 6, the visually topmost card is the last (face-up) one.
// Its position: base.y + fan * 6.
@@ -1644,7 +1656,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, true);
// 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
@@ -1665,7 +1677,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, true);
// 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
@@ -1704,7 +1716,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, true);
// 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
@@ -1736,7 +1748,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, true);
// 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);
@@ -1749,7 +1761,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, true);
// 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();
@@ -1761,7 +1773,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, true);
let pos = layout.pile_positions[&PileType::Tableau(3)];
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
assert_eq!(target, None);
@@ -1770,7 +1782,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, true);
// 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
@@ -1795,7 +1807,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, true);
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;
@@ -1811,7 +1823,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, true);
// 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)];
@@ -1822,7 +1834,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, true);
for pile in [
PileType::Waste,
PileType::Foundation(2),
@@ -2323,7 +2335,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, true),
));
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
app.add_systems(Update, handle_keyboard_hint);
@@ -2345,13 +2357,5 @@ mod tests {
);
}
// Task #27b — double-tap constants
#[test]
fn double_tap_window_is_wider_than_double_click_window() {
// Compile-time check: touch needs a wider window than mouse due to
// higher input latency. `const { assert! }` catches regressions at
// build time rather than waiting for a test run.
const { assert!(DOUBLE_TAP_WINDOW > DOUBLE_CLICK_WINDOW) }
}
}
+267 -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,8 +146,9 @@ 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, hud_visible: bool) -> Layout {
let window = window.max(MIN_WINDOW);
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
let card_width_width_based = window.x / 9.0;
@@ -113,7 +170,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 - band_h).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 +190,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 - band_h - 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 +210,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 +271,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, true));
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true));
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true));
}
#[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, true);
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true);
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 +289,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, true).
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0, true);
let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0, true);
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, true);
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, true);
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 +334,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, true);
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 +347,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, true);
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 +359,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, true);
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 +370,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, true);
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 +389,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, true);
let width_based = window.x / 9.0;
assert!(
layout.card_size.x < width_based,
@@ -293,7 +405,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, true);
let width_based = window.x / 9.0;
assert!(
(layout.card_size.x - width_based).abs() < 1e-3,
@@ -307,7 +419,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, true);
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 +438,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, true);
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 +450,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, true);
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0, true);
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, true);
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, true);
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 +501,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, true);
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 +520,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, true);
let with_inset = compute_layout(window, 32.0, 0.0, true);
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, true);
let with_inset = compute_layout(window, 32.0, 0.0, true);
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, true);
let with_inset = compute_layout(window, 0.0, 48.0, true);
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, true);
let with_inset = compute_layout(window, 0.0, 48.0, true);
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",
);
}
}
}
+273 -16
View File
@@ -9,23 +9,24 @@
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
//! the panel shows "Not available" immediately.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::{ButtonState, keyboard::KeyboardInput, mouse::{MouseScrollUnit, MouseWheel}};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::settings::SyncBackend;
use solitaire_data::{save_settings_to, settings::SyncBackend};
use solitaire_sync::LeaderboardEntry;
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
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, Z_PAUSE_DIALOG,
};
// ---------------------------------------------------------------------------
@@ -96,6 +97,30 @@ struct OptInTask(Option<Task<Result<(), String>>>);
#[derive(Resource, Default)]
struct OptOutTask(Option<Task<Result<(), String>>>);
/// Marker on the "Set Name" button inside the leaderboard panel.
#[derive(Component, Debug)]
struct SetDisplayNameButton;
/// Marker on the display-name editor modal root.
#[derive(Component, Debug)]
struct DisplayNameModal;
/// Text currently typed in the display-name modal's input field.
#[derive(Resource, Default)]
struct DisplayNameBuffer(String);
/// Marker on the text node inside the display-name input field.
#[derive(Component, Debug)]
struct DisplayNameTextField;
/// Marker on the "Save" button in the display-name modal.
#[derive(Component, Debug)]
struct DisplayNameConfirmButton;
/// Marker on the "Cancel" button in the display-name modal.
#[derive(Component, Debug)]
struct DisplayNameCancelButton;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -111,12 +136,13 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<ClosedThisFrame>()
.init_resource::<OptInTask>()
.init_resource::<OptOutTask>()
.init_resource::<DisplayNameBuffer>()
.add_message::<ToggleLeaderboardRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// leaderboard-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
// plugin under `DefaultPlugins`; register them explicitly so all
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<KeyboardInput>()
.add_systems(
Update,
(
@@ -129,6 +155,10 @@ impl Plugin for LeaderboardPlugin {
poll_opt_in_task,
handle_opt_out_button,
poll_opt_out_task,
handle_set_display_name_button,
handle_display_name_text_input,
handle_display_name_confirm,
handle_display_name_cancel,
)
.chain(),
)
@@ -156,6 +186,7 @@ fn toggle_leaderboard_screen(
screens: Query<Entity, With<LeaderboardScreen>>,
data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
mut task_res: ResMut<LeaderboardFetchTask>,
mut closed_flag: ResMut<ClosedThisFrame>,
@@ -174,7 +205,8 @@ fn toggle_leaderboard_screen(
let remote_available = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
// Start a background fetch if not already in flight.
if task_res.0.is_none()
@@ -201,12 +233,14 @@ fn poll_leaderboard_fetch(
/// When a fetch completes, cache the data and update any open panel.
/// Skips the panel rebuild if the user closed the panel in this same frame
/// (commands are deferred, so the query would still see the despawned entity).
#[allow(clippy::too_many_arguments)]
fn update_leaderboard_panel(
mut commands: Commands,
mut result_res: ResMut<LeaderboardFetchResult>,
mut data: ResMut<LeaderboardResource>,
screens: Query<Entity, With<LeaderboardScreen>>,
provider: Option<Res<SyncProviderResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>,
) {
@@ -235,9 +269,10 @@ fn update_leaderboard_panel(
let remote_available = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
for entity in &screens {
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
}
}
@@ -305,11 +340,17 @@ fn handle_opt_in_button(
let display_name = settings
.as_ref()
.and_then(|s| {
if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend {
Some(username.clone())
} else {
None
}
// Prefer an explicit display name; fall back to server username.
s.0.leaderboard_display_name
.as_deref()
.or_else(|| {
if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend {
Some(username.as_str())
} else {
None
}
})
.map(str::to_string)
})
.unwrap_or_else(|| "Player".to_string());
@@ -391,6 +432,7 @@ fn spawn_leaderboard_screen(
commands: &mut Commands,
data: &LeaderboardResource,
remote_available: bool,
effective_display_name: Option<&str>,
font_res: Option<&FontResource>,
) {
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
@@ -426,6 +468,33 @@ fn spawn_leaderboard_screen(
TextColor(TEXT_SECONDARY),
));
// Public name row: shows the effective display name + "Set Name" button.
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
let label = match effective_display_name {
Some(n) => format!("Public name: {n}"),
None => "Public name: (same as username)".to_string(),
};
row.spawn((
Text::new(label),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_modal_button(
row,
SetDisplayNameButton,
"Set Name",
None,
ButtonVariant::Tertiary,
font_res,
);
});
// Opt In / Opt Out row uses the same modal-button helpers as
// the rest of the UI for consistent hover / press feedback.
spawn_modal_actions(card, |row| {
@@ -606,6 +675,194 @@ fn data_cell(
));
}
// ---------------------------------------------------------------------------
// Display-name editor
// ---------------------------------------------------------------------------
/// Opens the display-name editor modal when the "Set Name" button is pressed.
fn handle_set_display_name_button(
button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>,
existing: Query<(), With<DisplayNameModal>>,
mut commands: Commands,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
mut buf: ResMut<DisplayNameBuffer>,
) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
return;
}
if !existing.is_empty() {
return; // already open
}
buf.0 = settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.clone())
.unwrap_or_default();
spawn_display_name_modal(&mut commands, &buf.0, font_res.as_deref());
}
/// Routes keyboard input into the display-name buffer while the editor is open.
fn handle_display_name_text_input(
screen: Query<(), With<DisplayNameModal>>,
mut key_events: MessageReader<KeyboardInput>,
mut buf: ResMut<DisplayNameBuffer>,
mut text_q: Query<&mut Text, With<DisplayNameTextField>>,
) {
if screen.is_empty() {
key_events.clear();
return;
}
for ev in key_events.read() {
if ev.state != ButtonState::Pressed {
continue;
}
if ev.key_code == KeyCode::Backspace {
buf.0.pop();
} else if let Some(ch) = ev.text.as_deref().and_then(printable_char_dn)
&& buf.0.len() < 32
{
buf.0.push(ch);
}
}
for mut text in &mut text_q {
text.0 = if buf.0.is_empty() {
" ".to_string()
} else {
buf.0.clone()
};
}
}
/// Saves the typed display name to `SettingsResource` and closes the modal.
fn handle_display_name_confirm(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
screens: Query<Entity, With<DisplayNameModal>>,
mut commands: Commands,
buf: Res<DisplayNameBuffer>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
return;
}
if let Some(mut settings) = settings {
let trimmed = buf.0.trim().to_string();
settings.0.leaderboard_display_name = if trimmed.is_empty() {
None
} else {
Some(trimmed)
};
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &settings.0)
{
warn!("failed to save settings: {e}");
}
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Discards any typed text and closes the display-name editor modal.
fn handle_display_name_cancel(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
screens: Query<Entity, With<DisplayNameModal>>,
mut commands: Commands,
) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
fn spawn_display_name_modal(
commands: &mut Commands,
current_name: &str,
font_res: Option<&FontResource>,
) {
let make_font = |size: f32| TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: size,
..default()
};
spawn_modal(commands, DisplayNameModal, Z_PAUSE_DIALOG, |card| {
spawn_modal_header(card, "Public Display Name", font_res);
card.spawn((
Text::new(
"Shown on the leaderboard when you opt in. Leave blank to use your username.",
),
make_font(TYPE_CAPTION),
TextColor(TEXT_SECONDARY),
));
// Input field container.
card.spawn((
Node {
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
min_height: Val::Px(32.0),
min_width: Val::Px(260.0),
..default()
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|border| {
let initial = if current_name.is_empty() {
" ".to_string()
} else {
current_name.to_string()
};
border.spawn((
DisplayNameTextField,
Text::new(initial),
make_font(TYPE_BODY),
TextColor(if current_name.is_empty() {
TEXT_DISABLED
} else {
TEXT_PRIMARY
}),
));
});
card.spawn((
Text::new("Max 32 characters."),
make_font(TYPE_CAPTION),
TextColor(TEXT_SECONDARY),
));
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
DisplayNameCancelButton,
"Cancel",
None,
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
actions,
DisplayNameConfirmButton,
"Save",
None,
ButtonVariant::Primary,
font_res,
);
});
});
}
/// Accepts printable ASCII characters (0x200x7e) for the display-name field.
fn printable_char_dn(text: &str) -> Option<char> {
let ch = text.chars().next()?;
(' '..='~').contains(&ch).then_some(ch)
}
fn format_secs(secs: u64) -> String {
let m = secs / 60;
let s = secs % 60;
+10 -4
View File
@@ -1,10 +1,11 @@
//! Bevy integration layer for Solitaire Quest.
//! Bevy integration layer for Ferrous Solitaire.
#[cfg(target_os = "android")]
pub mod android_clipboard;
pub mod assets;
pub mod card_animation;
pub mod achievement_plugin;
pub mod analytics_plugin;
pub mod animation_plugin;
pub mod auto_complete_plugin;
pub mod audio_plugin;
@@ -35,10 +36,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;
@@ -58,6 +61,7 @@ pub use theme::{
ThemeRegistryPlugin,
};
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
pub use challenge_plugin::{
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
};
@@ -111,9 +115,9 @@ pub use game_plugin::{
pub use help_plugin::{HelpPlugin, HelpScreen};
pub use home_plugin::{HomePlugin, HomeScreen};
pub use hud_plugin::{
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton,
MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton,
StreakFlourish, UndoButton,
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, HudVisibility,
MenuButton, MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton,
PauseButton, StreakFlourish, UndoButton,
};
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use input_plugin::InputPlugin;
@@ -138,6 +142,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,
};
@@ -148,6 +153,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,
+19 -4
View File
@@ -9,7 +9,7 @@
//!
//! Slides:
//!
//! 1. **Welcome** — brief introduction to Solitaire Quest.
//! 1. **Welcome** — brief introduction to Ferrous Solitaire.
//! 2. **How to play** — drag-and-drop, double-click, and right-click hints.
//! 3. **Keyboard shortcuts** — a summary pulled from the same canonical list
//! used in `HelpScreen`. Accelerators: `Esc` anywhere in the flow skips
@@ -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),
}
@@ -284,10 +292,10 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc
/// Slide 1 — Welcome.
fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Welcome to Solitaire Quest", font_res);
spawn_modal_header(card, "Welcome to Ferrous Solitaire", font_res);
spawn_modal_body_text(
card,
"Solitaire Quest is a free, offline-first Klondike Solitaire game. \
"Ferrous Solitaire is a free, offline-first Klondike Solitaire game. \
Play classic draw-1 or draw-3 Klondike, earn XP, unlock achievements, \
and compete on the leaderboard. Your progress is saved locally \
optional sync to your own server keeps it in step across all your devices.",
@@ -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]
+111 -46
View File
@@ -35,6 +35,7 @@ use crate::resources::{DragState, GameStateResource};
use crate::selection_plugin::{SelectionKeySet, SelectionState};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
use crate::stats_plugin::StatsResource;
use crate::hud_plugin::HudPopoverOpen;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant, ModalScrim,
@@ -52,9 +53,13 @@ pub struct PausedResource(pub bool);
#[derive(Component, Debug)]
pub struct PauseScreen;
/// Marker on the draw-mode toggle button inside the pause overlay.
/// Marker on the "Draw 1" option button inside the pause overlay.
#[derive(Component, Debug)]
struct PauseDrawToggle;
struct PauseDrawOneButton;
/// Marker on the "Draw 3" option button inside the pause overlay.
#[derive(Component, Debug)]
struct PauseDrawThreeButton;
/// Marker on the Resume primary button on the pause modal.
#[derive(Component, Debug)]
@@ -117,12 +122,13 @@ impl Plugin for PausePlugin {
toggle_pause
.before(SelectionKeySet)
.before(handle_forfeit_keyboard),
handle_pause_draw_toggle,
handle_pause_draw_buttons,
handle_pause_resume_button,
handle_pause_forfeit_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard,
auto_resume_on_overlay,
),
);
}
@@ -137,6 +143,7 @@ struct PauseModalQueries<'w, 's> {
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
open_hud_popovers: Query<'w, 's, Entity, With<HudPopoverOpen>>,
}
#[allow(clippy::too_many_arguments)]
@@ -162,6 +169,7 @@ fn toggle_pause(
forfeit_screens,
game_over_screens,
other_modal_scrims,
open_hud_popovers,
} = modal_queries;
// Either Esc or a click on the HUD "Pause" button (which fires
@@ -186,6 +194,12 @@ fn toggle_pause(
if !other_modal_scrims.is_empty() {
return;
}
// A HUD popover (Menu or Modes dropdown) is open — the popover's own
// Escape handler (in HudPlugin) will close it this frame. Don't also
// spawn the pause overlay on top of the closing popover.
if !open_hud_popovers.is_empty() {
return;
}
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
// own the Esc press — that handler stops the replay. Without this guard a
// single Esc both stops the replay AND opens the pause modal on top of the
@@ -240,12 +254,14 @@ fn toggle_pause(
}
}
/// Handles the draw-mode toggle button on the pause overlay.
/// Handles the draw-mode segmented control on the pause overlay.
///
/// Toggling flips the draw mode in `SettingsResource`, persists settings, and
/// fires `SettingsChangedEvent`. The change takes effect on the next new game.
fn handle_pause_draw_toggle(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseDrawToggle>)>,
/// Two explicit buttons replace the old cycle-toggle: pressing "Draw 1" sets
/// `DrawOne`, pressing "Draw 3" sets `DrawThree`. Fires `SettingsChangedEvent`
/// so the rest of the engine sees the update. Change takes effect next game.
fn handle_pause_draw_buttons(
draw_one_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawOneButton>)>,
draw_three_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawThreeButton>)>,
paused: Res<PausedResource>,
settings: Option<ResMut<SettingsResource>>,
path: Option<Res<SettingsStoragePath>>,
@@ -254,22 +270,23 @@ fn handle_pause_draw_toggle(
if !paused.0 {
return;
}
let Some(mut settings) = settings else { return };
for interaction in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
settings.0.draw_mode = match settings.0.draw_mode {
DrawMode::DrawOne => DrawMode::DrawThree,
DrawMode::DrawThree => DrawMode::DrawOne,
};
if let Some(p) = &path
&& let Some(target) = &p.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
warn!("failed to save settings after draw-mode toggle: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
let pressed_one = draw_one_q.iter().any(|i| *i == Interaction::Pressed);
let pressed_three = draw_three_q.iter().any(|i| *i == Interaction::Pressed);
if !pressed_one && !pressed_three {
return;
}
let Some(mut settings) = settings else { return };
let new_mode = if pressed_one { DrawMode::DrawOne } else { DrawMode::DrawThree };
if settings.0.draw_mode == new_mode {
return;
}
settings.0.draw_mode = new_mode;
if let Some(p) = &path
&& let Some(target) = &p.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
warn!("failed to save settings after draw-mode change: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
}
/// Closes the pause modal when the player clicks the Resume button.
@@ -414,6 +431,27 @@ fn close_forfeit_modal(
}
}
/// Automatically closes the pause modal when any non-pause overlay opens
/// on top of it (Stats, Settings, Help, Achievements, Profile, etc.).
///
/// The player reaches these overlays via the HUD menu while paused, which
/// causes both the pause modal and the overlay to be live simultaneously.
/// That is always unintentional — the overlay should own the screen.
fn auto_resume_on_overlay(
mut commands: Commands,
pause_screens: Query<Entity, With<PauseScreen>>,
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
mut paused: ResMut<PausedResource>,
) {
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
return;
}
for entity in &pause_screens {
commands.entity(entity).despawn();
}
paused.0 = false;
}
/// Spawns the pause modal using the standard `ui_modal` scaffold —
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
/// action buttons, plus a Draw Mode toggle row when settings are
@@ -460,8 +498,10 @@ fn spawn_pause_screen(
});
}
/// Inline "Draw Mode [Draw 1]" row + a caption explaining the change
/// applies to the next game. Spawned inside the modal body.
/// Inline "Draw Mode [Draw 1] [Draw 3]" segmented control + caption.
///
/// The active option renders as `Secondary` (elevated), the inactive one as
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
fn spawn_draw_mode_row(
parent: &mut ChildSpawnerCommands,
mode: DrawMode,
@@ -477,6 +517,10 @@ fn spawn_draw_mode_row(
font_size: TYPE_CAPTION,
..default()
};
let (one_variant, three_variant) = match mode {
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
};
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
@@ -490,14 +534,8 @@ fn spawn_draw_mode_row(
label_font,
TextColor(TEXT_PRIMARY),
));
spawn_modal_button(
row,
PauseDrawToggle,
draw_mode_label(mode),
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(row, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
});
parent.spawn((
Text::new("Takes effect next game"),
@@ -781,9 +819,9 @@ mod tests {
// Set paused so handle_pause_draw_toggle acts.
app.world_mut().resource_mut::<PausedResource>().0 = true;
// Spawn a PauseDrawToggle button with Pressed interaction.
// Pressing "Draw 3" while DrawOne is active should switch to DrawThree.
app.world_mut().spawn((
PauseDrawToggle,
PauseDrawThreeButton,
Button,
Interaction::Pressed,
));
@@ -798,18 +836,16 @@ mod tests {
assert_eq!(
*mode,
DrawMode::DrawThree,
"draw mode must flip from DrawOne to DrawThree when toggle is pressed"
"pressing Draw 3 must set mode to DrawThree"
);
// A second press should flip back.
{
let mut interaction_query = app
.world_mut()
.query::<&mut Interaction>();
for mut i in interaction_query.iter_mut(app.world_mut()) {
*i = Interaction::Pressed;
}
}
// Pressing "Draw 1" while DrawThree is active should switch back.
app.world_mut().spawn((
PauseDrawOneButton,
Button,
Interaction::Pressed,
));
app.update();
let mode2 = &app
@@ -820,7 +856,7 @@ mod tests {
assert_eq!(
*mode2,
DrawMode::DrawOne,
"draw mode must flip back from DrawThree to DrawOne on second press"
"pressing Draw 1 must set mode to DrawOne"
);
// Verify a SettingsChangedEvent was fired.
@@ -1084,6 +1120,35 @@ mod tests {
);
}
/// When a non-pause modal scrim appears (e.g. Settings overlay opens
/// from the menu while game is paused), `auto_resume_on_overlay` must
/// despawn the pause modal and clear `PausedResource`.
#[test]
fn auto_resume_closes_pause_when_overlay_opens() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
assert!(app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
1
);
// Simulate another overlay opening (e.g. Stats) by spawning a bare ModalScrim.
app.world_mut().spawn(ModalScrim);
app.update();
assert!(
!app.world().resource::<PausedResource>().0,
"auto_resume_on_overlay must clear PausedResource when another modal opens"
);
assert_eq!(
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
0,
"auto_resume_on_overlay must despawn PauseScreen when another modal opens"
);
}
#[test]
fn forfeit_confirm_y_also_closes_pause_modal() {
let mut app = forfeit_app();
+2 -2
View File
@@ -8,7 +8,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate};
use solitaire_core::achievement::achievement_by_id;
use solitaire_core::achievement::{achievement_by_id, ALL_ACHIEVEMENTS};
use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource;
@@ -323,7 +323,7 @@ fn spawn_profile_screen(
let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
body.spawn((
Text::new(format!("{unlocked_count} / 18 unlocked")),
Text::new(format!("{unlocked_count} / {} unlocked", ALL_ACHIEVEMENTS.len())),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
));
+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, true)));
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, true);
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, true);
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, true);
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, true);
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, true);
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
+1
View File
@@ -944,6 +944,7 @@ fn spawn_overlay(
},
TextColor(TEXT_SECONDARY),
));
#[cfg(not(target_os = "android"))]
footer.spawn((
Text::new(keybind_footer_hint_text()),
TextFont {
+273
View File
@@ -0,0 +1,273 @@
//! 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::*;
use crate::ui_modal::ModalScrim;
/// 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, apply_safe_area_to_modal_scrims));
#[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);
}
}
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
/// modal cards don't extend into the Android gesture-navigation zone.
///
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
/// arriving a few frames after app start) AND when a new `ModalScrim` is
/// spawned (covers modals opened after insets have already settled).
fn apply_safe_area_to_modal_scrims(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
mut scrims: Query<&mut Node, With<ModalScrim>>,
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
) {
let has_new = !new_scrims.is_empty();
if !insets.is_changed() && !has_new {
return;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let bottom_logical = insets.bottom / scale;
for mut node in &mut scrims {
node.padding.bottom = Val::Px(bottom_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());
}
}
+387 -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,
@@ -160,6 +166,11 @@ struct WinnableDealsOnlyText;
#[derive(Component, Debug)]
struct SmartDefaultSizeText;
/// Marks the `Text` node showing the current "Share usage data" (analytics)
/// state ("ON" / "OFF") in the Privacy section.
#[derive(Component, Debug)]
struct AnalyticsEnabledText;
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
@@ -230,7 +241,19 @@ enum SettingsButton {
/// flag only affects launches without saved geometry — the
/// player's last window size always wins.
ToggleSmartDefaultSize,
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
/// sync server is configured — there is no server to send to in
/// local-only mode.
ToggleAnalytics,
/// 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),
@@ -269,6 +292,8 @@ impl SettingsButton {
SettingsButton::ReplayMoveIntervalUp => 49,
// Smart-default-size toggle — sits at the end of Gameplay.
SettingsButton::ToggleSmartDefaultSize => 50,
// Privacy section — just before Sync.
SettingsButton::ToggleAnalytics => 89,
// Cosmetic section
SettingsButton::ToggleTheme => 55,
SettingsButton::ToggleColorBlind => 60,
@@ -282,8 +307,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,9 +362,13 @@ 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>()
.add_message::<bevy::input::touch::TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher
@@ -348,6 +381,7 @@ impl Plugin for SettingsPlugin {
handle_volume_keys,
toggle_settings_screen,
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
record_window_geometry_changes,
persist_window_geometry_after_debounce,
),
@@ -359,6 +393,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,
@@ -373,10 +409,11 @@ impl Plugin for SettingsPlugin {
update_replay_move_interval_text,
update_winnable_deals_only_text,
update_smart_default_size_text,
update_analytics_enabled_text,
attach_focusable_to_settings_buttons,
scroll_focus_into_view,
),
);
app.add_systems(Update, scroll_focus_into_view);
}
}
}
@@ -738,6 +775,20 @@ fn update_winnable_deals_only_text(
}
}
/// Refreshes the live "Share usage data" toggle value in the Privacy section
/// whenever `SettingsResource` changes.
fn update_analytics_enabled_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = on_off_label(settings.0.analytics_enabled);
}
}
/// Refreshes the live "Smart window size" toggle value whenever
/// `SettingsResource` changes. The flag is stored negatively as
/// `disable_smart_default_size`, so the label inverts.
@@ -840,7 +891,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>)>,
@@ -1025,6 +1075,12 @@ fn handle_settings_buttons(
// The Text node is refreshed by `update_winnable_deals_only_text`
// on the next frame via `settings.is_changed()`.
}
SettingsButton::ToggleAnalytics => {
settings.0.analytics_enabled = !settings.0.analytics_enabled;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_analytics_enabled_text` next frame.
}
SettingsButton::ToggleSmartDefaultSize => {
settings.0.disable_smart_default_size =
!settings.0.disable_smart_default_size;
@@ -1053,8 +1109,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 +1125,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(),
@@ -1427,6 +1513,7 @@ fn spawn_settings_panel(
row_gap: VAL_SPACE_3,
max_height: Val::Vh(60.0),
overflow: Overflow::scroll_y(),
padding: UiRect::bottom(Val::Px(96.0)),
..default()
},
))
@@ -1593,10 +1680,25 @@ fn spawn_settings_panel(
font_res,
);
}
import_themes_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
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 +2310,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 +2328,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 Ferrous Solitaire 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 +2452,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 +2954,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 Ferrous Solitaire sync server.",
"ConnectSync tooltip must use the canonical microcopy"
);
}
+3 -3
View File
@@ -2,7 +2,7 @@
//!
//! On app start the engine spawns a fullscreen, high-Z overlay that
//! reads the Terminal-style "boot screen" — an accent-coloured cursor block, the
//! "Solitaire Quest" wordmark, a short fixture boot log, a progress
//! "Ferrous Solitaire" wordmark, a short fixture boot log, a progress
//! bar, and a footer with the design-system palette swatches and the
//! build version. The overlay fades in over 300 ms, holds for ~1 s,
//! then fades out for 300 ms before despawning. The deal animation
@@ -383,7 +383,7 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
));
hdr.spawn((
SplashFadable { base_color: TEXT_PRIMARY },
Text::new("Solitaire Quest"),
Text::new("Ferrous Solitaire"),
title_font,
TextColor(transparent(TEXT_PRIMARY)),
));
@@ -1170,7 +1170,7 @@ mod tests {
"expected the cursor block (▌) on the splash, got: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Solitaire Quest"),
texts.iter().any(|t| t == "Ferrous Solitaire"),
"expected the wordmark on the splash, got: {texts:?}"
);
assert!(
+3 -1
View File
@@ -203,6 +203,7 @@ impl Plugin for StatsPlugin {
// `DefaultPlugins`; register it explicitly so the stats-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
// record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external
@@ -238,7 +239,8 @@ impl Plugin for StatsPlugin {
)
.chain(),
)
.add_systems(Update, scroll_stats_panel);
.add_systems(Update, scroll_stats_panel)
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<StatsScrollable>);
}
}
+49 -10
View File
@@ -1,4 +1,4 @@
//! Backend-agnostic sync plugin for Solitaire Quest.
//! Backend-agnostic sync plugin for Ferrous Solitaire.
//!
//! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`]
//! that fetches the remote payload from the active [`SyncProvider`]. Once the
@@ -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.
+877
View File
@@ -0,0 +1,877 @@
//! 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 — desktop only; no Tab key on Android.
#[cfg(not(target_os = "android"))]
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)
}
+93 -22
View File
@@ -10,7 +10,9 @@ use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
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 +84,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 +149,40 @@ fn setup_table(
existing_camera: Query<(), With<Camera>>,
settings: Option<Res<SettingsResource>>,
bg_images: Option<Res<BackgroundImageSet>>,
safe_area: Option<Res<SafeAreaInsets>>,
hud_vis: Option<Res<HudVisibility>>,
) {
// 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 hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
let layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
@@ -258,20 +283,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 +315,9 @@ 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>,
hud_vis: Option<Res<HudVisibility>>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
@@ -290,7 +329,12 @@ 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 hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone();
@@ -318,6 +362,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
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,6 +1,6 @@
//! Theme zip-archive importer.
//!
//! Phase 7 of the card-theme system (see `CARD_PLAN.md`). Players ship
//! Players ship
//! and install third-party themes as a single `.zip` containing a
//! `theme.ron` manifest at the archive root plus the 52 face SVGs and
//! one back SVG referenced by that manifest. [`import_theme`] is the
+1 -1
View File
@@ -84,7 +84,7 @@ mod tests {
ThemeMeta {
id: "default".into(),
name: "Default".into(),
author: "Solitaire Quest".into(),
author: "Ferrous Solitaire".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
}
+1 -1
View File
@@ -268,7 +268,7 @@ mod tests {
let meta = ThemeMeta {
id: "default".into(),
name: "Default".into(),
author: "Solitaire Quest".into(),
author: "Ferrous Solitaire".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
};
+2 -4
View File
@@ -3,10 +3,8 @@
//! with the currently-loaded theme so existing card-rendering systems
//! pick up the new artwork on the next state-changed tick.
//!
//! Phase 4 of `CARD_PLAN.md`. The plugin's `set_theme` helper is the
//! public API that the future picker UI (Phase 6) calls; for now it's
//! exposed for tests and for any embedder that wants to load an
//! alternative theme manually.
//! The plugin's `set_theme` helper is the public API used by the
//! Settings appearance picker and exposed for tests.
use std::collections::HashMap;
+1 -1
View File
@@ -115,7 +115,7 @@ fn default_entry() -> ThemeEntry {
meta: ThemeMeta {
id: "default".to_string(),
name: "Default".to_string(),
author: "Solitaire Quest".to_string(),
author: "Ferrous Solitaire".to_string(),
version: "1.0".to_string(),
card_aspect: (2, 3),
},
+1 -1
View File
@@ -1,6 +1,6 @@
//! Keyboard focus ring for modal buttons (Phase 1).
//!
//! Solitaire Quest's 11 modals (Help, Stats, Achievements, Settings,
//! Ferrous Solitaire's 11 modals (Help, Stats, Achievements, Settings,
//! Profile, Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new
//! game, Onboarding) ship without any keyboard focus support. Phase 1
//! gives every button spawned via [`crate::ui_modal::spawn_modal_button`]
+51
View File
@@ -48,6 +48,7 @@
//! );
//! ```
use bevy::input::touch::{TouchInput, TouchPhase};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::PrimaryWindow;
@@ -296,6 +297,10 @@ pub fn spawn_modal_body_text(
/// Spawns the bottom actions row — flex-row with primary right-aligned.
/// The closure populates the row's buttons via `spawn_modal_button`.
///
/// `flex_wrap: Wrap` lets the row reflow onto a second line when buttons
/// collectively exceed the modal width on narrow screens (e.g. high-DPI
/// Android where the logical viewport is ~411 dp).
pub fn spawn_modal_actions<F>(parent: &mut ChildSpawnerCommands, build_buttons: F)
where
F: FnOnce(&mut ChildSpawnerCommands),
@@ -305,7 +310,9 @@ where
ModalActions,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_3,
row_gap: VAL_SPACE_2,
justify_content: JustifyContent::FlexEnd,
margin: UiRect::top(VAL_SPACE_2),
..default()
@@ -328,6 +335,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(),
@@ -363,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
Button,
Node {
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
min_height: Val::Px(48.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
@@ -382,6 +392,47 @@ pub fn spawn_modal_button<M: Component>(
});
}
// ---------------------------------------------------------------------------
// Generic touch-scroll helper
// ---------------------------------------------------------------------------
/// Scrolls any `Overflow::scroll_y()` panel marked with `M` via single-finger
/// touch pan. Add this as a system for each scrollable modal panel:
///
/// ```ignore
/// app.add_message::<TouchInput>()
/// .add_systems(Update, touch_scroll_panel::<MyScrollable>);
/// ```
///
/// On desktop `TouchInput` events never fire, so the system is a no-op.
pub fn touch_scroll_panel<M: Component>(
mut touch_evr: MessageReader<TouchInput>,
mut scrollables: Query<&mut ScrollPosition, With<M>>,
mut last_y: Local<Option<f32>>,
) {
for event in touch_evr.read() {
match event.phase {
TouchPhase::Started => {
*last_y = Some(event.position.y);
}
TouchPhase::Moved => {
if let Some(prev) = *last_y {
let delta = event.position.y - prev;
for mut sp in scrollables.iter_mut() {
// Swiping up (delta < 0 in screen coords) scrolls
// content down, so sp.y increases.
sp.0.y = (sp.0.y - delta).max(0.0);
}
}
*last_y = Some(event.position.y);
}
TouchPhase::Ended | TouchPhase::Canceled => {
*last_y = None;
}
}
}
}
// ---------------------------------------------------------------------------
// Helpers + paint system
// ---------------------------------------------------------------------------

Some files were not shown because too many files have changed in this diff Show More