Compare commits

...

53 Commits

Author SHA1 Message Date
funman300 456b4d42e3 refactor(core): explicit Rank discriminants, checked arithmetic, possible_instructions
Build and Deploy / build-and-push (push) Successful in 3m55s
Android Release / build-apk (push) Successful in 4m37s
- Add Rank=1..13 explicit discriminants so `rank as u8 == rank.value()`; collapse 13-arm value() match to `self as u8`
- Add Rank::RANKS and Suit::SUITS iteration constants
- Add Rank::checked_add / checked_sub (const fn, type-safe boundary enforcement); update rules.rs to use them
- Add GameState::possible_instructions() enumerating all valid move_cards triples (foundation for hints/solver)
- Fix waste buffer card peeking through during draw-slide animation by setting Visibility::Hidden on the buffer entity in sync_cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:25:15 -07:00
funman300 e1c8ae0743 docs: recreate SESSION_HANDOFF.md — v0.35.1 state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:16:11 -07:00
funman300 8f86d66ffe fix(engine): fix three leaderboard bugs — wrong toast type, stale name label, name not synced to server
Android Release / build-apk (push) Successful in 3m51s
- poll_opt_in_task / poll_opt_out_task: error branches now fire WarningToastEvent instead of InfoToastEvent
- Settings gains leaderboard_opted_in: bool (serde-defaulted to false); set true/false when opt-in/out tasks succeed
- handle_display_name_confirm: when already opted in and a remote provider is active, spawns an opt_in_leaderboard task to push the new name (server endpoint is an upsert)
- LeaderboardPublicNameText marker component added; update_leaderboard_public_name_label system rewrites the label each frame the panel is open, so it reflects SettingsResource immediately after the display-name modal saves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:55:22 -07:00
funman300 87aec5bdf2 feat(engine): gate decorative motion animations under reduce_motion_mode
Android Release / build-apk (push) Successful in 4m27s
ScorePulse, ScoreFloater, StreakFlourish (hud_plugin) and ShakeAnim,
FoundationFlourish, FoundationMarkerFlourish (feedback_anim_plugin) are
now all suppressed when Settings::reduce_motion_mode is on. Events are
still drained so no messages accumulate. Closes the remaining gap from
the v0.21.1 "future scope" footnote for the reduce-motion flag.

Three new tests pin the gates:
- score_change_skips_pulse_and_floater_under_reduce_motion
- shake_anim_skipped_under_reduce_motion
- foundation_flourish_skipped_under_reduce_motion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:18:11 -07:00
funman300 6f5cebdb02 fix(engine): fire WarningToastEvent on sync pull failure
Sync errors were silently swallowed — the player had no feedback when a
pull failed due to network issues or an expired session. Now `poll_pull_result`
emits a `WarningToastEvent` with a human-readable message for every error
variant, and reopens the Connect modal on auth failure so the player can
re-enter credentials without navigating through Settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:57:09 -07:00
Gitea CI 9c96e2fade chore(deploy): bump image to eb6c93fb [skip ci] 2026-05-18 05:48:06 +00:00
funman300 eb6c93fb55 fix(engine): silence B0004 by adding Transform to ModalScrim
Build and Deploy / build-and-push (push) Successful in 3m51s
ModalCard carries Transform (for its 0.96→1.0 scale entrance animation),
which auto-inserts GlobalTransform. Bevy 0.18's on_insert hook on
GlobalTransform fires B0004 when the child has GlobalTransform but the
parent does not. ModalScrim had only Node (which gives InheritedVisibility
via UiTransform but not GlobalTransform), so every modal spawn triggered
the warning.

Adding Transform::default() to ModalScrim gives it GlobalTransform and
satisfies the hook. UI layout is unaffected because Bevy's layout pipeline
reads UiTransform, not Transform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:43:59 -07:00
funman300 4aafc0a53d refactor(engine): name HUD popover Z-layers; replace raw arithmetic (M-24)
ZIndex(Z_HUD + 4) and ZIndex(Z_HUD + 5) across four sites in
hud_plugin.rs were magic-number expressions. Define named constants in
ui_theme:

  Z_HUD_POPOVER_BACKDROP = Z_HUD + 4  (fullscreen dismiss backdrop)
  Z_HUD_POPOVER          = Z_HUD + 5  (popover panel)

The score-delta floater (Z_HUD + 10) now uses the existing Z_HUD_TOP
constant, whose doc is updated to mention transient annotations.
Both new constants are added to the monotonic z-hierarchy test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:35:35 -07:00
funman300 c8878d6e8b docs(engine): fix stale FOCUS_RING colour comment from Cyan to brick-red (M-23)
The FOCUS_RING constant was updated to match ACCENT_PRIMARY (brick-red,
srgb 0.647/0.259/0.259) during the Terminal palette swap but the doc
comment still described the old cyan value (rgba 111/194/239). Update
the colour name and rgba sample to match the actual constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:31:17 -07:00
funman300 2e52f544f1 fix(data): enforce 32-char display_name limit at sync client boundary (M-22)
opt_in_leaderboard in sync_client.rs was passing display_name through
as-is, relying solely on the engine's .chars().take(32) call upstream.
Add the truncation in the sync client so any caller is protected, and
also apply it at save-time in handle_display_name_confirm so settings
never stores an over-length name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:29:38 -07:00
funman300 2301cc65d3 fix(data): align android_keystore temp extension with cleanup glob (M-21)
The keystore atomic write used path.with_extension("tmp") producing
auth_tokens.tmp, while cleanup_orphaned_tmp_files only matched *.json.tmp.
A crash after the write but before the rename left an orphaned file
invisible to cleanup.

Fix: use path.with_extension("bin.tmp") to produce auth_tokens.bin.tmp,
and broaden the cleanup glob from ends_with(".json.tmp") to
ends_with(".tmp") so both JSON and binary temp files are caught.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:26:23 -07:00
funman300 0ecc1a92fd refactor(core): add missing derives to AchievementContext (M-20)
Add PartialEq, Eq, Serialize, Deserialize to AchievementContext per
CLAUDE.md §5.3 derive order. The struct holds only primitive types
(u32, u64, i32, bool, Option<u32>) so all four derives apply without
complications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:22:54 -07:00
funman300 132fea911c refactor(core): use saturating_add for move_count increments (M-19)
recycle_count already used saturating_add(1); move_count was
inconsistently using += 1 at all three call sites. No real-world
overflow risk (u32 at ~4 billion moves), but the inconsistency was
a code smell flagged by the review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:20:26 -07:00
funman300 18d7937b51 refactor(core): derive Copy for DrawMode; drop redundant .clone() calls (M-18)
DrawMode is a fieldless two-variant enum — it is trivially bitwise-
copyable. Adding Copy + updating choose_winnable_seed to take the value
directly eliminates 13 superfluous .clone() calls across solitaire_core,
solitaire_engine, solitaire_assetgen, and solitaire_wasm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:18:23 -07:00
funman300 fa84152429 fix(engine): correct Android help hint label from → to ! (M-17)
The HUD buttons section in the Android controls reference showed "→"
(right-arrow) for the Hint action, but the actual on-screen button is
labelled "!" (ASCII exclamation). Extract ANDROID_HINT_LABEL from
hud_plugin so both the spawn path and the help text share a single
source of truth. Add a cfg(android) regression test that asserts the
hint row's key string matches the const.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:08:11 -07:00
funman300 ffed6b27e9 perf(engine): share Tokio runtime across all network tasks (M-16)
Replace per-call new_current_thread() runtimes with a single
TokioRuntimeResource(Arc<Runtime>) built once at startup using
new_multi_thread(worker_threads(2)). The Arc is cloned cheaply into
each AsyncComputeTaskPool closure, eliminating repeated OS thread
allocation on every sync pull/push, auth, avatar fetch, and analytics
flush. Using a multi-threaded runtime ensures concurrent block_on calls
from different worker threads are safe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:58:51 -07:00
funman300 7fc98f8801 fix(wasm): state() and step() return Result so errors throw JS exceptions (CR-6)
Previously both ReplayPlayer::state() and ::step() returned JsValue::NULL for
both the expected "replay exhausted" case and the unexpected "serialisation
failed" case. JavaScript callers could not distinguish the two.

Now both methods return Result<JsValue, JsValue>:
- step() returns Ok(null) when the replay is finished (expected sentinel)
- step() and state() Err(string) when serde_wasm_bindgen fails (throws JS exception)

Same fix applied to SolitaireGame::state().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:48:30 -07:00
funman300 a4dfb0c6db fix(engine): differentiate leaderboard opt-in vs opt-out error toasts (M-12)
The same "Leaderboard update failed" message was shown for both join and
leave failures, leaving the player unable to tell which operation failed.
Now shows "Failed to join leaderboard" or "Failed to leave leaderboard"
with specific wording that matches the player's intent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:47:28 -07:00
funman300 67271266e1 refactor(data,core): consolidate APP_DIR_NAME and add #[must_use] on pure fns
- Hoist APP_DIR_NAME = "ferrous_solitaire" to solitaire_data crate root
  as pub(crate); remove 5 duplicate local definitions across achievements,
  progress, settings, storage, replay modules (L-9)
