Compare commits

...

85 Commits

Author SHA1 Message Date
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
funman300 ba17c026a3 chore(release): promote Unreleased to 0.28.0
Android Release / build-apk (push) Successful in 7m58s
Rename Solitaire Quest → Ferrous Solitaire; package id
com.solitairequest.app → com.ferrousapp.solitaire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:11:12 -07:00
funman300 6cedf36b01 fix(readme): use Dart class name "Codeberg" as overrideSource in Obtainium badge
Obtainium matches overrideSource via runtimeType.toString(), which is the
Dart class name "Codeberg", not the display name "Forgejo (Codeberg)".
The wrong name caused "URL does not match the source" on import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:06:38 -07:00
funman300 eb0831893d fix(readme): pass apkUrls/otherAssetUrls as JSON-encoded strings in Obtainium badge
Obtainium's fromJson calls jsonDecode(json['apkUrls']), so the field must
be a JSON-encoded string ("[]") not a raw array ([]). Passing a raw array
caused the Dart runtime error: List<dynamic> is not a subtype of String.
Also adds allowIdChange and otherAssetUrls fields required by v1.4.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:59:18 -07:00
funman300 ad9ac9c7bb fix(readme): correct Obtainium badge to URL-encoded JSON format
The redirect service at apps.obtainium.imranr.dev/redirect parses the
obtainium://app/ payload via JSON.parse(decodeURIComponent(...)), so
base64 caused an "invalid URL" error. Switch to URL-percent-encoded JSON.
Also updates package id to com.ferrousapp.solitaire (renamed package).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:56:48 -07:00
funman300 5f9f2745f9 docs: fix Obtainium deep link — use Forgejo (Codeberg) source, not Gitea
Obtainium has no dedicated Gitea source provider. The correct override
is "Forgejo (Codeberg)" which uses the same /api/v1/repos/ API that
Gitea exposes. Previous overrideSource "Gitea" was silently ignored,
falling back to failed auto-detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:51:31 -07:00
funman300 a18bcb84d3 docs: switch Obtainium badge to app/ deep link with Gitea source pre-set
The add/ scheme relies on auto-detection which fails for this self-hosted
Gitea instance. The app/ scheme encodes the full config (including
overrideSource: Gitea) in base64 so no detection is needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:46:21 -07:00
funman300 d5c7a149cb docs: clarify Obtainium setup requires manual Gitea source type selection
The self-hosted Gitea instance's /api/v1/meta endpoint returns 404,
causing Obtainium's auto-detection to fail on custom domains. Rewrite
the install steps to lead with the manual Add App flow (URL + set source
type to Gitea) and demote the one-tap badge to a secondary option.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:32:02 -07:00
Gitea CI fceb2be381 chore(deploy): bump image to d761a150 [skip ci] 2026-05-15 02:28:31 +00:00
funman300 d761a150d7 chore: rename app from Solitaire Quest to Ferrous Solitaire
Build and Deploy / build-and-push (push) Successful in 4m40s
Updates all in-tree references:
- Android package: com.solitairequest.app → com.ferrousapp.solitaire
- APK name: solitaire-quest → ferrous-solitaire
- Data dir: solitaire_quest → ferrous_solitaire (across all 6 data modules + engine)
- Keyring service: solitaire_quest_server → ferrous_solitaire_server
- Android Keystore key: solitaire_quest_token_key → ferrous_solitaire_token_key
- Gitea repo: Rusty_Solitare → Ferrous-Solitaire (also fixes "Solitare" typo)
- Renamed pkg/solitaire-quest* → pkg/ferrous-solitaire*
- Updated ArgoCD, docker-compose, CI workflow, build script, all docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:23:49 -07:00
funman300 d105fee319 docs: add Obtainium badge with deep-link to README 2026-05-14 19:13:29 -07:00
funman300 94c68a46a4 docs: add Android install section with Obtainium instructions 2026-05-14 19:12:59 -07:00
funman300 58f33da6bf fix(ci): suppress SIGPIPE from yes | sdkmanager --licenses
Android Release / build-apk (push) Successful in 7m29s
2026-05-14 19:04:07 -07:00
funman300 1b3fcca0d5 ci(android): add tag-triggered release workflow with Obtainium support
Android Release / build-apk (push) Failing after 1m30s
Fires on any v* tag push. Steps:
- Installs Android SDK + NDK 30.0.14904198 (cached by SDK version key)
- Builds release APK for arm64-v8a via scripts/build_android_apk.sh
- Signs with the release keystore stored in RELEASE_KEYSTORE_B64 secret
- Creates (or reuses) the Gitea release for the tag
- Uploads solitaire-quest.apk as a release asset

Obtainium users can track releases by adding:
  https://git.aleshym.co/funman300/Rusty_Solitare

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:00:23 -07:00
funman300 4e480d7cb5 ci: scope server workflow to server paths; harden deploy push
Build and Deploy / build-and-push (push) Failing after 25s
Switch docker-build.yml from paths-ignore to an explicit paths allowlist
so the workflow only fires on changes to solitaire_server/, solitaire_sync/,
solitaire_core/, Cargo.toml, Cargo.lock, or the workflow file itself.

Also harden the "commit and push updated kustomization" step:
- exit 0 early when the kustomization has no staged diff (nothing to push)
- retry the pull+push loop up to 3 times with a 5 s delay to handle
  concurrent pushes that race the CI commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:55:43 -07:00
Gitea CI 42a0a0bb8a chore(deploy): bump image to ca5d8a9c [skip ci] 2026-05-15 01:53:50 +00:00
funman300 ca5d8a9c55 fix(engine): silence Android-target dead-code and unused-import warnings
Build and Deploy / build-and-push (push) Successful in 34s
All 10 warnings were caused by hotkey/keyboard UI code behind
#[cfg(not(target_os = "android"))] call sites whose definitions lacked
the matching gate. Fixes:
- help_plugin: gate keyboard-chip imports and font_kbd; #[allow(dead_code)]
  on ControlRow (keys field is data, not dead)
- hud_plugin/ui_modal: replace cfg shadow pattern with cfg!() expression
  so the hotkey parameter is read on every platform
- home_plugin: gate fn hotkey behind not(android)
- onboarding_plugin: gate HotkeyRow, HOTKEYS, spawn_slide_hotkeys and
  their exclusive imports behind not(android)