- Add #[must_use] to can_place_on_foundation, can_place_on_tableau, and
  is_valid_tableau_sequence in solitaire_core::rules so callers that
  accidentally discard the result get a compile-time warning (L-6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:43:47 -07:00
funman300 aa7b0f6eed perf(engine): gate frame-hot ECS systems on resource changes
- find_draggable_at: break instead of return None on non-top non-tableau
  hit so remaining pile searches are not abandoned early (M-9)
- update_stock_count_badge: run only when GameStateResource changes (M-5)
- update_drop_highlights: run only when DragState changes (M-6)
- update_high_contrast_borders/backgrounds: run only when SettingsResource
  changes (M-7)
- update_selection_hud: run only when SelectionState or GameStateResource
  changes; uses resource_exists_and_changed to avoid panic in tests where
  SelectionState is not registered (M-8)
- Volume toast threshold: f32::EPSILON → 0.001 to avoid spurious toasts
  from float rounding noise in settings events (M-10)
- check_no_moves: collapse read().next().is_some() + clear() into a single
  read().count() > 0 drain (M-11)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:37:01 -07:00
funman300 69c6e88188 fix(core,sync,data): deterministic pile serialization, undo skip, url-encode bytes, merge_at
- Derive PartialOrd+Ord on PileType and sort pile entries in pile_map_serde
  before serializing so save-file output is deterministic (M-4)
- Add #[serde(skip)] to undo_stack so transient undo history is never written
  to save files, eliminating unnecessary bloat (M-3)
- Add merge_at() accepting an explicit resolved_at timestamp so callers can
  inject the server-side time; merge() wraps it with Utc::now() for
  backwards compatibility (M-1)
- Fix url_encode to percent-encode UTF-8 bytes rather than Unicode codepoints
  so multi-byte characters produce RFC 3986-compliant output (M-2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:28:46 -07:00
funman300 1eb40433a9 fix(server): auth-guard avatar serving, atomic write, user_id assertion in merge
- Move /avatars ServeDir behind require_auth middleware so avatar files
  can only be fetched by authenticated users (H-11)
- Make avatar upload atomic via .tmp write + rename, cleaning up stale
  extensions only after the rename succeeds (H-12)
- Return 401 instead of silently returning an empty username string when
  the user row is unexpectedly missing a username (L-17)
- Add user_id mismatch guard to merge(): returns local payload unchanged
  with a ConflictReport rather than silently cross-contaminating data (H-2)
- Truncate opt-in display_name to 32 chars client-side before sending,
  matching the server's DISPLAY_NAME_MAX validation (L-5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:22:38 -07:00
funman300 f8f1f26d64 fix(input): adaptive drop zones, touch event correctness, modal lifecycle guards
H-3:  cursor_plugin drop_overlay_rect and card_centre_for_index now use
      layout.tableau_fan_frac instead of the static TABLEAU_FAN_FRAC constant,
      so drop zones match the actual card fan on portrait Android.
      Removed now-unused TABLEAU_FAN_FRAC import.

H-4:  touch_end_drag uncommitted-tap branch no longer writes StateChangedEvent.
      The mouse path (end_drag) already omits this event for uncommitted drags;
      the touch path now matches, preventing double-animation on valid taps.

H-6:  update_selection_highlight is now gated with run_if(resource_changed)
      on SelectionState | KeyboardDragState | GameStateResource, eliminating
      the unconditional every-frame despawn+respawn of highlight sprites.

H-7:  toggle_home_screen (M-key) now checks other_modal_scrims.is_empty()
      before spawning the home screen, preventing a second concurrent ModalScrim
      when another overlay is already open.

H-8:  spawn_mode_card now inserts ModalButton(ButtonVariant::Secondary) so
      paint_modal_buttons applies hover/press colour feedback on Android.

H-10: auto_resume_on_overlay excludes ForfeitConfirmScreen from its
      "other scrims" query via NonPauseFamilyScrim type alias. Opening the
      forfeit confirm no longer immediately despawns its parent pause modal.
      Also guards paused.0 assignment with an if-check to suppress spurious
      change-detection writes (L-15).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:15:15 -07:00
funman300 3bb3ddb6f8 fix(engine): eliminate panics, fix dismiss hit-test scope, guard home respawn
CR-2: dismiss_modal_on_scrim_click now queries only the target scrim's
      Children rather than all ModalCard entities globally. Prevents
      dismissing the wrong scrim when two overlapping modals are open.

CR-5: handle_home_draw_mode_buttons and handle_home_difficulty_toggle
      now check other_modal_scrims.is_empty() before the despawn+respawn
      cycle, preventing a concurrent second ModalScrim in the same frame.

H-1:  solitaire_core::game_state — replaced all panicking piles[&key]
      index accesses with safe .get().ok_or(MoveError::InvalidSource)?,
      .get().is_some_and(...), or .get().and_then(...) in draw(),
      check_auto_complete(), next_auto_complete_move(), foundation_slot_for().

H-5:  input_plugin end_drag and touch_end_drag — replaced piles[&target]
      with .get(&target).is_some_and(...) so missing pile types reject the
      move rather than panicking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:09:01 -07:00
funman300 d3d8094ebb fix(android): wire FiraMono to stock-empty label, strip raw safe-area px from HUD spawns, replace tofu chevrons
CR-1: apply_stock_empty_indicator now receives a Handle<Font> from FontResource
      so the ↺ label uses FiraMono (Arrows block) instead of the default font.
      All three callers (startup, state-change, window-resize) updated.

CR-4: spawn_hud_band, spawn_hud, spawn_hud_avatar, spawn_action_buttons no
      longer add SafeAreaInsets physical-pixel values to initial Val::Px offsets.
      SafeAreaAnchoredTop/Bottom systems already divide by scale_factor and apply
      the correct logical-pixel offset when insets arrive; the initial spawn value
      is always 0.0 at Startup on Android anyway. Removed now-unused SafeAreaInsets
      import and parameter from all four Startup systems.

H-9:  Difficulty section chevrons ▶/▼ (U+25BA/U+25BC, Geometric Shapes — not in
      FiraMono) replaced with ASCII ">"/"v" which render correctly on Android.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:00:30 -07:00
funman300 04e99a8d24 fix(engine): correct Android waste fan overlap and resume layout desync
Android Release / build-apk (push) Successful in 4m41s
Bug 1 (card_plugin): waste Draw-Three fan step was a fixed 0.28×card_width,
chosen for the desktop gap ratio (H_GAP_DIVISOR=4). On Android
(H_GAP_DIVISOR=32) the column spacing is only 1.031×card_width, so the same
fraction pushed the top fanned card's centre past the waste column's right
edge. Fix: derive fan_step from column spacing × 0.224 — preserves 0.28×cw
on desktop while reducing to ≈0.231×cw on Android, keeping fanned cards
within their column footprint. Adds regression test on 900×2000 portrait window.

Bug 2 (safe_area): refresh_insets stored its retry counter as Local<u32>,
making it impossible to re-arm after a background/foreground cycle. On resume
the counter was already saturated so JNI was never re-queried; layouts
computed with stale (zero) insets pushed the top card row up under the HUD.
Fix: convert tries to SafeAreaPollTries Resource; add android::rearm_on_resumed
which resets both counter and SafeAreaInsets on AppLifecycle::WillResume so
the poller re-fires; add on_app_resumed (all platforms) which emits a synthetic
WindowResized on WillResume to immediately trigger layout recomputation. Adds
pure-function regression test in layout.rs pinning the suspend→resume invariant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:16:24 -07:00
funman300 980312c22c fix(assets): correct wrong bottom-right suit symbol on JS/QS/KS
All three spades face cards had a heart (♥) baked into their
bottom-right corner instead of a spade (♠). Fixed by rotating the
correct top-left corner 180° and stamping it over the wrong corner.
Pixel-count parity confirmed between TL and BR corners on all three cards.

Deletes QS_BUG.md now that the asset content bug is resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:38:42 -07:00
funman300 9623bdeede fix(engine): wire FiraMono to Android corner label and add CardImageSet tests
Bug #1 (QS wrong watermark): extracted card_face_asset_path() pure helper so
the (Rank, Suit) → filename mapping is tested in isolation. 6 new unit tests
confirm all 52 keys are unique and each suit resolves to its correct letter.
QS.png has the wrong artwork baked in (confirmed via MD5); QS_BUG.md documents
the required asset replacement.

Bug #2/#3 (red square / invisible black suit on Android): add_android_corner_label
used TextFont { ..default() } which gives Bevy's built-in font — that font
lacks U+2660–U+2666, so suit glyphs rendered as a colored missing-glyph
rectangle. Threaded Option<&Handle<Font>> from sync_cards_startup/on_change →
sync_cards → spawn/update_card_entity → add_android_corner_label, which now
passes FiraMono explicitly. Non-Android builds silence the unused param with
let _ = font_handle.

Bug #4 (waste pile): static analysis found no z or fan-offset bug; two new
tests (waste_pile_cards_have_strictly_increasing_z, _draw_one_cards_have_distinct_z)
pin the invariant for future changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 13:12:02 -07:00
funman300 4df13695fc fix(engine): use classic theme fallback in load_initial_theme
Android Release / build-apk (push) Successful in 3m21s
SettingsResource is not yet available at Startup, so load_initial_theme
fell back to "dark" on every run. On AMOLED the dark back (▒151515) is
invisible, showing only a 24×32 px red badge — the "tiny red squares"
bug. Cascade-collapse and top-row legibility were visual consequences of
the same invisible face-down cards, not layout bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:06:34 -07:00
funman300 df22338c8a fix(ui): remove grey HUD band background and constrain stock badge to pile bounds
Android Release / build-apk (push) Successful in 4m30s
Bug 1: StockCountBadge was centred 12 px inward from the stock pile's right
edge but its half-width of 17 px pushed the right edge 5 px past the pile
boundary. On Android (H_GAP_DIVISOR=32, inter-pile gap ~4 px) the badge
corner covered the waste pile's left edge at Z=30, making the waste card
appear clipped. STOCK_BADGE_INSET.x: -12 → -20 keeps the right edge 3 px
inside the stock pile on every device.

Bug 2: The top HUD band Node had an opaque dark-grey BackgroundColor sized to
HUD_BAND_HEIGHT (64/80 px). With only Tier-1 content (~30 px) visible in
typical gameplay the grey block appeared far taller than its content. Removed
BackgroundColor from the band entity; layout reservation in compute_layout is
unchanged and the bottom action bar retains its own background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:48:52 -07:00
funman300 7f450aab17 fix(android): default to classic theme to fix AMOLED card-back invisibility
Android Release / build-apk (push) Successful in 4m7s
Dark theme back.svg uses #151515 (near-black) as the card back background,
which AMOLED screens render as fully-off pixels, leaving only the tiny
#a54242 red badge visible — user sees solid red squares instead of card backs.

Fix: change fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly visible on all display types).
Also remove the stale "classic" -> "dark" sanitize migration, correct wrong
asset paths in load_card_images (classic/ subdirectory was missing), and
update tests that hardcoded the old TABLEAU_FAN_FRAC=0.25 constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:24:25 -07:00
funman300 d8f67dcad3 fix(ci): collapse multi-line Python to one-liner to fix YAML block scalar indentation error
Android Release / build-apk (push) Successful in 4m3s
2026-05-16 12:34:40 -07:00
funman300 ccb77f76b8 chore(release): promote Unreleased to 0.30.0 2026-05-16 12:31:51 -07:00
funman300 da54faf8e2 feat(engine): tighten tableau card fan offset (0.25→0.18, 0.20→0.14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:31:18 -07:00
funman300 f3d01b5890 fix(ci): delete existing APK assets before upload to avoid duplicates on re-runs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:20:10 -07:00
funman300 faefca0445 fix(android): remove hardcoded versionCode/Name from manifest so aapt2 CI injection works
Android Release / build-apk (push) Successful in 3m44s
aapt2 --version-code/--version-name only inject when the attribute is
absent — they silently no-op when the manifest already has a value.
Removed both attributes from AndroidManifest.xml so the CI flags take
effect. Local debug builds fall back to code=1 / name=0.0.0-dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:11:22 -07:00
funman300 24d83c9ae3 fix(ci): add Node.js 20 to android-builder for Gitea Actions composite steps
Build Android Builder Image / build (push) Successful in 5m7s
Android Release / build-apk (push) Successful in 5m43s
2026-05-16 11:30:10 -07:00
funman300 9d4234cded fix(ci): add build-essential to android-builder image for cargo-ndk compile
Build Android Builder Image / build (push) Successful in 7m5s
Android Release / build-apk (push) Failing after 3m30s
2026-05-16 10:51:48 -07:00
funman300 e48f652454 feat(ci): pre-built Android builder image + sccache
Build Android Builder Image / build (push) Failing after 3m54s
Replaces the 5 per-run tool-install steps (~2m 30s) with a pre-built
container image (git.aleshym.co/funman300/android-builder) that ships
Ubuntu 22.04 + Java 17 + Android SDK/NDK + Rust stable + aarch64 target
+ cargo-ndk + sccache. android-release.yml now runs inside the container
and adds two cache steps instead: Cargo registry and sccache directory.

sccache (RUSTC_WRAPPER) caches at the translation-unit level so partial
hits survive Cargo.lock changes — far more resilient than caching the
full target/ directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:47:05 -07:00
funman300 c24c7f6b61 chore(release): promote Unreleased to 0.29.0
Android Release / build-apk (push) Failing after 2m46s
2026-05-16 10:35:32 -07:00
funman300 686f57252c fix(android): stamp versionCode and versionName from the release tag
AndroidManifest.xml had hardcoded versionCode=1 / versionName=1.0, so
every shipped APK looked identical to Android and Obtainium could never
confirm the installed version matched the latest release tag — causing
a persistent false-update notification loop.

VERSION_NAME is now passed into the build script from the CI tag
(e.g. "v0.28.0" → versionCode=2800, versionName="0.28.0") and
forwarded to aapt2 link via --version-code / --version-name, overriding
the manifest without touching the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:34:14 -07:00
Gitea CI 059af2ac28 chore(deploy): bump image to 858012d9 [skip ci] 2026-05-16 17:29:27 +00:00
funman300 858012d926 fix(ci): pin kustomize to v5.4.3 to avoid GitHub API rate-limit failures
Build and Deploy / build-and-push (push) Successful in 22s
Replaced the curl|bash install_kustomize.sh approach (which makes an
unauthenticated GitHub API call to resolve the latest version) with a
direct pinned tarball download. Eliminates the tar glob failure that
broke run #226.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:29:02 -07:00
funman300 f6be961419 feat(web): show profile picture avatar in game page header
Build and Deploy / build-and-push (push) Failing after 4m17s
Fetches /api/me with the stored fs_token and renders a 32px circular
avatar in hud-right. Shows the profile photo when set, or the first
letter of the username as initials otherwise. Hidden when not signed in.
Clicking the avatar navigates to /account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:37:59 -07:00
Gitea CI 8a145154db chore(deploy): bump image to e17667d0 [skip ci] 2026-05-16 00:36:52 +00:00
funman300 e17667d034 feat(web): add undo button directly on the game board
Build and Deploy / build-and-push (push) Successful in 4m37s
Places a floating "↩ Undo" button at the bottom-right of the green felt
surface so it is visible without looking in the header. Both the board
button and the header button share the same handler; both track
undo_stack_len and disable when nothing can be undone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:32:16 -07:00
Gitea CI 005e29d1ab chore(deploy): bump image to a9285ccb [skip ci] 2026-05-16 00:25:22 +00:00
funman300 9d3cc94831 feat(web): add Restart button to replay viewer
Build and Deploy / build-and-push (push) Successful in 4m31s
Splits the old single "⏮ Restart" button into two: "⏮ Restart" (resets
to step 0 with card fade-in from dealt positions) and "◀ Back" (steps
back one move at a time via fast-forward replay). Both are disabled at
step 0 and enabled after any forward step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:24:25 -07:00
funman300 a9285ccb41 feat(web): add step-back to replay viewer
Build and Deploy / build-and-push (push) Successful in 3m47s
The "⏮ Restart" button now steps back one move at a time instead of
resetting to the beginning. Re-creates the ReplayPlayer and fast-forwards
to (step_idx - 1) without rendering intermediate frames; the CSS transform
transition then animates each card back to its previous position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:21:32 -07:00
funman300 648c3ed11d fix(engine): add opaque background behind Android corner label
The rank+suit text overlay was transparent, letting the card art's
own small corner text show through underneath — giving the appearance
of two sets of labels on each face-up card.

Add AndroidCornerBg, a CARD_FACE_COLOUR sprite child sized at
(2.0 × font_size) × (1.25 × font_size) rendered at z+0.015,
just below the text overlay (z+0.02). This covers the art corner
text so only the large overlay label is visible.

resize_android_corner_labels now also resizes AndroidCornerBg so
both layers stay aligned on orientation change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:58:34 -07:00
funman300 102506f799 feat(engine): add Android corner-label overlay for card readability
On Android, face-up cards now render a large rank+suit overlay in
the upper-left corner (FONT_SIZE_FRAC_MOBILE = 0.35 × card_width,
using Anchor::TOP_LEFT) so the rank and suit are legible at phone
scale. The baked-in SVG art corner text is only ~10–15 px physical;
the overlay is ~52 px physical — roughly 3-4× larger.

Accompanying changes:
- H_GAP_DIVISOR on Android raised 8 → 32, widening cards from
  112.5 → 124.1 logical px (135 → 149 physical px on Pixel 7 AVD).
- AndroidCornerLabel marker component tracks overlay entities so
  resize_android_corner_labels can update font-size + transform
  on orientation change without a full card respawn.
- Uses text_colour() for overlay tint so black suits render as
  near-white (BLACK_SUIT_COLOUR) on the dark Terminal card face,
  matching the existing fallback overlay behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:49:50 -07:00
funman300 9b00af29d9 fix(engine): Android HUD QA — glyph, avatar, toggle, modal-dismiss safety
Bug A: Replace U+21C4 (tofu on FiraMono) with plain ASCII "M" on the
Modes action button.

Bug B: HudAvatar disc was invisible against BG_HUD_BAND (same dark
grey). Switch background to ACCENT_PRIMARY and text to TEXT_PRIMARY so
the disc is clearly visible.

Bug C/D: toggle_hud_on_tap improvements:
- Drain buffered TouchInput events in the early-return path (scrim
  present or paused) so the modal-dismiss frame does not replay the
  button tap's Started+Ended pair as a spurious toggle.
- Stop clearing start_pos on TouchPhase::Moved — Android fires Moved
  even for clean taps (jitter), and the distance check at Ended already
  rejects real drags via drag.is_idle(). Clearing it silently swallowed
  toggle attempts on physical devices.
- Increase HUD_TAP_SLOP_PX from 15 → 25 for better tap recognition.

Also reduces Android HUD_BAND_HEIGHT from 128 → 80 px now that action
buttons live in the bottom bar rather than the top band.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:42:46 -07:00
funman300 ea28121675 feat(engine): add mini-tableau preview panel to replay overlay
Right-edge panel shows foundation tops (F: A♠ 7♥ 5♦ K♣) and
stock/waste head (STK:14 WST:7♥) while a replay plays, giving
players a compact game-state readout without scanning the dim tableau.

Architectural changes:
- DespawnWithReplay marker on every sibling root entity so
  react_to_state_change uses a single despawn query instead of
  one per entity type — future overlay surfaces just add the marker.
- react_to_state_change reduced from 9 args to 5 via the above.
- Two update systems (update_mini_tableau_foundations,
  update_mini_tableau_stock_waste) watch GameStateResource.is_changed()
  and repaint; split to avoid Bevy B0001 query conflict on &mut Text.

New format helpers: format_rank_short, format_suit_glyph,
format_card_short, format_foundations_row, format_stock_waste_row —
all use FiraMono-covered suit glyphs (U+2660–U+2666, verified Android).

+9 tests (lifecycle + format helper unit coverage).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 13:25:32 -07:00
66 changed files with 2875 additions and 522 deletions
+40 -53
View File
@@ -6,10 +6,6 @@ on:
- 'v*'
env:
ANDROID_HOME: /opt/android-sdk
NDK_VERSION: 30.0.14904198
BUILD_TOOLS_VERSION: "36.1.0"
PLATFORM: android-34
APK_OUT: target/release/apk/ferrous-solitaire.apk
GITEA_URL: https://git.aleshym.co
REPO: funman300/Ferrous-Solitaire
@@ -17,75 +13,56 @@ env:
jobs:
build-apk:
runs-on: ubuntu-latest
container:
image: git.aleshym.co/funman300/android-builder:latest
credentials:
username: ${{ gitea.actor }}
password: ${{ secrets.CI_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Cache Android SDK
uses: actions/cache@v4
id: android-cache
with:
path: /opt/android-sdk
key: android-sdk-${{ env.NDK_VERSION }}-${{ env.BUILD_TOOLS_VERSION }}
- name: Install Android SDK + NDK
if: steps.android-cache.outputs.cache-hit != 'true'
run: |
mkdir -p $ANDROID_HOME/cmdline-tools
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
-O /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d $ANDROID_HOME/cmdline-tools
mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null || true
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
"ndk;$NDK_VERSION" \
"build-tools;$BUILD_TOOLS_VERSION" \
"platforms;$PLATFORM"
- name: Set up Rust (stable + aarch64-android)
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android
- name: Cache Cargo
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-ndk
target/aarch64-linux-android
key: cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-android-
/usr/local/cargo/registry/index
/usr/local/cargo/registry/cache
/usr/local/cargo/git/db
key: cargo-registry-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-registry-android-
- name: Install cargo-ndk
run: cargo-ndk --version 2>/dev/null || cargo install cargo-ndk --version 4.1.2 --locked
- name: Cache sccache
uses: actions/cache@v4
with:
path: /root/.cache/sccache
key: sccache-android-aarch64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: sccache-android-aarch64-
- name: Get tag name
id: tag
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Decode release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
- name: Build release APK
env:
ANDROID_NDK_HOME: /opt/android-sdk/ndk/${{ env.NDK_VERSION }}
PROFILE: release
ABIS: arm64-v8a
KEYSTORE: ./release.jks
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
KEY_ALIAS: release
KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
VERSION_NAME: ${{ steps.tag.outputs.name }}
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /root/.cache/sccache
run: bash scripts/build_android_apk.sh
- name: Get tag name
id: tag
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: sccache stats
if: always()
run: sccache --show-stats
- name: Create or get Gitea release
id: release
@@ -94,7 +71,6 @@ jobs:
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
# Re-use an existing release for this tag (e.g. created manually).
ID=$(curl -sf -H "$AUTH" "$BASE/releases/tags/$TAG" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
2>/dev/null || true)
@@ -116,7 +92,18 @@ jobs:
- name: Upload APK to release
run: |
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
RELEASE_ID="${{ steps.release.outputs.id }}"
# Remove any existing APK assets so re-runs don't accumulate duplicates.
curl -sf -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets" \
| python3 -c "import sys,json; [print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
| while read AID; do
curl -sf -X DELETE -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets/$AID"
done
curl -sf -X POST \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
-H "$AUTH" \
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
"${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}/releases/${{ steps.release.outputs.id }}/assets"
"$BASE/releases/$RELEASE_ID/assets"
+41
View File
@@ -0,0 +1,41 @@
name: Build Android Builder Image
on:
push:
branches: [master]
paths:
- 'docker/android-builder.Dockerfile'
- '.gitea/workflows/builder-image.yml'
env:
IMAGE: git.aleshym.co/funman300/android-builder
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: git.aleshym.co
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: docker/android-builder.Dockerfile
push: true
tags: ${{ env.IMAGE }}:latest
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
- name: Install kustomize
run: |
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz | tar xz
sudo mv kustomize /usr/local/bin/kustomize
- name: Pin image tag in deploy manifests
+69
View File
@@ -6,6 +6,75 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.33.0] — 2026-05-16
### Fixed
- **Face-down cards render as tiny red squares (startup ordering bug)**. The
`load_initial_theme` system fell back to `"dark"` when `SettingsResource` was
not yet available at `Startup`, which happens on every fresh run before the
settings file is read. The dark theme's near-black card back (#151515) renders
as fully-off pixels on AMOLED screens, leaving only a 24×32 px red badge
visible. Changed the fallback to `"classic"` so startup behaviour matches the
`default_theme_id()` set in v0.31.0. Cascade-collapse and top-row legibility
issues were visual consequences of the same invisible-card-back problem, not
separate layout bugs.
## [0.32.0] — 2026-05-16
### Fixed
- **Stock-count badge overlaps waste pile on Android** (Bug 1). The badge was
centred 12 px inward from the stock pile's right edge, but its half-width of
17 px pushed it 5 px past the edge. On Android (`H_GAP_DIVISOR = 32`) the
inter-pile gap is only ~4 px, so the badge's top-right corner covered the
left edge of the adjacent waste card at `Z_STOCK_BADGE = 30` (above the
card's Z ≈ 1). Fixed by moving the inset to 20 px so the badge right edge
sits 3 px inside the stock card on every device.
- **Oversized grey header bar** (Bug 2). The top HUD band was a full-width
`Node` with an opaque dark-grey `BackgroundColor` sized to `HUD_BAND_HEIGHT`
(64 px desktop / 80 px Android). Typical gameplay only shows one tier of
score text (~30 px), leaving a large empty grey block. Removed the
`BackgroundColor` from the band entity; the green felt now shows through and
only the score text and avatar button are visible in the header area.
## [0.31.0] — 2026-05-16
### Fixed
- **Face-down cards rendered as solid red squares on AMOLED phones** (Bug 1).
The dark theme's card back (`back.svg`) uses a near-black background
(`#151515`) which AMOLED screens render as fully-off pixels, leaving only a
tiny `#a54242` red badge visible — exactly what was reported. Fixed by
changing the fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly readable on all display types).
Also corrected stale asset paths in `load_card_images` (`cards/backs/back_N`
`cards/backs/classic/back_N`, `cards/faces/XY``cards/faces/classic/XY`)
so the PNG fallback loads correctly when the embedded theme hasn't arrived yet.
## [0.30.0] — 2026-05-16
### Changed
- **Tableau card spacing tightened.** Face-up card fan reduced from 25% to 18%
of card height; face-down from 20% to 14%. Cards on tableau piles sit closer
together while still showing enough of each card to read the pile depth.
## [0.29.0] — 2026-05-16
### Fixed
- **APK versionCode hardcoded to 1** (`AndroidManifest.xml`, `build_android_apk.sh`).
Every release shipped with `versionCode="1"` / `versionName="1.0"`, so Android
silently refused upgrades and Obtainium permanently showed a false update
notification. The CI now derives the version code from the release tag
(e.g. v0.29.0 → 2900) and stamps it into the APK via `aapt2 link
--version-code / --version-name`.
- **CI kustomize install flaky** (`.gitea/workflows/docker-build.yml`).
The `curl | bash install_kustomize.sh` pattern hit GitHub API rate limits
on the shared runner IP, causing a `tar: no such file` failure. Replaced
with a direct pinned tarball download (kustomize v5.4.3).
## [0.28.0] — 2026-05-14
### Changed
+130
View File
@@ -0,0 +1,130 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
---
## Current state
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
- **Latest tag:** `v0.35.1`
- **Working tree:** clean
- **Build:** `cargo clippy --workspace -- -D warnings` clean
- **Tests:** 1277 passing / 0 failing across the workspace
---
## What shipped since the last handoff (v0.23.0 → v0.35.1)
### v0.34.0 — Android polish + code-quality sweep (2026-05-16/17)
| Commit | Summary |
|--------|---------|
| `9623bde` | Wire FiraMono to Android corner label; CardImageSet load tests |
| `980312c` | Fix wrong bottom-right suit symbol on JS/QS/KS card assets |
| `04e99a8` | Correct Android waste fan overlap and resume layout desync |
| `3bb3ddb` | Eliminate panics, fix dismiss hit-test scope, guard home respawn |
| `f8f1f26` | Adaptive drop zones, touch event correctness, modal lifecycle guards |
| `1eb4043` | Auth-guard avatar serving; atomic write; user_id assertion in merge |
| `69c6e88` | Deterministic pile serialization, undo skip, url-encode bytes, merge_at |
| `aa7b0f6` | Gate frame-hot ECS systems on resource changes (perf) |
| `6727126` | Consolidate APP_DIR_NAME; add `#[must_use]` on pure fns |
| `a4dfb0c` | Differentiate leaderboard opt-in vs opt-out error toasts (M-12) |
| `7fc98f8` | WASM: state() and step() return Result, errors throw JS exceptions (CR-6) |
| `ffed6b2` | Share Tokio runtime across all network tasks (M-16) |
| `fa84152` | Correct Android help hint label `→` to `!` (M-17) |
| `18d7937` | Derive Copy for DrawMode; drop redundant .clone() calls (M-18) |
| `132fea9` | Use saturating_add for move_count increments (M-19) |
| `0ecc1a9` | Add missing derives to AchievementContext (M-20) |
| `2301cc6` | Align android_keystore temp extension with cleanup glob (M-21) |
| `2e52f54` | Enforce 32-char display_name limit at sync client boundary (M-22) |
| `c8878d6` | Fix stale FOCUS_RING colour comment (M-23) |
| `4aafc0a` | Name HUD popover Z-layers; replace raw Z arithmetic (M-24) |
### v0.35.0 — Accessibility + sync reliability (2026-05-18)
| Commit | Summary |
|--------|---------|
| `eb6c93f` | Silence B0004 by adding Transform to ModalScrim |
| `6f5cebd` | Fire WarningToastEvent on sync pull failure (was InfoToastEvent) |
| `87aec5b` | Gate all decorative motion animations under `reduce_motion_mode` |
`reduce_motion_mode` now gates: score pulse, score floater, streak flourish
(hud_plugin), card-shake on rejected move, foundation completion flourish
(feedback_anim_plugin). Pattern: gate at the trigger/start system, never at
the tick system — if the component isn't inserted, the tick path never runs.
### v0.35.1 — Leaderboard bug fixes (2026-05-18)
| Commit | Summary |
|--------|---------|
| `8f86d66` | Fix three leaderboard bugs: wrong toast type, stale label, name not synced |
Three bugs fixed:
1. **Wrong toast type on error**`poll_opt_in_task` / `poll_opt_out_task` error
branches now fire `WarningToastEvent` instead of `InfoToastEvent`.
2. **Display name not pushed to server on change**`Settings` gains
`leaderboard_opted_in: bool` (serde-defaulted `false`). Set `true`/`false` when
opt-in/out tasks succeed and persisted to `settings.json`. `handle_display_name_confirm`
now spawns an `opt_in_leaderboard` task when already opted in — the server's upsert
endpoint updates only `display_name` without re-opting-in.
3. **"Public name" label stale after name change** — `LeaderboardPublicNameText` marker
component added to the label node. `update_leaderboard_public_name_label` system
rewrites the text each frame the panel is open; O(0) cost when panel is closed.
5 new regression tests cover all three bugs.
---
## Open punch list
### 1. CHANGELOG documentation debt
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
are missing. Low priority (git log is authoritative) but worth closing before the
next release.
### 2. Android APK launch verification (Option A)
Physical device test: install the latest APK on a real Android device (not AVD),
confirm:
- App launches without crash
- Safe area insets arrive and shift HUD correctly after ~3 frames
- All modal Done buttons are above the gesture bar
- Drag-and-drop works on all pile types
- Leaderboard panel opens and the "Public name" label updates correctly after
using "Set Name"
This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real
touch events, so physical-device smoke testing is the only gate.
### 3. Matomo analytics wiring
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
engine code consumes them — the analytics toggle in Settings is a no-op. If
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
and wired to `GameStateResource` events.
---
## Architectural notes for next session
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
(the trigger), not the `tick_*` system. If the component is never inserted, the
tick path never runs. See `hud_plugin.rs::detect_score_change` and
`feedback_anim_plugin.rs::start_shake_anim` for the canonical pattern.
- **Leaderboard server upsert:** `POST /api/leaderboard/opt-in` is idempotent —
calling it when already opted in just updates `display_name`. Safe to call from
`handle_display_name_confirm` without tracking a separate "needs update" flag.
- **`Messages<T>` API (Bevy 0.18.1):** write with
`resource_mut::<Messages<T>>().write(value)`; read in tests with
`msgs.get_cursor()` + `cursor.read(msgs).next()`.
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
with `input.release(key); input.clear()` between updates.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images:
- name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server
newTag: d761a150
newTag: eb6c93fb
+50
View File
@@ -0,0 +1,50 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
NDK_VERSION=30.0.14904198 \
BUILD_TOOLS_VERSION=36.1.0 \
PLATFORM=android-34 \
RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION} \
PATH=/usr/local/cargo/bin:$PATH
RUN apt-get update && apt-get install -y --no-install-recommends \
openjdk-17-jdk-headless \
build-essential wget unzip curl ca-certificates git zip python3 \
&& rm -rf /var/lib/apt/lists/*
# Node.js 20 — required by Gitea Actions composite actions (checkout, cache, etc.)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Android SDK command-line tools
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
-O /tmp/cmdtools.zip \
&& unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm /tmp/cmdtools.zip \
&& yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true \
&& "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"ndk;${NDK_VERSION}" \
"build-tools;${BUILD_TOOLS_VERSION}" \
"platforms;${PLATFORM}"
# Rust stable + aarch64-linux-android target
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable \
&& rustup target add aarch64-linux-android
# cargo-ndk (compiled once into the image)
RUN cargo install cargo-ndk --version 4.1.2 --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git"
# sccache — pre-built musl binary, no Rust compile needed
RUN curl -sL "https://github.com/mozilla/sccache/releases/download/v0.8.1/sccache-v0.8.1-x86_64-unknown-linux-musl.tar.gz" \
| tar xz -C /tmp \
&& mv /tmp/sccache-v0.8.1-x86_64-unknown-linux-musl/sccache /usr/local/bin/sccache \
&& rm -rf /tmp/sccache-v0.8.1-x86_64-unknown-linux-musl \
&& chmod +x /usr/local/bin/sccache
+15
View File
@@ -75,12 +75,27 @@ if [ -d "$RES_DIR" ]; then
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
fi
# Derive versionCode/versionName from VERSION_NAME env var (e.g. "v0.28.0" → code 2800, name "0.28.0").
# AndroidManifest.xml intentionally has no versionCode/versionName — aapt2's --version-* flags only
# inject when absent, so the manifest must be clean for CI injection to work. Local debug builds
# fall back to code=1 / name="0.0.0-dev".
if [ -n "${VERSION_NAME:-}" ]; then
VN="${VERSION_NAME#v}"
IFS='.' read -r _MAJ _MIN _PAT <<< "$VN"
VERSION_CODE=$(( ${_MAJ:-0} * 10000 + ${_MIN:-0} * 100 + ${_PAT:-0} ))
else
VERSION_CODE=1
VERSION_NAME="0.0.0-dev"
fi
LINK_ARGS=(
link
-o "$STAGING/app-unsigned.apk"
-I "$PLATFORM_JAR"
--manifest "$MANIFEST"
)
[ -n "$VERSION_CODE" ] && LINK_ARGS+=( --version-code "$VERSION_CODE" )
[ -n "${VERSION_NAME:-}" ] && LINK_ARGS+=( --version-name "${VERSION_NAME#v}" )
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
# Add compiled resources if any
shopt -s nullglob
+1 -3
View File
@@ -13,9 +13,7 @@
shared object name without the `lib` prefix or `.so` suffix.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.ferrousapp.solitaire"
android:versionCode="1"
android:versionName="1.0">
package="com.ferrousapp.solitaire">
<uses-sdk
android:minSdkVersion="26"
@@ -96,7 +96,7 @@ fn main() {
continue;
}
let cfg = SolverConfig { move_budget, state_budget };
match try_solve(seed, draw_mode.clone(), &cfg) {
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable => {
buckets[i].push(seed);
eprintln!(
+1 -1
View File
@@ -73,7 +73,7 @@ fn main() {
while found.len() < count {
tried += 1;
if matches!(
try_solve(seed, draw_mode.clone(), &cfg),
try_solve(seed, draw_mode, &cfg),
SolverResult::Winnable
) {
found.push(seed);
+3 -1
View File
@@ -8,9 +8,11 @@
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
//! unlock event for any `AchievementDef` whose record is not yet unlocked.
use serde::{Deserialize, Serialize};
/// Fields needed by achievement conditions. Constructed by the engine from
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AchievementContext {
/// Total number of games played (after this win has been recorded).
pub games_played: u32,
+88 -33
View File
@@ -10,6 +10,9 @@ pub enum Suit {
}
impl Suit {
/// All four suits in declaration order.
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
/// Returns `true` for red suits (Diamonds, Hearts).
pub fn is_red(self) -> bool {
matches!(self, Suit::Diamonds | Suit::Hearts)
@@ -24,38 +27,63 @@ impl Suit {
/// Card rank, Ace through King.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Rank {
Ace,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
Nine,
Ten,
Jack,
Queen,
King,
Ace = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
Six = 6,
Seven = 7,
Eight = 8,
Nine = 9,
Ten = 10,
Jack = 11,
Queen = 12,
King = 13,
}
impl Rank {
/// All thirteen ranks in ascending order.
pub const RANKS: [Self; 13] = [
Self::Ace, Self::Two, Self::Three, Self::Four, Self::Five,
Self::Six, Self::Seven, Self::Eight, Self::Nine, Self::Ten,
Self::Jack, Self::Queen, Self::King,
];
/// Numeric value: Ace = 1, King = 13.
pub fn value(self) -> u8 {
match self {
Rank::Ace => 1,
Rank::Two => 2,
Rank::Three => 3,
Rank::Four => 4,
Rank::Five => 5,
Rank::Six => 6,
Rank::Seven => 7,
Rank::Eight => 8,
Rank::Nine => 9,
Rank::Ten => 10,
Rank::Jack => 11,
Rank::Queen => 12,
Rank::King => 13,
self as u8
}
const fn new(n: u8) -> Option<Self> {
match n {
1 => Some(Self::Ace),
2 => Some(Self::Two),
3 => Some(Self::Three),
4 => Some(Self::Four),
5 => Some(Self::Five),
6 => Some(Self::Six),
7 => Some(Self::Seven),
8 => Some(Self::Eight),
9 => Some(Self::Nine),
10 => Some(Self::Ten),
11 => Some(Self::Jack),
12 => Some(Self::Queen),
13 => Some(Self::King),
_ => None,
}
}
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
pub const fn checked_add(self, n: u8) -> Option<Self> {
Self::new((self as u8).saturating_add(n))
}
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
pub const fn checked_sub(self, n: u8) -> Option<Self> {
match (self as u8).checked_sub(n) {
Some(v) => Self::new(v),
None => None,
}
}
}
@@ -79,16 +107,43 @@ mod tests {
#[test]
fn rank_values_are_sequential() {
let ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
];
for (i, r) in ranks.iter().enumerate() {
for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(r.value(), (i + 1) as u8);
}
}
#[test]
fn rank_as_u8_matches_value() {
for r in Rank::RANKS {
assert_eq!(r as u8, r.value());
}
}
#[test]
fn rank_checked_add_boundary() {
assert_eq!(Rank::King.checked_add(1), None);
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
}
#[test]
fn rank_checked_sub_boundary() {
assert_eq!(Rank::Ace.checked_sub(1), None);
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
}
#[test]
fn suit_suits_contains_all_four() {
assert_eq!(Suit::SUITS.len(), 4);
assert!(Suit::SUITS.contains(&Suit::Clubs));
assert!(Suit::SUITS.contains(&Suit::Diamonds));
assert!(Suit::SUITS.contains(&Suit::Hearts));
assert!(Suit::SUITS.contains(&Suit::Spades));
}
#[test]
fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
+179 -16
View File
@@ -31,7 +31,8 @@ mod pile_map_serde {
use crate::pile::{Pile, PileType};
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
let entries: Vec<(&PileType, &Pile)> = map.iter().collect();
let mut entries: Vec<(&PileType, &Pile)> = map.iter().collect();
entries.sort_by_key(|(k, _)| *k);
entries.serialize(s)
}
@@ -42,7 +43,7 @@ mod pile_map_serde {
}
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
@@ -154,6 +155,7 @@ pub struct GameState {
/// [`GAME_STATE_SCHEMA_VERSION`].
#[serde(default = "schema_v1")]
pub schema_version: u32,
#[serde(skip)]
undo_stack: VecDeque<StateSnapshot>,
}
@@ -224,10 +226,10 @@ impl GameState {
return Err(MoveError::GameAlreadyWon);
}
let stock_len = self.piles[&PileType::Stock].cards.len();
let stock_len = self.piles.get(&PileType::Stock).ok_or(MoveError::InvalidSource)?.cards.len();
if stock_len == 0 {
let waste_len = self.piles[&PileType::Waste].cards.len();
let waste_len = self.piles.get(&PileType::Waste).ok_or(MoveError::InvalidSource)?.cards.len();
if waste_len == 0 {
return Err(MoveError::StockEmpty);
}
@@ -245,7 +247,7 @@ impl GameState {
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
self.move_count += 1;
self.move_count = self.move_count.saturating_add(1);
return Ok(());
}
@@ -271,7 +273,7 @@ impl GameState {
waste.cards.push(card);
}
self.move_count += 1;
self.move_count = self.move_count.saturating_add(1);
Ok(())
}
@@ -379,7 +381,7 @@ impl GameState {
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
self.score = (self.score + score_delta).max(0);
self.move_count += 1;
self.move_count = self.move_count.saturating_add(1);
self.is_won = self.check_win();
if !self.is_won {
@@ -428,17 +430,101 @@ impl GameState {
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() {
if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) {
return false;
}
(0..7).all(|i| {
self.piles[&PileType::Tableau(i)]
.cards
.iter()
.all(|c| c.face_up)
self.piles
.get(&PileType::Tableau(i))
.is_some_and(|p| p.cards.iter().all(|c| c.face_up))
})
}
/// Returns all currently valid `move_cards` calls as `(from, to, count)` triples.
///
/// Does not include stock draws — callers check `piles[&PileType::Stock]` directly.
/// Every returned triple is guaranteed to succeed when passed to `move_cards`.
pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> {
if self.is_won {
return Vec::new();
}
let mut moves = Vec::new();
// Waste top card → foundation or tableau
if let Some(waste_top) = self.piles.get(&PileType::Waste).and_then(|p| p.cards.last()) {
for slot in 0..4_u8 {
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(waste_top, f)
{
moves.push((PileType::Waste, PileType::Foundation(slot), 1));
}
}
for dst in 0..7_usize {
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(waste_top, t)
{
moves.push((PileType::Waste, PileType::Tableau(dst), 1));
}
}
}
// Tableau sources
for src in 0..7_usize {
let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { continue };
if src_pile.cards.is_empty() {
continue;
}
let run_len = src_pile.cards.iter().rev().take_while(|c| c.face_up).count();
if run_len == 0 {
continue;
}
for count in 1..=run_len {
let seq_start = src_pile.cards.len() - count;
if !is_valid_tableau_sequence(&src_pile.cards[seq_start..]) {
continue;
}
let bottom = &src_pile.cards[seq_start];
if count == 1 {
for slot in 0..4_u8 {
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(bottom, f)
{
moves.push((PileType::Tableau(src), PileType::Foundation(slot), 1));
}
}
}
for dst in 0..7_usize {
if dst == src {
continue;
}
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(bottom, t)
{
moves.push((PileType::Tableau(src), PileType::Tableau(dst), count));
}
}
}
}
// Foundation top → tableau (only when house rule is enabled)
if self.take_from_foundation {
for slot in 0..4_u8 {
let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { continue };
let Some(top) = f.cards.last() else { continue };
for dst in 0..7_usize {
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(top, t)
{
moves.push((PileType::Foundation(slot), PileType::Tableau(dst), 1));
}
}
}
}
moves
}
/// Returns the next `(from, to)` move that advances auto-complete, or
/// `None` if no such move exists (or `is_auto_completable` is not set).
///
@@ -461,7 +547,8 @@ impl GameState {
// 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()
if let Some((card, slot)) = self.piles.get(&waste)
.and_then(|p| p.cards.last())
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
{
let _ = card; // borrow ends here
@@ -469,7 +556,8 @@ impl GameState {
}
for i in 0..7 {
let tableau = PileType::Tableau(i);
if let Some(slot) = self.piles[&tableau].cards.last()
if let Some(slot) = self.piles.get(&tableau)
.and_then(|p| p.cards.last())
.and_then(|c| self.foundation_slot_for(c))
{
return Some((tableau, PileType::Foundation(slot)));
@@ -487,7 +575,7 @@ impl GameState {
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)];
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { continue };
if pile.cards.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
@@ -501,7 +589,8 @@ impl GameState {
if card.rank.value() == 1 { empty_slot } else { None }
});
target.filter(|&slot| {
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
self.piles.get(&PileType::Foundation(slot))
.is_some_and(|p| can_place_on_foundation(card, p))
})
}
@@ -1362,4 +1451,78 @@ mod tests {
.unwrap_err();
assert!(matches!(err, MoveError::RuleViolation(_)));
}
// --- possible_instructions ---
#[test]
fn possible_instructions_empty_when_won() {
let mut g = new_game();
g.is_won = true;
assert!(g.possible_instructions().is_empty());
}
#[test]
fn possible_instructions_includes_ace_to_foundation() {
let mut g = new_game();
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();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
let moves = g.possible_instructions();
assert!(
moves.contains(&(PileType::Tableau(0), PileType::Foundation(0), 1)),
"Ace must be moveable to empty foundation slot 0; got {moves:?}"
);
}
#[test]
fn possible_instructions_all_valid_on_fresh_game() {
// Every triple returned must actually succeed when applied to a clone of the state.
let g = new_game();
for (from, to, count) in g.possible_instructions() {
let mut clone = g.clone();
assert!(
clone.move_cards(from.clone(), to.clone(), count).is_ok(),
"instruction ({from:?}, {to:?}, {count}) from possible_instructions must succeed"
);
}
}
#[test]
fn possible_instructions_no_face_down_sources() {
let g = new_game();
for (from, _, count) in g.possible_instructions() {
if let PileType::Tableau(i) = from {
let pile = &g.piles[&PileType::Tableau(i)];
let run_len = pile.cards.iter().rev().take_while(|c| c.face_up).count();
assert!(
count <= run_len,
"count {count} exceeds face-up run {run_len} for Tableau({i})"
);
}
}
}
#[test]
fn possible_instructions_waste_top_included() {
let mut g = new_game();
// Clear board, put a King on waste, and an empty tableau pile — waste→tableau must appear.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: 99, suit: Suit::Spades, rank: Rank::King, face_up: true,
});
let moves = g.possible_instructions();
// King goes on any of the 7 empty tableau piles
assert!(
(0..7).any(|dst| moves.contains(&(PileType::Waste, PileType::Tableau(dst), 1))),
"King on waste must be moveable to an empty tableau column"
);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use crate::card::{Card, Suit};
/// Identifies which pile on the board a set of cards belongs to.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum PileType {
/// The face-down draw pile.
Stock,
+9 -6
View File
@@ -1,4 +1,4 @@
use crate::card::Card;
use crate::card::{Card, Rank};
use crate::pile::Pile;
/// Returns `true` if `card` can be placed on the foundation `pile`.
@@ -9,22 +9,24 @@ use crate::pile::Pile;
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
/// - When the pile is non-empty, the next card must match the top card's
/// suit and be exactly one rank higher.
#[must_use]
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank.value() == 1,
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
None => card.rank == Rank::Ace,
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
}
}
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
///
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
#[must_use]
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank.value() == 13,
None => card.rank == Rank::King,
Some(top) => {
top.face_up
&& card.rank.value() + 1 == top.rank.value()
&& card.rank.checked_add(1) == Some(top.rank)
&& card.suit.is_red() != top.suit.is_red()
}
}
@@ -36,9 +38,10 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
/// only validates the sequence's *internal* structure, which the tableau
/// move path must enforce so a player can't smuggle an arbitrary stack
/// onto another column when the bottom card happens to land legally.
#[must_use]
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
cards.windows(2).all(|w| {
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
})
}
+1 -1
View File
@@ -665,7 +665,7 @@ impl SolverState {
foundation,
stock,
waste,
draw_mode: game.draw_mode.clone(),
draw_mode: game.draw_mode,
just_drew: false,
consecutive_draws: 0,
}
+1 -2
View File
@@ -10,12 +10,11 @@ use std::path::{Path, PathBuf};
pub use solitaire_sync::AchievementRecord;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const FILE_NAME: &str = "achievements.json";
/// Platform-specific default path for `achievements.json`.
pub fn achievements_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
}
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
+2 -2
View File
@@ -295,9 +295,9 @@ fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
let path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
let tmp = path.with_extension("tmp");
let tmp = path.with_extension("bin.tmp");
std::fs::write(&tmp, data)
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?;
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.bin.tmp: {e}")))?;
std::fs::rename(&tmp, &path)
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
}
+3
View File
@@ -168,3 +168,6 @@ pub use matomo_client::MatomoClient;
pub mod platform;
pub use platform::data_dir;
/// Application data subdirectory name, shared by all persistence modules.
pub(crate) const APP_DIR_NAME: &str = "ferrous_solitaire";
+5 -5
View File
@@ -111,12 +111,12 @@ impl MatomoClient {
}
fn url_encode(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
vec![c]
s.bytes()
.flat_map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
vec![b as char]
}
c => format!("%{:02X}", c as u32).chars().collect(),
b => format!("%{b:02X}").chars().collect(),
})
.collect()
}
+1 -2
View File
@@ -14,7 +14,6 @@ use chrono::{Datelike, NaiveDate};
pub use solitaire_sync::progress::level_for_xp;
pub use solitaire_sync::PlayerProgress;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const FILE_NAME: &str = "progress.json";
/// Deterministic seed derived from a date, identical for all players globally.
@@ -46,7 +45,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
/// Platform-specific default path for `progress.json`.
pub fn progress_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
}
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
+2 -3
View File
@@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -279,14 +278,14 @@ impl ReplayHistory {
in migrate_legacy_latest_replay"
)]
pub fn latest_replay_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
}
/// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
/// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
}
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
+12 -7
View File
@@ -11,7 +11,6 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
const APP_DIR_NAME: &str = "ferrous_solitaire";
const SETTINGS_FILE_NAME: &str = "settings.json";
/// Animation playback speed for card transitions.
@@ -239,6 +238,12 @@ pub struct Settings {
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
#[serde(default)]
pub leaderboard_display_name: Option<String>,
/// `true` once the player has successfully opted in to the leaderboard on
/// the server. Used to decide whether a display-name change should also
/// push an update via `opt_in_leaderboard`. Older `settings.json` files
/// deserialize cleanly to `false` via `#[serde(default)]`.
#[serde(default)]
pub leaderboard_opted_in: bool,
/// When `true`, the player may drag the top card of a foundation pile back
/// onto a compatible tableau column. Enabled by default (standard Klondike
/// rules). Older `settings.json` files without this key deserialize to
@@ -275,7 +280,7 @@ fn default_music_volume() -> f32 {
}
fn default_theme_id() -> String {
"dark".to_string()
"classic".to_string()
}
/// Default tooltip-hover dwell delay in seconds. Mirrors
@@ -388,6 +393,7 @@ impl Default for Settings {
replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None,
leaderboard_display_name: None,
leaderboard_opted_in: false,
take_from_foundation: true,
analytics_enabled: false,
matomo_url: None,
@@ -402,11 +408,10 @@ impl Settings {
/// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self {
// Migrate stale theme IDs: "default" was removed when the theme was
// renamed to "dark"; "classic" was briefly the default before "dark"
// was restored as the shipped default.
// Migrate stale theme IDs: "default" was the original name before it
// was renamed to "dark".
let selected_theme_id = match self.selected_theme_id.as_str() {
"default" | "classic" => "dark".to_string(),
"default" => "dark".to_string(),
_ => self.selected_theme_id,
};
Self {
@@ -480,7 +485,7 @@ impl Settings {
/// Returns the platform-specific path to `settings.json`, or `None` if
/// the platform's data directory is unavailable.
pub fn settings_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(SETTINGS_FILE_NAME))
}
/// Load settings from an explicit path. Returns `Settings::default()` if the
+7 -8
View File
@@ -13,7 +13,6 @@ use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use crate::stats::StatsSnapshot;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const STATS_FILE_NAME: &str = "stats.json";
const GAME_STATE_FILE_NAME: &str = "game_state.json";
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
@@ -21,7 +20,7 @@ const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
/// Returns the platform-specific path to `stats.json`, or `None` if
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
pub fn stats_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(STATS_FILE_NAME))
}
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
@@ -71,7 +70,7 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
/// Returns the platform-specific path to `game_state.json`, or `None` if
/// `crate::data_dir()` is unavailable.
pub fn game_state_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
}
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
@@ -123,14 +122,14 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
}
}
/// Remove any leftover `*.json.tmp` files in the app data directory.
/// Remove any leftover `*.tmp` files in the app data directory.
///
/// These can be left behind if the process crashes between the write and rename
/// in an atomic save. Safe to call on startup; missing or unreadable entries
/// are silently skipped.
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
let dir = match crate::data_dir() {
Some(d) => d.join(APP_DIR_NAME),
Some(d) => d.join(crate::APP_DIR_NAME),
None => return Ok(()),
};
@@ -181,7 +180,7 @@ pub struct TimeAttackSession {
/// Returns the platform-specific path to `time_attack_session.json`, or
/// `None` if `crate::data_dir()` is unavailable.
pub fn time_attack_session_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
}
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
@@ -267,7 +266,7 @@ pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttac
}
}
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
/// Inner helper: delete `*.tmp` entries inside `dir`.
///
/// Per-file errors (already deleted, permission denied) are silently ignored.
fn cleanup_tmp_files_in(dir: &Path) {
@@ -277,7 +276,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".json.tmp"))
.is_some_and(|n| n.ends_with(".tmp"))
{
let _ = fs::remove_file(&path);
}
+3
View File
@@ -309,6 +309,9 @@ impl SyncProvider for SolitaireServerClient {
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
// Enforce the server's 32-char column limit at the client boundary so
// the server never receives an over-length name regardless of caller.
let display_name: String = display_name.chars().take(32).collect();
let resp = self
.client
+9 -13
View File
@@ -12,7 +12,7 @@ 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::resources::{GameStateResource, TokioRuntimeResource};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
// ---------------------------------------------------------------------------
@@ -45,6 +45,7 @@ pub struct AnalyticsPlugin;
impl Plugin for AnalyticsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AnalyticsResource>()
.init_resource::<TokioRuntimeResource>()
.add_systems(Startup, init_analytics)
.add_systems(
Update,
@@ -80,28 +81,28 @@ fn react_to_settings_change(
fn on_game_won(
mut wins: MessageReader<GameWonEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
rt: Res<TokioRuntimeResource>,
) {
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);
fire_flush(client.clone(), rt.0.clone());
}
}
fn on_forfeit(
mut forfeits: MessageReader<ForfeitEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
rt: Res<TokioRuntimeResource>,
) {
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);
fire_flush(client.clone(), rt.0.clone());
}
}
@@ -137,14 +138,14 @@ fn on_achievement_unlocked(
fn tick_flush_timer(
time: Res<Time>,
mut analytics: ResMut<AnalyticsResource>,
settings: Res<SettingsResource>,
rt: Res<TokioRuntimeResource>,
) {
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);
fire_flush(client, rt.0.clone());
}
}
@@ -164,15 +165,10 @@ fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
}
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
fn fire_flush(client: Arc<MatomoClient>, rt: Arc<tokio::runtime::Runtime>) {
AsyncComputeTaskPool::get()
.spawn(async move {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
rt.block_on(client.flush());
}
})
.detach();
}
+2 -2
View File
@@ -454,8 +454,8 @@ fn handle_settings_toast(
for ev in events.read() {
let sfx = ev.0.sfx_volume;
let music = ev.0.music_volume;
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > f32::EPSILON);
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > f32::EPSILON);
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > 0.001);
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > 0.001);
*last_sfx = Some(sfx);
*last_music = Some(music);
if sfx_changed {
+6 -5
View File
@@ -21,6 +21,8 @@ use bevy::asset::RenderAssetUsages;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use crate::resources::TokioRuntimeResource;
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
/// has been fetched yet (new account, no internet, or fetch in progress).
#[derive(Resource, Default)]
@@ -46,6 +48,7 @@ pub struct AvatarPlugin;
impl Plugin for AvatarPlugin {
fn build(&self, app: &mut App) {
app.add_message::<AvatarFetchEvent>()
.init_resource::<TokioRuntimeResource>()
.init_resource::<AvatarResource>()
.init_resource::<PendingAvatarTask>()
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
@@ -54,17 +57,15 @@ impl Plugin for AvatarPlugin {
fn handle_avatar_fetch(
mut events: MessageReader<AvatarFetchEvent>,
rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingAvatarTask>,
) {
for ev in events.read() {
// Cancel any in-flight task and restart with the new URL.
let url = ev.url.clone();
let rt = rt.0.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?
.block_on(async move {
rt.block_on(async move {
let client = reqwest::Client::new();
let bytes = client
.get(&url)
+572 -36
View File
@@ -15,6 +15,8 @@ use std::collections::{HashMap, HashSet};
use bevy::color::Color;
use bevy::prelude::*;
use bevy::window::WindowResized;
#[cfg(target_os = "android")]
use bevy::sprite::Anchor;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
@@ -65,6 +67,12 @@ pub const STACK_FAN_FRAC: f32 = 0.003;
/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;
/// Font-size fraction for the large-print readability overlay on Android.
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
/// scale, where the baked-in PNG corner text is only ~10 px physical.
#[cfg(target_os = "android")]
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
@@ -163,6 +171,25 @@ pub struct CardEntity {
#[derive(Component, Debug)]
pub struct CardLabel;
/// Marker for the large-print rank+suit corner overlay on Android.
///
/// Spawned on top of PNG face cards (face-up only) at font size
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
/// readable at phone scale. Only exists when `CardImageSet` is present
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
#[cfg(target_os = "android")]
#[derive(Component, Debug, Clone, Copy)]
struct AndroidCornerLabel;
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
///
/// Covers the card art's own small corner rank/suit text so only the
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
#[cfg(target_os = "android")]
#[derive(Component, Debug, Clone, Copy)]
struct AndroidCornerBg;
/// Marker component indicating the card is currently highlighted as a hint.
/// `remaining` counts down in real seconds; the highlight is removed when it
/// reaches zero and the card sprite colour is restored to its normal value.
@@ -339,8 +366,8 @@ 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.
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
/// thin perimeter against the dark felt, regardless of face state.
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
parent.spawn((
CardBackFrame,
@@ -424,14 +451,62 @@ impl Plugin for CardPlugin {
clear_right_click_highlights_on_state_change.after(GameMutation),
clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation),
update_stock_count_badge.after(GameMutation),
update_stock_count_badge
.after(GameMutation)
.run_if(resource_changed::<crate::GameStateResource>),
collect_resize_events.after(LayoutSystem::UpdateOnResize),
snap_cards_on_window_resize.after(collect_resize_events),
),
);
#[cfg(target_os = "android")]
app.add_systems(Update, resize_android_corner_labels);
}
}
/// Returns the relative asset path for a card face PNG.
///
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
/// this function so the filename formula is tested in isolation from the
/// asset-loading machinery.
///
/// Note: this function verifies only the **code-side mapping**. If the PNG
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
/// diamond watermark baked in), that is an **asset content bug** and must be
/// fixed by replacing the file — no code change can correct it.
fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
const RANK_STRS: [&str; 13] = [
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
];
let suit_idx = match suit {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
let rank_idx = match rank {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
};
format!(
"cards/faces/classic/{}{}.png",
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
)
}
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
/// [`CardImageSet`].
///
@@ -446,21 +521,19 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
return;
};
// Suit index: Clubs=0, Diamonds=1, Hearts=2, Spades=3
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
// Rank index: Ace=0 … King=12
const RANK_STRS: [&str; 13] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const RANKS: [Rank; 13] = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven,
Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
];
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
std::array::from_fn(|rank| {
asset_server.load(format!(
"cards/faces/{}{}.png",
RANK_STRS[rank], SUIT_CHARS[suit]
))
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
std::array::from_fn(|ri| {
asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si]))
})
});
let backs = std::array::from_fn(|i| {
asset_server.load(format!("cards/backs/back_{i}.png"))
asset_server.load(format!("cards/backs/classic/back_{i}.png"))
});
commands.insert_resource(CardImageSet {
faces,
@@ -554,6 +627,7 @@ fn resync_cards_on_settings_change(
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
/// have already completed.
#[allow(clippy::too_many_arguments)]
fn sync_cards_startup(
commands: Commands,
game: Res<GameStateResource>,
@@ -562,6 +636,7 @@ fn sync_cards_startup(
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
@@ -569,7 +644,8 @@ fn sync_cards_startup(
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
let font_handle = font_res.as_ref().map(|r| &r.0);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle);
}
}
@@ -583,6 +659,7 @@ fn sync_cards_on_change(
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
if events.read().next().is_none() {
return;
@@ -593,7 +670,8 @@ fn sync_cards_on_change(
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
let font_handle = font_res.as_ref().map(|r| &r.0);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle);
}
}
@@ -609,9 +687,26 @@ fn sync_cards(
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) {
let positions = card_positions(game, layout);
// The waste buffer card exists only to keep its entity alive while the new
// top card's slide animation plays — it must never be visible to the player.
// Without this, the buffer sits at waste_base uncovered during the animation
// and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<u32> = {
let visible = match game.draw_mode {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
game.piles
.get(&PileType::Waste)
.filter(|w| w.cards.len() > visible)
.and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1)))
.map(|c| c.id)
};
// Map card_id -> (Entity, current_translation, has_card_animation) for
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
// skip the snap/slide path on cards that are already being driven by a
@@ -632,17 +727,26 @@ fn sync_cards(
}
}
// For each card in the current state: spawn or update its entity.
// For each card in the current state: spawn or update its entity, then
// apply visibility. The waste buffer card is hidden so it cannot peek
// behind the incoming top card during the draw slide animation.
for (card, position, z) in positions {
match existing.get(&card.id) {
let entity = match existing.get(&card.id) {
Some(&(entity, cur, has_anim)) => {
update_card_entity(
&mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back,
)
}
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back),
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
);
entity
}
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle),
};
let visibility = if waste_buffer_id == Some(card.id) {
Visibility::Hidden
} else {
Visibility::Inherited
};
commands.entity(entity).insert(visibility);
}
}
@@ -665,6 +769,19 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
PileType::Tableau(6),
];
// Compute the Draw-Three waste fan step proportional to the column spacing
// (waste_x stock_x = card_width + h_gap) rather than a fixed fraction of
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
// the top fanned card's centre within the waste column's own horizontal
// footprint instead of spilling into the adjacent gap.
let waste_fan_step = {
let s = layout.pile_positions.get(&PileType::Stock).copied().unwrap_or_default();
let w = layout.pile_positions.get(&PileType::Waste).copied().unwrap_or_default();
(w.x - s.x).abs() * 0.224
};
for pile_type in piles {
let Some(base) = layout.pile_positions.get(&pile_type) else {
continue;
@@ -706,7 +823,7 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
// 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
slot.saturating_sub(hidden) as f32 * waste_fan_step
} else {
0.0
};
@@ -738,7 +855,8 @@ fn spawn_card_entity(
high_contrast: bool,
card_images: Option<&CardImageSet>,
selected_back: usize,
) {
font_handle: Option<&Handle<Font>>,
) -> Entity {
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
let mut entity = commands.spawn((
@@ -747,6 +865,7 @@ fn spawn_card_entity(
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
));
let entity_id = entity.id();
// Every card gets a subtle drop-shadow child so the play surface reads
// as physical instead of flat. Spawned in idle state; the drag-tracking
// system retunes its offset / alpha when this card joins the dragged
@@ -754,15 +873,15 @@ 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 {
// Every card gets a thin border frame so it reads as a distinct
// rectangle against the dark felt, regardless of face state.
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).
// On Android we additionally spawn a large-print corner label even in
// image mode so the rank/suit are legible at phone scale.
if card_images.is_none() {
entity.with_children(|b| {
b.spawn((
@@ -778,6 +897,16 @@ fn spawn_card_entity(
));
});
}
#[cfg(target_os = "android")]
if card_images.is_some() {
entity.with_children(|b| {
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
});
}
// Suppress unused-variable warning when not building for Android.
#[cfg(not(target_os = "android"))]
let _ = font_handle;
entity_id
}
#[allow(clippy::too_many_arguments)]
@@ -796,6 +925,7 @@ fn update_card_entity(
has_card_animation: bool,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) {
let target = Vec3::new(pos.x, pos.y, z);
@@ -831,16 +961,15 @@ fn update_card_entity(
// Despawn any stale children and re-add the per-card drop shadow plus,
// in solid-colour fallback mode, the label overlay. In image mode the
// rank/suit are baked into the PNG, so no `Text2d` overlay is needed.
// rank/suit are baked into the PNG; on Android we also add a large-print
// corner overlay so they are legible at phone scale.
commands.entity(entity).despawn_related::<Children>();
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((
@@ -856,6 +985,15 @@ fn update_card_entity(
));
});
}
#[cfg(target_os = "android")]
if card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
});
}
// Suppress unused-variable warning when not building for Android.
#[cfg(not(target_os = "android"))]
let _ = font_handle;
}
fn label_for(card: &Card) -> String {
@@ -928,6 +1066,101 @@ fn label_visibility(card: &Card) -> Visibility {
}
}
/// Rank+suit string for the Android readability overlay.
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660U+2666, covered by FiraMono).
#[cfg(target_os = "android")]
fn mobile_label_for(card: &Card) -> String {
let rank = match card.rank {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "10",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
};
let suit = match card.suit {
Suit::Clubs => "",
Suit::Diamonds => "",
Suit::Hearts => "",
Suit::Spades => "",
};
format!("{rank}{suit}")
}
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards. The background sprite covers the card art's own small
/// corner text so only the large overlay is visible.
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards using FiraMono (passed via `font_handle`) so that the
/// suit Unicode glyphs U+2660U+2666 render correctly. Without an explicit
/// font handle Bevy falls back to its built-in face which does not include
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
/// the text colour — the root cause of the "red square on face-down cards"
/// visual bug (the box bleeds through near the card edge at z=0.02).
#[cfg(target_os = "android")]
fn add_android_corner_label(
parent: &mut ChildSpawnerCommands,
card: &Card,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
font_handle: Option<&Handle<Font>>,
) {
if !card.face_up {
return;
}
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
let inset = 3.0_f32;
// Background covers ~3 monospace chars wide × 1 line tall.
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
// (3 chars = 1.8× font_size) plus a small margin.
let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25;
// Solid background that hides the card art's small corner label.
parent.spawn((
AndroidCornerBg,
Sprite {
color: CARD_FACE_COLOUR,
custom_size: Some(Vec2::new(bg_w, bg_h)),
..default()
},
Transform::from_xyz(
-card_size.x / 2.0 + inset + bg_w / 2.0,
card_size.y / 2.0 - inset - bg_h / 2.0,
0.015,
),
));
// Large rank+suit text drawn on top of the background. FiraMono must be
// wired here explicitly — the suit glyphs (U+2660U+2666) are not in
// Bevy's built-in font and render as a coloured rectangle without it.
parent.spawn((
AndroidCornerLabel,
CardLabel,
Text2d::new(mobile_label_for(card)),
TextFont {
font: font_handle.cloned().unwrap_or_default(),
font_size,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Anchor::TOP_LEFT,
Transform::from_xyz(
-card_size.x / 2.0 + inset,
card_size.y / 2.0 - inset,
0.02,
),
));
}
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems
// ---------------------------------------------------------------------------
@@ -1394,6 +1627,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
layout: &Layout,
font: Handle<Font>,
) {
let stock_empty = game
.piles
@@ -1419,7 +1653,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
b.spawn((
StockEmptyLabel,
Text2d::new(""),
TextFont { font_size, ..default() },
TextFont { font: font.clone(), font_size, ..default() },
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
@@ -1445,16 +1679,19 @@ fn update_stock_empty_indicator_startup(
mut commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
font_res: Option<Res<FontResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
let Some(layout) = layout else { return };
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator(
&mut commands,
&game.0,
&mut pile_markers,
&label_children,
&layout.0,
font,
);
}
@@ -1465,6 +1702,7 @@ fn update_stock_empty_indicator(
mut commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
font_res: Option<Res<FontResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
@@ -1472,12 +1710,14 @@ fn update_stock_empty_indicator(
return;
}
let Some(layout) = layout else { return };
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator(
&mut commands,
&game.0,
&mut pile_markers,
&label_children,
&layout.0,
font,
);
}
@@ -1492,10 +1732,11 @@ fn update_stock_empty_indicator(
// ---------------------------------------------------------------------------
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
/// the centre of the count badge. A small inward offset keeps the chip from
/// drifting half-off the card while still reading as "attached" to the
/// corner.
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
/// the centre of the count badge. Must satisfy `|x| >= STOCK_BADGE_SIZE.x / 2`
/// so the badge right edge stays inside the stock pile and never overlaps the
/// adjacent waste pile — critical on Android where `H_GAP_DIVISOR = 32` gives
/// an inter-pile gap of only ~4 px.
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-20.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_BODY` (14 pt) text.
@@ -1687,6 +1928,7 @@ fn snap_cards_on_window_resize(
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
@@ -1735,12 +1977,14 @@ fn snap_cards_on_window_resize(
frame_query,
);
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator(
&mut commands,
&game.0,
&mut pile_markers,
&label_children,
&layout.0,
font,
);
throttle.last_applied_secs = now;
@@ -1836,6 +2080,43 @@ fn resize_cards_in_place(
}
}
/// Updates font size and top-left anchor transform of every
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
/// change or any window resize). The full despawn/respawn path in
/// `update_card_entity` already handles game-state changes; this system
/// covers the resize-only path where children are mutated in place.
#[cfg(target_os = "android")]
fn resize_android_corner_labels(
layout: Res<LayoutResource>,
card_images: Option<Res<CardImageSet>>,
mut text_query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>,
mut bg_query: Query<
(&mut Sprite, &mut Transform),
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
>,
) {
if !layout.is_changed() || card_images.is_none() {
return;
}
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
let inset = 3.0_f32;
let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25;
let text_x = -layout.0.card_size.x / 2.0 + inset;
let text_y = layout.0.card_size.y / 2.0 - inset;
for (mut font, mut transform) in text_query.iter_mut() {
font.font_size = font_size;
transform.translation.x = text_x;
transform.translation.y = text_y;
}
for (mut sprite, mut transform) in bg_query.iter_mut() {
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
transform.translation.x = text_x + bg_w / 2.0;
transform.translation.y = text_y - bg_h / 2.0;
}
}
/// 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.
@@ -2103,6 +2384,35 @@ mod tests {
}
}
/// The waste buffer card (slot below top) must be at the *same* XY as the
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
#[test]
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawOne);
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> =
g.piles[&PileType::Waste].cards.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 waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Buffer (slot 0) + top (slot 1) = 2 rendered waste cards.
assert_eq!(waste_rendered.len(), 2, "Draw-One with 3 waste cards must render exactly 2");
// Both must share the same XY so that hiding the buffer leaves no gap.
let (_, pos0, _) = waste_rendered[0];
let (_, pos1, _) = waste_rendered[1];
assert!(
(pos0.x - pos1.x).abs() < 1e-3 && (pos0.y - pos1.y).abs() < 1e-3,
"buffer and top card must be at the same XY; got buffer={pos0:?} top={pos1:?}"
);
}
#[test]
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
@@ -3007,4 +3317,230 @@ mod tests {
assert!((highlight.blue - success.blue).abs() < 1e-6);
assert!((highlight.alpha - 0.6).abs() < 1e-6);
}
// -----------------------------------------------------------------------
// Bug #1 — CardImageSet key lookup (code-side mapping)
//
// These tests verify that every (Rank, Suit) pair produces the expected
// filename via `card_face_asset_path`. They can only detect *code-side*
// mapping bugs (e.g. a suit index mismatch). They do NOT inspect pixel
// data — if `QS.png` contains a diamond watermark that is an *asset
// content* bug that requires replacing the PNG file.
// -----------------------------------------------------------------------
#[test]
fn card_face_asset_path_queen_of_spades_is_qs_png() {
assert_eq!(
card_face_asset_path(Rank::Queen, Suit::Spades),
"cards/faces/classic/QS.png",
"Queen of Spades must resolve to QS.png, not QD.png"
);
}
#[test]
fn card_face_asset_path_queen_of_diamonds_is_qd_png() {
assert_eq!(
card_face_asset_path(Rank::Queen, Suit::Diamonds),
"cards/faces/classic/QD.png"
);
}
#[test]
fn card_face_asset_path_ace_of_clubs_is_ac_png() {
assert_eq!(card_face_asset_path(Rank::Ace, Suit::Clubs), "cards/faces/classic/AC.png");
}
#[test]
fn card_face_asset_path_ten_of_hearts_is_10h_png() {
assert_eq!(card_face_asset_path(Rank::Ten, Suit::Hearts), "cards/faces/classic/10H.png");
}
#[test]
fn card_face_asset_path_king_of_spades_is_ks_png() {
assert_eq!(card_face_asset_path(Rank::King, Suit::Spades), "cards/faces/classic/KS.png");
}
#[test]
fn card_face_asset_path_all_52_keys_are_unique() {
use std::collections::HashSet;
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
];
let paths: HashSet<String> = suits
.iter()
.flat_map(|&s| ranks.iter().map(move |&r| card_face_asset_path(r, s)))
.collect();
assert_eq!(paths.len(), 52, "all 52 card face paths must be distinct");
}
#[test]
fn card_face_asset_path_suits_produce_correct_suffix() {
// Each suit must map to its own letter, not a neighbour's.
assert!(card_face_asset_path(Rank::Ace, Suit::Clubs).ends_with("AC.png"));
assert!(card_face_asset_path(Rank::Ace, Suit::Diamonds).ends_with("AD.png"));
assert!(card_face_asset_path(Rank::Ace, Suit::Hearts).ends_with("AH.png"));
assert!(card_face_asset_path(Rank::Ace, Suit::Spades).ends_with("AS.png"));
}
// -----------------------------------------------------------------------
// Bug #3 — Suit → color mapping for the Android corner overlay
//
// Black suits (♠♣) must use BLACK_SUIT_COLOUR (near-white) so they
// contrast against the dark card face. They must NOT share the red or
// lime colours assigned to red suits.
// -----------------------------------------------------------------------
#[test]
fn text_colour_black_suits_are_near_white_not_red() {
for suit in [Suit::Clubs, Suit::Spades] {
let card = Card { id: 0, suit, rank: Rank::Ace, face_up: true };
let colour = text_colour(&card, false, false);
assert_eq!(
colour, BLACK_SUIT_COLOUR,
"{suit:?} must map to BLACK_SUIT_COLOUR (near-white)"
);
assert_ne!(
colour, RED_SUIT_COLOUR,
"{suit:?} must not use the red suit colour"
);
// Confirm it's visually light (all channels > 0.85).
let srgba = colour.to_srgba();
assert!(
srgba.red > 0.85 && srgba.green > 0.85 && srgba.blue > 0.85,
"{suit:?} colour must be near-white for dark card background contrast, got {srgba:?}"
);
}
}
// -----------------------------------------------------------------------
// Bug #4 — Waste pile z-ordering
//
// Every rendered waste card must have a strictly greater z than the one
// below it so Bevy's CPU-side sprite sort renders them back-to-front.
// -----------------------------------------------------------------------
#[test]
fn waste_pile_cards_have_strictly_increasing_z() {
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
for _ in 0..5 {
let _ = g.draw();
}
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let mut waste_zs: Vec<f32> = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
.map(|(_, _, z)| *z)
.collect();
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
waste_zs.dedup();
assert!(
waste_zs.len() >= 2,
"expected multiple rendered waste cards, got {}",
waste_zs.len()
);
// All z values must be strictly ordered (no duplicates).
for w in waste_zs.windows(2) {
assert!(
w[1] > w[0],
"waste z values must be strictly increasing, got {} ≤ {}",
w[1],
w[0]
);
}
}
/// Regression: on tight layouts (e.g. Android H_GAP_DIVISOR=32) the
/// Draw-Three waste fan must be proportional to column spacing so that no
/// fanned card ever bleeds left into the stock column.
///
/// The invariant holds structurally (x_offset ≥ 0), but this test pins
/// the formula so a future change that accidentally introduces negative
/// offsets or flips the fan direction is caught immediately.
#[test]
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
for _ in 0..5 {
let _ = g.draw();
}
// Android-portrait window. In host tests H_GAP_DIVISOR uses the
// desktop value (4), but the no-overlap invariant must hold on any
// screen size and gap ratio.
let window = Vec2::new(900.0, 2000.0);
let layout = crate::layout::compute_layout(window, 32.0, 110.0, true);
let stock_x = layout.pile_positions[&PileType::Stock].x;
let stock_right_edge = stock_x + layout.card_size.x / 2.0;
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let positions = card_positions(&g, &layout);
for (card, pos, _) in positions.iter().filter(|(c, _, _)| waste_ids.contains(&c.id)) {
let left_edge = pos.x - layout.card_size.x / 2.0;
assert!(
left_edge >= stock_right_edge - 1e-3,
"waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window",
card.id,
left_edge,
stock_right_edge,
);
}
}
#[test]
fn waste_pile_draw_one_cards_have_distinct_z() {
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawOne);
for _ in 0..3 {
let _ = g.draw();
}
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let mut waste_zs: Vec<f32> = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
.map(|(_, _, z)| *z)
.collect();
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
waste_zs.dedup();
assert!(
waste_zs.len() >= 2,
"Draw-One must render at least 2 waste cards (visible + buffer)"
);
// Deduplicated length must equal pre-dedup length → all z distinct.
let raw_count = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
.count();
assert_eq!(
waste_zs.len(),
raw_count,
"all rendered waste card z values must be distinct"
);
}
}
+4 -4
View File
@@ -38,7 +38,7 @@ use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource};
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
Update,
(
update_cursor_icon,
update_drop_highlights,
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
update_drop_target_overlays,
),
);
@@ -387,7 +387,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec
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_centre_y = centre.y + fan * (card_count - 1) as f32;
let top_edge = centre.y + layout.card_size.y / 2.0;
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
@@ -478,7 +478,7 @@ fn tableau_or_stack_pos(
if is_tableau {
Vec2::new(
base.x,
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32),
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
)
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
+103 -1
View File
@@ -228,10 +228,15 @@ impl Plugin for FeedbackAnimPlugin {
fn start_shake_anim(
mut events: MessageReader<MoveRejectedEvent>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
mut commands: Commands,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
for ev in events.read() {
if reduce_motion {
continue;
}
let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile.
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
@@ -489,11 +494,16 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
fn start_foundation_flourish(
mut events: MessageReader<FoundationCompletedEvent>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity)>,
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
mut commands: Commands,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
for ev in events.read() {
if reduce_motion {
continue;
}
let pile_type = PileType::Foundation(ev.slot);
// Top card of the completed foundation is the King.
let Some(king_id) = game
@@ -785,7 +795,7 @@ mod tests {
#[test]
fn deal_stagger_jitter_varies_across_card_ids() {
// 52 cards should produce more than a couple distinct jitter factors;
// a constant function would return one value for all ids.
// a constant function would return one function for all ids.
use std::collections::HashSet;
let unique: HashSet<u64> = (0u32..52)
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
@@ -796,4 +806,96 @@ mod tests {
unique.len()
);
}
// -----------------------------------------------------------------------
// Reduce-motion gates — ShakeAnim, FoundationFlourish
// -----------------------------------------------------------------------
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
/// is on, even when the event targets a pile that has card entities present.
#[test]
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
// Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = PileType::Tableau(0);
let card_id = app
.world()
.resource::<GameStateResource>()
.0
.piles
.get(&dest_pile)
.and_then(|p| p.cards.last())
.map(|c| c.id)
.expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that id so the system would
// find it and insert ShakeAnim if the gate were absent.
app.world_mut().spawn((
CardEntity { card_id },
Transform::default(),
));
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()
.write(MoveRejectedEvent {
from: PileType::Stock,
to: dest_pile,
count: 1,
});
app.update();
let shake_count = app
.world_mut()
.query::<&ShakeAnim>()
.iter(app.world())
.count();
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
}
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
/// `reduce_motion_mode` is on.
#[test]
fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
app.world_mut()
.resource_mut::<Messages<FoundationCompletedEvent>>()
.write(FoundationCompletedEvent {
slot: 0,
suit: solitaire_core::card::Suit::Spades,
});
app.update();
let flourish_count = app
.world_mut()
.query::<&FoundationFlourish>()
.iter(app.world())
.count();
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
}
}
+7 -10
View File
@@ -380,11 +380,11 @@ fn poll_pending_new_game_seed(
/// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
let cfg = SolverConfig::default();
let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP {
match try_solve(seed, draw_mode.clone(), &cfg) {
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
SolverResult::Unwinnable => {
seed = seed.wrapping_add(1);
@@ -451,7 +451,7 @@ fn handle_new_game(
// where SettingsPlugin is not installed.
let draw_mode = settings
.as_ref()
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
.map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode);
let mode = ev.mode.unwrap_or(game.0.mode);
// Solver-backed retry: when the player has opted in to
@@ -473,9 +473,8 @@ fn handle_new_game(
.as_ref()
.is_some_and(|s| s.0.winnable_deals_only);
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
let dm = draw_mode.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { choose_winnable_seed(initial_seed, &dm) });
.spawn(async move { choose_winnable_seed(initial_seed, draw_mode) });
pending_seed.inner = Some(PendingSeedTask {
handle: task,
mode: ev.mode,
@@ -970,7 +969,7 @@ pub fn record_replay_on_win(
let win_move_index = recording.moves.len().checked_sub(1);
let replay = Replay::new(
game.0.seed,
game.0.draw_mode.clone(),
game.0.draw_mode,
game.0.mode,
ev.time_seconds,
ev.score,
@@ -1079,9 +1078,7 @@ fn check_no_moves(
) {
// Reset the debounce flag on every state change so if something changes
// we re-evaluate on the next state change.
let had_event = events.read().next().is_some();
// Drain remaining events to avoid leaking.
events.clear();
let had_event = events.read().count() > 0;
if !had_event {
return;
@@ -2649,7 +2646,7 @@ mod tests {
// resolves as Inconclusive — the engine treats Inconclusive
// as winnable (see `choose_winnable_seed` doc), so the
// helper must return 395 when started at 394.
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
assert_eq!(
chosen, 395,
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
+26 -1
View File
@@ -9,6 +9,8 @@ use bevy::prelude::*;
use crate::events::HelpRequestEvent;
use crate::font_plugin::FontResource;
#[cfg(target_os = "android")]
use crate::hud_plugin::ANDROID_HINT_LABEL;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
@@ -158,7 +160,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
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: ANDROID_HINT_LABEL, description: "Show a hint" },
ControlRow { keys: "", description: "Open menu (Stats, Settings, Profile...)" },
],
},
@@ -346,6 +348,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
mod tests {
use super::*;
/// Regression test for M-17: Android help screen showed "→" (right-arrow)
/// for the Hint button when the actual HUD button label is "!".
/// Verifies that the HUD Buttons section contains exactly one row whose
/// `keys` matches `ANDROID_HINT_LABEL`.
#[cfg(target_os = "android")]
#[test]
fn android_hint_row_matches_hud_label() {
use crate::hud_plugin::ANDROID_HINT_LABEL;
let hud_section = CONTROL_SECTIONS
.iter()
.find(|s| s.title == "HUD buttons")
.expect("HUD buttons section must exist");
let hint_row = hud_section
.rows
.iter()
.find(|r| r.description == "Show a hint")
.expect("hint row must exist");
assert_eq!(
hint_row.keys, ANDROID_HINT_LABEL,
"help hint row must match the HUD button label"
);
}
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
+17 -3
View File
@@ -35,6 +35,7 @@ use crate::stats_plugin::StatsResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalButton,
ScrimDismissible,
};
use crate::ui_theme::{
@@ -373,6 +374,7 @@ fn toggle_home_screen(
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
diff_expanded: Res<DifficultyExpanded>,
) {
if !keys.just_pressed(KeyCode::KeyM) {
@@ -380,7 +382,7 @@ fn toggle_home_screen(
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
} else if other_modal_scrims.is_empty() {
spawn_home_screen(
&mut commands,
build_home_context(
@@ -428,7 +430,7 @@ fn build_home_context<'a>(
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings
.map(|s| s.0.draw_mode.clone())
.map(|s| s.0.draw_mode)
.unwrap_or(DrawMode::DrawOne),
font_res,
difficulty_expanded,
@@ -589,6 +591,7 @@ fn handle_home_draw_mode_buttons(
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
@@ -601,6 +604,12 @@ fn handle_home_draw_mode_buttons(
if screens.is_empty() {
return;
}
// Don't respawn while another modal sits on top — the despawn queues
// immediately but executes at end of frame, so a respawn in the same
// frame would create a second concurrent ModalScrim.
if !other_modal_scrims.is_empty() {
return;
}
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
if !want_one && !want_three {
@@ -658,6 +667,7 @@ fn handle_home_difficulty_toggle(
mut commands: Commands,
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut diff_expanded: ResMut<DifficultyExpanded>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
@@ -668,6 +678,9 @@ fn handle_home_difficulty_toggle(
if screens.is_empty() {
return;
}
if !other_modal_scrims.is_empty() {
return;
}
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
return;
}
@@ -1103,7 +1116,7 @@ fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
let chevron = if ctx.difficulty_expanded { "" } else { "" };
let chevron = if ctx.difficulty_expanded { "v" } else { ">" };
// Header row — click to toggle expand/collapse.
parent
@@ -1337,6 +1350,7 @@ fn spawn_mode_card(
// bevy::ui — the click handler queries on `&Interaction`
// which Button drives.
Button,
ModalButton(ButtonVariant::Secondary),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
+286 -75
View File
@@ -13,12 +13,14 @@ use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::avatar_plugin::AvatarResource;
use solitaire_data::SyncBackend;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
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::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
@@ -138,6 +140,13 @@ pub struct HudColumn;
#[derive(Component, Debug)]
pub struct HudActionBar;
/// Marker on the circular profile-picture button anchored to the
/// top-right of the HUD band. Pressing it opens the Profile overlay.
/// Shows the server avatar image when loaded; falls back to the player's
/// initial on a filled disc when no image is available.
#[derive(Component, Debug)]
pub struct HudAvatar;
/// 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.
@@ -152,10 +161,13 @@ pub enum HudVisibility {
#[derive(Resource, Debug, Default)]
struct HudTapTracker {
start_pos: Option<bevy::math::Vec2>,
/// Set `true` when the finger-down hit an action button so the
/// finger-up never toggles bar visibility.
started_on_button: bool,
}
#[cfg(target_os = "android")]
const HUD_TAP_SLOP_PX: f32 = 15.0;
const HUD_TAP_SLOP_PX: f32 = 25.0;
/// Drives the score-readout pulse: scales the [`HudScore`] text from
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
@@ -286,6 +298,11 @@ pub struct HelpButton;
#[derive(Component, Debug)]
pub struct HintButton;
/// Android HUD label for the Hint button — shared with the help screen's
/// controls reference so both always agree.
#[cfg(target_os = "android")]
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
/// 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.
@@ -354,6 +371,9 @@ pub enum MenuOption {
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
/// Idle / hover / pressed colours shared by every action button. Aliased
/// to the theme tokens so the HUD picks up palette changes for free.
@@ -395,16 +415,23 @@ impl Plugin for HudPlugin {
// 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(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
.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_hud_avatar, handle_avatar_button))
.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_selection_hud.run_if(
resource_exists_and_changed::<SelectionState>
.or(resource_exists_and_changed::<GameStateResource>),
),
)
.add_systems(Update, update_hud_typography)
.add_systems(
Update,
@@ -446,7 +473,12 @@ impl Plugin for HudPlugin {
// Otherwise on a hover-state change (`Changed<Interaction>`),
// `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());
;
// Desktop-only: cursor-proximity fade. On Android the bar
// visibility is toggled explicitly; cursor_position() returning
// Some(touch_pos) during a tap would otherwise fade the bar out.
#[cfg(not(target_os = "android"))]
app.add_systems(Last, (update_action_fade, apply_action_fade).chain());
#[cfg(target_os = "android")]
{
app.init_resource::<HudTapTracker>()
@@ -461,32 +493,24 @@ impl Plugin for HudPlugin {
}
}
/// Spawns the translucent HUD band that anchors the action buttons
/// and primary readouts visually. Sits behind every other HUD element
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
/// without intercepting clicks from the buttons it sits under.
/// Spawns the invisible HUD band that reserves vertical space at the top of
/// the screen so the card layout (computed by `layout::compute_layout` using
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
///
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
/// same constant the card layout reserves at the top), so the band's
/// 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(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
/// The entity carries no `BackgroundColor` — the green felt shows through.
/// A slim grey background is handled by each content section individually
/// (the bottom action bar has its own `BG_HUD_BAND` background).
fn spawn_hud_band(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(BASE_TOP + top_inset),
top: Val::Px(BASE_TOP),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(HUD_BAND_HEIGHT),
..default()
},
BackgroundColor(BG_HUD_BAND),
// Sit one z-rung below the HUD content so the buttons and text
// 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,
@@ -514,10 +538,8 @@ fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
/// make Score the visual protagonist.
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(),
@@ -557,7 +579,7 @@ fn spawn_hud(
Node {
position_type: PositionType::Absolute,
left: VAL_SPACE_3,
top: Val::Px(SPACE_2 + top_inset),
top: Val::Px(SPACE_2),
flex_direction: FlexDirection::Column,
// Cap the column at 50% of viewport so on narrow
// (mobile) widths the inner tier rows have a bounded
@@ -684,6 +706,133 @@ fn spawn_hud(
});
}
/// Spawns the circular avatar / initials button anchored to the top-right
/// of the HUD band. Initial content is seeded from whatever resources are
/// available at startup; `update_hud_avatar` replaces the children whenever
/// `AvatarResource` or `SettingsResource` later changes.
fn spawn_hud_avatar(
font_res: Option<Res<FontResource>>,
avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands,
) {
const SIZE: f32 = 32.0;
let id = commands
.spawn((
HudAvatar,
Button,
Tooltip::new("Your profile — tap to open."),
Node {
position_type: PositionType::Absolute,
top: Val::Px(SPACE_2),
right: VAL_SPACE_3,
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(ACCENT_PRIMARY),
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
))
.id();
spawn_avatar_child(
&mut commands,
id,
avatar.as_deref(),
settings.as_deref(),
font_res.as_deref(),
);
}
/// Re-spawns the avatar circle content (image or initials) whenever either
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
/// image arriving after download and the username changing after login.
fn update_hud_avatar(
avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
q: Query<Entity, With<HudAvatar>>,
mut commands: Commands,
) {
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
if !avatar_changed && !settings_changed {
return;
}
let Ok(entity) = q.single() else {
return;
};
commands.entity(entity).despawn_related::<Children>();
spawn_avatar_child(
&mut commands,
entity,
avatar.as_deref(),
settings.as_deref(),
font_res.as_deref(),
);
}
/// Populates the avatar container with either the downloaded image or an
/// initials fallback disc. Called from both the startup spawn and the
/// reactive update system so the rendering logic lives in one place.
fn spawn_avatar_child(
commands: &mut Commands,
parent: Entity,
avatar: Option<&AvatarResource>,
settings: Option<&SettingsResource>,
font_res: Option<&FontResource>,
) {
const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| {
b.spawn((
ImageNode::new(handle),
Node {
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
..default()
},
));
});
} else {
let initial = settings
.and_then(|s| match &s.0.sync_backend {
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
SyncBackend::Local => None,
})
.and_then(|c| c.to_uppercase().next())
.unwrap_or('?');
commands.entity(parent).with_children(|b| {
b.spawn((
Text::new(initial.to_string()),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 14.0,
..default()
},
TextColor(TEXT_PRIMARY),
));
});
}
}
/// Opens the Profile overlay when the avatar button is pressed.
fn handle_avatar_button(
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
toggle_profile.write(ToggleProfileRequestEvent);
}
}
}
/// Spawns the action button bar anchored to the top-right of the window.
/// Each child is a clickable button mirroring a keyboard accelerator —
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
@@ -694,26 +843,20 @@ fn spawn_hud(
/// on its own visual edge.
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(),
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.
// On Android, compact Unicode symbols fit all 7 buttons in one row.
// 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));
let col_gap = 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);
let col_gap = VAL_SPACE_2;
#[cfg(target_os = "android")]
let labels = (
@@ -721,9 +864,8 @@ fn spawn_action_buttons(
/* 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
/* hint */ ANDROID_HINT_LABEL,
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
/* new */ "+",
);
#[cfg(not(target_os = "android"))]
@@ -737,23 +879,33 @@ fn spawn_action_buttons(
"New Game",
);
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
// Android reports it (frames 1-3); initial value is 0.0.
commands
.spawn((
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
top: Val::Px(SPACE_2 + top_inset),
bottom: Val::Px(0.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
max_width,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::FlexEnd,
justify_content: JustifyContent::Center,
column_gap: col_gap,
row_gap: row_gap_val,
row_gap: VAL_SPACE_2,
align_items: AlignItems::Center,
padding: UiRect {
left: VAL_SPACE_3,
right: VAL_SPACE_3,
top: VAL_SPACE_2,
bottom: VAL_SPACE_2,
},
..default()
},
BackgroundColor(BG_HUD_BAND),
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
SafeAreaAnchoredBottom { base_bottom: 0.0 },
HudActionBar,
))
.with_children(|row| {
@@ -1012,6 +1164,14 @@ fn spawn_modes_popover(
));
}
// Popover opens upward from just above the bottom action bar.
// Use a platform-aware offset that clears the bar height + safe-area
// gesture zone on Android, and the flat bar height on desktop.
#[cfg(target_os = "android")]
let popover_bottom = Val::Px(200.0);
#[cfg(not(target_os = "android"))]
let popover_bottom = Val::Px(80.0);
commands
.spawn((
ModesPopover,
@@ -1019,7 +1179,7 @@ fn spawn_modes_popover(
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
top: Val::Px(50.0),
bottom: popover_bottom,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_2),
@@ -1027,7 +1187,7 @@ fn spawn_modes_popover(
..default()
},
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5),
ZIndex(Z_HUD_POPOVER),
))
.with_children(|panel| {
for (option, label, tooltip) in rows {
@@ -1054,8 +1214,8 @@ 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.
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
commands.spawn((
ModesPopoverBackdrop,
Button,
@@ -1068,7 +1228,7 @@ fn spawn_modes_popover(
..default()
},
BackgroundColor(Color::NONE),
ZIndex(Z_HUD + 4),
ZIndex(Z_HUD_POPOVER_BACKDROP),
));
}
@@ -1204,6 +1364,12 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
),
];
// Same upward-opening placement as ModesPopover.
#[cfg(target_os = "android")]
let popover_bottom = Val::Px(200.0);
#[cfg(not(target_os = "android"))]
let popover_bottom = Val::Px(80.0);
commands
.spawn((
MenuPopover,
@@ -1211,7 +1377,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
top: Val::Px(50.0),
bottom: popover_bottom,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_2),
@@ -1219,7 +1385,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
..default()
},
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5),
ZIndex(Z_HUD_POPOVER),
))
.with_children(|panel| {
for (option, label, tooltip) in rows {
@@ -1260,7 +1426,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
..default()
},
BackgroundColor(Color::NONE),
ZIndex(Z_HUD + 4),
ZIndex(Z_HUD_POPOVER_BACKDROP),
));
}
@@ -1423,9 +1589,9 @@ impl Default for HudActionFade {
}
}
/// Cursor-y threshold (in window pixels, 0 = top) below which the bar
/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades
/// in as the cursor approaches, not only once it crosses into the band.
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
/// cursor approaches, not only when it crosses into the band itself.
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
@@ -1434,7 +1600,7 @@ const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// Updates the fade state from cursor position. Sets `target = 1.0` if
/// the cursor is in the reveal zone (top of window) or off-screen
/// the cursor is in the reveal zone (bottom of window) or off-screen
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
@@ -1446,8 +1612,9 @@ fn update_action_fade(
let Ok(window) = windows.single() else {
return;
};
let height = window.resolution.height();
fade.target = match window.cursor_position() {
Some(pos) if pos.y <= ACTION_FADE_REVEAL_PX => 1.0,
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
Some(_) => 0.0,
// Off-window cursor: assume keyboard navigation and keep the
// bar visible so Tab cycling doesn't lead to invisible focus.
@@ -1588,6 +1755,11 @@ fn detect_score_change(
return;
}
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
if reduce_motion {
return;
}
let speed = settings
.as_ref()
.map(|s| s.0.animation_speed)
@@ -1633,7 +1805,7 @@ fn detect_score_change(
top: Val::Px(0.0),
..default()
},
ZIndex(Z_HUD + 10),
ZIndex(Z_HUD_TOP),
Text::new(format!("+{delta}")),
font,
TextColor(ACCENT_PRIMARY),
@@ -1761,6 +1933,9 @@ fn start_streak_flourish(
let Some(latest) = events.read().last() else {
return;
};
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
return;
}
let speed = settings
.as_ref()
.map(|s| s.0.animation_speed)
@@ -2280,15 +2455,9 @@ fn update_hud_typography(
}
}
#[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>,
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
) {
if !hud_vis.is_changed() {
return;
@@ -2298,16 +2467,11 @@ fn apply_hud_visibility(
} 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(),
});
for mut vis in &mut action_bar {
*vis = v;
}
// The bottom action bar is a pure overlay — it does not claim any
// space in the card layout, so no WindowResized event is needed.
}
fn restore_hud_on_modal(
@@ -2327,29 +2491,47 @@ fn toggle_hud_on_tap(
paused: Option<Res<PausedResource>>,
mut tracker: ResMut<HudTapTracker>,
mut hud_vis: ResMut<HudVisibility>,
buttons: Query<&Interaction, With<ActionButton>>,
) {
use bevy::input::touch::TouchPhase;
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
// Drain buffered events so they don't replay in the frame after
// the scrim despawns, which would trigger a spurious visibility
// toggle as the resume/close button tap's Started+Ended pair
// replays in the now-scrim-free frame.
for _ in touch_events.read() {}
tracker.start_pos = None;
tracker.started_on_button = false;
return;
}
for event in touch_events.read() {
match event.phase {
TouchPhase::Started => {
tracker.start_pos = Some(event.position);
// Record whether the finger-down landed on a button so
// the finger-up doesn't double-fire (toggle bar + press
// button at the same time).
tracker.started_on_button =
buttons.iter().any(|i| *i != Interaction::None);
}
TouchPhase::Ended if drag.is_idle() => {
let on_button = tracker.started_on_button;
if let Some(start) = tracker.start_pos.take() {
if (event.position - start).length() < HUD_TAP_SLOP_PX {
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX {
*hud_vis = match *hud_vis {
HudVisibility::Visible => HudVisibility::Hidden,
HudVisibility::Hidden => HudVisibility::Visible,
};
}
}
tracker.started_on_button = false;
}
TouchPhase::Canceled | TouchPhase::Moved => {
// Moved: don't clear start_pos — Android fires Moved for normal
// tap jitter, and the distance check at Ended already rejects
// real drags. Clearing here would silently swallow tap toggles.
TouchPhase::Canceled => {
tracker.start_pos = None;
tracker.started_on_button = false;
}
_ => {}
}
@@ -2837,6 +3019,35 @@ mod tests {
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
}
// -----------------------------------------------------------------------
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
// -----------------------------------------------------------------------
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
#[test]
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
use solitaire_data::Settings;
let mut app = headless_app();
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
0,
"ScorePulse must not spawn under reduce-motion"
);
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"ScoreFloater must not spawn under reduce-motion"
);
}
// -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — HUD action bar
// -----------------------------------------------------------------------
+15 -13
View File
@@ -717,13 +717,12 @@ fn end_drag(
let ok = match &target {
PileType::Foundation(_) => {
count == 1
&& can_place_on_foundation(
&bottom_card,
&game.0.piles[&target],
)
&& game.0.piles.get(&target)
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
}
PileType::Tableau(_) => {
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
game.0.piles.get(&target)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
}
_ => false,
};
@@ -941,10 +940,10 @@ fn touch_end_drag(
continue;
}
// Uncommitted tap — cancel cleanly.
// Uncommitted tap — cancel cleanly. No StateChangedEvent: nothing
// changed. The mouse path (end_drag) follows the same convention.
if !drag.committed {
drag.clear();
changed.write(StateChangedEvent);
return;
}
@@ -972,10 +971,12 @@ fn touch_end_drag(
let ok = match &target {
PileType::Foundation(_) => {
count == 1
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target])
&& game.0.piles.get(&target)
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
}
PileType::Tableau(_) => {
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
game.0.piles.get(&target)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
}
_ => false,
};
@@ -1161,7 +1162,9 @@ fn find_draggable_at(
(i, pile_cards.cards.len())
} else {
if i != pile_cards.cards.len() - 1 {
return None;
// Non-top card on a non-tableau pile — not draggable; skip
// this pile and continue searching remaining piles.
break;
}
(i, i + 1)
};
@@ -1785,9 +1788,8 @@ mod tests {
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
// size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5.
let expected = layout.card_size.y * 2.5;
// Expected: card_height + 6 fan steps.
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
assert!(
(size.y - expected).abs() < 1e-3,
"expected {expected}, got {}",
+105 -20
View File
@@ -48,6 +48,24 @@ pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
/// which rendered the cards ~3.6 % squashed vertically.
const CARD_ASPECT: f32 = 1.4523;
/// Divisor used to derive the horizontal gap between columns from the card
/// width: `h_gap = card_width / H_GAP_DIVISOR`.
///
/// This constant also drives `card_width_width_based`:
/// total layout width = 7*card_width + 8*h_gap = card_width*(7 + 8/H_GAP_DIVISOR)
/// → card_width = window.x / (7 + 8/H_GAP_DIVISOR)
///
/// Desktop (H_GAP_DIVISOR = 4): card_width = window.x / 9 — existing behaviour.
/// Android (H_GAP_DIVISOR = 32): card_width = window.x / 7.25 — cards are ~10 %
/// wider than at divisor 8, with very tight gaps (~4 px) that are still visible
/// as a faint seam between columns. The primary readability boost on Android
/// comes from the `AndroidCornerLabel` overlay in `card_plugin`, but maximising
/// the physical card size helps too.
#[cfg(not(target_os = "android"))]
const H_GAP_DIVISOR: f32 = 4.0;
#[cfg(target_os = "android")]
const H_GAP_DIVISOR: f32 = 32.0;
/// Fraction of card height used as vertical padding between the top row and
/// the tableau row.
const VERTICAL_GAP_FRAC: f32 = 0.2;
@@ -57,7 +75,7 @@ const VERTICAL_GAP_FRAC: f32 = 0.2;
/// 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;
const TABLEAU_FAN_FRAC: f32 = 0.18;
/// Minimum fraction for face-down tableau cards. Scales proportionally with
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
@@ -66,7 +84,7 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
/// 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;
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// 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
@@ -77,15 +95,14 @@ 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.
///
/// 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.
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
/// Android: 80 px gives the same content rows comfortable clearance.
/// (Previously 128 px when action buttons lived in the top band; those are
/// now in the bottom bar so the larger reserve is no longer needed.)
#[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;
pub const HUD_BAND_HEIGHT: f32 = 80.0;
/// Table background colour (dark green felt).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -150,8 +167,10 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
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;
// Width-based candidate: 7 cards + 8 h_gaps where h_gap = card_width/H_GAP_DIVISOR.
// Total = card_width*(7 + 8/H_GAP_DIVISOR) = window.x → card_width = window.x/card_width_divisor.
let card_width_divisor = 7.0 + 8.0 / H_GAP_DIVISOR;
let card_width_width_based = window.x / card_width_divisor;
// Height-based candidate. The vertical budget below the top row must hold
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
@@ -176,13 +195,12 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
let card_height = card_width * CARD_ASPECT;
let card_size = Vec2::new(card_width, card_height);
let h_gap = card_width / 4.0;
// Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card
// sizing is height-limited (tall/narrow windows), this is smaller than
// window.x, so the grid is centred horizontally; otherwise side_margin
// collapses to h_gap and the geometry matches the original width-based
// layout exactly.
let total_grid_width = 9.0 * card_width;
let h_gap = card_width / H_GAP_DIVISOR;
// Total occupied width = 7*card_width + 8*h_gap = card_width_divisor*card_width.
// When card sizing is height-limited (tall/narrow windows) this is smaller than
// window.x and the grid is centred horizontally; otherwise side_margin collapses
// to h_gap and the geometry fills the window exactly.
let total_grid_width = card_width_divisor * card_width;
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
let left_edge = -window.x / 2.0;
let col_x = |col: usize| -> f32 {
@@ -402,11 +420,10 @@ mod tests {
#[test]
fn tall_narrow_window_keeps_width_based_sizing() {
// Tall narrow window: there's plenty of vertical budget, so width is
// the bottleneck and card_width matches the legacy window.x / 9
// derivation exactly.
// the bottleneck and card_width matches window.x / (7 + 8/H_GAP_DIVISOR).
let window = Vec2::new(900.0, 1600.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let width_based = window.x / 9.0;
let width_based = window.x / (7.0 + 8.0 / H_GAP_DIVISOR);
assert!(
(layout.card_size.x - width_based).abs() < 1e-3,
"expected width-based sizing (card_width {} should equal {})",
@@ -588,6 +605,74 @@ mod tests {
);
}
/// Suspend → resume layout-consistency invariant.
///
/// If the resume handler resets `SafeAreaInsets` to zero and then the JNI
/// poller re-resolves the same values, `compute_layout` must produce an
/// identical result to the fresh-launch layout. This test also verifies
/// that a layout computed with `safe_area_top = 0` (the brief window while
/// insets haven't re-resolved after resume) differs visibly from the
/// correct layout, confirming that the bug would manifest without the fix.
#[test]
fn suspend_resume_layout_matches_fresh_launch() {
let window = Vec2::new(900.0, 2000.0);
let safe_top = 27.0_f32;
let safe_bottom = 110.0_f32;
// Fresh-launch layout — insets known from startup.
let fresh = compute_layout(window, safe_top, safe_bottom, true);
// Layout computed during the brief post-resume window before insets
// re-resolve (safe_area_top temporarily 0).
let wrong = compute_layout(window, 0.0, safe_bottom, true);
// Verify the "wrong" layout actually differs — the bug would push the
// top card row upward by exactly safe_top pixels.
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
// downward (y direction). So wrong_stock_y > fresh_stock_y by safe_top.
assert!(
(wrong_stock_y - fresh_stock_y - safe_top).abs() < 1e-3,
"wrong layout must displace stock upward by safe_top ({safe_top}): \
fresh={fresh_stock_y:.2} wrong={wrong_stock_y:.2} delta={:.2}",
wrong_stock_y - fresh_stock_y,
);
// After the poller re-resolves correct insets the layout must be
// identical to the fresh-launch layout.
let corrected = compute_layout(window, safe_top, safe_bottom, true);
assert_eq!(
corrected.card_size, fresh.card_size,
"card size must be preserved after resume",
);
assert!(
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
"stock y must match fresh launch after resume: \
corrected={:.2} fresh={fresh_stock_y:.2}",
corrected.pile_positions[&PileType::Stock].y,
);
assert!(
(corrected.pile_positions[&PileType::Stock].x
- fresh.pile_positions[&PileType::Stock].x)
.abs()
< 1e-3,
"stock x must be unchanged after resume",
);
// The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| {
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
};
assert!(
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
"top-of-card must match fresh launch after resume: \
corrected={:.2} fresh={:.2}",
card_top(&corrected),
card_top(&fresh),
);
}
/// safe_area_bottom must not affect horizontal positions.
#[test]
fn safe_area_bottom_does_not_affect_horizontal_layout() {
+264 -9
View File
@@ -15,7 +15,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::{save_settings_to, settings::SyncBackend};
use solitaire_sync::LeaderboardEntry;
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent, WarningToastEvent};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource;
@@ -138,6 +138,7 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<OptOutTask>()
.init_resource::<DisplayNameBuffer>()
.add_message::<ToggleLeaderboardRequestEvent>()
.add_message::<WarningToastEvent>()
// `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.
@@ -159,6 +160,7 @@ impl Plugin for LeaderboardPlugin {
handle_display_name_text_input,
handle_display_name_confirm,
handle_display_name_cancel,
update_leaderboard_public_name_label,
)
.chain(),
)
@@ -350,7 +352,7 @@ fn handle_opt_in_button(
None
}
})
.map(str::to_string)
.map(|n| n.chars().take(32).collect::<String>())
})
.unwrap_or_else(|| "Player".to_string());
@@ -361,10 +363,13 @@ fn handle_opt_in_button(
}
}
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
/// Polls the opt-in task; fires a toast and persists opted-in state on completion.
fn poll_opt_in_task(
mut task_res: ResMut<OptInTask>,
mut toast: MessageWriter<InfoToastEvent>,
mut warn_toast: MessageWriter<WarningToastEvent>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
@@ -372,10 +377,18 @@ fn poll_opt_in_task(
match result {
Ok(()) => {
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
if let Some(mut s) = settings {
s.0.leaderboard_opted_in = true;
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &s.0)
{
warn!("failed to save settings after opt-in: {e}");
}
}
}
Err(e) => {
warn!("leaderboard opt-in failed: {e}");
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
warn_toast.write(WarningToastEvent("Failed to join leaderboard".to_string()));
}
}
}
@@ -401,10 +414,13 @@ fn handle_opt_out_button(
}
}
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
/// Polls the opt-out task; fires a toast and clears opted-in state on completion.
fn poll_opt_out_task(
mut task_res: ResMut<OptOutTask>,
mut toast: MessageWriter<InfoToastEvent>,
mut warn_toast: MessageWriter<WarningToastEvent>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
@@ -412,10 +428,18 @@ fn poll_opt_out_task(
match result {
Ok(()) => {
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
if let Some(mut s) = settings {
s.0.leaderboard_opted_in = false;
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &s.0)
{
warn!("failed to save settings after opt-out: {e}");
}
}
}
Err(e) => {
warn!("leaderboard opt-out failed: {e}");
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
warn_toast.write(WarningToastEvent("Failed to leave leaderboard".to_string()));
}
}
}
@@ -428,6 +452,12 @@ fn poll_opt_out_task(
#[derive(Component, Debug)]
pub struct LeaderboardCloseButton;
/// Marker on the "Public name: …" label inside the leaderboard panel so it
/// can be updated reactively when the player changes their display name
/// without a full panel rebuild.
#[derive(Component, Debug)]
struct LeaderboardPublicNameText;
fn spawn_leaderboard_screen(
commands: &mut Commands,
data: &LeaderboardResource,
@@ -481,6 +511,7 @@ fn spawn_leaderboard_screen(
None => "Public name: (same as username)".to_string(),
};
row.spawn((
LeaderboardPublicNameText,
Text::new(label),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
@@ -733,7 +764,9 @@ fn handle_display_name_text_input(
}
}
/// Saves the typed display name to `SettingsResource` and closes the modal.
/// Saves the typed display name to `SettingsResource`, closes the modal, and
/// pushes the new name to the server when the player is already opted in.
#[allow(clippy::too_many_arguments)]
fn handle_display_name_confirm(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
screens: Query<Entity, With<DisplayNameModal>>,
@@ -741,22 +774,58 @@ fn handle_display_name_confirm(
buf: Res<DisplayNameBuffer>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
provider: Option<Res<SyncProviderResource>>,
mut task_res: ResMut<OptInTask>,
) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
return;
}
if let Some(mut settings) = settings {
let trimmed = buf.0.trim().to_string();
let trimmed: String = buf.0.trim().chars().take(32).collect();
settings.0.leaderboard_display_name = if trimmed.is_empty() {
None
} else {
Some(trimmed)
Some(trimmed.clone())
};
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}");
}
// Push updated name to the server when already opted in and no task
// is in flight. The server's opt-in endpoint is an upsert, so calling
// it a second time only updates the display_name column.
let is_remote = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
if settings.0.leaderboard_opted_in && is_remote && task_res.0.is_none() {
let display_name = settings
.0
.leaderboard_display_name
.clone()
.unwrap_or_else(|| {
if let solitaire_data::settings::SyncBackend::SolitaireServer {
ref username,
..
} = settings.0.sync_backend
{
username.chars().take(32).collect()
} else {
"Player".to_string()
}
});
if let Some(p) = provider {
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.opt_in_leaderboard(&display_name)
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
}
for entity in &screens {
commands.entity(entity).despawn();
@@ -857,6 +926,25 @@ fn spawn_display_name_modal(
});
}
/// Keeps the "Public name: …" label in the leaderboard panel in sync with
/// `SettingsResource` after the player saves a new display name. No-op when
/// the panel is closed (`labels.is_empty()` exits immediately).
fn update_leaderboard_public_name_label(
settings: Option<Res<SettingsResource>>,
mut labels: Query<&mut Text, With<LeaderboardPublicNameText>>,
) {
if labels.is_empty() {
return;
}
let new_label = match settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()) {
Some(n) => format!("Public name: {n}"),
None => "Public name: (same as username)".to_string(),
};
for mut text in &mut labels {
text.0 = new_label.clone();
}
}
/// Accepts printable ASCII characters (0x200x7e) for the display-name field.
fn printable_char_dn(text: &str) -> Option<char> {
let ch = text.chars().next()?;
@@ -1048,4 +1136,171 @@ mod tests {
// 65 seconds = 1:05, not 1:5
assert_eq!(format_secs(65), "1:05");
}
// -------------------------------------------------------------------------
// Bug-fix regression tests
// -------------------------------------------------------------------------
fn headless_app_with_settings() -> App {
let mut app = headless_app();
app.insert_resource(SettingsResource(solitaire_data::settings::Settings::default()));
app
}
/// Bug 1: opt-in errors must fire `WarningToastEvent`, not `InfoToastEvent`.
#[test]
fn opt_in_error_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with_settings();
// Inject a pre-resolved failed task directly into OptInTask.
let failed_task = AsyncComputeTaskPool::get()
.spawn(async { Err::<(), String>("network error".to_string()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
// Allow the task to complete and be polled.
for _ in 0..5 {
app.update();
}
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
assert!(
cursor.read(msgs).next().is_some(),
"WarningToastEvent must be fired when opt-in fails"
);
}
/// Bug 1: opt-out errors must fire `WarningToastEvent`, not `InfoToastEvent`.
#[test]
fn opt_out_error_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with_settings();
let failed_task = AsyncComputeTaskPool::get()
.spawn(async { Err::<(), String>("network error".to_string()) });
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
for _ in 0..5 {
app.update();
}
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
assert!(
cursor.read(msgs).next().is_some(),
"WarningToastEvent must be fired when opt-out fails"
);
}
/// Bug 2: successful opt-in must set `leaderboard_opted_in = true` in Settings.
#[test]
fn opt_in_success_sets_opted_in_flag() {
let mut app = headless_app_with_settings();
// Confirm the flag starts false.
assert!(!app
.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in);
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
for _ in 0..5 {
app.update();
}
assert!(
app.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in,
"leaderboard_opted_in must be true after successful opt-in"
);
}
/// Bug 2: successful opt-out must clear `leaderboard_opted_in`.
#[test]
fn opt_out_success_clears_opted_in_flag() {
let mut app = headless_app_with_settings();
// Seed as opted in.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.leaderboard_opted_in = true;
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
for _ in 0..5 {
app.update();
}
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in,
"leaderboard_opted_in must be false after successful opt-out"
);
}
/// Bug 3: `LeaderboardPublicNameText` label must reflect a display-name
/// change applied to `SettingsResource` without a panel rebuild.
#[test]
fn public_name_label_updates_reactively() {
let mut app = headless_app_with_settings();
// Open the panel.
press(&mut app, KeyCode::KeyL);
app.update();
// Verify the label starts with the default copy.
let initial: String = app
.world_mut()
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
.iter(app.world())
.next()
.expect("LeaderboardPublicNameText must exist while panel is open")
.0
.clone();
assert!(
initial.contains("same as username"),
"initial label should say '(same as username)' when no display name is set"
);
// Clear just-pressed state so `toggle_leaderboard_screen` doesn't
// re-fire in the next frame (MinimalPlugins has no input-tick system).
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyL);
input.clear();
}
// Update the display name in SettingsResource.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.leaderboard_display_name = Some("TestPlayer".to_string());
app.update();
let updated: String = app
.world_mut()
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
.iter(app.world())
.next()
.expect("LeaderboardPublicNameText must still exist")
.0
.clone();
assert!(
updated.contains("TestPlayer"),
"label must reflect new display name after settings change"
);
}
}
+9 -2
View File
@@ -235,7 +235,7 @@ fn toggle_pause(
// Snapshot current level and streak at pause time.
let level = progress.as_deref().map(|p| p.0.level);
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode.clone());
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode);
spawn_pause_screen(
&mut commands,
level,
@@ -437,10 +437,15 @@ fn close_forfeit_modal(
/// 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.
/// Query filter for modals that are not part of the pause flow.
/// Excludes both `PauseScreen` (the pause modal itself) and
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
type NonPauseFamilyScrim = (With<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
fn auto_resume_on_overlay(
mut commands: Commands,
pause_screens: Query<Entity, With<PauseScreen>>,
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
other_modal_scrims: Query<Entity, NonPauseFamilyScrim>,
mut paused: ResMut<PausedResource>,
) {
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
@@ -449,8 +454,10 @@ fn auto_resume_on_overlay(
for entity in &pause_screens {
commands.entity(entity).despawn();
}
if paused.0 {
paused.0 = false;
}
}
/// Spawns the pause modal using the standard `ui_modal` scaffold —
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
+1 -1
View File
@@ -338,7 +338,7 @@ fn tick_debounce_and_spawn_solver_task(
let draw_mode = settings
.as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode.clone());
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let cfg = SolverConfig::default();
let task = AsyncComputeTaskPool::get()
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
+336 -24
View File
@@ -33,8 +33,11 @@ use crate::replay_playback::{
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
toggle_pause_replay_playback, ReplayPlaybackState,
};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_data::ReplayMove;
use crate::resources::GameStateResource;
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
@@ -154,6 +157,12 @@ const MOVE_LOG_PREV_ROWS: usize = 2;
/// preview-shape might need rethinking.
const MOVE_LOG_NEXT_ROWS: usize = 2;
/// Vertical offset from the top edge of the window to the top edge of the
/// mini-tableau preview panel. Places the panel 8 px below the banner's
/// bottom edge so the two surfaces don't overlap. Derived from
/// `BANNER_HEIGHT` so the gap stays consistent if the banner ever grows.
const MINI_TABLEAU_TOP_OFFSET: f32 = BANNER_HEIGHT + 8.0;
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
/// reads as a clear "this is a UI strip" callout while still letting the
/// felt show through enough to anchor the banner to the play surface.
@@ -404,6 +413,34 @@ pub struct ReplayOverlayMoveLogNextRow {
pub offset: u8,
}
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
/// to despawn all of them, rather than keeping a separate query per
/// entity type. Future sibling overlay surfaces just need this marker
/// at spawn time — no changes to the despawn logic required.
#[derive(Component, Debug)]
pub struct DespawnWithReplay;
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
/// panel that shows a compact summary of the live game state during
/// replay: the four foundation tops and the stock / waste heads.
/// Spawned as a sibling root entity (same lifecycle pattern as
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauPanel;
/// Marker on the foundations row `Text` inside the mini-tableau panel.
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauFoundations;
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
/// [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauStockWaste;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -451,6 +488,8 @@ impl Plugin for ReplayOverlayPlugin {
update_move_log_active_row,
update_move_log_prev_rows,
update_move_log_next_rows,
update_mini_tableau_foundations,
update_mini_tableau_stock_waste,
update_pause_button_label,
handle_pause_button,
handle_step_button,
@@ -476,10 +515,8 @@ impl Plugin for ReplayOverlayPlugin {
fn react_to_state_change(
mut commands: Commands,
state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>,
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
dim_layers: Query<Entity, With<ReplayTableauDimLayer>>,
roots: Query<Entity, With<ReplayOverlayRoot>>,
despawnable: Query<Entity, With<DespawnWithReplay>>,
font_res: Option<Res<FontResource>>,
) {
if !state.is_changed() {
@@ -487,30 +524,15 @@ fn react_to_state_change(
}
let should_be_visible = state.is_playing() || state.is_completed();
let already_spawned = existing.iter().next().is_some();
let already_spawned = roots.iter().next().is_some();
if should_be_visible && !already_spawned {
spawn_overlay(&mut commands, font_res.as_deref(), &state);
} else if !should_be_visible && already_spawned {
for entity in &existing {
commands.entity(entity).despawn();
}
// Floating chip lives outside the UI tree (world-space
// entity), so the banner-root despawn doesn't reach it.
// Despawn separately on the same state transition so both
// disappear together when the replay ends.
for entity in &floating_chips {
commands.entity(entity).despawn();
}
// Move-log panel is also a separate root entity (sibling
// of the banner anchored to the viewport's bottom edge),
// so the banner-root despawn doesn't reach it either.
for entity in &move_log_panels {
commands.entity(entity).despawn();
}
// Tableau dim layer is also a separate root entity — same
// pattern as the move-log panel.
for entity in &dim_layers {
// Despawn all sibling root entities in one loop — every entity
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
// exactly this purpose.
for entity in &despawnable {
commands.entity(entity).despawn();
}
}
@@ -546,6 +568,8 @@ fn spawn_overlay(
// entity spawned after the banner closure closes. Mirrors the
// floating-chip clone reasoning.
let font_handle_for_move_log = font_handle.clone();
// Fourth clone for the mini-tableau preview panel.
let font_handle_for_mini_tableau = font_handle.clone();
let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
@@ -562,6 +586,7 @@ fn spawn_overlay(
// component — purely visual.
commands.spawn((
ReplayTableauDimLayer,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
@@ -585,6 +610,7 @@ fn spawn_overlay(
commands
.spawn((
ReplayOverlayRoot,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
@@ -967,6 +993,7 @@ fn spawn_overlay(
// when the replay state transitions back to `Inactive`.
commands.spawn((
ReplayFloatingProgressChip,
DespawnWithReplay,
Text2d::new(format_progress(state)),
TextFont {
font: font_handle_for_floating,
@@ -996,6 +1023,7 @@ fn spawn_overlay(
commands
.spawn((
ReplayOverlayMoveLogPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
@@ -1111,6 +1139,68 @@ fn spawn_overlay(
));
}
});
// Mini-tableau preview panel — right-edge anchor, just below the banner.
// Compact two-row readout: foundation tops then stock/waste head.
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayMiniTableauPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
right: Val::Px(0.0),
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: VAL_SPACE_1,
border: UiRect::left(Val::Px(1.0)),
..default()
},
BackgroundColor(banner_bg),
BorderColor::all(BORDER_SUBTLE),
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|panel| {
panel.spawn((
Text::new("\u{258C} BOARD"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauFoundations,
Text::new("F: -- -- -- --"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauStockWaste,
Text::new("STK:-- WST:--"),
TextFont {
font: font_handle_for_mini_tableau,
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
}
/// Pure helper — returns the scrub-fill width as a percentage of the
@@ -1554,6 +1644,118 @@ fn format_active_move_row(state: &ReplayPlaybackState) -> String {
format!("\u{25B6} {body}") // ▶
}
// ---------------------------------------------------------------------------
// Mini-tableau format helpers and update system
// ---------------------------------------------------------------------------
/// Pure helper — short rank symbol. Single character for all ranks
/// except Ten which uses "T" (keeps every card a consistent 2-char
/// wide render: rank-char + suit-glyph). Players familiar with
/// solitaire shorthand read "T" instantly; the suit glyph immediately
/// follows and disambiguates from an ambiguous "T".
fn format_rank_short(rank: Rank) -> &'static str {
match rank {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "T",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
}
}
/// Pure helper — Unicode suit glyph from FiraMono's covered range
/// (U+2660U+2666). These four code points are confirmed present in
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
fn format_suit_glyph(suit: Suit) -> &'static str {
match suit {
Suit::Spades => "\u{2660}", // ♠
Suit::Hearts => "\u{2665}", // ♥
Suit::Diamonds => "\u{2666}", // ♦
Suit::Clubs => "\u{2663}", // ♣
}
}
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
/// known card, or `"--"` for an absent top card (empty pile).
fn format_card_short(card: Option<&Card>) -> String {
match card {
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
None => "--".to_string(),
}
}
/// Pure helper — one-line summary of the four foundation tops.
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
/// Foundation slots are displayed in their natural 0-3 order
/// (matching the visual left-to-right order on screen).
fn format_foundations_row(game: &GameState) -> String {
let slots: [String; 4] = std::array::from_fn(|i| {
let top = game.piles
.get(&PileType::Foundation(i as u8))
.and_then(|p| p.cards.last());
format_card_short(top)
});
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
}
/// Pure helper — one-line stock / waste summary.
/// Renders as `STK:N WST:X♠` where N is the stock card count and
/// X♠ is the top waste card (or `--` when the waste pile is empty).
fn format_stock_waste_row(game: &GameState) -> String {
let stock_count = game.piles
.get(&PileType::Stock)
.map(|p| p.cards.len())
.unwrap_or(0);
let waste_top = game.piles
.get(&PileType::Waste)
.and_then(|p| p.cards.last());
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
}
/// Repaints the foundations row whenever [`GameStateResource`] changes.
/// Split into its own system (rather than combined with the stock/waste
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
/// queries in one system are always ambiguous regardless of marker
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
fn update_mini_tableau_foundations(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_foundations_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
/// guard, separate system to avoid the B0001 query conflict.
fn update_mini_tableau_stock_waste(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_stock_waste_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
// ---------------------------------------------------------------------------
// Playback-control button handlers
// ---------------------------------------------------------------------------
@@ -1763,6 +1965,7 @@ fn handle_stop_keyboard(
mod tests {
use super::*;
use chrono::NaiveDate;
use solitaire_core::card::{Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
@@ -3990,4 +4193,113 @@ mod tests {
fn dim_layer_z_is_below_replay_chrome() {
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
}
// -----------------------------------------------------------------------
// Mini-tableau preview tests
// -----------------------------------------------------------------------
fn mini_tableau_panel_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayMiniTableauPanel>()
.iter(app.world())
.count()
}
/// Mini-tableau panel spawns alongside the other overlay surfaces
/// when playback starts and despawns when it ends.
#[test]
fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
let mut app = headless_app();
app.update();
assert_eq!(
mini_tableau_panel_count(&mut app),
0,
"no mini-tableau panel while playback is Inactive",
);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
mini_tableau_panel_count(&mut app),
1,
"mini-tableau panel must spawn when playback starts",
);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
mini_tableau_panel_count(&mut app),
0,
"mini-tableau panel must despawn when playback ends",
);
}
/// `format_rank_short` maps every `Rank` variant to a single ASCII
/// character except Ten which maps to `"T"`.
#[test]
fn format_rank_short_all_ranks() {
assert_eq!(format_rank_short(Rank::Ace), "A");
assert_eq!(format_rank_short(Rank::Two), "2");
assert_eq!(format_rank_short(Rank::Three), "3");
assert_eq!(format_rank_short(Rank::Four), "4");
assert_eq!(format_rank_short(Rank::Five), "5");
assert_eq!(format_rank_short(Rank::Six), "6");
assert_eq!(format_rank_short(Rank::Seven), "7");
assert_eq!(format_rank_short(Rank::Eight), "8");
assert_eq!(format_rank_short(Rank::Nine), "9");
assert_eq!(format_rank_short(Rank::Ten), "T");
assert_eq!(format_rank_short(Rank::Jack), "J");
assert_eq!(format_rank_short(Rank::Queen), "Q");
assert_eq!(format_rank_short(Rank::King), "K");
}
/// `format_suit_glyph` returns the FiraMono-covered Unicode suit
/// glyphs for each `Suit` variant (U+2660U+2666 confirmed on Android).
#[test]
fn format_suit_glyph_all_suits() {
assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}");
assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}");
assert_eq!(format_suit_glyph(Suit::Diamonds), "\u{2666}");
assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}");
}
/// `format_foundations_row` with a freshly-dealt game (all empty).
#[test]
fn format_foundations_row_empty_board() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
);
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
}
/// `format_stock_waste_row` with a freshly-dealt game: stock has
/// 24 cards, waste is empty.
#[test]
fn format_stock_waste_row_initial_state() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
);
let text = format_stock_waste_row(&game);
assert!(
text.starts_with("STK:"),
"row must start with STK: prefix; got {text:?}",
);
assert!(
text.contains("WST:--"),
"waste must show -- on a fresh deal; got {text:?}",
);
}
}
+1 -1
View File
@@ -190,7 +190,7 @@ pub fn start_replay_playback(
) {
use solitaire_core::game_state::GameState;
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
commands.insert_resource(GameStateResource(fresh));
// Initial `secs_to_next` uses the constant rather than reading
+25
View File
@@ -1,5 +1,7 @@
//! Bevy resources owned by the engine crate.
use std::sync::Arc;
use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
@@ -111,3 +113,26 @@ pub struct HintCycleIndex(pub usize);
/// returns to the same position in the list without re-scrolling.
#[derive(Resource, Debug, Clone, Default)]
pub struct SettingsScrollPos(pub f32);
/// Shared Tokio runtime used by all async-task closures that need HTTP I/O.
///
/// Bevy's `AsyncComputeTaskPool` uses `async-executor` (not Tokio), so spawned
/// closures that call `reqwest`/`hyper` need a Tokio reactor. A single
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply
/// into every network task — safe for concurrent `block_on` calls from multiple
/// worker threads.
#[derive(Resource, Clone)]
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
impl Default for TokioRuntimeResource {
fn default() -> Self {
// Building the Tokio runtime is startup-time initialization; failure
// here means the OS refused to create threads, which is unrecoverable.
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("failed to build shared Tokio runtime");
Self(Arc::new(rt))
}
}
+113 -9
View File
@@ -18,6 +18,7 @@
//! changes flow through automatically.
use bevy::prelude::*;
use bevy::window::{AppLifecycle, WindowResized};
use crate::ui_modal::ModalScrim;
@@ -51,15 +52,39 @@ pub struct SafeAreaAnchoredTop {
pub base_top: f32,
}
/// Marker for `Node` entities whose `bottom` offset should be re-applied
/// as `base_bottom + SafeAreaInsets::bottom / scale`.
///
/// Use this for elements anchored to the bottom edge (e.g. a bottom action
/// bar) so they clear the Android gesture-navigation zone automatically.
#[derive(Component, Debug, Clone, Copy)]
pub struct SafeAreaAnchoredBottom {
pub base_bottom: 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));
// Both message types may already be registered by GamePlugin / TablePlugin;
// add_message is idempotent.
app.add_message::<AppLifecycle>()
.add_message::<WindowResized>()
.init_resource::<SafeAreaInsets>()
.add_systems(
Update,
(
apply_safe_area_anchors,
apply_safe_area_bottom_anchors,
apply_safe_area_to_modal_scrims,
on_app_resumed,
),
);
#[cfg(target_os = "android")]
app.add_systems(Update, android::refresh_insets);
app.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets)
.add_systems(Update, android::rearm_on_resumed);
}
}
@@ -89,6 +114,23 @@ fn apply_safe_area_anchors(
}
}
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
fn apply_safe_area_bottom_anchors(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
) {
if !insets.is_changed() {
return;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let bottom_logical = insets.bottom / scale;
for (anchor, mut node) in &mut q {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
}
}
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
/// modal cards don't extend into the Android gesture-navigation zone.
///
@@ -112,33 +154,73 @@ fn apply_safe_area_to_modal_scrims(
}
}
/// Emits a synthetic `WindowResized` on `AppLifecycle::WillResume` so that
/// `on_window_resized` (in `table_plugin`) recomputes the board layout with
/// whatever `SafeAreaInsets` are current at that moment.
///
/// On Android the `android::rearm_on_resumed` system runs in the same frame
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
/// synthetic `WindowResized` and the layout converges to the right position.
///
/// On non-Android targets this handler still fires — it ensures that a resume
/// event always refreshes the layout (e.g., after a minimise/restore on
/// desktop) even though insets are always zero.
fn on_app_resumed(
mut lifecycle: MessageReader<AppLifecycle>,
windows: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
for event in lifecycle.read() {
if !matches!(event, AppLifecycle::WillResume) {
continue;
}
let Some((entity, window)) = windows.iter().next() else {
return;
};
resize_events.write(WindowResized {
window: entity,
width: window.resolution.width(),
height: window.resolution.height(),
});
}
}
#[cfg(target_os = "android")]
mod android {
use super::SafeAreaInsets;
use super::{AppLifecycle, SafeAreaInsets};
use bevy::prelude::*;
/// Tracks how many frames `refresh_insets` has polled. Stored as a
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
/// when `AppLifecycle::WillResume` fires, causing the poller to re-query JNI
/// after a background/foreground cycle.
#[derive(Resource, Default)]
pub(super) struct SafeAreaPollTries(pub u32);
/// 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>,
mut poll: ResMut<SafeAreaPollTries>,
) {
// 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() {
if poll.0 >= MAX_TRIES || insets.is_populated() {
return;
}
*tries += 1;
poll.0 += 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
v.top, v.bottom, v.left, v.right, poll.0
);
*insets = v;
}
@@ -147,13 +229,35 @@ mod android {
}
Err(e) => {
// Don't spam — log once and let polling continue silently.
if *tries == 1 {
if poll.0 == 1 {
warn!("safe_area: JNI query failed (will retry): {e}");
}
}
}
}
/// Resets the inset poller and clears cached insets on
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
/// frames immediately after the app returns to the foreground.
///
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
/// `WindowResized`. `on_window_resized` then recomputes the layout;
/// once `refresh_insets` resolves the real values a second synthetic
/// `WindowResized` fires and the layout converges to the correct position.
pub(super) fn rearm_on_resumed(
mut lifecycle: MessageReader<AppLifecycle>,
mut poll: ResMut<SafeAreaPollTries>,
mut insets: ResMut<SafeAreaInsets>,
) {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) {
poll.0 = 0;
*insets = SafeAreaInsets::default();
}
}
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP;
use jni::{objects::JObject, JavaVM};
+7 -1
View File
@@ -156,7 +156,13 @@ impl Plugin for SelectionPlugin {
.in_set(SelectionKeySet)
.before(GameMutation),
clear_selection_on_state_change.after(GameMutation),
update_selection_highlight.after(GameMutation),
update_selection_highlight
.after(GameMutation)
.run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>),
),
),
);
}
+4 -2
View File
@@ -401,8 +401,10 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text,
update_color_blind_text,
update_high_contrast_text,
update_high_contrast_borders,
update_high_contrast_backgrounds,
update_high_contrast_borders
.run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds
.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
+46 -40
View File
@@ -26,12 +26,12 @@ use solitaire_sync::{merge, SyncPayload, SyncResponse};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
SyncConfigureRequestEvent,
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
WarningToastEvent,
};
use crate::game_plugin::RecordingReplay;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource};
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
// ---------------------------------------------------------------------------
@@ -101,6 +101,7 @@ impl SyncPlugin {
impl Plugin for SyncPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(SyncProviderResource(self.provider.clone()))
.init_resource::<TokioRuntimeResource>()
.init_resource::<SyncStatusResource>()
.init_resource::<PullTaskResult>()
.init_resource::<PullTask>()
@@ -108,7 +109,7 @@ impl Plugin for SyncPlugin {
.add_message::<ManualSyncRequestEvent>()
.add_message::<SyncCompleteEvent>()
.add_message::<SyncConfigureRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<WarningToastEvent>()
.add_systems(Startup, start_pull)
.add_systems(
Update,
@@ -130,19 +131,14 @@ impl Plugin for SyncPlugin {
/// Startup system: spawns the async pull task and sets status to `Syncing`.
fn start_pull(
provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
mut task_res: ResMut<PullTask>,
mut status: ResMut<SyncStatusResource>,
) {
let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
// 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())
rt.block_on(provider.pull())
});
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
@@ -153,6 +149,7 @@ fn start_pull(
fn handle_manual_sync_request(
mut events: MessageReader<ManualSyncRequestEvent>,
provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
mut task_res: ResMut<PullTask>,
mut status: ResMut<SyncStatusResource>,
) {
@@ -164,12 +161,9 @@ fn handle_manual_sync_request(
return; // Already pulling — ignore.
}
let provider = provider.0.clone();
let rt = rt.0.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(provider.pull())
rt.block_on(provider.pull())
});
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
@@ -197,7 +191,7 @@ fn poll_pull_result(
progress_path: Res<ProgressStoragePath>,
mut complete_writer: MessageWriter<SyncCompleteEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else {
return;
@@ -251,13 +245,13 @@ fn poll_pull_result(
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
SyncError::UnsupportedPlatform => unreachable!("handled above"),
};
warning_toast.write(WarningToastEvent(msg.clone()));
// 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());
@@ -274,6 +268,7 @@ fn poll_pull_result(
fn push_on_exit(
mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
stats: Res<StatsResource>,
achievements: Res<AchievementsResource>,
progress: Res<ProgressResource>,
@@ -284,21 +279,7 @@ fn push_on_exit(
exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone();
// 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(_) => 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}"))),
},
};
let result = rt.0.block_on(provider.0.push(&payload));
match result {
Ok(_) => {}
// `UnsupportedPlatform` is the expected response of
@@ -327,6 +308,7 @@ fn push_on_exit(
fn push_replay_on_win(
mut wins: MessageReader<GameWonEvent>,
provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
game: Res<GameStateResource>,
recording: Res<RecordingReplay>,
mut pending: ResMut<PendingReplayUpload>,
@@ -340,7 +322,7 @@ fn push_replay_on_win(
}
let replay = Replay::new(
game.0.seed,
game.0.draw_mode.clone(),
game.0.draw_mode,
game.0.mode,
ev.time_seconds,
ev.score,
@@ -348,12 +330,9 @@ fn push_replay_on_win(
recording.moves.clone(),
);
let provider = provider.0.clone();
let rt = rt.0.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(provider.push_replay(&replay))
rt.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
@@ -571,6 +550,33 @@ mod tests {
);
}
#[test]
fn pull_failure_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with(FailingProvider);
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
if matches!(
app.world().resource::<SyncStatusResource>().0,
SyncStatus::Error(_)
) {
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
}
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
assert!(
cursor.read(msgs).next().is_some(),
"a WarningToastEvent must fire when the pull fails"
);
}
#[test]
fn build_payload_sets_nil_user_id() {
let payload = build_payload(
+7 -10
View File
@@ -53,6 +53,7 @@ use crate::events::{
};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
use crate::resources::TokioRuntimeResource;
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::spawn_modal;
use crate::ui_theme::{
@@ -301,6 +302,7 @@ fn handle_auth_button(
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingAuthTask>,
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
@@ -363,13 +365,10 @@ fn handle_auth_button(
let is_register = register_clicked;
let client = SolitaireServerClient::new(url.clone(), username.clone());
let pw = password.clone();
let rt = rt.0.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 {
rt.block_on(async {
let (access_token, refresh_token) = if is_register {
client.register(&pw).await?
} else {
@@ -575,6 +574,7 @@ fn handle_delete_cancel(
fn handle_delete_confirm(
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingDeleteTask>,
screen: Query<Entity, With<DeleteConfirmScreen>>,
mut commands: Commands,
@@ -587,12 +587,9 @@ fn handle_delete_confirm(
commands.entity(entity).despawn();
}
let provider = provider.0.clone();
let rt = rt.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())
rt.block_on(provider.delete_account())
}));
}
+1 -1
View File
@@ -129,7 +129,7 @@ fn load_initial_theme(
let id = settings
.as_deref()
.map(|s| s.0.selected_theme_id.as_str())
.unwrap_or("dark");
.unwrap_or("classic");
let url = bundled_theme_url(id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
+14 -3
View File
@@ -212,6 +212,13 @@ where
// modal at `Z_PAUSE` (220) in some scenes.
GlobalZIndex(z_panel),
ZIndex(z_panel),
// B0004: ModalCard carries Transform (for the scale animation).
// Bevy's GlobalTransform hook fires B0004 when a child has
// GlobalTransform but the parent does not. Adding Identity
// Transform here gives the scrim GlobalTransform so the check
// passes. UI layout still uses UiTransform; this has no layout
// effect.
Transform::default(),
))
.with_children(|root| {
root.spawn((
@@ -603,7 +610,7 @@ pub fn dismiss_modal_on_scrim_click(
mut commands: Commands,
mouse: Option<Res<ButtonInput<MouseButton>>>,
windows: Query<&Window, With<PrimaryWindow>>,
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
scrims: Query<(Entity, &Children), (With<ModalScrim>, With<ScrimDismissible>)>,
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
) {
let Some(mouse) = mouse else { return };
@@ -620,15 +627,19 @@ pub fn dismiss_modal_on_scrim_click(
// Topmost-only: bail after the first dismissible scrim. Stacked
// dismissible modals are not currently a real case, but this guard
// keeps the behaviour predictable if they ever arise.
let Some(scrim_entity) = scrims.iter().next() else {
let Some((scrim_entity, scrim_children)) = scrims.iter().next() else {
return;
};
let cursor_over_card = cards.iter().any(|(transform, computed)| {
// Only test the ModalCard(s) that belong to THIS scrim, not cards
// from any other concurrently-open modal.
let cursor_over_card = scrim_children.iter().any(|child| {
cards.get(child).is_ok_and(|(transform, computed)| {
let inv = computed.inverse_scale_factor;
let size_logical = computed.size() * inv;
let centre_logical = transform.translation * inv;
cursor_is_inside_rect(cursor, centre_logical, size_logical)
})
});
if !cursor_over_card {
+11 -4
View File
@@ -313,10 +313,10 @@ impl HighContrastBackground {
/// `outline` from the design system. `#505050`.
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
/// 2 px ring drawn around the focused interactive element. Cyan
/// 2 px ring drawn around the focused interactive element. Brick-red
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
/// against both elevated surfaces and the modal scrim backdrop.
/// `rgba(111, 194, 239, 0.85)`.
/// `rgba(165, 66, 66, 0.85)`.
pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85);
// ---------------------------------------------------------------------------
@@ -401,8 +401,13 @@ pub const Z_BACKGROUND: i32 = -10;
pub const Z_PILE_MARKER: i32 = -1;
/// Base layer for HUD readouts (top-left).
pub const Z_HUD: i32 = 50;
/// Action bar + popovers — above HUD readouts so dropdowns can overlap.
pub const Z_HUD_TOP: i32 = 60;
/// Fullscreen transparent dismiss-backdrop spawned behind a HUD popover so
/// tapping outside it light-dismisses the panel without blocking other input.
pub const Z_HUD_POPOVER_BACKDROP: i32 = Z_HUD + 4;
/// HUD popovers (Modes dropdown, etc.) — above the dismiss backdrop.
pub const Z_HUD_POPOVER: i32 = Z_HUD + 5;
/// Transient HUD annotations (score-delta floaters) — above popovers.
pub const Z_HUD_TOP: i32 = Z_HUD + 10;
pub const Z_MODAL_SCRIM: i32 = 200;
pub const Z_MODAL_PANEL: i32 = 210;
/// Pause overlay outranks normal modals — pausing should always be on top.
@@ -648,6 +653,8 @@ mod tests {
Z_BACKGROUND,
Z_PILE_MARKER,
Z_HUD,
Z_HUD_POPOVER_BACKDROP,
Z_HUD_POPOVER,
Z_HUD_TOP,
Z_MODAL_SCRIM,
Z_MODAL_PANEL,
+1 -1
View File
@@ -82,7 +82,7 @@ fn evaluate_weekly_goals(
let ctx = WeeklyGoalContext {
time_seconds: ev.time_seconds,
used_undo: game.0.undo_count > 0,
draw_mode: game.0.draw_mode.clone(),
draw_mode: game.0.draw_mode,
};
for def in WEEKLY_GOALS {
if !def.matches(&ctx) {
+10 -4
View File
@@ -336,7 +336,7 @@ pub async fn get_me(
Ok(Json(MeResponse {
id: user.user_id,
username: row.username.unwrap_or_default(),
username: row.username.ok_or(AppError::Unauthorized)?,
avatar_url: row.avatar_url,
}))
}
@@ -386,13 +386,19 @@ pub async fn upload_avatar(
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
let filename = format!("{}.{}", user.user_id, ext);
let path = std::path::Path::new("avatars").join(&filename);
// Remove stale files with other extensions first.
let tmp_path = std::path::Path::new("avatars").join(format!("{}.{}.tmp", user.user_id, ext));
// Write to a temp file then atomically rename so concurrent readers never
// see a partially-written avatar.
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
std::fs::rename(&tmp_path, &path).map_err(|e| AppError::Internal(e.to_string()))?;
// Remove stale files with other extensions after the atomic rename.
for old_ext in &["jpg", "png", "webp", "gif"] {
if *old_ext != ext {
let _ = std::fs::remove_file(
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
);
}
std::fs::write(&path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
}
let avatar_url = format!("/avatars/{filename}");
sqlx::query!(
@@ -412,7 +418,7 @@ pub async fn upload_avatar(
Ok(Json(MeResponse {
id: user.user_id,
username: username.unwrap_or_default(),
username: username.ok_or(AppError::Unauthorized)?,
avatar_url: Some(avatar_url),
}))
}
+1 -1
View File
@@ -146,6 +146,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
.route("/api/account", delete(auth::delete_account))
.route("/api/me", get(auth::get_me))
.route("/api/me/avatar", put(auth::upload_avatar))
.nest_service("/avatars", ServeDir::new("avatars"))
.layer(axum_middleware::from_fn_with_state(
state.clone(),
middleware::require_auth,
@@ -231,7 +232,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
)
.nest_service("/web", ServeDir::new("solitaire_server/web"))
.nest_service("/assets", ServeDir::new("assets"))
.nest_service("/avatars", ServeDir::new("avatars"))
.layer(axum_middleware::from_fn(security_headers));
Router::new()
+30
View File
@@ -59,6 +59,25 @@ header {
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
.hud-right { display: flex; align-items: center; gap: 10px; }
.hud-avatar-link { display: flex; align-items: center; text-decoration: none; }
.hud-avatar-inner {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.15);
background: var(--panel-hi);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
transition: border-color 120ms;
}
.hud-avatar-link:hover .hud-avatar-inner { border-color: var(--accent); }
.hud-avatar-inner img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.logo { font-size: 16px; font-weight: 700; }
.muted { color: var(--text-muted); font-size: 12px; }
.home-link {
@@ -98,6 +117,16 @@ button:disabled { opacity: 0.4; cursor: default; }
/* ── Board ───────────────────────────────────────────────────────────── */
.board-undo {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 50;
font-size: 15px;
padding: 8px 20px;
pointer-events: auto;
}
main {
flex: 1;
display: flex;
@@ -108,6 +137,7 @@ main {
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
#board {
position: relative;
flex: 1;
background: var(--felt);
display: flex;
+7
View File
@@ -40,12 +40,19 @@
<input type="checkbox" id="chk-draw3"> Draw 3
</label>
<button id="btn-theme" title="Switch card theme">Dark</button>
<a id="hud-avatar" href="/account" title="Account" class="hud-avatar-link" style="display:none">
<div class="hud-avatar-inner">
<img id="hud-avatar-img" src="" alt="" style="display:none">
<span id="hud-avatar-initials"></span>
</div>
</a>
</div>
</header>
<main>
<section id="board">
<div id="card-area"></div>
<button id="btn-board-undo" class="board-undo" title="Undo (Z)" disabled>↩ Undo</button>
</section>
</main>
+32 -5
View File
@@ -104,6 +104,7 @@ const hudTimer = document.getElementById("hud-timer");
const hudStock = document.getElementById("hud-stock");
const hudSeed = document.getElementById("hud-seed");
const btnUndo = document.getElementById("btn-undo");
const btnBoardUndo = document.getElementById("btn-board-undo");
const btnNew = document.getElementById("btn-new");
const chkDraw3 = document.getElementById("chk-draw3");
const btnTheme = document.getElementById("btn-theme");
@@ -244,6 +245,7 @@ function render(s) {
hudMoves.textContent = `Moves: ${s.move_count}`;
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
btnUndo.disabled = s.undo_stack_len === 0;
btnBoardUndo.disabled = s.undo_stack_len === 0;
const visible = new Map();
const addPile = (name, cards) =>
@@ -385,10 +387,9 @@ function flashIllegal(cardIds) {
// ── Input ─────────────────────────────────────────────────────────────────────
function attachHandlers() {
btnUndo.addEventListener("click", () => {
const r = game.undo();
if (r.ok) render(r.snapshot);
});
const doUndo = () => { const r = game.undo(); if (r.ok) render(r.snapshot); };
btnUndo.addEventListener("click", doUndo);
btnBoardUndo.addEventListener("click", doUndo);
btnNew.addEventListener("click", () => startGame(randomSeed()));
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
chkDraw3.addEventListener("change", () => {
@@ -404,7 +405,7 @@ function attachHandlers() {
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
if (e.key === "z" || e.key === "Z") { const r = game.undo(); if (r.ok) render(r.snapshot); }
if (e.key === "z" || e.key === "Z") doUndo();
if (e.key === "n" || e.key === "N") startGame(randomSeed());
});
@@ -662,5 +663,31 @@ function onBoardDblClick(e) {
if (!smartMove(hit.pileName, fromIndex)) flashIllegal([cards[fromIndex].id]);
}
// ── Avatar ────────────────────────────────────────────────────────────────────
async function loadAvatar() {
const token = localStorage.getItem("fs_token");
if (!token) return;
try {
const res = await fetch("/api/me", {
headers: { Authorization: "Bearer " + token },
});
if (!res.ok) return;
const me = await res.json();
const link = document.getElementById("hud-avatar");
const img = document.getElementById("hud-avatar-img");
const init = document.getElementById("hud-avatar-initials");
link.style.display = "flex";
if (me.avatar_url) {
img.src = me.avatar_url;
img.style.display = "block";
init.style.display = "none";
} else {
img.style.display = "none";
init.textContent = (me.username || "P")[0].toUpperCase();
}
} catch { /* not signed in — avatar stays hidden */ }
}
// ── Start ─────────────────────────────────────────────────────────────────────
bootstrap().catch(console.error);
loadAvatar();
+2 -1
View File
@@ -30,7 +30,8 @@
<section id="board"></section>
<section id="controls">
<button id="btn-prev" disabled>⏮ Restart</button>
<button id="btn-restart" disabled>⏮ Restart</button>
<button id="btn-prev" disabled>◀ Back</button>
<button id="btn-play">▶ Play</button>
<button id="btn-step">⏭ Step</button>
<span id="progress" class="muted">step 0 / 0</span>
+30 -5
View File
@@ -62,6 +62,7 @@ const resultEl = document.getElementById("result");
const btnPlay = document.getElementById("btn-play");
const btnStep = document.getElementById("btn-step");
const btnPrev = document.getElementById("btn-prev");
const btnRestart = document.getElementById("btn-restart");
let player = null;
let replayJson = null;
@@ -122,6 +123,7 @@ function resetPlayer() {
}
player = new ReplayPlayer(replayJson);
btnPrev.disabled = true;
btnRestart.disabled = true;
btnStep.disabled = false;
btnPlay.disabled = false;
render(player.state());
@@ -134,6 +136,7 @@ function step() {
return null;
}
btnPrev.disabled = false;
btnRestart.disabled = false;
render(snap);
return snap;
}
@@ -301,13 +304,35 @@ btnPlay.addEventListener("click", () => {
}, STEP_INTERVAL_MS);
});
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
/// forwards to (step_idx - 1) without rendering intermediate frames, then
/// renders once so the CSS transition animates each card to its previous
/// position.
function stepBack() {
if (!player || player.step_idx() === 0) return;
if (playInterval) {
clearInterval(playInterval);
playInterval = null;
btnPlay.textContent = "▶ Play";
}
const target = player.step_idx() - 1;
player = new ReplayPlayer(replayJson);
for (let i = 0; i < target; i++) {
player.step();
}
render(player.state());
btnPrev.disabled = player.step_idx() === 0;
btnRestart.disabled = player.step_idx() === 0;
btnStep.disabled = false;
btnPlay.disabled = false;
}
btnPrev.addEventListener("click", () => {
if (player) stepBack();
});
btnRestart.addEventListener("click", () => {
if (!replayJson) return;
// Drop every existing card so the next render fades them all in
// at the freshly-dealt positions. Without this, cards from the
// current state would slide to wherever the new deal puts them
// — confusing since the deal is supposed to look like a fresh
// start, not a continuation.
cardEls.forEach((el) => el.remove());
cardEls.clear();
resetPlayer();
+1 -1
View File
@@ -12,7 +12,7 @@ pub mod progress;
pub mod stats;
pub use achievements::AchievementRecord;
pub use merge::merge;
pub use merge::{merge, merge_at};
pub use progress::{level_for_xp, PlayerProgress};
pub use stats::StatsSnapshot;
+43 -18
View File
@@ -3,13 +3,18 @@
//! All functions are free of I/O and side effects — safe to call from any
//! context including unit tests and the Bevy main thread.
use chrono::{NaiveDate, Utc};
use chrono::{DateTime, NaiveDate, Utc};
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
/// Merge two [`SyncPayload`]s into a single authoritative result.
///
/// `resolved_at` is recorded as `last_modified` on the merged payload and all
/// sub-structs. Pass an explicit timestamp when the caller needs deterministic
/// output (e.g. server handlers); use [`merge`] as a convenience wrapper that
/// captures the current time automatically.
///
/// The merge strategy is additive and conflict-free for most fields:
/// - Counters: take the maximum (games_played, games_won, etc.)
/// - Best records: take the minimum for times, maximum for scores/xp
@@ -20,6 +25,38 @@ use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
/// Fields that cannot be merged deterministically (e.g. diverged streak
/// counts) are recorded in [`ConflictReport`] entries returned alongside
/// the merged payload. Data is never silently discarded.
pub fn merge_at(
local: &SyncPayload,
remote: &SyncPayload,
resolved_at: DateTime<Utc>,
) -> (SyncPayload, Vec<ConflictReport>) {
let mut conflicts = Vec::new();
if local.user_id != remote.user_id {
conflicts.push(ConflictReport {
field: "user_id".to_string(),
local_value: local.user_id.to_string(),
remote_value: remote.user_id.to_string(),
});
return (local.clone(), conflicts);
}
let stats = merge_stats(&local.stats, &remote.stats, resolved_at, &mut conflicts);
let achievements = merge_achievements(&local.achievements, &remote.achievements);
let progress = merge_progress(&local.progress, &remote.progress, resolved_at, &mut conflicts);
let merged = SyncPayload {
user_id: local.user_id,
stats,
achievements,
progress,
last_modified: resolved_at,
};
(merged, conflicts)
}
/// Convenience wrapper around [`merge_at`] that captures the current UTC time.
///
/// # Examples
/// ```
@@ -45,21 +82,7 @@ use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
/// assert!(conflicts.is_empty());
/// ```
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<ConflictReport>) {
let mut conflicts = Vec::new();
let stats = merge_stats(&local.stats, &remote.stats, &mut conflicts);
let achievements = merge_achievements(&local.achievements, &remote.achievements);
let progress = merge_progress(&local.progress, &remote.progress, &mut conflicts);
let merged = SyncPayload {
user_id: local.user_id,
stats,
achievements,
progress,
last_modified: Utc::now(),
};
(merged, conflicts)
merge_at(local, remote, Utc::now())
}
// ---------------------------------------------------------------------------
@@ -69,6 +92,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<Con
fn merge_stats(
local: &StatsSnapshot,
remote: &StatsSnapshot,
resolved_at: DateTime<Utc>,
conflicts: &mut Vec<ConflictReport>,
) -> StatsSnapshot {
// win_streak_current cannot be merged deterministically — record conflict
@@ -128,7 +152,7 @@ fn merge_stats(
local.challenge_fastest_win_seconds,
remote.challenge_fastest_win_seconds,
),
last_modified: Utc::now(),
last_modified: resolved_at,
}
}
@@ -213,6 +237,7 @@ fn merge_achievements(
fn merge_progress(
local: &PlayerProgress,
remote: &PlayerProgress,
resolved_at: DateTime<Utc>,
conflicts: &mut Vec<ConflictReport>,
) -> PlayerProgress {
// daily_challenge_streak cannot be merged deterministically.
@@ -303,7 +328,7 @@ fn merge_progress(
challenge_index,
daily_challenge_history,
daily_challenge_longest_streak,
last_modified: Utc::now(),
last_modified: resolved_at,
}
}
+19 -8
View File
@@ -119,7 +119,7 @@ impl ReplayPlayer {
let replay: Replay =
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
let game =
GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
Ok(Self {
game,
moves: replay.moves,
@@ -193,16 +193,24 @@ impl ReplayPlayer {
}
/// Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
pub fn state(&self) -> JsValue {
serde_wasm_bindgen::to_value(&self.snapshot()).unwrap_or(JsValue::NULL)
///
/// Throws a JS string exception on serialisation failure (should never
/// occur in practice — `StateSnapshot` contains only primitive types).
pub fn state(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.snapshot())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Apply the next move; returns the post-step snapshot, or `null`
/// once the move list is exhausted.
pub fn step(&mut self) -> JsValue {
///
/// Returns `null` (not an exception) when the replay is finished.
/// Throws a JS string exception on serialisation failure.
pub fn step(&mut self) -> Result<JsValue, JsValue> {
match self.step_native() {
Some(snap) => serde_wasm_bindgen::to_value(&snap).unwrap_or(JsValue::NULL),
None => JsValue::NULL,
Some(snap) => serde_wasm_bindgen::to_value(&snap)
.map_err(|e| JsValue::from_str(&e.to_string())),
None => Ok(JsValue::NULL),
}
}
@@ -364,8 +372,11 @@ impl SolitaireGame {
}
/// Full pile snapshot as a JS object.
pub fn state(&self) -> JsValue {
serde_wasm_bindgen::to_value(&self.snap()).unwrap_or(JsValue::NULL)
///
/// Throws a JS string exception on serialisation failure.
pub fn state(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.snap())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// The seed used to deal this game.