- replay_overlay: gate keybind_footer_hint_text behind not(android)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:53:24 -07:00
Gitea CI 48befd7e9b chore(deploy): bump image to 5559f326 [skip ci] 2026-05-15 01:16:54 +00:00
funman300 51fecb24b0 chore: remove stale docs, mockups, and one-off artifacts
Build and Deploy / build-and-push (push) Failing after 43s
Delete CLAUDE_PROMPT_PACK/SPEC/WORKFLOW (superseded by CLAUDE.md),
SESSION_HANDOFF files, old android investigation notes, phase-plan docs
under docs/superpowers/, and all docs/ui-mockups/ (HTML+PNG mockups
from the pre-Terminal design pass). Also removes local artifacts:
analytics_impl_prompt.md, review_project.py, delete_runs.sh, ruvector.db
files, and the stray solitaire_wasm/solitaire_server/ build artefact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:15:11 -07:00
funman300 5559f32672 feat(web): smart-move on double-click and right-click
Build and Deploy / build-and-push (push) Successful in 3m55s
Double-clicking or right-clicking a face-up card now auto-places it to
the best valid pile (foundation preferred for single cards, tableau
otherwise). Right-click also suppresses the browser context menu.
Theme button re-render now calls game.state() instead of reusing snap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:13:00 -07:00
Gitea CI 17c320c08f chore(deploy): bump image to c35c045f [skip ci] 2026-05-15 01:02:19 +00:00
funman300 c35c045f08 fix(core): enable take-from-foundation by default (closes #3)
Build and Deploy / build-and-push (push) Successful in 4m55s
Standard Klondike allows returning the top card of a foundation pile to a
compatible tableau column. The flag was previously off by default, making the
move impossible for all players.

Changes:
- GameState::new_with_mode: take_from_foundation now initialises to true
- Settings: default changed to true; custom serde default function ensures
  older settings.json files without the key also resolve to true
- Tests: rename "blocked_by_default" → "allowed_by_default" (asserts the
  move now succeeds); add "blocked_when_disabled" to cover the flag=false path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:57:25 -07:00
Gitea CI 8fe0891866 chore(deploy): bump image to 677999a5 [skip ci] 2026-05-15 00:31:41 +00:00
funman300 677999a51e feat(engine): wire avatar download and display into profile modal
Build and Deploy / build-and-push (push) Successful in 4m15s
- Add avatar_plugin: AvatarPlugin, AvatarResource, AvatarFetchEvent
  - After AvatarFetchEvent fires, spawns an async reqwest download task
  - On completion, decodes image bytes via image::load_from_memory →
    Image::from_dynamic and inserts into Assets<Image>
- Expand auth task to also call fetch_me_with_token immediately after
  login/register so avatar_url is available without a second round-trip
- poll_auth_task fires AvatarFetchEvent when avatar_url is Some, building
  the full URL from base_url + relative avatar path
- Profile modal shows 48px circular avatar ImageNode when AvatarResource
  is populated, or an initials disc (first letter of username) as fallback
- Add image = "0.25" and reqwest to solitaire_engine deps
- Add fetch_me_with_token helper to SolitaireServerClient for use when
  the access token hasn't been persisted to keychain yet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:27:27 -07:00
Gitea CI 7177f0eb1b chore(deploy): bump image to 407cae20 [skip ci] 2026-05-15 00:19:45 +00:00
funman300 407cae2040 feat(auth): add /api/me endpoint, avatar upload, and profile picture support
Build and Deploy / build-and-push (push) Successful in 5m7s
- Add migration 005: nullable avatar_url column on users table
- Add GET /api/me: returns id, username, avatar_url from DB (fixes UUID-on-profile bug)
- Add PUT /api/me/avatar: accepts raw image bytes (≤1 MB, jpeg/png/webp/gif),
  writes to avatars/ dir, updates avatar_url in DB
- Serve /avatars via ServeDir so uploaded images are publicly accessible
- Update account.html: fetch username from /api/me instead of parsing JWT;
  add circular avatar display with initials fallback and click-to-upload
- Add SolitaireServerClient::fetch_me() for desktop/Android profile display
- Add avatar_url field to SyncBackend::SolitaireServer settings (serde default None)
- Update sqlx offline query cache for new avatar_url queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:14:42 -07:00
Gitea CI eb906fe968 chore(deploy): bump image to 8d31a37a [skip ci] 2026-05-14 23:59:49 +00:00
funman300 8d31a37a39 feat(web): add classic/dark card theme picker
Build and Deploy / build-and-push (push) Successful in 4m10s
- Reorganise card PNGs into assets/cards/faces/{classic,dark}/ and
  assets/cards/backs/{classic,dark}/
- Rasterise dark SVG theme alongside existing classic set
- Add "Dark / Classic" toggle button in the game HUD; persists to
  localStorage as fs_theme (defaults to classic)
- Preload both themes on page load so switching is instant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:55:43 -07:00
Gitea CI 2bf990388b chore(deploy): bump image to 7238ef22 [skip ci] 2026-05-14 23:51:51 +00:00
funman300 7238ef225e feat(web): replace dark-theme card PNGs with classic (white) theme
Build and Deploy / build-and-push (push) Successful in 26s
Rasterized all 52 classic SVGs via rsvg-convert at 256×384. The web
game was showing dark-background cards; it now shows the traditional
white card face style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:51:27 -07:00
funman300 b984161d46 ci: remove android build and release workflows
Build and Deploy / build-and-push (push) Has been cancelled
Building locally or via a different pipeline; the self-hosted runner's
resource constraints made the 3-ABI release build unreliable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:41:01 -07:00
funman300 c69d732d5a ci(android): add disk-space diagnostic step before release build
Android Build / build-apk (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
Helps diagnose whether the 3-ABI release build is hitting disk limits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:39:24 -07:00
Gitea CI 5e0e8d000b chore(deploy): bump image to 27eed989 [skip ci] 2026-05-14 23:21:28 +00:00
funman300 27eed98922 ci(android): remove workflow_dispatch — unsupported in this Gitea version
Android Build / build-apk (push) Successful in 12m3s
Build and Deploy / build-and-push (push) Successful in 41s
Android Release / build-release-apk (push) Has been cancelled
workflow_dispatch caused the entire android-release workflow to be silently
dropped; tag-triggered releases (v0.25.6) worked fine before this trigger
was added. Removing it restores tag-based release triggering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:08:40 -07:00
funman300 f304917d62 ci(android): add workflow_dispatch trigger to release workflow
Android Build / build-apk (push) Successful in 13m48s
Build and Deploy / build-and-push (push) Failing after 53s
Android Release / build-release-apk (push) Has been cancelled
Allows manually triggering the release build from the Gitea UI
(Actions tab → Android Release → Run workflow) without needing
to push a version tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:44:24 -07:00
318 changed files with 3290 additions and 13356 deletions
-131
View File
@@ -1,131 +0,0 @@
name: Android Build
on:
push:
branches: [master]
# Rebuild whenever app/engine/asset code changes.
# Skip server-only, deploy, and doc changes.
paths-ignore:
- 'deploy/**'
- 'argocd/**'
- 'solitaire_server/**'
- '**.md'
env:
ANDROID_SDK: /opt/android-sdk
NDK_VERSION: "25.2.9519653"
BUILD_TOOLS_VERSION: "34.0.0"
PLATFORM: "android-34"
jobs:
build-apk:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set short SHA
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
# ── System dependencies ────────────────────────────────────────────
- name: Install system dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y openjdk-17-jdk-headless unzip zip
# ── Android SDK (shared cache key with release workflow) ──────────
- name: Cache Android SDK
uses: actions/cache@v4
id: sdk-cache
with:
path: ${{ env.ANDROID_SDK }}
key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
- name: Install Android SDK + NDK
if: steps.sdk-cache.outputs.cache-hit != 'true'
run: |
sudo mkdir -p ${{ env.ANDROID_SDK }}/cmdline-tools
curl -sL \
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
-o /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=${{ env.ANDROID_SDK }} \
"build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
"platforms;${{ env.PLATFORM }}" \
"ndk;${{ env.NDK_VERSION }}"
# ── Rust toolchain ─────────────────────────────────────────────────
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --no-modify-path
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Add Android cross-compilation targets
run: |
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android
# ── Cargo caches ───────────────────────────────────────────────────
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-registry-
- name: Cache cargo-ndk binary
uses: actions/cache@v4
id: ndk-tool-cache
with:
path: ~/.cargo/bin/cargo-ndk
key: cargo-ndk-${{ runner.os }}-stable
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: target
key: android-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
restore-keys: android-target-${{ hashFiles('**/Cargo.lock') }}-
- name: Install cargo-ndk
if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
run: cargo install cargo-ndk --locked
# ── Build APK ──────────────────────────────────────────────────────
# Debug CI only builds arm64-v8a — full three-ABI debug builds blow
# past the runner's disk budget (~25 GB of target/ + intermediate
# APKs caused apksigner to OOM-on-disk in the previous run). Release
# CI still ships all three ABIs from android-release.yml.
- name: Build debug APK
env:
ANDROID_HOME: ${{ env.ANDROID_SDK }}
ANDROID_NDK_HOME: ${{ env.ANDROID_SDK }}/ndk/${{ env.NDK_VERSION }}
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOLS_VERSION }}
PLATFORM: ${{ env.PLATFORM }}
PROFILE: debug
ABIS: arm64-v8a
run: ./scripts/build_android_apk.sh
# ── Artifact ───────────────────────────────────────────────────────
# Pinned to v3 because Gitea Actions doesn't implement the github.com
# artifact service that upload-artifact@v4+ requires; v3 uses the
# older chunked HTTP API that Gitea's GHES-compatibility layer
# supports.
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: solitaire-quest-debug-${{ steps.meta.outputs.sha }}
path: target/debug/apk/solitaire-quest.apk
retention-days: 30
+76 -127
View File
@@ -3,158 +3,107 @@ name: Android Release
on:
push:
tags:
- 'v*.*.*'
- 'v*'
env:
ANDROID_SDK: /opt/android-sdk
NDK_VERSION: "25.2.9519653"
BUILD_TOOLS_VERSION: "34.0.0"
PLATFORM: "android-34"
GITEA_API: https://git.aleshym.co/api/v1
REPO: funman300/Rusty_Solitare
APK_OUT: target/release/apk/ferrous-solitaire.apk
GITEA_URL: https://git.aleshym.co
REPO: funman300/Ferrous-Solitaire
jobs:
build-release-apk:
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: Extract version from tag
id: meta
run: echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
# ── System dependencies ────────────────────────────────────────────
- name: Install system dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y openjdk-17-jdk-headless unzip zip jq
# ── Android SDK (shared cache key with debug workflow) ─────────────
- name: Cache Android SDK
uses: actions/cache@v4
id: sdk-cache
with:
path: ${{ env.ANDROID_SDK }}
key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
- name: Install Android SDK + NDK
if: steps.sdk-cache.outputs.cache-hit != 'true'
run: |
sudo mkdir -p ${{ env.ANDROID_SDK }}/cmdline-tools
curl -sL \
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
-o /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=${{ env.ANDROID_SDK }} \
"build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
"platforms;${{ env.PLATFORM }}" \
"ndk;${{ env.NDK_VERSION }}"
# ── Rust toolchain ─────────────────────────────────────────────────
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --no-modify-path
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Add Android cross-compilation targets
run: |
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android
# ── Cargo caches ───────────────────────────────────────────────────
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-registry-
/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: Cache cargo-ndk binary
uses: actions/cache@v4
id: ndk-tool-cache
with:
path: ~/.cargo/bin/cargo-ndk
key: cargo-ndk-${{ runner.os }}-stable
- name: Cache build artifacts
- name: Cache sccache
uses: actions/cache@v4
with:
path: target
key: android-release-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
restore-keys: android-release-target-${{ hashFiles('**/Cargo.lock') }}-
path: /root/.cache/sccache
key: sccache-android-aarch64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: sccache-android-aarch64-
- name: Install cargo-ndk
if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
run: cargo install cargo-ndk --locked
- name: Get tag name
id: tag
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
# ── Build & sign with release keystore ─────────────────────────────
- name: Decode keystore
run: |
secret_len=$(echo -n "${{ secrets.KEYSTORE_BASE64 }}" | wc -c)
echo "KEYSTORE_BASE64 secret length: ${secret_len} chars"
set +e
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > /tmp/solitaire-release.jks 2>/tmp/b64_err.txt
b64_exit=$?
set -e
size=$(wc -c < /tmp/solitaire-release.jks)
echo "base64 exit code: ${b64_exit}, keystore size: ${size} bytes"
[ -s /tmp/b64_err.txt ] && echo "base64 error: $(cat /tmp/b64_err.txt)" || true
[ "$size" -gt 0 ] || { echo "ERROR: KEYSTORE_BASE64 is empty or invalid base64"; exit 1; }
- name: Decode release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
- name: Build signed release APK
- name: Build release APK
env:
ANDROID_HOME: ${{ env.ANDROID_SDK }}
ANDROID_NDK_HOME: ${{ env.ANDROID_SDK }}/ndk/${{ env.NDK_VERSION }}
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOLS_VERSION }}
PLATFORM: ${{ env.PLATFORM }}
PROFILE: release
KEYSTORE: /tmp/solitaire-release.jks
KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASS: ${{ secrets.KEY_PASS }}
APK_OUT: ferrous-solitaire-${{ steps.meta.outputs.tag }}.apk
run: ./scripts/build_android_apk.sh
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
# ── Publish to Gitea release ───────────────────────────────────────
- name: Create Gitea release
- name: sccache stats
if: always()
run: sccache --show-stats
- name: Create or get Gitea release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
RESPONSE=$(curl -s -o /tmp/release.json -w "%{http_code}" \
-X POST "$GITEA_API/repos/$REPO/releases" \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}")
if [ "$RESPONSE" = "409" ]; then
curl -sf "$GITEA_API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
> /tmp/release.json
elif [ "$RESPONSE" != "201" ]; then
echo "Release creation failed with HTTP $RESPONSE"
cat /tmp/release.json
exit 1
fi
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
TAG="${{ steps.tag.outputs.name }}"
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
- name: Upload signed APK
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)
if [ -z "$ID" ]; then
ID=$(curl -sf -X POST \
-H "$AUTH" -H "Content-Type: application/json" \
"$BASE/releases" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$TAG\",
\"body\": \"## Android release $TAG\n\n**Install / update with Obtainium** — add this source URL:\n\`\`\`\nhttps://git.aleshym.co/funman300/Ferrous-Solitaire\n\`\`\`\n\nOr download \`ferrous-solitaire.apk\` below and sideload it directly.\",
\"draft\": false,
\"prerelease\": false
}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
fi
echo "id=$ID" >> "$GITHUB_OUTPUT"
- name: Upload APK to release
run: |
TAG="${{ steps.meta.outputs.tag }}"
APK="ferrous-solitaire-${TAG}.apk"
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 \
"$GITEA_API/repos/$REPO/releases/${{ steps.release.outputs.release_id }}/assets?name=$APK" \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$APK"
-H "$AUTH" \
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
"$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
+14 -9
View File
@@ -3,11 +3,13 @@ name: Build and Deploy
on:
push:
branches: [master]
# Only run when server code changes, not when CI itself updates deploy/.
paths-ignore:
- 'deploy/**'
- 'argocd/**'
- '**.md'
paths:
- 'solitaire_server/**'
- 'solitaire_sync/**'
- 'solitaire_core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/docker-build.yml'
env:
REGISTRY: git.aleshym.co
@@ -55,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
@@ -68,6 +70,9 @@ jobs:
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
git add deploy/kustomization.yaml
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true
git pull --rebase origin master
git push
git diff --cached --quiet && exit 0 # nothing to commit — skip push
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]"
for i in 1 2 3; do
git pull --rebase origin master && git push && break
sleep 5
done
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT username FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "username",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE users SET avatar_url = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT username, avatar_url FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "username",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "avatar_url",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "fae33e7159b43c756131b1545360dcb0250988b43550881e3f0a336d9516dd45"
}
+8 -8
View File
@@ -58,7 +58,7 @@ Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enf
## 2. Workspace Structure
```
solitaire_quest/
ferrous_solitaire/
├── Cargo.toml # Workspace manifest
├── .env.example # Server environment variable template
@@ -366,12 +366,12 @@ Minimum window: 800×600. At this size cards are small but usable.
### Local Storage
All files stored under `dirs::data_dir() / "solitaire_quest"/`:
All files stored under `dirs::data_dir() / "ferrous_solitaire"/`:
```
~/.local/share/solitaire_quest/ (Linux)
~/Library/Application Support/solitaire_quest/ (macOS)
%APPDATA%\solitaire_quest\ (Windows)
~/.local/share/ferrous_solitaire/ (Linux)
~/Library/Application Support/ferrous_solitaire/ (macOS)
%APPDATA%\ferrous_solitaire\ (Windows)
├── stats.json # StatsSnapshot
├── progress.json # PlayerProgress (XP, level, unlocks, daily challenge)
@@ -426,7 +426,7 @@ pub enum SyncBackend {
url: String,
username: String,
// JWT access + refresh tokens stored in OS keychain
// key: "solitaire_quest_server_{username}"
// key: "ferrous_solitaire_server_{username}"
},
}
```
@@ -980,8 +980,8 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
### Docker Compose (Recommended)
```bash
git clone https://github.com/yourname/solitaire_quest
cd solitaire_quest
git clone https://github.com/yourname/ferrous_solitaire
cd ferrous_solitaire
cp .env.example .env
# Edit .env — set JWT_SECRET and SERVER_PORT
docker compose up -d
+82 -4
View File
@@ -6,6 +6,84 @@ 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
- **Rename: Solitaire Quest → Ferrous Solitaire.** Android package id changed
from `com.solitairequest.app` to `com.ferrousapp.solitaire`; existing installs
must be uninstalled first (Android treats the new id as a new app).
Data directory renamed from `solitaire_quest/` to `ferrous_solitaire/`.
### Fixed
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
@@ -1431,7 +1509,7 @@ candidate — the app-icon round — stays open.
- **Android build target — first working APK** (`fb8b2ac`).
`cargo apk build -p solitaire_app --target x86_64-linux-android`
now produces a 54 MB debug-signed APK at
`target/debug/apk/solitaire-quest.apk`. Five gating points
`target/debug/apk/ferrous-solitaire.apk`. Five gating points
resolved end-to-end:
- **`solitaire_app` split into bin + lib.** cargo-apk needs a
`cdylib` to bundle as `libmain.so`; pure-bin crates panic
@@ -1548,7 +1626,7 @@ candidate — the app-icon round — stays open.
achievements, replays, game-state, time-attack sessions, user
themes). New `solitaire_data::platform::data_dir()` shim falls
through to `dirs::data_dir()` on desktop and returns the per-app
sandbox at `/data/data/com.solitairequest.app/files` on Android
sandbox at `/data/data/com.ferrousapp.solitaire/files` on Android
— no JNI needed, since the package id is pinned in
`[package.metadata.android]`. Six call sites across
`solitaire_data` plus `solitaire_engine/assets/user_dir.rs`
@@ -1690,7 +1768,7 @@ fully reverted and is not part of this release.
The test's single-frame `app.update()` was sensitive to
first-frame `Time::delta_secs()` variance under heavy parallel
cargo-test load, and to production-disk
`~/.local/share/solitaire_quest/game_state.json` state leaking
`~/.local/share/ferrous_solitaire/game_state.json` state leaking
into the test world via `GamePlugin::build`'s load path.
`test_app` now resets `PendingRestoredGame(None)` after plugin
build (preventing the dev machine's saved-game state from
@@ -2386,7 +2464,7 @@ the binary shipped with bundled artwork.
patterns.
- **Ambient audio loop** wired through the kira mixer.
- **Arch Linux PKGBUILDs** for the game client and sync server (under
the separate `solitaire-quest-pkgbuild` directory).
the separate `ferrous-solitaire-pkgbuild` directory).
- **Workspace README, CI workflow, migration guide.**
### Changed
+1 -1
View File
@@ -447,7 +447,7 @@ raw `z_index` values — they drift and cause ordering bugs.
```bash
cargo apk build --package solitaire_app --lib
adb install -r target/debug/apk/solitaire-quest.apk
adb install -r target/debug/apk/ferrous-solitaire.apk
```
## 15.2 Coordinate system reminder
-497
View File
@@ -1,497 +0,0 @@
# CLAUDE_PROMPT_PACK.md
version: 1.0
---
# 0. GLOBAL INSTRUCTION (prepend to every prompt)
```
You must follow CLAUDE_SPEC.md strictly.
Rules:
- Do not expand scope beyond what is defined
- Do not refactor unrelated code
- Do not introduce new dependencies to solitaire_core or solitaire_sync without confirmation
- Prefer minimal, surgical changes
- Use existing patterns in the codebase
- Return minimal diffs or changed functions only
Before writing code:
1. List relevant constraints from CLAUDE_SPEC.md
2. Identify risks
3. Then implement
```
---
# 1. FEATURE IMPLEMENTATION
```
# TASK: Feature Implementation
feature: "<name>"
goal:
"<clear outcome>"
scope:
crates: []
systems: []
files: []
non_goals:
- ""
constraints:
- must follow CLAUDE_SPEC.md
- event-driven architecture required
- no blocking operations
- no cross-crate leakage
acceptance_criteria:
- ""
- ""
edge_cases:
- ""
---
## Required Patterns
Use this pattern for systems:
<PASTE EXISTING SYSTEM SNIPPET HERE>
---
## Output Format
intent:
plan:
constraints_used:
risks:
code_changes:
(minimal diffs only)
notes:
```
---
# 2. BUGFIX
```
# TASK: Bug Fix
bug_description:
"<what is broken>"
expected_behavior:
"<correct behavior>"
root_cause_hint (optional):
""
scope:
crates: []
files: []
constraints:
- minimal fix only
- no refactors unless required
- must add regression protection if applicable
---
## Requirements
1. Identify root cause
2. Fix it minimally
3. Preserve all invariants
4. Do not change unrelated logic
---
## Output Format
analysis:
root_cause:
fix_strategy:
code_changes:
(minimal diff)
regression_test (only if high-value):
notes:
```
---
# 3. REFACTOR
```
# TASK: Refactor
target:
"<what is being improved>"
goal:
"<what improves>"
scope:
crates: []
files: []
non_goals:
- no behavior changes
- no new features
constraints:
- must preserve behavior exactly
- must respect crate boundaries
- must not duplicate logic
---
## Refactor Type
- [ ] simplify logic
- [ ] reduce duplication
- [ ] improve readability
- [ ] performance (non-invasive)
---
## Output Format
analysis:
issues_found:
refactor_plan:
code_changes:
(diff only)
verification:
- behavior unchanged: yes/no
- invariants preserved: yes/no
notes:
```
---
# 4. SYSTEM DESIGN (NEW FEATURE)
```
# TASK: System Design
feature:
"<name>"
goal:
"<what problem it solves>"
constraints:
- must fit existing architecture
- must follow plugin + event model
- must not violate crate boundaries
---
## Required Output
design:
components:
- plugins:
- systems:
- events:
- resources:
data_flow:
(step-by-step)
integration_points:
- where it connects to existing systems
risks:
- ""
tradeoffs:
- ""
---
## DO NOT
- write full implementation
- modify unrelated systems
```
---
# 5. NEW BEVY SYSTEM
```
# TASK: Add Bevy System
system_name:
""
trigger:
(event or condition)
reads:
[Resources]
writes:
[Resources]
emits:
[Events]
constraints:
- must be event-driven
- must not directly mutate unrelated state
- must be single responsibility
---
## Output Format
system_signature:
implementation:
(code only)
notes:
```
---
# 6. CORE LOGIC FUNCTION (solitaire_core)
```
# TASK: Core Logic Implementation
function:
"<name>"
goal:
"<what it does>"
rules:
- no IO
- no async
- no Bevy
- deterministic
invariants:
- ""
- ""
errors:
- ""
---
## Output Format
constraints_checked:
implementation:
(code only)
edge_case_handling:
notes:
```
---
# 7. SYNC / MERGE LOGIC
```
# TASK: Sync Logic
goal:
"<what is being merged or synced>"
constraints:
- must be deterministic
- must be idempotent
- must be lossless
- must not delete data
rules:
- counters → max
- times → min
- collections → union
---
## Output Format
analysis:
merge_logic:
code_changes:
invariants_verified:
- deterministic
- idempotent
- lossless
notes:
```
---
# 8. PERFORMANCE OPTIMIZATION
```
# TASK: Optimization
target:
"<what is slow>"
constraints:
- no behavior change
- no architecture change
- minimal code changes
---
## Output Format
analysis:
bottleneck:
optimization_strategy:
code_changes:
impact_estimate:
notes:
```
---
# 9. TEST GENERATION (STRICT MODE)
```
# TASK: Test Generation
target:
"<function/system>"
reason:
- bugfix | complex logic | invariant protection
constraints:
- no redundant tests
- must test real behavior
- must fail if logic breaks
---
## Output Format
test_cases:
- ""
test_code:
notes:
```
---
# 10. DEBUGGING / INVESTIGATION
```
# TASK: Debug
problem:
"<symptom>"
context:
"<relevant code or system>"
---
## Required Steps
1. List possible causes
2. Narrow down most likely
3. Suggest verification steps
4. Provide minimal fix
---
## Output Format
hypotheses:
most_likely:
verification_steps:
fix:
notes:
```
---
# 11. HARD CONSTRAINT OVERRIDE (RARE)
```
# TASK: Exception Handling
reason:
"<why constraints must be bent>"
requested_exception:
"<rule being broken>"
justification:
"<why unavoidable>"
---
## Output Format
analysis:
alternatives_considered:
final_decision:
risk:
```
---
# 12. STOP CONDITIONS (always append)
```
Stop when:
- acceptance criteria are met
- code is minimal and correct
Do NOT:
- expand scope
- refactor unrelated code
- optimize prematurely
```
---
# END
-296
View File
@@ -1,296 +0,0 @@
# CLAUDE_SPEC.md
version: 1.0
---
## 0. Global Rules
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
rules:
* id: single_source_of_truth
description: "GameStateResource is the only mutable game state in runtime"
* id: sync_is_additive
description: "Remote data must never destructively overwrite local data"
---
## 1. Crate Graph
crates:
solitaire_core:
depends_on: [rand, serde, chrono]
forbidden_deps: [bevy, reqwest, tokio, std::fs]
solitaire_sync:
depends_on: [serde, serde_json, uuid, chrono]
role: "shared_types"
solitaire_data:
depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring]
role: "persistence_and_sync"
solitaire_engine:
depends_on: [bevy, kira, solitaire_core, solitaire_data]
role: "runtime_engine"
solitaire_server:
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
role: "backend"
solitaire_wasm:
depends_on: [solitaire_core, wasm-bindgen, serde-wasm-bindgen]
role: "wasm_replay_player"
solitaire_app:
depends_on: [solitaire_engine]
role: "entrypoint"
---
## 2. Data Ownership
ownership:
GameState:
owner: solitaire_core
mutable_in: solitaire_engine
access_pattern: "via GameStateResource only"
StatsSnapshot:
owner: solitaire_data
PlayerProgress:
owner: solitaire_data
AchievementRecord:
owner: solitaire_data
SyncPayload:
owner: solitaire_sync
---
## 3. State Transitions
state_machine:
GameState:
transitions:
- action: move_cards
returns: Result<GameState, MoveError>
```
- action: draw
returns: Result<GameState, MoveError>
- action: undo
returns: Result<GameState, MoveError>
invariants:
- "52 cards always exist"
- "no duplicate card IDs"
- "all cards belong to exactly one pile"
```
---
## 4. Event System
events:
input:
- MoveRequestEvent
- DrawRequestEvent
- UndoRequestEvent
- NewGameRequestEvent
state:
- StateChangedEvent
- GameWonEvent
meta:
- AchievementUnlockedEvent
- SyncCompleteEvent
rules:
* "Input events trigger core logic"
* "Core logic emits state events"
* "UI reacts to state events only"
---
## 5. Sync Contract
sync:
provider_trait:
methods:
- pull() -> SyncPayload
- push(payload) -> SyncResponse
guarantees:
- "non-blocking during gameplay"
- "blocking allowed on exit only"
merge:
rules:
counters: "max"
best_times: "min"
collections: "union"
achievements: "never removed"
```
properties:
- deterministic
- idempotent
- lossless
```
---
## 6. Persistence
storage:
format: json
files:
- stats.json
- progress.json
- achievements.json
- settings.json
- game_state.json
guarantees:
- atomic_write: true
- crash_safe: true
---
## 7. Engine Rules
engine:
mutation_rules:
- "Only GameLogicSystem mutates GameState"
- "UI systems are read-only"
threading:
- "sync runs on AsyncComputeTaskPool"
- "main thread must never block"
plugins:
pattern: "feature_isolation"
communication: "events and resources"
---
## 8. Server Contract
server:
auth:
method: jwt
access_expiry: 24h
refresh_expiry: 30d
endpoints:
- POST /api/auth/register
- POST /api/auth/login
- GET /api/sync/pull
- POST /api/sync/push
limits:
payload_max: 1MB
rate_limit: "10 req/min auth routes"
---
## 9. Achievement System
achievements:
definition_location: solitaire_core
state_location: solitaire_data
types:
- condition_based
- event_driven
rule:
- "achievements cannot be revoked"
---
## 10. Testing Rules
testing:
philosophy:
- "test real failures"
- "avoid redundant tests"
required_coverage:
solitaire_core:
- move_validation
- undo_integrity
- win_detection
```
solitaire_sync:
- merge_correctness
- idempotency
```
---
## 11. Prohibited Patterns
(See CLAUDE.md §11 for the canonical forbidden-patterns list.)
---
## 12. Extension Points
extensibility:
sync_backends:
pattern: "implement SyncProvider"
game_modes:
location: solitaire_core::GameMode
plugins:
rule: "new feature = new plugin"
---
## 13. Validation Checklist (for Claude)
validation:
* check: "crate dependency rules respected"
* check: "no panics in core"
* check: "events used for cross-system communication"
* check: "GameState mutations centralized"
* check: "merge function properties preserved"
* check: "no blocking operations in main loop"
---
## 14. Mental Model
model:
layers:
- core
- engine
- data
- server
flow:
- input -> engine -> core -> engine -> ui
- data <-> sync <-> server
-335
View File
@@ -1,335 +0,0 @@
# CLAUDE_WORKFLOW.md
version: 1.0
---
## 0. Overview
This workflow defines a **two-agent system**:
* **Builder Agent** → writes and modifies code
* **Guardian Agent** → enforces architecture + rejects invalid changes
No code is considered valid unless it passes Guardian validation.
---
## 1. Agent Roles
### 1.1 Builder Agent
role: "code_generation"
responsibilities:
* implement features
* refactor code
* generate tests (only when justified)
* follow CLAUDE_SPEC.md
constraints:
* cannot bypass validation
* must declare intent before writing code
output_contract:
must_produce:
- change_summary
- files_modified
- reasoning (short)
- code_diff
---
### 1.2 Guardian Agent
role: "architecture_enforcement"
responsibilities:
* validate against CLAUDE_SPEC.md
* detect violations
* reject or approve changes
* suggest minimal fixes (not full rewrites)
constraints:
* no feature implementation
* no large rewrites
* must be deterministic
output_contract:
must_produce:
- status: APPROVED | REJECTED
- violations[]
- required_fixes[]
- optional_improvements[]
---
## 2. Workflow Pipeline
```text
User Request
Builder Agent (proposal + code)
Guardian Agent (validation)
IF approved → commit
IF rejected → feedback → Builder retry
```
---
## 3. Builder Protocol
### Step 1 — Intent Declaration
Builder MUST start with:
```yaml
intent:
feature: "<name>"
crates_touched: []
systems_affected: []
risk_level: low|medium|high
```
---
### Step 2 — Plan
```yaml
plan:
- step: "..."
- step: "..."
```
---
### Step 3 — Implementation
* Only modify declared crates
* Follow ownership rules
* Use events for cross-system communication
---
### Step 4 — Output
```yaml
change_summary: "..."
files_modified:
- path: ...
change: "..."
violations_self_check:
- none | list
notes: "short reasoning"
```
---
## 4. Guardian Protocol
### Step 1 — Spec Validation
Check against:
* crate boundaries
* mutation rules
* event system usage
* sync guarantees
* forbidden patterns
---
### Step 2 — Invariant Validation
Must verify:
* GameState invariants preserved
* no new panic paths
* no blocking calls in engine
* merge properties unchanged
---
### Step 3 — Output Decision
#### APPROVED
```yaml
status: APPROVED
notes:
- "no violations"
```
---
#### REJECTED
```yaml
status: REJECTED
violations:
- id: core_purity_violation
file: "solitaire_core/src/..."
reason: "uses std::fs"
required_fixes:
- "move IO to solitaire_data"
optional_improvements:
- "simplify event naming"
```
---
## 5. Enforcement Rules
### Hard Fail (automatic rejection)
* core crate uses IO / Bevy / network
* GameState mutated outside GameLogicSystem
* blocking async on main thread
* duplicate logic across crates
* merge function altered incorrectly
---
### Soft Fail (allowed but flagged)
* unnecessary complexity
* redundant tests
* minor architectural drift
---
## 6. Iteration Loop
Max attempts per task: **3**
```text
Attempt 1 → Reject → Fix
Attempt 2 → Reject → Fix
Attempt 3 → Final decision
```
If still failing:
→ escalate to user
---
## 7. Diff Strategy
Builder MUST produce:
* minimal diffs
* no unrelated refactors
* no formatting-only changes
---
## 8. Test Strategy Integration
Builder rules:
* only add tests if:
* fixing a bug
* protecting complex logic
* validating invariants
Guardian rejects:
* redundant tests
* no-op tests
---
## 9. Optional Extensions
### 9.1 Third Agent (Optimizer)
role: performance + cleanup
runs AFTER approval:
* reduce allocations
* simplify logic
* improve ECS scheduling
---
### 9.2 CI Integration
Pipeline:
```text
Builder → Guardian → cargo check → clippy → tests
```
Guardian runs BEFORE compilation to catch structural issues early.
---
## 10. Example Interaction
### Builder
```yaml
intent:
feature: "undo stack limit fix"
crates_touched: [solitaire_core]
risk_level: low
```
```yaml
change_summary: "limit undo stack to 64 entries"
files_modified:
- solitaire_core/src/game_state.rs
notes: "prevents unbounded memory growth"
```
---
### Guardian
```yaml
status: APPROVED
notes:
- "respects core constraints"
- "no invariant violations"
```
---
## 11. Mental Model
* Builder = **creative**
* Guardian = **strict**
Builder explores
Guardian enforces
Neither replaces the other.
---
## 12. Success Criteria
System is working if:
* architectural violations go to ~0
* code stays consistent across features
* refactors become safe
* complexity grows sub-linearly
Generated
+7
View File
@@ -4034,9 +4034,14 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"zune-core",
"zune-jpeg",
]
[[package]]
@@ -7013,8 +7018,10 @@ dependencies = [
"bevy",
"chrono",
"dirs",
"image",
"jni 0.21.1",
"kira",
"reqwest",
"resvg",
"ron",
"serde",
+3
View File
@@ -110,6 +110,9 @@ ron = "0.12"
# only `deflate` is needed because the importer rejects other
# compression methods anyway (see Phase 7 spec).
zip = { version = "8.6", default-features = false, features = ["deflate"] }
# Image decoding for avatar bytes received from the server.
# Features mirror what Bevy already enables via bevy_image.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
# Importer-only test dependency: tests build zip archives in a
# scratch directory so they don't pollute the real user themes path
+17
View File
@@ -31,6 +31,23 @@ optional self-hosted sync so your stats follow you across machines.
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
glyph
## Android Install
### Obtainium (recommended — automatic updates)
1. Install [Obtainium](https://github.com/ImranR98/Obtainium/releases) on your device
2. Tap the badge below on your Android device — the source type is pre-configured, no manual selection needed:
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="40">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.ferrousapp.solitaire%22%2C%22url%22%3A%22https%3A//git.aleshym.co/funman300/Ferrous-Solitaire%22%2C%22author%22%3A%22funman300%22%2C%22name%22%3A%22Ferrous%20Solitaire%22%2C%22installedVersion%22%3Anull%2C%22latestVersion%22%3Anull%2C%22apkUrls%22%3A%22%5B%5D%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%7D%22%2C%22lastUpdateCheck%22%3Anull%2C%22pinned%22%3Afalse%2C%22categories%22%3A%5B%5D%2C%22releaseDate%22%3Anull%2C%22changeLog%22%3Anull%2C%22overrideSource%22%3A%22Codeberg%22%2C%22allowIdChange%22%3Afalse%2C%22otherAssetUrls%22%3A%22%5B%5D%22%7D)
3. Tap **Install** to download the current release — Obtainium will notify you when updates are available.
### Direct APK
Download the latest `ferrous-solitaire.apk` from the
[Releases](https://git.aleshym.co/funman300/Ferrous-Solitaire/releases) page,
enable **Install from unknown sources** in your device settings, and open the file.
## Building
**Prerequisites**
-177
View File
@@ -1,177 +0,0 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin.
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
modal, re-auth on token expiry, account deletion flow, server deployment
artifacts (Dockerfile + docker-compose), replay upload on win, web replay
player (WASM + HTML/CSS/JS served by the server), leaderboard opt-in/out,
and full server integration tests.
---
## Current state
- **HEAD locally:** `03be4fc` (feat: leaderboard custom display name).
- **HEAD on origin:** `03be4fc` (fully pushed).
- **Working tree:** clean (only `solitaire-release.jks.bak2` untracked — intentional).
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **1300+ passing / 0 failing** across the workspace.
- **Tags on origin:** `v0.9.0` through `v0.22.0`.
---
## What shipped in Phase 8 (432061c bd388fe)
| Commit | Summary |
|--------|---------|
| `432061c` | Sync setup modal (login/register/connect/disconnect) |
| `6ce5564` | Re-auth on expired session + server deployment artifacts |
| `272d31f` | Account deletion flow + `handle_sync_buttons` refactor |
| `bd388fe` | CHANGELOG v0.23.0 documentation |
Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
- `solitaire_wasm` crate: WASM ReplayPlayer bindings for browser-side replay playback
- Server replay API: `POST /api/replays`, `GET /api/replays/recent`, `GET /api/replays/:id`
- Server web UI: `/replays/:id` HTML route + `ServeDir /web` static assets
- DB migration 002: `replays` table + two indexes
- Full server integration tests for replay endpoints
- `push_replay` in `sync_plugin` (uploads on win, writes share URL into replay history)
- Stats panel "Copy Share Link" button reads `share_url` from replay history
---
## Open punch list (ordered by priority)
### 1. Documentation debt (no code)
- [x] CHANGELOG [Unreleased] → v0.23.0 — done this session
- [x] ARCHITECTURE.md update — all 8 gaps closed, bumped to v1.3
- [x] SESSION_HANDOFF.md update — this file
### 2. Leaderboard wiring gaps
- [x] **Best-score auto-post.** Done (`303c78a`): `update_leaderboard_if_opted_in`
called from both first-push and merge paths in `sync.rs`; uses SQLite `MIN`/`MAX`
in the UPDATE so scores never regress on stale data.
- [x] **Display name = username.** Done (`03be4fc`): `leaderboard_display_name:
Option<String>` added to `Settings`; editor modal in leaderboard panel; persists
to `settings.json`; `handle_opt_in_button` prefers custom name over username.
### 3. Security hardening
- [x] **Refresh token rotation.** Done (`b129664`): `refresh_tokens` table
(migration 003); jti embedded in JWT; rotate-on-use pattern; 3 integration
tests.
- [x] **Sync endpoint rate limiting.** Done (`6e6f3ef`): `UserIdKeyExtractor`
decodes JWT for per-user identity; falls back to IP; burst 10 / 6 min
steady-state; integration test passes.
### 4. Android validation
- [x] **Android Keystore functional test.** Done (2026-05-11, Pixel 7 AVD,
Android 14): `load_access_token()` exercised via `start_pull`; logcat confirmed
`NotFound` returned cleanly — no JNI panic. See `docs/android/PLAYABILITY_TODO.md` P4.
- [x] **JNI clipboard functional test.** Done (2026-05-11): temporary `KEYCODE_C`
hook confirmed `ClipboardManager.setPrimaryClip()` succeeds on Android 14.
Hook reverted. Production path requires Interaction::Pressed + non-null `share_url`.
Note: `adb shell input tap` doesn't deliver touch events on headless AVD (documented).
- [x] **`cargo apk build --lib` noisy stderr** — upstream cargo-apk bug; `--lib`
is the canonical command (CLAUDE.md §15.1, docs/ANDROID.md). No in-repo fix possible.
### 5. Feature completeness
- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in
Settings Appearance section. Shows import path label, scans user_theme_dir()
for .zip archives, fires InfoToastEvent per file, refreshes ThemeRegistry.
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
default never overridden and never called; achievements already sync via
`SyncPayload` push. Deleted from trait and blanket impl.
- [x] **WASM build script.** Done (`40d0712`): `build_wasm.sh` at repo root
documents `wasm-pack build --target web`, cleans up pkg metadata files,
includes dependency guard + install instructions.
- [x] **Server password reset.** Done (`7514684`): `--reset-password <username>`
subcommand reads new password from stdin, bcrypt-hashes it, invalidates all
active sessions for the user.
### 5b. Android UX polish (2026-05-12)
- [x] **UX-1 — Modal Done button in gesture zone.** `apply_safe_area_to_modal_scrims` system
added to `SafeAreaInsetsPlugin` (`safe_area.rs`). Pads every `ModalScrim` bottom by
`insets.bottom / scale`. Fires on resource change + `Added<ModalScrim>`. Verified on device.
- [x] **UX-5b — Home mode glyph corruption.** Geometric Shapes (U+25xx, absent from FiraMono)
replaced with card suits U+26602666 in `home_plugin.rs`. Affects Zen/Challenge/Daily mode
selector buttons at level 5+.
- [x] **UX-7 — Help text wrap.** Android HUD entry shortened to
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs` — fits one line.
- [x] **BUG-3 — Multi-modal stacking.** `handle_menu_button` now checks
`scrims: Query<(), With<ModalScrim>>` and guards `spawn_menu_popover` with `scrims.is_empty()`.
Verified on device: ≡ tap while Stats open does nothing.
**Note:** These 4 fixes are implemented and verified but not yet committed.
### 6. Testing gaps
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
`jwt_refresh_on_401_succeeds` (pull) and
`push_retries_after_401_on_expired_access_token` (push) in
`solitaire_data/tests/sync_round_trip.rs`.
- [x] **WASM winning-replay step-through.** Done (`b4ada2a`): greedy solver
searches seeds 1200 at test time; steps every move through `ReplayPlayer`;
asserts `is_won = true` on the final `StateSnapshot`.
---
## ARCHITECTURE.md gaps (for the update pass)
Items missing from the doc:
1. `solitaire_wasm` crate (§2 workspace + §3 responsibilities)
2. Replay API endpoints (§9 API Reference — 3 new routes)
3. Web replay player route (`/replays/:id` + `ServeDir /web`)
4. `SyncProvider` trait: 6 added methods
5. Theme system in Bevy plugin table (§5)
6. `Settings` new fields: `color_blind_mode`, `high_contrast_mode`,
`reduce_motion_mode`, `window_geometry`, `selected_card_back`,
`selected_background`
7. DB migration 002 (§7)
8. Update "Last Updated" date
---
## Process notes
- **Commit attribution:** use `funman300` as git user. Co-author line:
`Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
- **Commit format:** `type(scope): description` per CLAUDE.md §7.
- **Never commit without:** `cargo test --workspace` passing + clippy clean.
- **Sub-agents** stage/verify only; orchestrator commits.
- **`CARD_PLAN.md`** referenced in `theme/` module comments but not present in
repo. Clean up references or commit the file.
- **Token-port pattern** (v0.20.0): when migrating tokens, walk every concrete
artifact downstream — PNGs, SVGs, literals, comments. Three "walked past this"
follow-ups in v0.21.0 all had this shape.
---
## Resume prompt
```
You are a senior Rust + Bevy developer working on Ferrous Solitaire.
Working directory: <Rusty_Solitaire clone path>.
Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed.
READ FIRST (in order):
1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.23.0] section has full Phase 8 detail
3. CLAUDE.md — unified-4.0 rule set
4. ARCHITECTURE.md — v1.3, fully up to date
5. docs/ui-mockups/ — design system + mockup library
6. docs/android/ — Android setup + build runbook
7. ~/.claude/projects/<this-project>/memory/MEMORY.md
OPEN WORK:
Phase 8 punch list is fully closed. All items verified complete.
Remaining nuisance: `cargo apk build --lib` noisy stderr (cosmetic, non-blocking).
4 Android UX fixes are implemented and verified but NOT YET COMMITTED:
- BUG-3 (hud_plugin.rs): multi-modal stacking guard
- UX-7 (help_plugin.rs): help text wrap on Android
- UX-5b (home_plugin.rs): FiraMono glyph corruption in mode selector
- UX-1 (safe_area.rs): modal Done button in gesture zone
Commit those first, then suggest Phase 9 planning.
```
+1 -1
View File
@@ -6,7 +6,7 @@ metadata:
spec:
project: default
source:
repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
targetRevision: master
path: deploy
destination:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

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