Compare commits

...

315 Commits

Author SHA1 Message Date
funman300 a2f02e1cbc ci(argocd): watch deploy branch for kustomization updates
Android Release / build-apk (push) Successful in 4m50s
targetRevision changed from master to deploy so Argo CD tracks the
image-tag commits the CI bot writes there, not the source branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:58:42 -07:00
Gitea CI 8426d89856 chore(deploy): bump image to da601beb [skip ci] 2026-05-19 23:58:25 +00:00
funman300 ecab227b8d ci(deploy): push kustomization updates to deploy branch, not master
Build and Deploy / build-and-push (push) Successful in 21s
The CI bot was committing image-tag bumps back to master after every
Docker build, which forced a `git pull --rebase` before every developer
push. Moving the kustomization commit to a dedicated `deploy` branch
keeps master clean — the build bot no longer diverges it.

Argo CD / Flux should now watch the `deploy` branch (targetRevision:
deploy) instead of master.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:57:20 -07:00
funman300 da601bebd6 fix(engine,wasm,web): detect no-legal-moves correctly and surface banner
Build and Deploy / build-and-push (push) Successful in 4m24s
Engine: replace broken has_legal_moves loop (which checked buried
mid-column cards without sequence validation) with a delegation to
possible_instructions(), mirroring the hint system's logic exactly.

WASM: add has_moves: bool to GameSnapshot, computed in snap() using the
same stock/waste/possible_instructions check so the web client gets the
flag in every state update at no extra round-trip cost.

Web: show a non-blocking no-moves banner (slide-up toast) with Undo and
New Game actions when has_moves is false and the game is not won. Banner
hides automatically once a move restores legal play (e.g. after undo).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:54:01 -07:00
Gitea CI a2dd8d220c chore(deploy): bump image to d5d869a6 [skip ci] 2026-05-19 23:31:16 +00:00
funman300 d5d869a6c8 fix(multi): resolve 16 bugs from comprehensive rules and code review
Build and Deploy / build-and-push (push) Successful in 4m12s
Core (solitaire_core):
- fix(core): auto-complete now requires waste empty to prevent deadlock
- fix(core): reject multi-card moves from waste pile (Klondike rule)
- fix(core): reject foundation-to-foundation moves (score farming exploit)
- fix(core): undo restores score from snapshot baseline, not live score
- feat(scoring): add +5 flip bonus when face-down tableau card is exposed
- feat(scoring): add recycle penalty (Draw-1: -100/pass, Draw-3: -20/pass)

Engine (solitaire_engine):
- fix(engine): remove TokioRuntimeResource::default() panic; degrade gracefully
- fix(engine): add ModalScrim guard to handle_new_game spawn site
- fix(engine): add ModalScrim guard to spawn_restore_prompt spawn site
- fix(engine): add ModalScrim guard to check_no_moves spawn site

Server / Web (solitaire_server):
- fix(web): correct draw_mode casing in replay submission (DrawOne/DrawThree)
- fix(web): correct mode casing in replay submission (Classic) for leaderboard
- fix(web): trim recorded_at to YYYY-MM-DD for NaiveDate deserialization
- fix(server): move /avatars route outside auth middleware (was always 401)

Data / Sync (solitaire_data, solitaire_sync):
- fix(data): namespace Android token file under APP_DIR_NAME with migration
- fix(data): Android token store now multi-user (HashMap); no silent overwrite
- fix(sync): draw_one_wins + draw_three_wins invariant preserved after merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:09 -07:00
Gitea CI 42898c0b3f chore(deploy): bump image to f6e7de10 [skip ci] 2026-05-19 22:53:25 +00:00
funman300 f6e7de1093 fix(core): make take_from_foundation true by default across all clients
Build and Deploy / build-and-push (push) Successful in 3m51s
Android Release / build-apk (push) Successful in 4m36s
The flag was modelled as an opt-in non-standard rule but moving a card
off a foundation is in fact standard Klondike — disabling it is the
non-standard variant.

Changing the core default to true means every client (desktop, Android,
web) gets correct behaviour without each having to independently patch
the value after construction. Clients that expose a settings toggle
(desktop/Android) can still disable it through SettingsResource.

- game_state.rs: flip default from false → true in new_with_mode
- game_state.rs: rename/update take_from_foundation_disabled_by_default
  test to reflect the new intended default
- solitaire_wasm/lib.rs: remove now-redundant override in new()
  (from_saved keeps its override to fix old saves that serialised false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:44:10 -07:00
Gitea CI b5a780ddf4 chore(deploy): bump image to 90eb5fd2 [skip ci] 2026-05-19 22:41:00 +00:00
funman300 3322fd4250 fix(wasm): enable take-from-foundation in web game client
Android Release / build-apk (push) Successful in 3m56s
GameState::new_with_mode defaults take_from_foundation=false (non-
standard; the flag exists so the desktop can offer it as a setting).
The WASM web client has no settings layer, so this flag was never
flipped on — every drag or double-click from a foundation pile was
silently rejected by the rules engine.

Set take_from_foundation=true in both SolitaireGame::new (fresh games)
and SolitaireGame::from_saved (restored games, which may have the old
default serialised).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:40:16 -07:00
funman300 90eb5fd207 feat(web): persist game state across page refreshes with resume dialog
Build and Deploy / build-and-push (push) Successful in 2m54s
Android Release / build-apk (push) Successful in 4m38s
- solitaire_wasm: add SolitaireGame::serialize() and from_saved() so JS
  can round-trip the full GameState through localStorage as JSON
- game.js: save {gameState, elapsedSecs, drawThree} to localStorage
  (key: fs_game_save) on every render(); clear the save on win
- game.js: on bootstrap, check for a saved game and show a resume
  dialog if one exists; Resume restores state + timer, New Game discards
  the save and starts fresh with a random seed
- game.html: add #resume-overlay markup (same pattern as win-overlay)
- game.css: add styles for the resume dialog and its secondary button

localStorage failures (private-browsing quota) are silently ignored so
they never block gameplay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:38:07 -07:00
funman300 76cf41e7a9 fix(ui): open sync-setup modal when Connect clicked from Settings
Android Release / build-apk (push) Successful in 3m49s
The sync-setup modal was silently blocked by its own guard:
other_modal_scrims checks for any ModalScrim without SyncSetupScreen,
but the Settings panel IS a ModalScrim, so clicking Connect from within
Settings always hit the guard and returned early.

Two fixes:
- handle_sync_buttons: set SettingsScreen.0 = false when ConnectSync
  is pressed so settings closes as the event is fired
- open_sync_setup_modal: exclude SettingsPanel from other_modal_scrims
  to handle the deferred-despawn timing window (settings scrim entity
  still exists in the world until command buffers flush at frame end)
- Make SettingsPanel pub so sync_setup_plugin can reference it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:32:14 -07:00
funman300 fae5933d29 fix(engine): enable take-from-foundation for restored and startup games
Android Release / build-apk (push) Successful in 3m42s
GameState serializes take_from_foundation=false (the core default),
so saved games on disk and direct-loaded states never had the setting
applied from SettingsResource — only freshly dealt games did.

Two fixes:
- sync_settings_to_game: new system that reads SettingsChangedEvent
  and patches game.0.take_from_foundation on every settings change
  (covers initial settings load at startup and in-session toggles)
- handle_restore_prompt: apply settings immediately after game.0 =
  restored so the Continue path also respects the current setting
- Register SettingsChangedEvent in GamePlugin::build (idempotent with
  SettingsPlugin) so the message is available in headless test apps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:26:42 -07:00
funman300 6cd8c6c013 fix(multi): resolve 3 remaining Android UI bugs
Android Release / build-apk (push) Successful in 3m33s
- radial_menu: replace active_id.unwrap() with let Some guard — no
  runtime panic possible even if DragState races (§2.3)
- card_plugin: add bottom-right AndroidCornerBg overlay to mask the
  rotated baked-in text on classic PNG cards (mirrors top-left treatment)
- hud_plugin: bump Android action button min_width 44→52 px to give
  ~22px glyphs adequate padding after dynamic font-size increase
- layout: fix doc-lazy-continuation clippy lint in BOTTOM_BAR_HEIGHT comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:16:24 -07:00
funman300 ec94cb34aa fix(layout): reserve action-bar height so tableau never hides behind buttons
Android Release / build-apk (push) Successful in 4m15s
compute_layout only subtracted safe_area_bottom (OS gesture/nav bar) from
the vertical budget, but the app's own action bar (≡ ← || ? ! M +) sits
*above* that zone — invisible to safe_area_bottom. On Android the bar is
60 px tall (44 px min-height buttons + 8 px top + 8 px bottom bar padding),
so deep tableau columns scrolled 60 px behind the button row.

Fix: add BOTTOM_BAR_HEIGHT (60 px Android, 0 desktop) to safe_area_bottom
before both affected calculations:
  • card_width_height_based — height-based card sizing
  • avail — budget fed to update_tableau_fan_frac for adaptive fan spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:55:09 -07:00
funman300 40768f3b0a feat(engine): scale action-bar glyph font size dynamically on Android
Android Release / build-apk (push) Successful in 4m15s
The bottom bar's 7 icon buttons (≡ ← || ? ! M +) used TYPE_BODY = 14 px,
a fixed size that is too small on phone screens.

New behaviour:
- `action_bar_font_size(window_width)` returns `(window_width / 40).clamp(16, 30)`,
  giving ~22 px on a 900 logical-px phone and ~16 px on narrow viewports.
- `ActionButtonLabel` marker added to each button's text node (Android only).
- `spawn_action_buttons` reads `Query<&Window>` at startup to apply the
  correct initial size before the first frame renders.
- `resize_action_bar_labels` system re-runs whenever `LayoutResource`
  changes (window resize / orientation change) to keep glyphs in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:45:49 -07:00
funman300 2186f55913 fix(engine): fix classic-card corner label colours and HUD-band overlap
Android Release / build-apk (push) Successful in 4m0s
card_plugin: AndroidCornerLabel used CARD_FACE_COLOUR (dark ~#1a1a1a) as
the background and BLACK_SUIT_COLOUR (near-white) for clubs/spades text —
both designed for the Terminal theme. On classic PNG cards (white face),
this produced an ugly dark box with invisible black-suit text. Switch the
corner-label background to Color::WHITE and black-suit text to
CARD_FACE_COLOUR (dark ink on white), matching traditional card printing.

layout: HUD_BAND_HEIGHT on Android raised 80 → 112 px. The HUD column has
4 flex tiers plus 3 inter-tier gaps (4 px each) and a SPACE_2 = 8 px top
offset. With empty tiers still occupying gap height in Bevy's flex layout,
the actual rendered HUD could reach ~80 px, overlapping the top card row
by up to one text line. 112 px provides ~28 px clearance in the common
case (Tiers 1 + 3 visible) and remains workable even when Tier 1 wraps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:34:04 -07:00
funman300 e0f369d322 fix(engine): raise STACK_FAN_FRAC above corner label z to fix foundation pile bleed-through
Android Release / build-apk (push) Successful in 4m37s
Android corner label children sit at local z=0.02; with STACK_FAN_FRAC=0.003
the card below's label (world z=1.02) rendered above the card on top's sprite
(world z=1.003), causing overlapping rank/suit text on foundation piles.
Raising STACK_FAN_FRAC to 0.025 ensures every card sprite covers all children
of the card below it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:01:10 -07:00
Gitea CI ea98774ccb chore(deploy): bump image to ea9dd848 [skip ci] 2026-05-19 20:44:38 +00:00
funman300 ea9dd848fd fix(multi): resolve 14 bugs from second comprehensive review
Build and Deploy / build-and-push (push) Successful in 4m2s
Core (solitaire_core):
- fix(scoring): apply -15 penalty for Foundation→Tableau moves when
  take_from_foundation is enabled; update test
- fix(solver): is_won() validates full Ace→King suit sequence, not
  just card count — prevents hint system from emitting invalid paths

Engine — animation / layout:
- fix(animation): guard CardAnim advance against duration=0 to prevent
  NaN-poisoned Transform (analogous to CardAnimation's instant-snap path)
- fix(card_plugin): align TABLEAU_FAN_FRAC (0.25→0.18) and
  TABLEAU_FACEDOWN_FAN_FRAC (0.20→0.14) with layout.rs so the initial
  layout and first dynamic update produce identical fan spacing
- fix(layout): update tableau_fan_frac doc comment from 0.25→0.18

Engine — ECS / modal guards:
- fix(auto_complete): drive_auto_complete now checks PausedResource so
  cooldown does not tick while paused (prevents instant-move on unpause)
- fix(play_by_seed): handle_open_dialog checks global ModalScrim guard
  to prevent stacking over an existing modal
- fix(win_summary): spawn_win_summary_after_delay checks global
  ModalScrim guard; collect_session_achievements uses .next() not
  .last() to avoid draining the new_games stream

Engine — message registration:
- fix(leaderboard): register InfoToastEvent in LeaderboardPlugin::build
  so opt-in/opt-out toasts work under MinimalPlugins
- fix(replay_playback): register StateChangedEvent in
  ReplayPlaybackPlugin::build to prevent panic when used standalone

Security:
- fix(sync_setup): zero password SyncFieldBuffer immediately after
  spawning auth task — credential must not linger in ECS components

Server:
- fix(auth): replace MIME contains-chain with exact match for avatar
  upload; removes illusory starts_with guard and dead ALLOWED_IMAGE_TYPES

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:40:32 -07:00
funman300 a328059933 fix(ci): add workflow_dispatch trigger to android-release workflow
Tag-push events are not reliably processed by the self-hosted Gitea
runner. workflow_dispatch with a tag input allows manual triggering
via the Gitea UI or API as a fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:25:12 -07:00
Gitea CI 18659d19d1 chore(deploy): bump image to 7840ef9e [skip ci] 2026-05-19 20:19:02 +00:00
funman300 7840ef9eb2 fix(multi): resolve 26 bugs found in comprehensive codebase review
Build and Deploy / build-and-push (push) Successful in 3m40s
Core fixes (issues #12, #13, #22):
- #12: undo now preserves score delta instead of restoring snapshot score
- #13: take_from_foundation defaults to false (non-standard house rule)
- #22: check_win validates full suit sequence, not just card count

Engine fixes:
- #8:  replay keyboard input guard against non-replay state
- #9:  help modal scrims.is_empty() guard added
- #10: settings modal scrims.is_empty() guard added
- #11: sync_plugin builds payload at poll time (not task-spawn time)
- #14: server replay mode case-sensitivity fix ("Classic")
- #15: play_by_seed_plugin confirmed flag set to true on launch
- #16: replay back-step debounce via Local<bool> + StateChangedEvent;
       register StateChangedEvent in ReplayOverlayPlugin (fixes 52 tests)
- #17: time-attack timer ignores win-summary overlay
- #18: HUD dropdown glyphs U+25BE → U+2193 (FiraMono-safe arrow)
- #19: theme plugin applies immediate visual update on A→B→A switch
- #20: SyncAuthError / SyncBusyOverlay split into separate entities so
       auth errors are visible after busy overlay is hidden
- #21: handle_forfeit ordered before update_stats_on_new_game
- #23: server merge uses correct avg_time_seconds and games_lost math
- #24: win_summary migrated to ModalScrim pattern
- #25: card_animation apply_deferred between animation systems
- #26: cursor_plugin HashMap access uses .get() with fallback
- #27: auto_complete mid-sequence deactivation guard
- #28: feedback_anim SettleAnim ordered before FoundationFlourish
- #29: achievement_plugin iterates all win events; adds scrims guard
- #30: leaderboard modal scrims.is_empty() guard added
- #31: server auth tmp file cleanup on rename failure
- #32: sync_setup modal scrims.is_empty() guard added
- #33: font_plugin uses match fallback; TokioRuntimeResource graceful
       current-thread fallback on runtime init failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:14:47 -07:00
funman300 6d061d23a1 fix(engine): cancel stale win-cascade CardAnimation on new-game; refresh Android corner label text on resize (closes #6, closes #7)
Issue #7 — new game during win cascade:
sync_cards now stores each in-flight CardAnimation's end position instead of
a plain bool. Before calling update_card_entity, the end position is compared
against the game-state target. If they differ by more than 2 px (stale cascade
scatter vs. new-game dealt position) the CardAnimation is removed immediately
so the card slides to its correct dealt position. Drag-rejection tweens are
unaffected because their end equals the card's current game-state position.

Issue #6 — Android stale corner label text:
AndroidCornerLabel now carries the label string as AndroidCornerLabel(String).
resize_android_corner_labels refreshes Text2d content from the stored value
alongside the existing font-size and transform updates, closing the narrow
race where a layout change could display a previous card's rank/suit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:32:07 -07:00
funman300 25f22231a6 fix(test): make leaderboard opt-in/opt-out tests robust under parallel runner (closes #5)
The four tests polled the async task pool with a fixed budget of five
app.update() calls. Under cargo test --workspace the pool's background
threads are starved by other tests, so even an instantly-resolving future
can take more than five frames to be polled. Replace the fixed loop with a
deadline-bounded loop (5 s timeout) that exits early once the expected
side-effect is observable — the same pattern used in sync_plugin.rs tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:32:07 -07:00
funman300 c66ff26d1d fix(engine): lift card z during CardAnim to prevent corner bleed-through
When a card slides to a foundation slot already occupied, both card entities
share the same x,y for the duration of the tween. With STACK_FAN_FRAC only
0.003 apart, the incoming card partially occludes the stationary one, making
the two exposed corners look like a single mismatched card.

Elevate every CardAnim-driven card to target.z + 50 during transit so it
fully occludes any card resting at the destination. On completion the card
snaps to the correct resting z. The value sits below DRAG_Z (500) so dragged
cards still render above animated ones.

Closes #implicitly-related-to-corner-mismatch-investigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:32:07 -07:00
funman300 cd792b20b2 chore: ignore ruflo runtime state files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:32:07 -07:00
Gitea CI 73c7f50f74 chore(deploy): bump image to 83c40116 [skip ci] 2026-05-19 02:03:57 +00:00
funman300 83c40116af fix(web): freeze timer when auto-complete begins (closes #4)
Build and Deploy / build-and-push (push) Successful in 4m5s
The game timer kept counting during the auto-complete animation even
though the player had already made their last decision. stopTimer() is
now called the moment is_auto_completable fires so elapsed_seconds
reflects only real play time, not the animation delay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:59:54 -07:00
Gitea CI 347d5a4b4f chore(deploy): bump image to 93f2ceaa [skip ci] 2026-05-19 01:50:10 +00:00
funman300 93f2ceaabe fix(web): rebuild WASM pkg — foundation→tableau moves now work
Build and Deploy / build-and-push (push) Successful in 4m20s
The pre-built pkg predated fix c35c045 (enable take-from-foundation by
default) so the WASM game always had take_from_foundation=false, silently
rejecting every drag from a foundation pile to a tableau column.

Rebuilt with wasm-pack --release against current solitaire_core.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:45:51 -07:00
funman300 e390b72222 chore(tooling): add ruflo-core scaffolding and MCP server registration
Initialised ruflo v3 via `npx @claude-flow/cli@latest init --wizard --force`.
Registers the ruflo MCP server in .mcp.json (hierarchical-mesh topology,
max 15 agents). Includes .claude-flow/ runtime config and capability manifest.

.claude/ remains gitignored (local agents/commands/settings stay per-developer).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:19:28 -07:00
funman300 3650788dc5 fix(engine): prevent stock-tap from toggling HUD on Android
Every draw-from-stock tap was also firing the HUD auto-hide toggle
because the stock pile is not an ActionButton and toggle_hud_on_tap
had no way to know the tap was consumed by game logic.

Add GameInputConsumedResource(bool): handle_touch_stock_tap sets it
on TouchPhase::Started when a draw fires; toggle_hud_on_tap checks
and clears it on TouchPhase::Ended, treating it as equivalent to
started_on_button so the HUD stays put.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:09:58 -07:00
Gitea CI 39cf8dcd6c chore(deploy): bump image to 456b4d42 [skip ci] 2026-05-18 20:29:08 +00:00
funman300 456b4d42e3 refactor(core): explicit Rank discriminants, checked arithmetic, possible_instructions
Build and Deploy / build-and-push (push) Successful in 3m55s
Android Release / build-apk (push) Successful in 4m37s
- Add Rank=1..13 explicit discriminants so `rank as u8 == rank.value()`; collapse 13-arm value() match to `self as u8`
- Add Rank::RANKS and Suit::SUITS iteration constants
- Add Rank::checked_add / checked_sub (const fn, type-safe boundary enforcement); update rules.rs to use them
- Add GameState::possible_instructions() enumerating all valid move_cards triples (foundation for hints/solver)
- Fix waste buffer card peeking through during draw-slide animation by setting Visibility::Hidden on the buffer entity in sync_cards

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Same fix applied to SolitaireGame::state().

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 13:25:32 -07:00
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
funman300 d49c478efa fix(ci): diagnose KEYSTORE_BASE64 decode failure
Android Build / build-apk (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
Android Release / build-release-apk (push) Has been cancelled
base64 -d is failing (1s step, keystore 0 bytes). Add diagnostics:
- Print secret length to check for truncation
- Capture base64 error output
- Print exit code and file size
- Use --decode instead of -d for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:37:17 -07:00
Gitea CI 29f9b9358e chore(deploy): bump image to 9ef5759f [skip ci] 2026-05-14 22:27:55 +00:00
funman300 9ef5759f40 fix(ci): fail fast on empty keystore before 7-min cargo build
Android Build / build-apk (push) Successful in 14m52s
Build and Deploy / build-and-push (push) Successful in 44s
Android Release / build-release-apk (push) Failing after 3m42s
If KEYSTORE_BASE64 is unset, base64 -d writes an empty file silently,
cargo ndk then spends ~7 min compiling all ABIs, and only then does
apksigner fail. Add a size check after decode so the job fails in
seconds with a clear error message instead of wasting a full build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:12:25 -07:00
funman300 9c9c0c76d3 fix(ci): restore 3-ABI release build now that LXC has 106 GB disk
Android Build / build-apk (push) Successful in 20m40s
Build and Deploy / build-and-push (push) Failing after 50s
Android Release / build-release-apk (push) Failing after 10m29s
The runner LXC was bumped from ~56 GB to 106 GB, giving ~70 GB of free
space — well above the ~40 GB a full 3-ABI release build needs. Revert
the disk-budget workarounds added in ab35fcf:

- Remove "Free disk space" step (no longer needed)
- Restore x86_64 target (arm64-v8a + armeabi-v7a + x86_64)
- Remove ABIS override so build script uses its full default set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:04:09 -07:00
Gitea CI d4fb9e36a8 chore(deploy): bump image to 32991301 [skip ci] 2026-05-14 21:00:48 +00:00
funman300 ab35fcf906 fix(ci): free disk space + drop x86_64 from release build to fix OOM
Android Build / build-apk (push) Successful in 16m44s
Build and Deploy / build-and-push (push) Failing after 30s
Android Release / build-release-apk (push) Failing after 12m5s
Run 181 (v0.25.0 tag) failed at "Build signed release APK" after ~7 min —
same disk-exhaustion pattern that hit the debug build. The debug workflow
was already fixed to arm64-v8a only; the release workflow still built all 3
ABIs and exceeded the runner's disk budget.

Changes:
- Add "Free disk space" step before system deps: removes /usr/local/lib/android,
  /usr/share/dotnet, /opt/ghc, /usr/local/share/boost (~10 GB reclaimed).
- Limit ABIS to arm64-v8a + armeabi-v7a (drops x86_64, which is emulator-only).
- Remove x86_64 from rustup target add to match.

arm64-v8a covers all modern Android devices; armeabi-v7a covers legacy ARM.
x86_64 can be re-added later if a simulator-targeted test build is needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:53:48 -07:00
funman300 32991301dd fix(engine): restore Dark as default theme; migrate stale theme IDs
Android Build / build-apk (push) Successful in 12m19s
Build and Deploy / build-and-push (push) Successful in 55s
- default_theme_id() returns "dark" (was briefly "classic" after the
  rename commit 20b7a61)
- sanitized() migrates "default" and "classic" → "dark" so existing
  settings.json files are upgraded automatically on next launch
- Registry lists Dark first so the Settings picker opens with it at top
- Classic remains available as an option in the picker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:47:43 -07:00
Gitea CI c5fd928dcb chore(deploy): bump image to f6907671 [skip ci] 2026-05-14 20:20:49 +00:00
funman300 f6907671be fix(ci): pin upload-artifact to v3 for Gitea Actions compatibility
Android Build / build-apk (push) Successful in 12m49s
Build and Deploy / build-and-push (push) Successful in 47s
Android Release / build-release-apk (push) Failing after 10m11s
The disk-budget fix worked — debug APK now builds, signs, and verifies
in ~6 minutes on a single ABI. But the upload step failed with:

  GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
  and download-artifact@v4+ are not currently supported on GHES.

upload-artifact@v4 rewrote the upload path to use a new artifact
service hosted on github.com; Gitea's GHES-compatibility layer doesn't
implement that endpoint. v3 still uses the older chunked HTTP upload
API that Gitea supports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:07:09 -07:00
Gitea CI a54fff7257 chore(deploy): bump image to 533bcec2 [skip ci] 2026-05-14 20:01:12 +00:00
funman300 533bcec2d8 fix(ci): limit debug APK to arm64-v8a so apksigner has disk to write
Android Build / build-apk (push) Failing after 9m10s
Build and Deploy / build-and-push (push) Successful in 52s
The previous run got all the way through compile + link + zipalign and
then died inside apksigner with `IOException: No space left on device`.
Cross-compiling all three Android ABIs (arm64-v8a, armeabi-v7a, x86_64)
in debug mode blows target/ past 25 GB, and by the time apksigner is
streaming the signed APK to disk the runner has nothing left.

Two changes:

  1. build_android_apk.sh now reads `ABIS` from the environment (defaults
     to all three for backwards compat) and uses it to assemble the
     cargo-ndk `-t` flags.
  2. android-build.yml passes ABIS=arm64-v8a, since the debug artifact
     is consumed by adb-installing to a single arm64 device and the
     other two ABIs were dead weight.

Also frees \$STAGING/app-unsigned.apk right after zipalign so it's not
sitting next to the aligned APK and the output APK during signing.

Release workflow is untouched — release APKs still ship all three ABIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:51:17 -07:00
Gitea CI ba786f5a09 chore(deploy): bump image to 7ee7cb6d [skip ci] 2026-05-14 19:17:40 +00:00
funman300 7ee7cb6d93 ci(android): replace cargo-apk with cargo-ndk + manual APK assembly
Android Build / build-apk (push) Failing after 23m0s
Build and Deploy / build-and-push (push) Successful in 43s
cargo-apk 0.10 and its fork cargo-apk2 both failed to discover the
installed Android platform in this Gitea runner, despite ANDROID_HOME,
platforms;android-34, build-tools, and NDK all being present, readable,
and pointed at correctly. We never isolated whether the bug is in the
shared ndk-build crate's discovery logic or in the runner's env-var
propagation through cargo subcommand exec, so this commit stops fighting
either tool and assembles the APK from explicit toolchain steps instead:

  cargo ndk          -> per-ABI .so files
  aapt2 compile/link -> manifest + resources -> base APK
  zip                -> bundle native libs into lib/<abi>/
  zipalign           -> 4-byte alignment
  apksigner          -> v2/v3 signing (debug keystore for CI, real for release)

The pipeline lives in scripts/build_android_apk.sh so it's reproducible
locally (same env vars, same commands). AndroidManifest.xml is now
checked in under solitaire_app/android/ and mirrors what cargo-apk would
have generated from [package.metadata.android] — keep them in sync if
either is changed. Local `cargo apk build` still works on developer
machines where cargo-apk is happy; CI just stops depending on it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:53:55 -07:00
funman300 14324b09ef ci(android): switch from cargo-apk 0.10.0 to cargo-apk2
Android Build / build-apk (push) Failing after 3m49s
Build and Deploy / build-and-push (push) Failing after 23s
cargo-apk 0.10.0 has been unable to discover an installed Android
platform in this runner environment despite ANDROID_HOME, NDK,
build-tools, and platforms;android-34 all being present and readable.
cargo-apk2 is the maintained community fork on crates.io that reads
the same `[package.metadata.android]` block, so the solitaire_app
Cargo.toml needs no changes. Cache keys updated to apk2- so we don't
restore the broken cargo-apk binary from prior runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:43:50 -07:00
Gitea CI 124f1f5cf5 chore(deploy): bump image to a6a73b5f [skip ci] 2026-05-14 18:38:04 +00:00
funman300 a6a73b5f36 fix(ci): add permission and env diagnostics to build step
Android Build / build-apk (push) Failing after 3m47s
Build and Deploy / build-and-push (push) Successful in 36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:33:54 -07:00
Gitea CI b84fe79806 chore(deploy): bump image to 3248f00d [skip ci] 2026-05-14 18:32:22 +00:00
funman300 3248f00d66 fix(ci): deeper SDK verification — find android.jar actual location
Android Build / build-apk (push) Failing after 3m41s
Build and Deploy / build-and-push (push) Successful in 25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:28:17 -07:00
Gitea CI c680a043ae chore(deploy): bump image to d0ab7ed9 [skip ci] 2026-05-14 18:26:23 +00:00
funman300 d0ab7ed97b fix(ci): add SDK verification step to diagnose platforms-not-found
Android Build / build-apk (push) Failing after 3m28s
Build and Deploy / build-and-push (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:22:11 -07:00
Gitea CI 1144a96757 chore(deploy): bump image to eba1f66b [skip ci] 2026-05-14 18:18:13 +00:00
funman300 ac6668cee7 fix(ci): apply template-expansion pattern to release workflow
Android Build / build-apk (push) Failing after 2m59s
Build and Deploy / build-and-push (push) Failing after 46s
Mirror the fix from android-build.yml: rename ANDROID_HOME -> ANDROID_SDK
in the env block to avoid the Docker-image-baked ANDROID_HOME overriding
the workflow value in run scripts. Use ${{ env.ANDROID_SDK }} template
expressions throughout, and explicitly export ANDROID_HOME/ANDROID_NDK_HOME
before cargo-apk build so it finds the SDK at the right path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:17:58 -07:00
funman300 eba1f66b45 fix(ci): use template-expanded paths in run scripts to bypass Docker ENV
Android Build / build-apk (push) Failing after 3m6s
Build and Deploy / build-and-push (push) Successful in 31s
Replace shell variable $ANDROID_HOME references in run blocks with
${{ env.ANDROID_SDK }} template expressions. Gitea runner v1 may not
override Docker-image-baked ENV vars via docker exec; template expansion
happens at workflow compilation time, so the literal path is hardcoded
into the script before the shell runs it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:14:34 -07:00
Gitea CI 90959728b1 chore(deploy): bump image to 8b30f877 [skip ci] 2026-05-14 18:11:34 +00:00
funman300 8b30f8778b fix(ci): use fresh /opt/android-sdk path to avoid container ENV conflict
Android Build / build-apk (push) Failing after 3m9s
Build and Deploy / build-and-push (push) Successful in 50s
Remove SDK detection logic and install directly to /opt/android-sdk,
matching the release workflow. The container Docker image has ANDROID_HOME
baked in at /usr/local/lib/android/sdk; installing there with sudo while
cargo-apk resolves ANDROID_HOME from the image ENV created a divergence.
Using a controlled path we own eliminates that class of conflict entirely.
Add SDK cache shared with the release workflow (same key prefix v2-).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:07:46 -07:00
Gitea CI d6a7924f14 chore(deploy): bump image to 4db43fb3 [skip ci] 2026-05-14 18:02:16 +00:00
funman300 4db43fb3fb fix(ci): replace ANDROID_SDK_ROOT with ANDROID_HOME in release workflow
Android Build / build-apk (push) Failing after 3m49s
Build and Deploy / build-and-push (push) Successful in 54s
ANDROID_SDK_ROOT was never set; zipalign and apksigner were resolving
to empty paths and would fail. All three occurrences replaced with
ANDROID_HOME which is defined in the workflow env block.

Also adds sudo to the cache-miss SDK install (mkdir/mv/sdkmanager) to
match the debug workflow pattern — /opt/android-sdk requires root on
a fresh runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:56:42 -07:00
funman300 01d6b27e61 fix(ci): detect existing container SDK before installing, set ANDROID_HOME via GITHUB_ENV
Android Build / build-apk (push) Failing after 3m50s
Build and Deploy / build-and-push (push) Failing after 33s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:53:14 -07:00
funman300 3cffbc2c51 feat(engine): embed classic theme into binary like dark theme
Classic SVGs and manifest are now compiled in via include_bytes!(),
making the theme available on all platforms (desktop, Android) without
requiring filesystem assets. Removes the now-redundant Dockerfile COPY
of solitaire_engine/assets/themes/classic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:53:14 -07:00
Gitea CI 2ef25934ac chore(deploy): bump image to bb670d6c [skip ci] 2026-05-14 17:51:40 +00:00
funman300 bb670d6cc6 fix(ci): drop ANDROID_SDK_ROOT, pass --sdk_root to sdkmanager explicitly
Android Build / build-apk (push) Failing after 3m19s
Build and Deploy / build-and-push (push) Successful in 30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:47:52 -07:00
Gitea CI 76911c57c9 chore(deploy): bump image to 8391235a [skip ci] 2026-05-14 17:45:46 +00:00
funman300 8391235a1a fix(ci): check android.jar existence in platform dir
Android Build / build-apk (push) Failing after 3m25s
Build and Deploy / build-and-push (push) Successful in 30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:41:57 -07:00
funman300 2f3a6b9586 fix(ci): dump env at build time to diagnose ANDROID_HOME visibility
Android Build / build-apk (push) Failing after 3m23s
Build and Deploy / build-and-push (push) Failing after 40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:37:37 -07:00
Gitea CI 4d20b70809 chore(deploy): bump image to bfadcf0e [skip ci] 2026-05-14 17:32:04 +00:00
funman300 bfadcf0e0d fix(ci): add SDK layout debug step to diagnose platforms-not-found error
Android Build / build-apk (push) Failing after 3m19s
Build and Deploy / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:28:10 -07:00
Gitea CI 356dbebe57 chore(deploy): bump image to c90c7831 [skip ci] 2026-05-14 17:28:04 +00:00
funman300 c90c783177 fix(ci): set ANDROID_HOME/NDK_HOME in workflow env block instead of GITHUB_ENV
Android Build / build-apk (push) Failing after 3m23s
Build and Deploy / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:23:58 -07:00
Gitea CI bbf4b2c14a chore(deploy): bump image to 1f46785b [skip ci] 2026-05-14 17:19:04 +00:00
funman300 62be72e918 fix(ci): bust SDK cache key to force fresh SDK install after prior broken cache
Android Build / build-apk (push) Failing after 3m16s
Build and Deploy / build-and-push (push) Failing after 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:18:57 -07:00
funman300 1f46785b31 fix(ci): add apt-get update before package install to fix exit code 100
Android Build / build-apk (push) Failing after 3m50s
Build and Deploy / build-and-push (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:14:32 -07:00
Gitea CI 2e5d82f83c chore(deploy): bump image to 396ba6bc [skip ci] 2026-05-14 17:12:21 +00:00
funman300 396ba6bc97 fix(ci): always install Java regardless of SDK cache hit; harden release creation
Android Build / build-apk (push) Failing after 13s
Build and Deploy / build-and-push (push) Successful in 38s
Android Release / build-release-apk (push) Failing after 11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:11:42 -07:00
Gitea CI 88298206bb chore(deploy): bump image to 0f650311 [skip ci] 2026-05-14 17:11:05 +00:00
funman300 0f65031114 ci: add Android release workflow — sign and publish APK on version tag
Android Build / build-apk (push) Failing after 11s
Build and Deploy / build-and-push (push) Successful in 23s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:10:27 -07:00
funman300 c91ce9436e fix(deploy): copy classic theme assets into Docker runtime image
Build and Deploy / build-and-push (push) Failing after 27s
solitaire_engine/assets/themes/classic/ was absent from the container
because only the workspace-root assets/ directory was copied. The
AssetServer serves themes/classic/ from that same root, so the classic
theme manifested as a missing-asset load failure at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:00:43 -07:00
Gitea CI ace96b4a47 chore(deploy): bump image to ea079af9 [skip ci] 2026-05-14 05:58:01 +00:00
funman300 ea079af9e1 ci: add Android APK build workflow
Android Build / build-apk (push) Failing after 59s
Build and Deploy / build-and-push (push) Successful in 26s
Triggers on every master push that touches app/engine/asset code
(ignores deploy/, argocd/, solitaire_server/, *.md).

Three-layer cache strategy:
  1. Android SDK + NDK keyed by NDK + build-tools versions (~2 GB, stable)
  2. cargo-apk binary keyed by OS + toolchain (avoids recompiling the tool)
  3. Cargo registry + build artifacts keyed by Cargo.lock + SHA

Outputs a debug APK as a workflow artifact retained for 30 days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:56:34 -07:00
Gitea CI c66d81c73a chore(deploy): bump image to 20b7a617 [skip ci] 2026-05-14 05:53:08 +00:00
funman300 20b7a617e0 feat(engine): rename themes — Classic is default, Dark replaces Default
Build and Deploy / build-and-push (push) Successful in 33s
- Rename assets/themes/default/ → assets/themes/dark/; update theme.ron
  id/name to "dark"/"Dark"
- Rename all DEFAULT_THEME_* constants → DARK_THEME_* and
  default_theme_svg_bytes / populate_embedded_default_theme → dark_*
- Add bundled_theme_url() helper for URL resolution without needing the
  registry (used by Startup systems where ordering isn't guaranteed)
- Registry now lists Classic first (new player default), Dark second
- settings.rs default_theme_id() returns "classic" so fresh installs
  start on the white card theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:52:44 -07:00
funman300 7a0d57b2b1 feat(engine): add Classic card theme
White/cream card faces with traditional red (hearts/diamonds) and black
(clubs/spades) colours, plus a navy diamond-pattern card back. Shipped
as a bundled AssetServer theme alongside the existing Default theme.

Registry updated to include the Classic entry; registry tests updated
to reflect the new BUNDLED_COUNT of 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:52:44 -07:00
Gitea CI 93ec4a7478 chore(deploy): bump image to 72dfd741 [skip ci] 2026-05-14 05:34:53 +00:00
funman300 72dfd741c4 fix(web): add Matomo tracking snippet to all pages
Build and Deploy / build-and-push (push) Successful in 4m10s
Only game.html had the snippet; the other five pages were missing it,
causing the Matomo installation verification check to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:30:08 -07:00
funman300 3837a10b15 fix(deploy): use matomo.php for liveness/readiness probes
/index.php returns 302 after tables are created (installer redirect),
which fails k8s HTTP probes. /matomo.php is the tracker endpoint and
always returns 200 regardless of installation state. Also add
timeoutSeconds: 5 since PHP startup can exceed the 1s default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:03:07 -07:00
funman300 574115cb71 fix(deploy): switch matomo to official image 5.10.0
bitnami/matomo was removed from Docker Hub (0 tags). Switch to the
official matomo:5.10.0 image; update port 8080→80, volume path to
/var/www/html, and env var names to match the official image schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:52:00 -07:00
Gitea CI 1707553790 chore(deploy): bump image to 6905f26b [skip ci] 2026-05-14 04:37:19 +00:00
funman300 6905f26b56 security: remove secrets from git, gitignore k8s secret files
Build and Deploy / build-and-push (push) Successful in 35s
Secrets committed in prior commits (matomo-secret.yaml,
secret-analytics-auth.yaml) have been scrubbed from history via
filter-branch — rotate those credentials immediately.

Going forward:
- deploy/*-secret.yaml is gitignored; apply manually with kubectl
- deploy/matomo-secret.yaml.example shows the required shape
- ArgoCD ignoreDifferences on matomo-secret prevents it pruning a
  manually-applied secret
- Remove matomo-secret.yaml from kustomization.yaml so ArgoCD never
  manages it again

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:36:46 -07:00
funman300 1b7c4d92aa fix(web): auto-complete now works with cards remaining in waste
check_auto_complete no longer requires the waste pile to be empty —
only the stock must be exhausted and all tableau cards face-up.
next_auto_complete_move checks the waste top card before scanning
tableau, and auto_complete_step falls back to draw() when no direct
foundation move is available so the waste drains automatically.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:50:54 -07:00
Gitea CI f5da9398f2 chore(deploy): bump image to 31d0a1b6 [skip ci] 2026-05-13 23:43:30 +00:00
funman300 b82573e7b1 feat(web): add home arrow link to game page header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:38:58 -07:00
Gitea CI 40818f5bd2 chore(deploy): bump image to 56dbc3ff [skip ci] 2026-05-13 23:37:19 +00:00
funman300 228ebbad8a fix(ci): rebase before kustomization push to handle concurrent runs
Two runs for the same SHA racing to push the kustomization update
caused the second to fail with "failed to push some refs".

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:32:09 -07:00
Gitea CI 7be7f4395c chore(deploy): bump image to 597aba20 [skip ci] 2026-05-13 15:04:01 -07:00
funman300 66c2907c25 fix(docker): rename binary to ./server to avoid collision with solitaire_server/web dir 2026-05-13 15:03:45 -07:00
funman300 c2811fa661 ci: trigger with dockerfile change for debug 2026-05-13 14:46:09 -07:00
funman300 933cc55ea9 fix(docker): copy web/ to builder stage for include_str! macros 2026-05-13 14:18:05 -07:00
funman300 58faae1911 fix(docker): stub all workspace crates for cargo fetch in CI 2026-05-13 14:15:24 -07:00
funman300 96be1b85fb ci: retrigger after fixing runner instance URL 2026-05-13 14:11:54 -07:00
funman300 bbf7709912 ci: retrigger build after enabling Actions 2026-05-13 14:05:23 -07:00
funman300 9983b873f9 feat(ops): add k3s + ArgoCD GitOps pipeline
- Dockerfile: copy web/ and assets/ to runtime stage so ServeDir routes work
- .gitea/workflows/docker-build.yml: build/push image on master push, pin SHA
  tag back into deploy/kustomization.yaml so ArgoCD sees a real manifest change
- deploy/: Kustomize manifests — Namespace, PVC, Deployment (Recreate for
  SQLite), Service, Traefik Ingress at klondike.aleshym.co
- argocd/application.yaml: auto-sync Application watching deploy/ on Gitea

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also adds an integration test: sync_push_rate_limit_returns_429_on_11th_request.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:37:46 -07:00
funman300 002d96f2c8 fix(android): add type annotation for hotkey None in spawn_modal_button
The Android aarch64 compiler cannot infer the type of `let hotkey = None` inside
the `#[cfg(target_os = "android")]` block — it needs to know the Option's inner
type to resolve the rebinding downstream. Added `: Option<&'static str>` to match
the parameter type and match the non-Android path.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    ideal = avail / (12 * card_height)

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:05:17 -07:00
funman300 e107f5e218 fix(android): 48dp min hit targets on action buttons
Release / Build · Linux x86_64 (push) Has been cancelled
Release / Build · Android APK (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
Action buttons sized to text + 8 px padding made "Undo" end up
~46 x 33 px — fine for a mouse but at the threshold of a finger.
Adds `min_width: 48 px` and `min_height: 48 px` to the button
Node so every button meets Material's 48 dp thumb-target guideline.

Applied universally; the floor is a no-op for buttons whose
content already exceeds 48 px on either axis (Menu, Modes,
New Game, Pause, Help all clear 48 px wide; height was the
binding constraint at ~33 px).

Closes P1 #2 of docs/android/PLAYABILITY_TODO.md. Engine tests
pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:53:40 -07:00
funman300 463b7465ed fix(android): hide keyboard-hint chips on action buttons
The U / Esc / F1 / N caption chips next to the HUD action buttons
are meaningless on a touch device and visibly clutter the
narrow-viewport action row (visible as "Esc A [] N" in the v0.22.3
screenshot). `spawn_action_button` now rebinds `hotkey` to `None`
under `#[cfg(target_os = "android")]` so the chip-spawn branch is
skipped on touch builds.

Menu / Modes chevrons are unaffected — they indicate dropdown
behaviour and still apply on touch. Other hint surfaces
(onboarding, pause modal Esc hint, mode-card chips, replay
footer, modal toggle chips, help screen) live behind navigation
and are tracked as a P3 sweep in PLAYABILITY_TODO.md.

Closes P1 #1 of docs/android/PLAYABILITY_TODO.md. 855 engine
tests pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:52:12 -07:00
funman300 92a5ebb15e fix(android): lower MIN_WINDOW floor so phone viewports lay out correctly
`compute_layout` runs `window.max(MIN_WINDOW)`, which acts as a
component-wise floor: any window smaller than MIN_WINDOW on either
axis gets clamped up. The previous floor of 800x600 was set with
desktop in mind, but on Android the OS-provided window size is the
device resolution (~360 dp wide on a typical phone) and the clamp
silently re-laid the board for an 800 dp width.

Side effect: total grid width (9 * card_width) became ~800 px on a
360 dp viewport, so the leftmost foundation x-position fell past
-180 and the rightmost tableau pile past +180 — both clipped at
the visible edges, matching the v0.22.3 hardware screenshot.

Lowered MIN_WINDOW to 320x400, below the smallest reasonable phone
(~360x640), so every real device flows through compute_layout
unclamped. The floor is preserved as a sentinel against degenerate
windows (Bevy can briefly report 0-size during startup or after
minimisation on some compositors). Desktop's "minimum supported
playable size" is enforced separately via WindowResizeConstraints
in solitaire_app.

Updates `layout_below_minimum_clamps_to_minimum` to use values
below the new floor, and adds a new regression test
`phone_portrait_layout_fits_horizontally` that asserts all 13
piles fit inside a 360 x 800 dp viewport.

Closes P0 #4 of docs/android/PLAYABILITY_TODO.md. 855 engine tests
pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:48:56 -07:00
funman300 89a21c0587 fix(android): wrap HUD column and action button row on narrow viewport
The v0.22.3 hardware screenshot showed the 6-button action row
(~510 px when laid out) overflowing into a 360 dp viewport from
the right anchor, with Menu and Undo clipped off-screen left and
Pause/Help/Modes/New_Game overlapping the left HUD column's
Score / Moves / Timer text.

Cap both clusters at `max_width: 50 %` so on mobile each takes
half the viewport (~180 px) and on desktop the cap is wider than
either cluster's natural width so the existing single-line
layout is preserved.

- Action button row: adds `flex_wrap: Wrap`, `row_gap`, and
  `justify_content: FlexEnd` so the row breaks to multiple
  right-aligned lines instead of clipping. 6 buttons become 2-3
  lines of 2-3 buttons.
- HUD column tier rows: add `flex_wrap: Wrap` and `row_gap` to
  the shared `row_node` helper so a long Mode/Challenge/Draw-cycle
  combo soft-wraps onto two lines instead of pushing into the
  action button column.

Closes P0 #2 of docs/android/PLAYABILITY_TODO.md. All 854 engine
tests pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:45:41 -07:00
funman300 304cb050a7 docs(android): close P0 safe-area + card-back items in playability TODO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:41:28 -07:00
funman300 fcc7337c97 fix(android): gate AssetPlugin file_path override to desktop only
`AssetPlugin::file_path = "../assets"` was set unconditionally to
make `cargo run -p solitaire_app` find the workspace-root assets
directory from inside `solitaire_app/`. On Android cargo-apk packages
the same directory into the APK at `assets/`, and Bevy's
AndroidAssetReader is already rooted there — prepending `../` walked
the reader out of the APK assets root and every load failed silently.

The cascade: CardImageSet handles were inserted but pointed at
non-existent paths, so `card_sprite` saw `Some(set)` but the textures
never resolved. The face-down branch then rendered with `Color::WHITE`
over a missing texture — which on hardware showed as the
`card_back_colour(0)` solid-red brick fallback that's *supposed* to
only fire under MinimalPlugins in tests.

Gates the `file_path` override behind
`#[cfg(not(target_os = "android"))]` so Android picks up the default
empty path. Closes P0 #3 of docs/android/PLAYABILITY_TODO.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:41:06 -07:00
funman300 16ce2b88d2 chore: gitignore keystores and refresh Cargo.lock
Adds *.jks / *.jks.bak / *.keystore to .gitignore so the
release signing material can never be committed accidentally.

Cargo.lock drift catches up with 7c07f71 (bevy dep added to
solitaire_data for Android target) — the prior commit edited
solitaire_data/Cargo.toml but didn't regenerate the lockfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:37:11 -07:00
funman300 b9aa2620b8 feat(android): safe-area insets for HUD positioning
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.

UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.

Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).

Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:37:06 -07:00
funman300 47f02a60ae docs(android): add screenshot-driven playability TODO
Captures the gap between "boots without crashing" (v0.22.3 status)
and "actually playable on a phone." Tracks P0-P4 work items grouped
by impact: safe-area, HUD layout, card-back rendering, viewport
overflow, touch UX, density.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:36:33 -07:00
funman300 a5c3188686 ci(release): scope cargo apk build to --lib to avoid post-sign panic
Release / Build · Android APK (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
Release / Build · Linux x86_64 (push) Has been cancelled
cargo-apk panics with "Bin is not compatible with Cdylib" after
successfully signing the APK, when cargo-subcommand's artifact
iterator walks the bin target after the cdylib has been produced.
The APK file survives the panic on disk, but the non-zero exit
fails the workflow step before the upload runs.

Passing --lib scopes the build to the cdylib target only.
SESSION_HANDOFF.md documented this as the canonical workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:18:46 -07:00
funman300 6a289b7b50 ci: bump GitHub Actions to v5 for Node 24 compatibility
actions/checkout, actions/cache, actions/upload-artifact, and
actions/download-artifact bumped from v4 to v5 across both
ci.yml and release.yml. Pre-empts the 2026-06-02 Node 20
deprecation deadline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:18:35 -07:00
funman300 bee712c5ab ci(release): replace Python heredoc with printf for signing config injection
Release / Build · Linux x86_64 (push) Has been cancelled
Release / Build · Android APK (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
The Python heredoc had TOML section lines at column 0 inside a YAML
literal block, which YAML interprets as terminating the block (parse
error, instant workflow failure). printf keeps all lines at proper
indentation within the run block while avoiding sed escaping issues
with special characters in passwords.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:58:58 -07:00
funman300 0db5e9dac4 ci(release): inject Android signing config at build time via Python
cargo-apk refuses --release builds without [package.metadata.android.
signing.release] in the package Cargo.toml. Instead of committing
credentials, the workflow now: decodes the keystore secret to a temp
file, uses a Python heredoc to append the signing section referencing
the absolute keystore path and secret env-vars, then removes the
keystore after the build. This replaces the post-build apksigner step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:56:16 -07:00
funman300 681a54d9bb fix(android): gate Monitor/PrimaryMonitor/PrimaryWindow imports to non-Android
These three bevy::window types are only referenced by
apply_smart_default_window_size, which is already cfg(not(android)).
The unconditional import triggered -D unused-imports on the Android
cross-compile. Split into a separate cfg-gated use statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:15:36 -07:00
funman300 7894559ca7 fix(android): gate had_saved_geometry and apply_smart_default_window_size
Both symbols are desktop-only: the variable feeds apply_smart_default_
window_size which is only registered inside a cfg(not(android)) block.
Without the matching cfg gate on the declaration / definition, the
Android cross-compile emits unused-variable and dead-code errors
(-D warnings turns them into hard failures).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:04:54 -07:00
funman300 ab803c07af fix(android): remove unused JValue import and fix match arm types
Two cfg(android) issues hidden from Linux CI:
- android_clipboard.rs: JValue was imported but never used (JValueOwned
  covers all call sites). Removed to satisfy -D unused-imports.
- stats_plugin.rs: both arms of the clipboard match now return () via
  explicit block+semicolon, resolving the type mismatch that pinged-pong
  between runs due to bidirectional match-arm type inference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:53:36 -07:00
funman300 e43b329fc1 fix(android): remove trailing semicolon in android clipboard match arm
The Err arm in stats_plugin.rs had a trailing semicolon on
toast.write(...) making it return () while the Ok arm returned
MessageId<InfoToastEvent>. Only caught on Android because the block is
cfg(target_os = "android") gated; the Linux CI never compiled it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:42:32 -07:00
funman300 7c07f71f02 fix(android): declare bevy dep in solitaire_data for Android target
android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
process-wide JavaVM handle, but bevy was absent from the Android-target
dep block in solitaire_data/Cargo.toml. Cargo resolved the symbol in
the workspace dev build (where bevy is reachable transitively) but the
Android cross-compile with cargo-apk failed with E0433. Adding bevy
under [target.'cfg(target_os = "android")'.dependencies] fixes it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:31:17 -07:00
funman300 c1329bbb21 ci(release): add Linux x86_64 and Android APK release workflow
Tag-triggered (v*) workflow builds a Linux tarball (binary + assets) and
a multi-arch Android APK signed with a release keystore stored in GitHub
secrets. A final job creates the GitHub Release with both files attached
so Obtainium can track and auto-download the APK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:07:56 -07:00
funman300 4303ef3f5b feat(difficulty): add difficulty-tier game mode with seed catalogs and home UI
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to
solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel).
Five seed catalogs (40 seeds each) are pre-verified by the new
gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves).
DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed →
NewGameRequestEvent; Random uses a system-time seed and bypasses the
winnable-only filter. The home overlay gets an expandable Difficulty section
between Draw Mode and the mode grid; last-played tier persists in Settings.
Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:07:49 -07:00
funman300 4df962ee07 docs(handoff): close JNI clipboard + Keystore; 1298 tests; Phase Android items done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:06:02 -07:00
funman300 f281425b45 feat(android): Android Keystore AES-GCM token storage via JNI
Replaces the four KeychainUnavailable stubs in auth_tokens.rs with a
real Android Keystore implementation:

- Device-bound AES-256/GCM/NoPadding key under alias
  'solitaire_quest_token_key'; generated on first use, survives
  restarts, destroyed on uninstall.
- Tokens serialised as JSON, encrypted to
  {data_dir}/auth_tokens.bin as [12-byte IV][ciphertext+GCM-tag];
  writes are atomic (tmp → rename).
- Key invalidation (biometric/lock change) surfaces as
  TokenError::KeychainUnavailable, matching desktop fallback semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:05:20 -07:00
funman300 2c822ba2d7 feat(android): JNI clipboard bridge for Stats share-link button
Replaces the informational "Share link: {url}" toast on Android with a
real clipboard write via ClipboardManager JNI. Falls back to the old
toast on JNI error so the user can still copy the URL manually.

Adds `jni = "0.21"` (default-features = false) as a workspace dep;
`jni 0.21.1` was already in Cargo.lock as a transitive dep so no new
packages are fetched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:05:11 -07:00
funman300 7ddf2733c9 docs(handoff): drop GPGS from punch list and resume prompt
GPGS integration will not be implemented. Removed from Phase Android
open items and from the Phase 8 (sync) description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:34:27 -07:00
funman300 585570559c docs(handoff): record double-tap, Play-by-Seed, handle_fullscreen gate; 1292 tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:32:23 -07:00
funman300 45436d0eda fix(android): gate handle_fullscreen and its imports to non-Android
F11 fullscreen toggle only makes sense on desktop; Android windows are
always full-screen.  Gates the fn and the MonitorSelection/WindowMode
imports with #[cfg(not(target_os = "android"))] to keep clippy clean
on the Android target.  The add_systems call is extracted as a separate
statement so #[cfg] can annotate it (cannot appear mid-chain).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:19:18 -07:00
funman300 2062bd06f3 feat(data): expand challenge seed pool with 75 verified wins
Adds a gen_seeds binary to solitaire_assetgen that brute-searches seeds
for hands solvable in ≤250 moves, then writes the list.  The 75 new
seeds (0xCAFEBABE prefix) are appended to CHALLENGE_SEEDS in
solitaire_data::challenge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:19:11 -07:00
funman300 0cb15872b1 feat(engine): add Play-by-Seed dialog with solver preview
Adds a numeric-input modal (PlayBySeedPlugin) that lets the player type
a decimal seed and receive an instant solver-verified verdict before the
hand is dealt.  A new HomeMode::PlayBySeed card surfaces it in the home
overlay, matched by the StartPlayBySeedRequestEvent carrier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:19:02 -07:00
funman300 395a322adc feat(android): add double-tap auto-move for touch input
Mirrors handle_double_click for the touch pipeline. A double-tap on a
face-up card fires MoveRequestEvent to the best legal destination using
the same priority order (foundation first, tableau second; stack move
as priority 2 when the tapped card is a stack base).

Implementation:
- handle_double_tap reads TouchPhase::Ended events. When
  drag.active_touch_id is set and drag.committed is false, the touch
  ended without crossing the drag threshold = pure tap. The top card ID
  from drag.cards is used as the tracking key.
- DOUBLE_TAP_WINDOW = 0.5s (wider than DOUBLE_CLICK_WINDOW = 0.35s;
  touch screens have higher input latency; pinned by a const-assert test).
- System is inserted between touch_follow_drag and touch_end_drag in
  the .chain() so drag state is readable before touch_end_drag clears it.
- touch_end_drag's uncommitted-tap cleanup path still fires after
  handle_double_tap — the drag.clear() + StateChangedEvent are
  harmless in sequence with a MoveRequestEvent already queued.

1 new test (1283 total): double_tap_window_is_wider_than_double_click_window
(compile-time const assert).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:37:22 -07:00
funman300 5199a5e499 docs(handoff): record Android launch verification; update status
Closes the APK launch verification punch-list item. Three fixes in
202a64d boot the app on Pixel_7 AVD (Android 14, x86_64). Next open
arcs: Phase 8 (sync) or Android JNI follow-ups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:23:15 -07:00
funman300 16242e6d77 chore: ignore .idea/ IDE project files
Android Studio created .idea/ when the project was opened during the
Android APK verification run. These are IDE-local and should not be
tracked; adding .gitignore entry and removing the accidentally-committed
files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:22:07 -07:00
funman300 202a64db45 fix(android): export android_main and gate desktop-only window config
Three changes to get the APK past the NativeActivity launch crash:

1. Export `android_main` — NativeActivity dlopen-s libsolitaire_app.so
   and calls `android_main` as its entry point. Without the symbol the
   app crashed immediately with UnsatisfiedLinkError. The function sets
   bevy::android::ANDROID_APP (required by WinitPlugin) then delegates
   to the existing `run()`.

2. Gate `resize_constraints` to non-Android — on Android max_width and
   max_height default to 0.0; Bevy's clamp panicked with min=800 > max=0.

3. Gate `apply_smart_default_window_size` to non-Android — the system
   calls `.clamp(800.0, logical_w)` which panics when the window surface
   reports zero dimensions during early Android lifecycle events. Window
   sizing is OS-controlled on Android so the system is irrelevant there.

Verified: app boots on x86_64 Android 14 emulator (Pixel_7 AVD,
SwiftShader Vulkan), runs for 2+ minutes without crashing. Desktop
build: clippy clean, 1282 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:21:41 -07:00
funman300 c0415eb0ee docs(handoff): record Stats selector spawn; 1282 tests; next is A or C
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:43:01 -07:00
funman300 a449f60bc5 feat(stats): spawn Prev/Next replay selector in the Stats overlay
Wire the long-dormant ReplayPrevButton / ReplaySelectorCaption /
ReplayNextButton / ReplaySelectorDetail spawn site that was missing
since v0.19.0. The click handler and repaint systems already existed;
this commit adds the actual UI nodes so players can step through all
stored replays (up to REPLAY_HISTORY_CAP) instead of always watching
the most recent win.

Also fix an assertion-on-constant clippy lint in the replay_overlay
dim-layer z-order test (const { assert!() } form required).

1282 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:41:17 -07:00
funman300 ad5f613277 docs(handoff): cut v0.21.8 — replay arc fully closed; 1276 tests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:20:24 -07:00
funman300 c50eaf81f7 feat(replay): add HC bump for WIN MOVE scrub-bar marker; extend HighContrastBackground
HighContrastBackground gains an optional hc_color field so sites can
specify a domain-specific HC variant rather than always bumping to
BORDER_SUBTLE_HC (gray). with_default() fills hc_color = BORDER_SUBTLE_HC
preserving all existing behaviour; new with_hc(default, hc) lets callers
specify both ends. update_high_contrast_backgrounds reads marker.hc_color
instead of the hardcoded constant.

STATE_SUCCESS_HC (#c8e862, L≈0.73) added to ui_theme — a brighter lime
that maintains the success hue while standing out from bumped notch
ticks (BORDER_SUBTLE_HC gray, L≈0.60) under HC mode.

WIN MOVE marker now carries HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC): lime stays lime under HC instead of turning gray.
Unit test pins both the default and hc color fields on the spawned marker.

1276 tests pass / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:19:00 -07:00
funman300 b44d2777ec fix(replay): centre scrub-bar notch labels on their notch ticks
The three middle scrub-bar labels (25%, 50%, 75%) previously had their
left edge anchored at the notch percentage, making them read as
"starting after" the notch. Apply the CSS translateX(-50%) pattern for
Bevy 0.18 UI: give each middle label a fixed-width container
(SCRUB_LABEL_CENTER_WIDTH = 36px), offset the container's left edge by
-width/2 via margin.left, and add Justify::Center so the text renders
centred within the container. The container's centre then coincides with
the notch line at the chosen percentage.

Endpoints (0%, 100%) keep their flush-left / flush-right anchoring
unchanged. 1275 tests pass / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:14:14 -07:00
funman300 52407e7256 docs(handoff): cut v0.21.7 — B-2 replay arc closed; dim layer ships
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:03:32 -07:00
funman300 da3e5423dc feat(replay): add full-screen tableau dim layer for mini-tableau preview
Spawn a `ReplayTableauDimLayer` UI node (100% × 100%, 50% opacity black)
at z=54 (Z_REPLAY_OVERLAY − 1) whenever a replay starts. The dim layer
darkens the entire card world so the replay chrome (banner at z=55,
move-log panel at z=55) reads clearly against the scene without
obscuring card positions — matching the mockup's "Game Peek Band at
50% opacity" spec. Bevy's UI/world compositor means no changes to
card_plugin are needed: UI nodes always render above world-space sprites
regardless of Transform.z values.

The dim layer carries no Interaction component (purely visual; pointer
events pass through). Despawned alongside the banner and move-log panel
in `react_to_state_change` when the replay ends.

Adds Z_REPLAY_DIM (= 54) and TABLEAU_DIM_ALPHA (= 0.5) constants plus
two new tests: lifecycle (spawn/despawn mirrors floating chip pattern)
and z-ordering invariant (Z_REPLAY_DIM < Z_REPLAY_OVERLAY pinned).

1275 tests pass / 0 failing. Closes the last major B-2 sub-piece.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:01:22 -07:00
funman300 a1864271de docs(handoff): refresh post-v0.21.6 — anchor to new tag, reset menu state
Fold the six post-v0.21.5 commit narratives into CHANGELOG §
[0.21.6] (now the source of truth for that release's scope).
Reset the Since-cut log to "no threads in flight." Update
status (HEAD f63db76, tags through v0.21.6, tests 1273
passing). Resume prompt now anchors at v0.21.6.

The post-cut menu's main item is now the mini-tableau preview
— the only major B-2 sub-piece left after Move Log panel
shipped. Architectural change (touches card_plugin rendering),
best tackled in a fresh session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:48:51 -07:00
509 changed files with 19191 additions and 14122 deletions
+7
View File
@@ -0,0 +1,7 @@
# Claude Flow runtime files
data/
logs/
sessions/
neural/
*.log
*.tmp
+403
View File
@@ -0,0 +1,403 @@
# RuFlo V3 - Complete Capabilities Reference
> Generated: 2026-05-19T00:18:20.864Z
> Full documentation: https://github.com/ruvnet/claude-flow
## 📋 Table of Contents
1. [Overview](#overview)
2. [Swarm Orchestration](#swarm-orchestration)
3. [Available Agents (60+)](#available-agents)
4. [CLI Commands (26 Commands, 140+ Subcommands)](#cli-commands)
5. [Hooks System (27 Hooks + 12 Workers)](#hooks-system)
6. [Memory & Intelligence (RuVector)](#memory--intelligence)
7. [Hive-Mind Consensus](#hive-mind-consensus)
8. [Performance Targets](#performance-targets)
9. [Integration Ecosystem](#integration-ecosystem)
---
## Overview
RuFlo V3 is a domain-driven design architecture for multi-agent AI coordination with:
- **15-Agent Swarm Coordination** with hierarchical and mesh topologies
- **HNSW Vector Search** - 150x-12,500x faster pattern retrieval
- **SONA Neural Learning** - Self-optimizing with <0.05ms adaptation
- **Byzantine Fault Tolerance** - Queen-led consensus mechanisms
- **MCP Server Integration** - Model Context Protocol support
### Current Configuration
| Setting | Value |
|---------|-------|
| Topology | hierarchical-mesh |
| Max Agents | 15 |
| Memory Backend | hybrid |
| HNSW Indexing | Enabled |
| Neural Learning | Enabled |
| LearningBridge | Enabled (SONA + ReasoningBank) |
| Knowledge Graph | Enabled (PageRank + Communities) |
| Agent Scopes | Enabled (project/local/user) |
---
## Swarm Orchestration
### Topologies
| Topology | Description | Best For |
|----------|-------------|----------|
| `hierarchical` | Queen controls workers directly | Anti-drift, tight control |
| `mesh` | Fully connected peer network | Distributed tasks |
| `hierarchical-mesh` | V3 hybrid (recommended) | 10+ agents |
| `ring` | Circular communication | Sequential workflows |
| `star` | Central coordinator | Simple coordination |
| `adaptive` | Dynamic based on load | Variable workloads |
### Strategies
- `balanced` - Even distribution across agents
- `specialized` - Clear roles, no overlap (anti-drift)
- `adaptive` - Dynamic task routing
### Quick Commands
```bash
# Initialize swarm
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized
# Check status
npx @claude-flow/cli@latest swarm status
# Monitor activity
npx @claude-flow/cli@latest swarm monitor
```
---
## Available Agents
### Core Development (5)
`coder`, `reviewer`, `tester`, `planner`, `researcher`
### V3 Specialized (4)
`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer`
### Swarm Coordination (5)
`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`, `collective-intelligence-coordinator`, `swarm-memory-manager`
### Consensus & Distributed (7)
`byzantine-coordinator`, `raft-manager`, `gossip-coordinator`, `consensus-builder`, `crdt-synchronizer`, `quorum-manager`, `security-manager`
### Performance & Optimization (5)
`perf-analyzer`, `performance-benchmarker`, `task-orchestrator`, `memory-coordinator`, `smart-agent`
### GitHub & Repository (9)
`github-modes`, `pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`, `workflow-automation`, `project-board-sync`, `repo-architect`, `multi-repo-swarm`
### SPARC Methodology (6)
`sparc-coord`, `sparc-coder`, `specification`, `pseudocode`, `architecture`, `refinement`
### Specialized Development (8)
`backend-dev`, `mobile-dev`, `ml-developer`, `cicd-engineer`, `api-docs`, `system-architect`, `code-analyzer`, `base-template-generator`
### Testing & Validation (2)
`tdd-london-swarm`, `production-validator`
### Agent Routing by Task
| Task Type | Recommended Agents | Topology |
|-----------|-------------------|----------|
| Bug Fix | researcher, coder, tester | mesh |
| New Feature | coordinator, architect, coder, tester, reviewer | hierarchical |
| Refactoring | architect, coder, reviewer | mesh |
| Performance | researcher, perf-engineer, coder | hierarchical |
| Security | security-architect, auditor, reviewer | hierarchical |
| Docs | researcher, api-docs | mesh |
---
## CLI Commands
### Core Commands (12)
| Command | Subcommands | Description |
|---------|-------------|-------------|
| `init` | 4 | Project initialization |
| `agent` | 8 | Agent lifecycle management |
| `swarm` | 6 | Multi-agent coordination |
| `memory` | 11 | AgentDB with HNSW search |
| `mcp` | 9 | MCP server management |
| `task` | 6 | Task assignment |
| `session` | 7 | Session persistence |
| `config` | 7 | Configuration |
| `status` | 3 | System monitoring |
| `workflow` | 6 | Workflow templates |
| `hooks` | 17 | Self-learning hooks |
| `hive-mind` | 6 | Consensus coordination |
### Advanced Commands (14)
| Command | Subcommands | Description |
|---------|-------------|-------------|
| `daemon` | 5 | Background workers |
| `neural` | 5 | Pattern training |
| `security` | 6 | Security scanning |
| `performance` | 5 | Profiling & benchmarks |
| `providers` | 5 | AI provider config |
| `plugins` | 5 | Plugin management |
| `deployment` | 5 | Deploy management |
| `embeddings` | 4 | Vector embeddings |
| `claims` | 4 | Authorization |
| `migrate` | 5 | V2→V3 migration |
| `process` | 4 | Process management |
| `doctor` | 1 | Health diagnostics |
| `completions` | 4 | Shell completions |
### Example Commands
```bash
# Initialize
npx @claude-flow/cli@latest init --wizard
# Spawn agent
npx @claude-flow/cli@latest agent spawn -t coder --name my-coder
# Memory operations
npx @claude-flow/cli@latest memory store --key "pattern" --value "data" --namespace patterns
npx @claude-flow/cli@latest memory search --query "authentication"
# Diagnostics
npx @claude-flow/cli@latest doctor --fix
```
---
## Hooks System
### 27 Available Hooks
#### Core Hooks (6)
| Hook | Description |
|------|-------------|
| `pre-edit` | Context before file edits |
| `post-edit` | Record edit outcomes |
| `pre-command` | Risk assessment |
| `post-command` | Command metrics |
| `pre-task` | Task start + agent suggestions |
| `post-task` | Task completion learning |
#### Session Hooks (4)
| Hook | Description |
|------|-------------|
| `session-start` | Start/restore session |
| `session-end` | Persist state |
| `session-restore` | Restore previous |
| `notify` | Cross-agent notifications |
#### Intelligence Hooks (5)
| Hook | Description |
|------|-------------|
| `route` | Optimal agent routing |
| `explain` | Routing decisions |
| `pretrain` | Bootstrap intelligence |
| `build-agents` | Generate configs |
| `transfer` | Pattern transfer |
#### Coverage Hooks (3)
| Hook | Description |
|------|-------------|
| `coverage-route` | Coverage-based routing |
| `coverage-suggest` | Improvement suggestions |
| `coverage-gaps` | Gap analysis |
### 12 Background Workers
| Worker | Priority | Purpose |
|--------|----------|---------|
| `ultralearn` | normal | Deep knowledge |
| `optimize` | high | Performance |
| `consolidate` | low | Memory consolidation |
| `predict` | normal | Predictive preload |
| `audit` | critical | Security |
| `map` | normal | Codebase mapping |
| `preload` | low | Resource preload |
| `deepdive` | normal | Deep analysis |
| `document` | normal | Auto-docs |
| `refactor` | normal | Suggestions |
| `benchmark` | normal | Benchmarking |
| `testgaps` | normal | Coverage gaps |
---
## Memory & Intelligence
### RuVector Intelligence System
- **SONA**: Self-Optimizing Neural Architecture (<0.05ms)
- **MoE**: Mixture of Experts routing
- **HNSW**: 150x-12,500x faster search
- **EWC++**: Prevents catastrophic forgetting
- **Flash Attention**: 2.49x-7.47x speedup
- **Int8 Quantization**: 3.92x memory reduction
### 4-Step Intelligence Pipeline
1. **RETRIEVE** - HNSW pattern search
2. **JUDGE** - Success/failure verdicts
3. **DISTILL** - LoRA learning extraction
4. **CONSOLIDATE** - EWC++ preservation
### Self-Learning Memory (ADR-049)
| Component | Status | Description |
|-----------|--------|-------------|
| **LearningBridge** | ✅ Enabled | Connects insights to SONA/ReasoningBank neural pipeline |
| **MemoryGraph** | ✅ Enabled | PageRank knowledge graph + community detection |
| **AgentMemoryScope** | ✅ Enabled | 3-scope agent memory (project/local/user) |
**LearningBridge** - Insights trigger learning trajectories. Confidence evolves: +0.03 on access, -0.005/hour decay. Consolidation runs the JUDGE/DISTILL/CONSOLIDATE pipeline.
**MemoryGraph** - Builds a knowledge graph from entry references. PageRank identifies influential insights. Communities group related knowledge. Graph-aware ranking blends vector + structural scores.
**AgentMemoryScope** - Maps Claude Code 3-scope directories:
- `project`: `<gitRoot>/.claude/agent-memory/<agent>/`
- `local`: `<gitRoot>/.claude/agent-memory-local/<agent>/`
- `user`: `~/.claude/agent-memory/<agent>/`
High-confidence insights (>0.8) can transfer between agents.
### Memory Commands
```bash
# Store pattern
npx @claude-flow/cli@latest memory store --key "name" --value "data" --namespace patterns
# Semantic search
npx @claude-flow/cli@latest memory search --query "authentication"
# List entries
npx @claude-flow/cli@latest memory list --namespace patterns
# Initialize database
npx @claude-flow/cli@latest memory init --force
```
---
## Hive-Mind Consensus
### Queen Types
| Type | Role |
|------|------|
| Strategic Queen | Long-term planning |
| Tactical Queen | Execution coordination |
| Adaptive Queen | Dynamic optimization |
### Worker Types (8)
`researcher`, `coder`, `analyst`, `tester`, `architect`, `reviewer`, `optimizer`, `documenter`
### Consensus Mechanisms
| Mechanism | Fault Tolerance | Use Case |
|-----------|-----------------|----------|
| `byzantine` | f < n/3 faulty | Adversarial |
| `raft` | f < n/2 failed | Leader-based |
| `gossip` | Eventually consistent | Large scale |
| `crdt` | Conflict-free | Distributed |
| `quorum` | Configurable | Flexible |
### Hive-Mind Commands
```bash
# Initialize
npx @claude-flow/cli@latest hive-mind init --queen-type strategic
# Status
npx @claude-flow/cli@latest hive-mind status
# Spawn workers
npx @claude-flow/cli@latest hive-mind spawn --count 5 --type worker
# Consensus
npx @claude-flow/cli@latest hive-mind consensus --propose "task"
```
---
## Performance Targets
| Metric | Target | Status |
|--------|--------|--------|
| HNSW Search | 150x-12,500x faster | ✅ Implemented |
| Memory Reduction | 50-75% | ✅ Implemented (3.92x) |
| SONA Integration | Pattern learning | ✅ Implemented |
| Flash Attention | 2.49x-7.47x | 🔄 In Progress |
| MCP Response | <100ms | ✅ Achieved |
| CLI Startup | <500ms | ✅ Achieved |
| SONA Adaptation | <0.05ms | 🔄 In Progress |
| Graph Build (1k) | <200ms | ✅ 2.78ms (71.9x headroom) |
| PageRank (1k) | <100ms | ✅ 12.21ms (8.2x headroom) |
| Insight Recording | <5ms/each | ✅ 0.12ms (41x headroom) |
| Consolidation | <500ms | ✅ 0.26ms (1,955x headroom) |
| Knowledge Transfer | <100ms | ✅ 1.25ms (80x headroom) |
---
## Integration Ecosystem
### Integrated Packages
| Package | Version | Purpose |
|---------|---------|---------|
| agentic-flow | 3.0.0-alpha.1 | Core coordination + ReasoningBank + Router |
| agentdb | 3.0.0-alpha.10 | Vector database + 8 controllers |
| @ruvector/attention | 0.1.3 | Flash attention |
| @ruvector/sona | 0.1.5 | Neural learning |
### Optional Integrations
| Package | Command |
|---------|---------|
| ruv-swarm | `npx ruv-swarm mcp start` |
| flow-nexus | `npx flow-nexus@latest mcp start` |
| agentic-jujutsu | `npx agentic-jujutsu@latest` |
### MCP Server Setup
```bash
# Add Ruflo MCP
claude mcp add ruflo -- npx -y ruflo@latest
# Optional servers
claude mcp add ruv-swarm -- npx -y ruv-swarm mcp start
claude mcp add flow-nexus -- npx -y flow-nexus@latest mcp start
```
---
## Quick Reference
### Essential Commands
```bash
# Setup
npx ruflo@latest init --wizard
npx ruflo@latest daemon start
npx ruflo@latest doctor --fix
# Swarm
npx ruflo@latest swarm init --topology hierarchical --max-agents 8
npx ruflo@latest swarm status
# Agents
npx ruflo@latest agent spawn -t coder
npx ruflo@latest agent list
# Memory
npx ruflo@latest memory search --query "patterns"
# Hooks
npx ruflo@latest hooks pre-task --description "task"
npx ruflo@latest hooks worker dispatch --trigger optimize
```
### File Structure
```
.claude-flow/
├── config.yaml # Runtime configuration
├── CAPABILITIES.md # This file
├── data/ # Memory storage
├── logs/ # Operation logs
├── sessions/ # Session state
├── hooks/ # Custom hooks
├── agents/ # Agent configs
└── workflows/ # Workflow templates
```
---
**Full Documentation**: https://github.com/ruvnet/claude-flow
**Issues**: https://github.com/ruvnet/claude-flow/issues
+43
View File
@@ -0,0 +1,43 @@
# RuFlo V3 Runtime Configuration
# Generated: 2026-05-19T00:18:20.863Z
version: "3.0.0"
swarm:
topology: hierarchical-mesh
maxAgents: 15
autoScale: true
coordinationStrategy: consensus
memory:
backend: hybrid
enableHNSW: true
persistPath: .claude-flow/data
cacheSize: 100
# ADR-049: Self-Learning Memory
learningBridge:
enabled: true
sonaMode: balanced
confidenceDecayRate: 0.005
accessBoostAmount: 0.03
consolidationThreshold: 10
memoryGraph:
enabled: true
pageRankDamping: 0.85
maxNodes: 5000
similarityThreshold: 0.8
agentScopes:
enabled: true
defaultScope: project
neural:
enabled: true
modelPath: .claude-flow/neural
hooks:
enabled: true
autoExecute: true
mcp:
autoStart: false
port: 3000
+17
View File
@@ -0,0 +1,17 @@
{
"initialized": "2026-05-19T00:18:20.864Z",
"routing": {
"accuracy": 0,
"decisions": 0
},
"patterns": {
"shortTerm": 0,
"longTerm": 0,
"quality": 0
},
"sessions": {
"total": 0,
"current": null
},
"_note": "Intelligence grows as you use Ruflo"
}
+18
View File
@@ -0,0 +1,18 @@
{
"timestamp": "2026-05-19T00:18:20.864Z",
"processes": {
"agentic_flow": 0,
"mcp_server": 0,
"estimated_agents": 0
},
"swarm": {
"active": false,
"agent_count": 0,
"coordination_active": false
},
"integration": {
"agentic_flow_active": false,
"mcp_active": false
},
"_initialized": true
}
+26
View File
@@ -0,0 +1,26 @@
{
"version": "3.0.0",
"initialized": "2026-05-19T00:18:20.864Z",
"domains": {
"completed": 0,
"total": 5,
"status": "INITIALIZING"
},
"ddd": {
"progress": 0,
"modules": 0,
"totalFiles": 0,
"totalLines": 0
},
"swarm": {
"activeAgents": 0,
"maxAgents": 15,
"topology": "hierarchical-mesh"
},
"learning": {
"status": "READY",
"patternsLearned": 0,
"sessionsCompleted": 0
},
"_note": "Metrics will update as you use Ruflo. Run: npx ruflo@latest daemon start"
}
+8
View File
@@ -0,0 +1,8 @@
{
"initialized": "2026-05-19T00:18:20.864Z",
"status": "PENDING",
"cvesFixed": 0,
"totalCves": 3,
"lastScan": null,
"_note": "Run: npx @claude-flow/cli@latest security scan"
}
+120
View File
@@ -0,0 +1,120 @@
name: Android Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v0.36.2)'
required: true
default: 'v0.36.2'
env:
APK_OUT: target/release/apk/ferrous-solitaire.apk
GITEA_URL: https://git.aleshym.co
REPO: funman300/Ferrous-Solitaire
jobs:
build-apk:
runs-on: ubuntu-latest
container:
image: git.aleshym.co/funman300/android-builder:latest
credentials:
username: ${{ gitea.actor }}
password: ${{ secrets.CI_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
/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 sccache
uses: actions/cache@v4
with:
path: /root/.cache/sccache
key: sccache-android-aarch64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: sccache-android-aarch64-
- name: Get tag name
id: tag
run: |
if [ -n "${{ github.event.inputs.tag }}" ]; then
echo "name=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi
- name: Decode release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
- name: Build release APK
env:
PROFILE: release
ABIS: arm64-v8a
KEYSTORE: ./release.jks
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
KEY_ALIAS: release
KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
VERSION_NAME: ${{ steps.tag.outputs.name }}
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /root/.cache/sccache
run: bash scripts/build_android_apk.sh
- name: sccache stats
if: always()
run: sccache --show-stats
- name: Create or get Gitea release
id: release
run: |
TAG="${{ steps.tag.outputs.name }}"
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
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: |
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
RELEASE_ID="${{ steps.release.outputs.id }}"
# Remove any existing APK assets so re-runs don't accumulate duplicates.
curl -sf -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets" \
| python3 -c "import sys,json; [print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
| while read AID; do
curl -sf -X DELETE -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets/$AID"
done
curl -sf -X POST \
-H "$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
+76
View File
@@ -0,0 +1,76 @@
name: Build and Deploy
on:
push:
branches: [master]
paths:
- 'solitaire_server/**'
- 'solitaire_sync/**'
- 'solitaire_core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/docker-build.yml'
env:
REGISTRY: git.aleshym.co
IMAGE: git.aleshym.co/funman300/solitaire-server
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Need full history so we can push the tag-update commit back.
fetch-depth: 0
token: ${{ secrets.CI_TOKEN }}
- name: Set image tag
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.CI_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: solitaire_server/Dockerfile
push: true
tags: |
${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
${{ env.IMAGE }}:latest
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
- name: Install kustomize
run: |
curl -sL https://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 and push to deploy branch
run: |
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
# Switch to the deploy branch, creating it from the current HEAD if absent.
git fetch origin deploy 2>/dev/null && git checkout deploy || git checkout -b deploy
# Update the pinned image tag.
cd deploy
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
cd ..
git add deploy/kustomization.yaml
git diff --cached --quiet && exit 0
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]"
git push origin deploy
-88
View File
@@ -1,88 +0,0 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install Linux audio/display dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev \
libudev-dev \
libwayland-dev \
libxkbcommon-dev
- name: Cache cargo registry and build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Clippy (all crates, zero warnings)
run: cargo clippy --workspace -- -D warnings
- name: Test (headless crates only — no display required)
run: |
cargo test -p solitaire_core
cargo test -p solitaire_sync
cargo test -p solitaire_data
cargo test -p solitaire_server
build:
name: Release Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install Linux audio/display dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev \
libudev-dev \
libwayland-dev \
libxkbcommon-dev
- name: Cache cargo registry and build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-release-
- name: Build release binaries
run: cargo build --workspace --release
+18
View File
@@ -7,3 +7,21 @@
*.tmp
data/
.claude/
# ruflo runtime state
agentdb.rvf
agentdb.rvf.lock
# IDE project files
.idea/
# Android signing keystores — never commit
*.jks
*.jks.bak
*.jks.bak*
*.keystore
# Kubernetes secrets — apply manually, never commit
deploy/matomo-secret.yaml
deploy/*-secret.yaml
deploy/*-auth-secret.yaml
+22
View File
@@ -0,0 +1,22 @@
{
"mcpServers": {
"ruflo": {
"command": "npx",
"args": [
"-y",
"ruflo@latest",
"mcp",
"start"
],
"env": {
"npm_config_update_notifier": "false",
"CLAUDE_FLOW_MODE": "v3",
"CLAUDE_FLOW_HOOKS_ENABLED": "true",
"CLAUDE_FLOW_TOPOLOGY": "hierarchical-mesh",
"CLAUDE_FLOW_MAX_AGENTS": "15",
"CLAUDE_FLOW_MEMORY_BACKEND": "hybrid"
},
"autoStart": false
}
}
}
@@ -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 leaderboard\n SET best_score = ?,\n best_time_secs = ?,\n recorded_at = ?\n WHERE user_id = ?\n AND (\n best_score IS NULL\n OR ? > best_score\n OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "0e199cafab7e71b0c7f28ede85a622e38649d2fe5a73a5c715f2319f5450f729"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE users SET avatar_url = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC\n LIMIT 100",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112"
"hash": "2b814989a6632ca930ae1e895f97a7fc3389c91d1d2abf6900a21fb0d6e94ef3"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT jti FROM refresh_tokens WHERE jti = ?",
"describe": {
"columns": [
{
"name": "jti",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "893c45c27854ba15ea611e8254ef980ced21dc64e6ca95fde56af513f86ffa46"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "c9ee5c64ca547f0c730379919a642bd649cbf81fc1804159101a70efabf08b33"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE users SET password_hash = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
}
@@ -0,0 +1,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"
}
+99 -16
View File
@@ -1,9 +1,9 @@
# Solitaire Quest — Architecture Document
# Ferrous Solitaire — Architecture Document
> **Version:** 1.1
> **Version:** 1.3
> **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable)
> **Last Updated:** 2026-04-29
> **Last Updated:** 2026-05-12
---
@@ -34,7 +34,7 @@
## 1. Project Overview
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
Ferrous Solitaire is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
### Sync Backend by Platform
@@ -43,6 +43,7 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
| macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set |
| Linux | Self-hosted server | Full feature set |
| Android | Self-hosted server | Touch input; safe-area insets via JNI; `cargo-apk` build |
### Design Principles
@@ -57,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
@@ -86,6 +87,7 @@ solitaire_quest/
├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
└── solitaire_app/ # Main binary entry point
```
@@ -160,6 +162,20 @@ Owns:
- Daily challenge seed generation
- Leaderboard management
### `solitaire_wasm`
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
Owns:
- `ReplayPlayer` — WASM-exported state machine; steps through a replay's `Vec<ReplayMove>` against a live `GameState`
- `StateSnapshot` — JS-facing pile snapshot returned by each `step()` call
- `ReplayMove` / `Replay` mirrors — same serde shape as `solitaire_data` v2 equivalents
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
### `solitaire_app`
**Dependencies:** `bevy`, `solitaire_engine`.
@@ -261,6 +277,8 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
| `ThemePlugin` | — | Owns `ActiveTheme` resource; registers the `CardTheme` SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting `Image`; handles the embedded default theme and user themes from `user_theme_dir()` |
| `SyncSetupPlugin` | — | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when `SyncError::Auth` is returned by a pull |
| `LeaderboardPlugin` | L | Leaderboard overlay |
| `HelpPlugin` | H | Help / controls overlay |
| `PausePlugin` | Esc | Pause and resume |
@@ -305,6 +323,12 @@ struct FontResource(Handle<Font>);
struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 04 match selected_background setting
}
// OS-reserved edge insets (physical px); zero on desktop
struct SafeAreaInsets { top: f32, bottom: f32, left: f32, right: f32 }
// Whether the HUD band is visible (auto-hide chrome feature)
enum HudVisibility { Visible, Hidden }
```
### Key Bevy Events
@@ -342,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)
@@ -365,10 +389,22 @@ All sync backends implement a single trait in `solitaire_data`. The `SyncPlugin`
```rust
#[async_trait]
pub trait SyncProvider: Send + Sync {
// Required — must be implemented by every backend:
async fn pull(&self) -> Result<SyncPayload, SyncError>;
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
fn backend_name(&self) -> &'static str;
fn is_authenticated(&self) -> bool;
// Optional — all have default no-op / empty implementations:
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError>;
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError>;
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError>;
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError>;
async fn opt_out_leaderboard(&self) -> Result<(), SyncError>;
async fn delete_account(&self) -> Result<(), SyncError>;
// Returns the shareable web URL on success; defaults to Err(UnsupportedPlatform)
// so LocalOnlyProvider silently no-ops the push-on-win path.
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError>;
}
```
@@ -390,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}"
},
}
```
@@ -454,6 +490,24 @@ CREATE TABLE leaderboard (
recorded_at TEXT NOT NULL,
PRIMARY KEY (user_id)
);
-- migrations/002_replays.sql
CREATE TABLE IF NOT EXISTS replays (
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
seed INTEGER NOT NULL,
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
time_seconds INTEGER NOT NULL,
final_score INTEGER NOT NULL,
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
replay_json TEXT NOT NULL -- full Replay serialisation
);
CREATE INDEX IF NOT EXISTS replays_received_at_idx ON replays(received_at DESC);
CREATE INDEX IF NOT EXISTS replays_user_id_idx ON replays(user_id);
```
### Request Lifecycle
@@ -579,12 +633,25 @@ pub struct AchievementRecord {
pub struct Settings {
pub draw_mode: DrawMode,
pub sfx_volume: f32, // 0.01.0
pub sfx_volume: f32, // 0.01.0
pub music_volume: f32,
pub animation_speed: AnimSpeed,
pub theme: Theme,
pub sync_backend: SyncBackend, // Local | SolitaireServer
pub sync_backend: SyncBackend, // Local | SolitaireServer
pub selected_card_back: usize, // index into PlayerProgress::unlocked_card_backs
pub selected_background: usize, // index into PlayerProgress::unlocked_backgrounds
pub first_run_complete: bool,
pub color_blind_mode: bool, // blue tint on red suits
pub high_contrast_mode: bool, // boosted luminance for low-vision users
pub reduce_motion_mode: bool, // WCAG reduce-motion — snaps instead of slides
pub window_geometry: Option<WindowGeometry>, // persisted size + position; None on first run
}
pub struct WindowGeometry {
pub width: u32, // logical pixels
pub height: u32,
pub x: i32, // physical pixels, top-left corner
pub y: i32,
}
```
@@ -600,7 +667,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|---|---|---|---|---|
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token}` |
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token, refresh_token}` (rotated) |
### Sync
@@ -617,6 +684,21 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
### Replays
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | `/api/replays` | Bearer JWT | Replay JSON | `{id, share_url}` |
| GET | `/api/replays/recent` | None | — (`?limit=N`) | `Vec<ReplaySummary>` |
| GET | `/api/replays/:id` | None | — | Full Replay JSON |
### Web Replay Player
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
### Account Management
| Method | Path | Auth | Body | Response |
@@ -825,7 +907,7 @@ All sound effect WAV files are embedded at compile time via `include_bytes!()` i
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
| Android | Active | Self-hosted server | `cargo-apk`; touch + long-press + double-tap; safe-area JNI; portrait layout |
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
@@ -898,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
@@ -945,6 +1027,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
| Token expiry | Access: 24h, Refresh: 30d |
| Refresh token rotation | Each `/api/auth/refresh` call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
| Payload abuse | 1MB max request body, enforced by Axum middleware |
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
+389 -7
View File
@@ -1,13 +1,395 @@
# Changelog
All notable changes to Solitaire Quest are documented here. The format is
All notable changes to Ferrous Solitaire are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
No threads in flight. v0.21.6 cut on 2026-05-08; CHANGELOG accumulates
the next cycle here.
## [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`
now checks `scrims.is_empty()` — a `Query<(), With<ModalScrim>>` guard —
before calling `spawn_menu_popover`. Tapping ≡ while any modal (Stats,
Settings, Profile, Help) is open is now a no-op. Previously Stats + Profile
could be open simultaneously.
- **UX-7: Help text single-line overflow** (`help_plugin.rs`). The HUD menu
button description "Menu: Stats, Settings, Profile, Achievements" wrapped to
two lines on Android. Shortened to "Open menu (Stats, Settings, Profile...)"
which fits on one line. Verified on device.
- **UX-5b: Home mode glyph corruption** (`home_plugin.rs`). Mode selector icons
were using Geometric Shapes block (U+25xx) absent from the bundled FiraMono
font — rendered as missing-glyph rectangles on Android. Replaced with card
suits (U+26602666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge.
- **UX-1: Modal Done button in gesture zone** (`safe_area.rs`). New
`apply_safe_area_to_modal_scrims` Bevy system pads every `ModalScrim` bottom
by `SafeAreaInsets.bottom / scale_factor`. Modal cards are now centred over
the safe area, not the full physical screen. The Settings / Help / Stats Done
buttons are reachable on gesture-nav Android devices. Verified on device.
---
## [0.23.0] — 2026-05-12
Phase 8 sync UI: the self-hosted-server connection flow is now fully
playable end-to-end. Players can open a Connect modal from Settings,
enter a server URL + credentials, log in or register, and see the
sync-status section update live. Token expiry auto-reopens the modal.
Account deletion ships a two-click destroy flow. Server deployment
artifacts (Dockerfile + docker-compose) let self-hosters spin up in one
command.
### Added
- **Sync setup modal — Connect / Disconnect flow** (`432061c`).
New `SyncSetupPlugin` (`solitaire_engine/src/sync_setup_plugin.rs`)
provides the full server-connection UI. Three tab-stopped text fields
(URL, Username, Password) handle keyboard input via `MessageReader<KeyboardInput>`
with focus cycling on Tab. "Log In" and "Register" buttons each spawn an
async `AsyncComputeTaskPool` task that calls the new
`SolitaireServerClient::login()` / `::register()` methods; `poll_auth_task`
harvests the result, stores tokens via `store_tokens()`, hot-swaps
`SyncProviderResource` to the new server backend, fires
`ManualSyncRequestEvent` to pull immediately, and closes the modal.
An inline `SyncAuthError` label displays credential errors without a
toast. The modal is idempotent (`existing.is_empty()` guard) — safe
to open programmatically.
- **`SyncConfigureRequestEvent`, `SyncLogoutRequestEvent`,
`DeleteAccountRequestEvent`** (`432061c`). Three new engine events
wire the Settings buttons → plugin handlers. `SyncConfigureRequestEvent`
opens the setup modal; `SyncLogoutRequestEvent` disconnects and resets
`SyncProviderResource` to `LocalOnlyProvider`; `DeleteAccountRequestEvent`
opens the deletion confirmation modal.
- **Settings sync section — dynamic backend UI** (`432061c`).
`sync_row()` in `SettingsPlugin` now takes `backend: &SyncBackend` and
renders conditionally: `Local` → "Connect" button; `SolitaireServer`
username label + "Sync Now" + "Disconnect" + "Delete Account". Three new
`SettingsButton` discriminants (`ConnectSync` tab 91, `DisconnectSync`
tab 92, `DeleteAccount` tab 93) feed into a new `handle_sync_buttons`
system extracted from `handle_settings_buttons` to stay within Bevy's
16-parameter system limit.
- **`SolitaireServerClient::login()` and `::register()`** (`432061c`).
Both POST to `/api/auth/login` and `/api/auth/register` respectively.
Private helper `extract_auth_tokens` parses `{ access_token, refresh_token }`.
409 CONFLICT → "username already taken"; 401/403 → "invalid credentials";
400 → server message echoed to the player.
- **Re-auth prompt on token expiry** (`6ce5564`).
`poll_pull_result` in `SyncPlugin` now fires `InfoToastEvent("Session
expired — please reconnect")` + `SyncConfigureRequestEvent` when the
pull task resolves to `SyncError::Auth(_)`. Because the modal is
idempotent the re-open is safe to trigger from any system path.
- **Server deployment artifacts** (`6ce5564`).
`solitaire_server/Dockerfile`: multi-stage build (`rust:1.95-slim`
`debian:bookworm-slim`); copies `.sqlx` offline cache so `SQLX_OFFLINE=true`
succeeds without a live database at build time; exposes port 8080.
`solitaire_server/docker-compose.yml`: single-service compose file;
`db-data` volume at `/app/data`; `DATABASE_URL` and `JWT_SECRET` from
environment; HTTP health-check via `wget`. `solitaire_server/.env.example`:
documents all required variables with generation hint (`openssl rand -hex 32`).
- **Account deletion flow** (`272d31f`).
"Delete Account" in Settings fires `DeleteAccountRequestEvent`
`SyncSetupPlugin::open_delete_confirm_modal` spawns a danger-red
confirmation modal with "Cancel" and "Delete Forever" buttons.
"Delete Forever" submits an async `PendingDeleteTask` that calls
`SyncProvider::delete_account()`; `poll_delete_task` on Ok fires
`SyncLogoutRequestEvent` + a success toast; on Err shows an error toast
and leaves the modal open. Two-click destroy pattern — no accidental
account deletion possible.
### Removed
- **`SyncAuthResultEvent`** (`432061c`). Defined but never emitted or
consumed; removed as dead code.
### Stats
- Tests: **1300+ passing** / 0 failing
- Clippy: clean
- Crates touched: `solitaire_data` (sync_client), `solitaire_engine`
(events, settings_plugin, sync_plugin, sync_setup_plugin [new], lib),
`solitaire_app` (lib.rs), `solitaire_server` (Dockerfile,
docker-compose.yml, .env.example [new])
## [0.22.0] — 2026-05-08
Adds difficulty-tier game selection, Android JNI bridges for keystore and
clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens.
Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
### Added
- **Difficulty-tier game mode** (this release).
`DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster /
Random`) added to `solitaire_core::game_state` alongside a new
`GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed
catalogs (40 seeds each, 200 total) are generated by the new
`gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog
contains seeds proven winnable at progressively larger solver budgets
(1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent`
→ catalog seed → `NewGameRequestEvent`; the `Random` tier uses a
system-time seed and intentionally bypasses the winnable-only filter.
The home overlay gains an expandable `▶ Difficulty` section between the
Draw Mode row and the mode-card grid; the last-played tier is persisted
in `Settings::last_difficulty` and pre-expands/highlights on re-open.
Difficulty wins pool into Classic stats (no separate buckets).
- **Prev/Next replay selector in the Stats overlay** (`a449f60`).
`ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and
`ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen`
as a flex row of two bordered chips flanking a `"Replay N / M"`
caption, with a detail line below showing the selected replay's
duration + date and an optional `"· Shareable"` badge. Both chips
carry `ModalButton(Secondary)` so the existing `repaint_modal_buttons`
paint loop gives them hover/press feedback at zero extra cost.
`repaint_replay_selector_detail` is wired into the existing
`.chain()` alongside `handle_replay_selector_buttons` and
`repaint_replay_selector_caption`. The click handler and repaint
systems have been registered (and dormant) since v0.19.0; this
commit is purely the missing spawn site.
- **6 new selector unit tests** (`a449f60`). Covers: spawn-site
presence (Prev, Next, Caption, Detail all spawn with the screen),
caption initial text ("Replay 1 / 1"), detail initial text
("{dur} win on {date}"), Shareable badge when `share_url` is set,
empty-history "No replays" caption, and ordinal wrapping.
`make_test_replay(time_seconds, share_url)` helper encapsulates
`Replay::new(...)` + `chrono::NaiveDate`.
### Fixed
- **`const { assert!() }` for dim-layer z-order test** (`a449f60`).
Converted `assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)` in
`replay_overlay` tests to `const { assert!(…) }` to satisfy
`clippy::assertions_on_constants` (constant-fold at compile time
rather than a runtime no-op).
### Added (post-cut, same pending release)
- **Double-tap auto-move on touch screens** (`395a322`).
`handle_double_tap` fires `MoveRequestEvent` (single card to
foundation/tableau, or a whole face-up stack via
`best_tableau_destination_for_stack`) when two `TouchPhase::Ended`
events on the same card arrive within `DOUBLE_TAP_WINDOW` (0.5 s,
slightly wider than the mouse `DOUBLE_CLICK_WINDOW` to account for
touch latency). If no legal destination exists, fires
`MoveRejectedEvent` (audio + visual rejection feedback). The system
is inserted into the touch drag chain immediately before
`touch_end_drag` so `DragState.active_touch_id` and `committed` are
still readable; the tap timestamp is tracked in a `Local<HashMap<u32,
f32>>` keyed by card ID.
- **Play-by-Seed dialog** (`0cb1587`).
`PlayBySeedPlugin` adds a numeric-input modal that accepts a decimal
seed, runs a solver preview in the background (debounced 500 ms via
`AsyncComputeTaskPool`), and shows a win/no-win verdict before
dealing. A new `HomeMode::PlayBySeed` card in the home overlay fires
`StartPlayBySeedRequestEvent`; the handler in `PlayBySeedPlugin`
spawns the dialog. Digit, Backspace, Enter (confirm), and Escape
(cancel) are handled via `ButtonInput<KeyCode>`. Five unit tests
cover spawn, digit append, buffer read, confirm, and cancel paths.
- **75 new challenge seeds** (`2062bd0`).
New `gen_seeds` binary in `solitaire_assetgen` brute-searches seeds
in the `0xCAFEBABE…` namespace and filters for hands solvable in
≤250 moves via the core solver. The 75 confirmed-win seeds are
appended to `CHALLENGE_SEEDS` in `solitaire_data::challenge`.
### Fixed (post-cut, same pending release)
- **Gate `handle_fullscreen` to non-Android** (`45436d0`).
F11 fullscreen toggle makes no sense on Android (the OS owns window
sizing); the fn and its `MonitorSelection`/`WindowMode` imports are
now `#[cfg(not(target_os = "android"))]`-gated. The `add_systems`
call is extracted as a separate statement so `#[cfg]` can annotate it
(attributes cannot appear mid-chain in Rust).
- **Android APK launch: export `android_main`** (`202a64d`).
`NativeActivity` dlopen-s `libsolitaire_app.so` and calls
`android_main` as its entry point. Without the symbol the app
crashed immediately with `UnsatisfiedLinkError`. The new function
sets `bevy::android::ANDROID_APP` (required by `WinitPlugin`) then
delegates to `run()` — equivalent to what `#[bevy_main]` would
generate, but usable on an arbitrary entry point name.
- **Android APK launch: gate `resize_constraints` to non-Android**
(`202a64d`). On Android `max_width/max_height` default to `0.0`;
Bevy's clamp panicked with `min=800 > max=0`.
- **Android APK launch: gate `apply_smart_default_window_size` to
non-Android** (`202a64d`). The system calls `.clamp(800.0,
logical_w)` which panics when the emulator reports zero window
dimensions during early Android lifecycle events. The OS controls
window size on Android; the system is irrelevant there.
- **Ignore `.idea/` IDE project files** (`16242e6`). Android Studio
created `.idea/` when the project was opened during APK
verification; added to `.gitignore` and removed the accidentally-
committed files.
### Android verification result
APK boots on `x86_64-linux-android` in a Pixel_7 AVD (Android 14 /
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
Bevy renderer initialises, splash screen loads. This is the first
confirmed end-to-end device run.
### Stats
- Tests: **1300+ passing** / 0 failing
- Clippy: clean
- Crates touched: `solitaire_core` (game_state), `solitaire_data`
(settings, stats, difficulty_seeds, challenge), `solitaire_engine`
(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin,
input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs),
`solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries)
## [0.21.8] — 2026-05-08
Patch release for replay-overlay polish. Through-line:
**notch-label centering + WIN MOVE HC legibility + HC system extension**.
All three items were "optional polish" flagged in the v0.21.7 handoff;
all three ship in two commits.
### Added
- **`STATE_SUCCESS_HC` constant** (`c50eaf8`). Brighter lime
(`#c8e862`, L≈0.73) in `ui_theme` for use wherever the
standard `STATE_SUCCESS` (`#acc267`, L≈0.51) needs extra
luminance under HC mode. Sits above the bumped notch ticks
(`BORDER_SUBTLE_HC` gray, L≈0.60) so a WIN MOVE marker at
this colour is unambiguous.
- **`HighContrastBackground::with_hc(default, hc)` constructor**
(`c50eaf8`). Extends `HighContrastBackground` with an
`hc_color: Color` field (default = `BORDER_SUBTLE_HC` via
`with_default()`). `update_high_contrast_backgrounds` now
reads `marker.hc_color` instead of the hardcoded constant —
backwards-compatible; all existing `with_default()` usages
continue to bump to gray.
- **WIN MOVE scrub-bar marker HC bump** (`c50eaf8`). Marker
now carries `HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC)` so the lime stays lime under HC (brighter
lime rather than gray). Pin test locks both the default and
HC colour fields on the spawned entity.
### Fixed
- **Scrub-bar notch-label centering** (`b44d277`). Middle
three labels ("25%", "50%", "75%") previously had their
left edge at the notch; now their text centre coincides
with the notch tick. Implemented using the CSS
`translateX(-50%)` pattern for Bevy 0.18 UI: a fixed
`SCRUB_LABEL_CENTER_WIDTH = 36 px` container with
`margin.left = -18 px` is placed at `left: Percent(pct)`,
and `Justify::Center` centres the text within it. Endpoint
labels ("0%", "100%") keep their flush-left / flush-right
anchoring. `with_default()` remains one-argument.
### Stats
- Tests: 1276 passing / 0 failing (engine: 831)
- Clippy: clean
- Crates touched: `solitaire_engine` (replay_overlay.rs,
ui_theme.rs, settings_plugin.rs)
## [0.21.7] — 2026-05-08
Patch release closing the last major B-2 sub-piece. Through-line:
**mini-tableau preview dim layer**. The mockup's "Game Peek Band at
50 % opacity" is now implemented as a full-screen UI scrim that darkens
the card world during replay so the chrome (banner + move-log panel)
reads clearly against the scene.
### Added
- **Full-screen tableau dim layer** (`da3e542`). Spawns a
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
black) at `Z_REPLAY_DIM = Z_REPLAY_OVERLAY 1 = 54` whenever
a replay starts; despawned alongside the banner and move-log
panel when the replay ends. Bevy's UI/world compositor means
no changes to `card_plugin` are needed — UI nodes always
render above world-space sprites regardless of `Transform.z`.
The dim layer carries no `Interaction` component (purely
visual; pointer events pass through). Adds `Z_REPLAY_DIM`
and `TABLEAU_DIM_ALPHA` constants plus two new tests:
lifecycle (spawn/despawn mirrors the floating-chip pattern)
and z-ordering invariant (`Z_REPLAY_DIM < Z_REPLAY_OVERLAY`
pinned). 1275 tests pass / 0 failing.
### Stats
- Tests: 1275 passing / 0 failing
- Clippy: clean
- Crates touched: `solitaire_engine` (replay_overlay.rs)
## [0.21.6] — 2026-05-08
@@ -1127,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
@@ -1244,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`
@@ -1386,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
@@ -2082,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
+148 -26
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
version: unified-3.0
version: unified-4.0
---
@@ -29,8 +29,9 @@ solitaire_sync/ # Shared API + merge logic
solitaire_data/ # Persistence + sync client
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer)
solitaire_wasm/ # WASM bindings for browser-side replay player
solitaire_app/ # Entry binary
assets/ # Runtime assets (except audio)
assets/ # Runtime assets (except audio + default theme)
```
---
@@ -72,12 +73,16 @@ These override all other instructions.
* NO `unwrap()`
* NO `panic!()` in runtime/game logic
* All state transitions:
* Core game state mutations MUST return:
```rust id="err_model"
Result<T, MoveError>
```
* Engine / UI state changes follow ECS patterns (Resources, Events) —
they do not return `MoveError`
* Use `thiserror`-derived types for any new error enums outside `solitaire_core`
---
## 2.4 Threading Rules
@@ -126,10 +131,15 @@ trait SyncProvider
## 3.1 ECS Design
* systems = single responsibility
* communication = Events only
* shared state = Resources only
* cross-system communication = Events (fire-and-forget triggers)
* persistent shared state = Resources (polled every frame or on change)
* per-entity state = Components only
Events and Resources are both valid communication paths — use Events when
the receiver needs to react once; use Resources when the receiver polls
or when multiple systems read the same value (e.g. `SafeAreaInsets`,
`HudVisibility`, `LayoutResource`).
---
## 3.2 Game State Authority
@@ -149,11 +159,22 @@ Every player action MUST:
Keyboard shortcuts are:
→ optional accelerators only
**Exception — UI chrome gestures:**
Tap-to-toggle visibility of UI chrome (e.g. auto-hiding HUD band) is
permitted without a visible button. The gesture MUST:
* affect only chrome visibility, never game state
* restore chrome automatically when any modal opens
* be purely additive (game remains fully playable with chrome always visible)
---
## 3.4 Layout System
* recompute on `WindowResized`
* recompute on `SafeAreaInsets` changed
* recompute on `HudVisibility` changed
* `compute_layout` MUST accept `hud_visible: bool`; pass `HUD_BAND_HEIGHT`
when `true`, `0.0` when `false`
* no fixed resolution assumptions
---
@@ -178,11 +199,18 @@ Includes:
## 4.2 Embedded Assets
Only audio:
Embed via `include_bytes!()` only when ALL of the following are true:
```text id="audio_rule"
include_bytes!()
```
* the asset is small (< 500 KB uncompressed)
* it changes rarely (not user-customisable)
* a missing file would be a hard crash, not a graceful degradation
Currently embedded:
* **Audio** — all `.wav` files in `audio_plugin.rs`
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
Do NOT embed card face PNGs, background images, or user fonts —
these are loaded via `AssetServer` so art can be swapped without recompile.
---
@@ -210,7 +238,9 @@ Must degrade gracefully under `MinimalPlugins`.
## 5.2 Public API Rules
* prefer `Into<T>` over concrete types
* all public items require doc comments
* publicly exported functions, traits, and non-trivial types require doc comments
* simple marker components, newtype wrappers, and internal `pub` items
used only within the same crate are exempt from doc comment requirements
---
@@ -276,11 +306,13 @@ NEVER commit otherwise
Claude must request confirmation before:
* adding dependencies
* modifying `solitaire_sync`
* changing DB schema
* adding dependencies to `solitaire_core` or `solitaire_sync`
(engine/server crates may add deps without confirmation)
* modifying `solitaire_sync` types or the `SyncProvider` trait
* changing DB schema (migrations are append-only)
* introducing `unsafe`
* changing merge strategy
* changing the merge strategy in `solitaire_sync::merge`
* changing the `SyncPayload` wire format (breaking change for existing servers)
---
@@ -304,10 +336,29 @@ Core is always the source of truth.
Must always be handled explicitly:
**All platforms**
* Bevy `Time` uses `f32`
* `sqlx::migrate!()` path is crate-relative
* `dirs::data_dir()` may return `None`
* Linux may lack keyring backend
* Linux may lack keyring backend — handle `keyring::Error` gracefully
**Android (active target — not stretch)**
* Safe-area insets arrive in frames 13 via JNI polling, not at startup;
UI that depends on them must handle the zero-inset initial state
* Physical pixels ≠ logical pixels: `SafeAreaInsets` values are physical
(from `WindowInsets` API); divide by `window.scale_factor()` before
passing to Bevy `Val::Px`
* `adb shell input tap` uses physical pixel coordinates
* FiraMono (bundled font) covers: ASCII, card suits U+26602666,
Arrows U+219021FF. It does NOT cover Geometric Shapes (U+25xx) —
those render as missing-glyph rectangles on Android
* The gesture/navigation bar at the bottom (≈132px physical on common
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
avoid placing interactive elements in that zone
* `HUD_BAND_HEIGHT` is 112px on Android vs 64px on desktop;
layout constants are `#[cfg(target_os = "android")]` gated
* JNI calls must use `attach_current_thread_permanently` — not
`attach_current_thread` — to avoid detach-on-drop panics
---
@@ -318,6 +369,12 @@ Must always be handled explicitly:
* blocking async calls in ECS
* insecure credential storage
* bypassing core logic layer
* hardcoded pixel coordinates in layout — always derive from `compute_layout`
* Unicode Geometric Shapes block (U+25xx) in UI text — not in FiraMono
* spawning a second `ModalScrim` while one already exists without first
dismissing the existing one (use `scrims.is_empty()` guard)
* reading `SafeAreaInsets` physical values directly into `Val::Px` without
dividing by `window.scale_factor()`
---
@@ -345,9 +402,74 @@ If unclear:
| Both combined | full system understanding |
---
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
# 14. Modal System Conventions
## 14.1 Purpose
All full-screen overlay panels MUST use the `spawn_modal` / `ModalScrim` pattern
from `solitaire_engine::ui_modal`.
## 14.1 Spawn pattern
```rust
let scrim = spawn_modal(commands, MyScreenMarker, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Title", font_res);
// ... body nodes ...
spawn_modal_actions(card, |actions| {
spawn_modal_button(actions, MyCloseButton, "Done", None,
ButtonVariant::Primary, font_res);
});
});
// Optional: allow clicking the scrim outside the card to dismiss
commands.entity(scrim).insert(ScrimDismissible);
```
## 14.2 Guard rule
Before spawning a new modal, check `scrims: Query<(), With<ModalScrim>>`
and return early if `!scrims.is_empty()` — unless the new modal is
explicitly replacing the current one (despawn first, then spawn).
## 14.3 Safe area
Every `ModalScrim` automatically receives `padding.bottom` equal to the
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
## 14.4 Z-ordering
Use `Z_MODAL_PANEL` from `ui_theme` for all modal scrims. Do not use
raw `z_index` values — they drift and cause ordering bugs.
---
# 15. Android Build & Verification
## 15.1 Build command
```bash
cargo apk build --package solitaire_app --lib
adb install -r target/debug/apk/ferrous-solitaire.apk
```
## 15.2 Coordinate system reminder
Device physical: 1080×2400. Bevy logical: 900×2000. Scale factor: 1.20.
`adb shell input tap X Y` takes PHYSICAL coordinates.
To convert from what you see on screen (logical): multiply by 1.20.
## 15.3 Android-specific test checklist
Before shipping any Android build:
- [ ] Safe area insets arrive and shift HUD correctly (check after 3s)
- [ ] All modal Done buttons are above the gesture bar
- [ ] No Geometric Shapes glyphs in UI text
- [ ] HUD band does not overlap the top status bar
- [ ] Touch drag-and-drop works on all pile types
---
# 16. Context Injection System (AUTOMATIC SCOPE FILTER)
## 16.1 Purpose
Before generating any response, Claude MUST construct a **minimal relevant context set**.
@@ -360,7 +482,7 @@ This prevents:
---
## 14.2 Input Classification Step (MANDATORY)
## 16.2 Input Classification Step (MANDATORY)
Every request MUST be classified into exactly one task type:
@@ -381,13 +503,13 @@ If uncertain → ask clarification.
---
## 14.3 Context Selection Engine
## 16.3 Context Selection Engine
After classification, Claude MUST include ONLY the relevant sections below.
---
## 14.4 Context Map (CORE RULESET)
## 16.4 Context Map (CORE RULESET)
### feature
@@ -495,7 +617,7 @@ Include:
---
## 14.5 Context Compression Rules
## 16.5 Context Compression Rules
Claude MUST obey:
@@ -506,7 +628,7 @@ Claude MUST obey:
---
## 14.6 Context Priority Order
## 16.6 Context Priority Order
When space is limited:
@@ -517,7 +639,7 @@ When space is limited:
---
## 14.7 “No Context Pollution” Rule
## 16.7 “No Context Pollution” Rule
Claude must NOT include:
@@ -529,7 +651,7 @@ Claude must NOT include:
---
## 14.8 Self-Check Before Execution
## 16.8 Self-Check Before Execution
Before writing code, Claude MUST verify:
@@ -542,7 +664,7 @@ If any fail → revise context selection.
---
## 14.9 Injection Output Format (Internal Model)
## 16.9 Injection Output Format (Internal Model)
Claude should behave as if it constructed:
@@ -560,7 +682,7 @@ Claude should behave as if it constructed:
---
## 14.10 Relationship to ARCHITECTURE.md
## 16.10 Relationship to ARCHITECTURE.md
* ARCHITECTURE.md = source of truth
* CLAUDE.md = execution constraints
-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
- 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:CLAUDE_WORKFLOW.md
- 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
-292
View File
@@ -1,292 +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_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"
---
## 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
+3 -3
View File
@@ -1,6 +1,6 @@
# Credits
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
Ferrous Solitaire is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
the work of many open-source projects and a small handful of third-party
assets. This file lists every component that ships in the binary or in the
`assets/` directory.
@@ -43,7 +43,7 @@ copyleft code is statically linked into the game binary.
| File(s) | Source | License |
|---|---|---|
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) |
| `solitaire_engine/assets/themes/default/back.svg` | Original — Ferrous Solitaire | MIT (this project) |
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
| `assets/cards/backs/back_0.png` `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
@@ -107,6 +107,6 @@ Audio files are MIT-licensed alongside the rest of this project.
backs, every audio file) are original work covered by this project's MIT
license.
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
If you redistribute Ferrous Solitaire, you must ship this `CREDITS.md` and the
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
and OFL (FiraMono) notices remain visible to end users.
Generated
+13
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]]
@@ -6967,6 +6972,8 @@ version = "0.1.0"
dependencies = [
"ab_glyph",
"png 0.17.16",
"solitaire_core",
"solitaire_data",
]
[[package]]
@@ -6984,8 +6991,10 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"bevy",
"chrono",
"dirs",
"jni 0.21.1",
"jsonwebtoken",
"keyring-core",
"reqwest",
@@ -7009,10 +7018,14 @@ dependencies = [
"bevy",
"chrono",
"dirs",
"image",
"jni 0.21.1",
"kira",
"reqwest",
"resvg",
"ron",
"serde",
"serde_json",
"solitaire_core",
"solitaire_data",
"solitaire_sync",
+4
View File
@@ -31,6 +31,7 @@ keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
arboard = { version = "3", default-features = false }
jni = { version = "0.21", default-features = false }
solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
@@ -109,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
+18 -1
View File
@@ -1,4 +1,4 @@
# Solitaire Quest
# Ferrous Solitaire
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and
@@ -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**
+27 -1
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Self-Hosting Guide
# Ferrous Solitaire — Self-Hosting Guide
## Prerequisites
@@ -42,3 +42,29 @@ git pull
docker compose build
docker compose up -d
```
## Admin — Password Reset
If a player loses access to their account, the server binary includes a
built-in password reset command. Run it on the host (or inside the container)
with `DATABASE_URL` pointing at your database:
```bash
# Interactive (prompts for the new password):
DATABASE_URL=sqlite://./data/solitaire.db \
./solitaire_server --reset-password <username>
# Non-interactive (piped from a script or password manager):
echo "new_password" | \
DATABASE_URL=sqlite://./data/solitaire.db \
./solitaire_server --reset-password <username>
# Inside a running Docker container:
docker compose exec server sh -c \
'echo "new_password" | ./solitaire_server --reset-password alice'
```
On success the user's `password_hash` is updated and **all active refresh
tokens are deleted**, so every open session must log in again with the new
password. `JWT_SECRET` does not need to be set for this command.
+105 -369
View File
@@ -1,394 +1,130 @@
# Solitaire Quest — Session Handoff
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-08 — **v0.21.5 cut and tagged at
`a2432df`**, working tree clean, all post-tag work pushed to
origin.
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
v0.21.5 is a patch release with one through-line:
**replay-overlay scrubbing affordances + accessibility**.
v0.21.4 shipped pause / resume / step + the WIN MOVE marker as
the first scrubbing-shaped additions to the replay overlay;
v0.21.5 fills out the rest of the scrubbing UX so the player
has both visual anchor points (notches + labels) and a complete
keyboard control surface (Space / Esc / ← / →) for navigating a
paused replay.
---
Six commits on the B-2 replay screen-takeover redesign arc land
here. Two of them are layout-changing — banner height grew
60 → 76 → 92 px to make room for the notch labels and keybind
footer. Banner geometry was fixed for every prior B-2 commit;
this release establishes the "grow the container, add a
flex-column child" pattern that the remaining B-2 sub-pieces
(move-log scroller, mini-tableau preview) will inherit when
they land.
## Current state
Full v0.21.5 detail lives in `CHANGELOG.md` § [0.21.5]. This
file from here on focuses on what's *open* post-cut and how to
resume.
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
- **Latest tag:** `v0.35.1`
- **Working tree:** clean
- **Build:** `cargo clippy --workspace -- -D warnings` clean
- **Tests:** 1277 passing / 0 failing across the workspace
## Status at pause
---
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
`a2432df`; any post-cut docs edits ride on top of that.
- **HEAD on origin:** matches local. v0.21.5 is fully on origin.
- **Working tree:** clean. No WIP outstanding.
- **`artwork/` directory:** still untracked. Intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean.
- **Tests:** **1254 passing / 0 failing** across the workspace
(1250 in v0.21.5 + 2 from `d3cb1a5`'s HC-marker tests + 2
from `2e25476`'s continuous-scrub tests). The
time-dependent `daily_challenge` flake noted in v0.21.5's
CHANGELOG passes again (UTC clock has moved past the
trigger window). Detail in `CHANGELOG.md` § [0.21.5] § Stats
for the v0.21.5 baseline; post-cut delta tracked in this
file's Since-cut log.
- **Tags on origin:** `v0.9.0` through `v0.21.5`. v0.21.5 is on
`a2432df`; v0.21.4 stays on `23ff62c`; v0.21.3 stays on
`3d92a91`; v0.21.2 stays on `f23df3b`; v0.21.1 stays on
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
`41a009a`.
## What shipped since the last handoff (v0.23.0 → v0.35.1)
## Since the v0.21.5 cut
### v0.34.0 — Android polish + code-quality sweep (2026-05-16/17)
- **`d3cb1a5` — `feat(replay): HC-mode coverage for scrub
track + notches`.** Adds a parallel primitive to ui_theme
(`HighContrastBackground` marker carrying `default_color`)
and a paint system in settings_plugin
(`update_high_contrast_backgrounds`) that mirrors the
existing border-marker pattern but targets `BackgroundColor`
instead of `BorderColor`. Tags the 1 px scrub track Node and
all five quarter-mark notch ticks with the new marker so
they bump from `BORDER_SUBTLE` (#505050) → `BORDER_SUBTLE_HC`
(#a0a0a0) under HC mode. Scrub fill (ACCENT_PRIMARY) and
WIN MOVE marker (STATE_SUCCESS) don't get the marker —
accent and state colours are already saturated. 2 new tests;
1250 → 1252.
- **`2e25476` — `feat(replay): continuous scrub on key-held
arrow keys`.** Holding ← or → now triggers continuous step
at 100 ms cadence (10 steps/sec) — matches the mockup's
`[← →] scrub` terminology while keeping single-press =
single-step semantics. Per-key accumulators in a new
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
the accumulator and fire immediately. Release resets to 0
so the next fresh press fires immediately rather than at
half-interval. Footer text unchanged (`[← →] step`) —
held-key scrub is a discoverable enhancement to the same
keybind, not a new keybind. 2 new tests using
`TimeUpdateStrategy::ManualDuration`; 1252 → 1254.
| Commit | Summary |
|--------|---------|
| `9623bde` | Wire FiraMono to Android corner label; CardImageSet load tests |
| `980312c` | Fix wrong bottom-right suit symbol on JS/QS/KS card assets |
| `04e99a8` | Correct Android waste fan overlap and resume layout desync |
| `3bb3ddb` | Eliminate panics, fix dismiss hit-test scope, guard home respawn |
| `f8f1f26` | Adaptive drop zones, touch event correctness, modal lifecycle guards |
| `1eb4043` | Auth-guard avatar serving; atomic write; user_id assertion in merge |
| `69c6e88` | Deterministic pile serialization, undo skip, url-encode bytes, merge_at |
| `aa7b0f6` | Gate frame-hot ECS systems on resource changes (perf) |
| `6727126` | Consolidate APP_DIR_NAME; add `#[must_use]` on pure fns |
| `a4dfb0c` | Differentiate leaderboard opt-in vs opt-out error toasts (M-12) |
| `7fc98f8` | WASM: state() and step() return Result, errors throw JS exceptions (CR-6) |
| `ffed6b2` | Share Tokio runtime across all network tasks (M-16) |
| `fa84152` | Correct Android help hint label `→` to `!` (M-17) |
| `18d7937` | Derive Copy for DrawMode; drop redundant .clone() calls (M-18) |
| `132fea9` | Use saturating_add for move_count increments (M-19) |
| `0ecc1a9` | Add missing derives to AchievementContext (M-20) |
| `2301cc6` | Align android_keystore temp extension with cleanup glob (M-21) |
| `2e52f54` | Enforce 32-char display_name limit at sync client boundary (M-22) |
| `c8878d6` | Fix stale FOCUS_RING colour comment (M-23) |
| `4aafc0a` | Name HUD popover Z-layers; replace raw Z arithmetic (M-24) |
Open next-step menu (B-2 keyboard accelerator coverage +
accessibility + scrub UX are all complete):
1. **Move-log scroller / mini-tableau preview** — both need
a much larger banner-height grow (effectively the takeover
container itself). Multi-session arcs that close B-2.
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`.
2. **Polish: notch label centering.** Bevy 0.18 lacks a clean
`translate-x: -50%` primitive so middle three labels sit
slightly right-of-notch. Could use a child Text wrapper
with computed left-margin compensation. Tiny commit.
3. **Polish: WIN MOVE marker HC bump.** Currently the marker
uses `STATE_SUCCESS` lime which stays visible under HC,
but a slight saturation / contrast bump under HC would
make the marker even more legible alongside the bumped
notches. Optional.
### v0.35.0 — Accessibility + sync reliability (2026-05-18)
Recommended order: option 2 (notch label centering) is the
smallest concrete next-step. Option 1 is the multi-session
arc that closes B-2 — natural place to start a fresh session.
| Commit | Summary |
|--------|---------|
| `eb6c93f` | Silence B0004 by adding Transform to ModalScrim |
| `6f5cebd` | Fire WarningToastEvent on sync pull failure (was InfoToastEvent) |
| `87aec5b` | Gate all decorative motion animations under `reduce_motion_mode` |
`reduce_motion_mode` now gates: score pulse, score floater, streak flourish
(hud_plugin), card-shake on rejected move, foundation completion flourish
(feedback_anim_plugin). Pattern: gate at the trigger/start system, never at
the tick system — if the component isn't inserted, the tick path never runs.
### v0.35.1 — Leaderboard bug fixes (2026-05-18)
| Commit | Summary |
|--------|---------|
| `8f86d66` | Fix three leaderboard bugs: wrong toast type, stale label, name not synced |
Three bugs fixed:
1. **Wrong toast type on error**`poll_opt_in_task` / `poll_opt_out_task` error
branches now fire `WarningToastEvent` instead of `InfoToastEvent`.
2. **Display name not pushed to server on change**`Settings` gains
`leaderboard_opted_in: bool` (serde-defaulted `false`). Set `true`/`false` when
opt-in/out tasks succeed and persisted to `settings.json`. `handle_display_name_confirm`
now spawns an `opt_in_leaderboard` task when already opted in — the server's upsert
endpoint updates only `display_name` without re-opting-in.
3. **"Public name" label stale after name change** — `LeaderboardPublicNameText` marker
component added to the label node. `update_leaderboard_public_name_label` system
rewrites the text each frame the panel is open; O(0) cost when panel is closed.
5 new regression tests cover all three bugs.
---
## Open punch list
### Phase Android (build + persistence shipped; runtime gaps remain)
### 1. CHANGELOG documentation debt
- **APK launch verification on AVD / device.** `adb install` then
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
The build works and persistence is wired, but no end-to-end
device run has been logged. Shakes out runtime bugs the build +
unit tests can't catch.
- **JNI ClipboardManager bridge.** Replaces the Android stub for
the Stats "Copy share link" toast. `arboard` doesn't ship an
Android backend; small custom JNI call.
- **Android Keystore for credentials.** `keyring` is target-gated
to a stub returning `KeychainUnavailable`; replace with Android
Keystore via JNI when sync auth ships on mobile.
- **Google Play Games (gpgs) integration.** Listed as a
Phase-Android target since Phase 1; now unblocked by the build
target.
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
panic doesn't affect the APK on disk but produces noisy stderr.
Either upstream a cargo-apk fix or document `--lib` as
canonical in the runbook.
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
are missing. Low priority (git log is authoritative) but worth closing before the
next release.
### Visual-identity follow-ups (post-v0.21.0)
### 2. Android APK launch verification (Option A)
The visual-identity arc is effectively complete: token system,
chrome migration, splash boot screen, replay-overlay banner,
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
Physical device test: install the latest APK on a real Android device (not AVD),
confirm:
- App launches without crash
- Safe area insets arrive and shift HUD correctly after ~3 frames
- All modal Done buttons are above the gesture bar
- Drag-and-drop works on all pile types
- Leaderboard panel opens and the "Public name" label updates correctly after
using "Set Name"
- **Replay-overlay screen-takeover redesign.** The full mockup
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a
mini-tableau preview, playback controls, move-log scroll, and
a WIN MOVE marker on the scrub bar. Banner-local pieces all
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
`e080b49`); the floating MOVE chip above the focused card
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
(UI). Playback controls (pause / resume / step + Space
accelerator) shipped post-v0.21.3 in `fbe48ac`. v0.21.5
bundled six more commits under "replay-overlay scrubbing
affordances + accessibility" — scrub notches + percentage
labels + keybind-hint footer + ESC and ← / → accelerators
+ HC marker for the footer top border. Banner height grew
60 → 76 → 92 px across two layout-changing commits in
v0.21.5; banner geometry is now mutable. Full per-commit
detail in `CHANGELOG.md` § [0.21.5]. Keyboard accelerator
coverage is complete. What still needs to land: HC-mode
coverage for the scrub-track / notches / WIN MOVE marker
(they render via `BackgroundColor` so the
`HighContrastBorder` marker doesn't apply — needs a
settings-aware paint), continuous scrub on key-held ← / →
(vs single-step), then the bigger pieces — a move-log
scroller and a mini-tableau preview — both screen-
takeover-only pieces that need a much larger banner height
grow (effectively the takeover container itself).
Multi-session.
- *Floating `MOVE N/M` chip above the focused card during
playback — closed 2026-05-08 by `2fb2d63`.* World-space
`Text2d` entity sibling to the banner overlay; uses the same
`LayoutResource` pile coordinates so it survives window
resizes without UI/camera math.
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
Daily-challenge-expiry toast fires once per `daily.date` when
within 30 min of UTC midnight reset and today is incomplete.
`ToastVariant` is now fully load-bearing (every variant has at
least one real driver). Future Warning drivers can either reuse
the generic `WarningToastEvent(String)` carrier or add their
own domain message + `animation_plugin` handler.
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
`MoveRejectedEvent` now fires a 2-second pink-bordered
"Invalid move" toast as the third leg of the
audio + visual + text rejection-feedback stool.
- *High-contrast accessibility mode — closed 2026-05-08 by
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
dynamic-paint rollout (`c153363`).* Card text rendering plus
8 static-border chrome surfaces (modal scaffold, tooltip,
onboarding key chips, help panel key chips, stats panel
cells, home Level/XP/Score row, home mode buttons, home
mode-hotkey chips, 4 settings panel surfaces) all boost
borders to `BORDER_SUBTLE_HC` under HC via the
`HighContrastBorder` marker. The previously-carved-out
dynamic-paint sites are now also covered: HUD action buttons
and modal buttons take the same marker (their paint cycles
only mutate `BackgroundColor`, so no race); the radial menu
rim folds HC into its per-frame spawn via
`radial_rim_outline` so the focused rim boosts to
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
hierarchy that naive marker substitution would invert).
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
card animations; `pulse_splash_cursor` skips the per-frame
pulse multiplier; `spawn_splash` skips the scanline overlay
entirely. Future scope: gate any future card-lift z-bump
animation, warning-chip pulse (when one materialises).
This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real
touch events, so physical-device smoke testing is the only gate.
### Carried forward from v0.19.0
### 3. Matomo analytics wiring
- *App icon round — closed 2026-05-08 by `3eb3a26` + `716a025`.*
Runtime `Window::icon` wired (Linux/macOS/Windows); 9-size
PNG hierarchy at `assets/icon/icon_<size>.png` covers Linux
hicolor + downstream `.icns`/`.ico` packaging needs. The
`.ico` and `.icns` bundle-format files themselves are *not*
generated — both would need new crate deps (`ico` and
`icns` respectively) and only matter at app-bundle time
(cargo-bundle / packaging), not at `cargo run`. Open if the
project later ships as a packaged macOS / Windows app.
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
engine code consumes them — the analytics toggle in Settings is a no-op. If
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
and wired to `GameStateResource` events.
### Other small candidates
---
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
noted Prev/Next markers exist in `stats_plugin` but no spawn
site renders them today — the Shareable badge therefore lands
on the single-replay caption. If/when Prev/Next is plumbed,
the badge will need to follow.
- **Toast queue / immediate unification.** The two toast paths
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
for fire-and-forget) now share visual treatment but remain
separate functions because they serve different temporal
needs (sequential vs. parallel). If overlap becomes a UX
issue, merge into one queue with priority lanes.
## Architectural notes for next session
### Process notes
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
(the trigger), not the `tick_*` system. If the component is never inserted, the
tick path never runs. See `hud_plugin.rs::detect_score_change` and
`feedback_anim_plugin.rs::start_shake_anim` for the canonical pattern.
- **The desktop-adaptation spec is the canonical reference for
geometry decisions** when porting any future plugin. Read
`docs/ui-mockups/desktop-adaptation.md` first; apply the
universal rules to every surface; consult the per-screen
table for the priority surfaces. The 9 missing-plugin screens
(splash now ported; eight remaining) inherit the universal
rules without dedicated guidance.
- **Stitch `generate_variants` is unreliable for layout-only
adaptation prompts** as of 2026-05-07. The first call timed
out and no variant ever landed in `list_screens`. If a future
session wants visual desktop mockups, prefer
`generate_screen_from_text` with a fresh narrow prompt per
screen rather than `generate_variants` against existing
mobile screens.
- **Token-port pattern.** v0.20.0's chrome-migration commits
set a reusable shape for "centralised design system applied
across N plugins":
1. Constants module (`ui_theme.rs`) is the source of truth.
2. Const sites that can't call `Alpha::with_alpha` (not yet
`const` on stable) use a literal RGB matching the token,
with a unit test pinning the RGB to the token (e.g.
`MARKER_VALID`, `HINT_PILE_HIGHLIGHT_COLOUR`,
`RIGHT_CLICK_HIGHLIGHT_COLOUR`).
3. Cross-plugin duplication (e.g. `MARKER_DEFAULT` ↔
`PILE_MARKER_DEFAULT_COLOUR`) collapses to a single
promoted const re-exported from one plugin and imported
by the other — replaces "kept in sync" doc comments with a
compile-time invariant.
4. Domain colours (suit pips, card faces, lerp helpers) stay
as literals with a comment naming the rationale; only UI
chrome routes through tokens.
- **`SplashFadable` scaffolding pattern** (introduced in
`cacb19c`). Any future overlay that needs to fade `N >> 3`
elements together should follow the same shape: one tiny
marker carrying the full-alpha base colour, one global query
that lerps every marker's alpha each frame, no per-element
query plumbing. Cleanly outscales the `Without<X>, Without<Y>`
query exclusion pattern that the old splash was hitting at
three siblings.
- **Leaderboard server upsert:** `POST /api/leaderboard/opt-in` is idempotent —
calling it when already opted in just updates `display_name`. Safe to call from
`handle_display_name_confirm` without tracking a separate "needs update" flag.
### Canonical remote
- **`Messages<T>` API (Bevy 0.18.1):** write with
`resource_mut::<Messages<T>>().write(value)`; read in tests with
`msgs.get_cursor()` + `cursor.read(msgs).next()`.
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
Always push there. As of v0.21.0 origin matches local; the next
push happens when post-cut work accumulates and is ready to roll
into a v0.21.1 / v0.22.0 cut.
### Design direction (Terminal — base16-eighties)
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
monospaced-forward typography (JetBrains Mono / FiraMono), tight
16 px edge margins, 8 px card radius.
- **Palette:** near-black surface ramp (`#151515` / `#202020` /
`#2a2a2a` / `#353535`), brick-red primary CTA (`#a54242` —
swapped from cyan `#6fc2ef` in v0.21.0 commit `a292a7e`), lime
success (`#acc267`), gold warning (`#ddb26f`), pink error /
suit-red (`#fb9fb1`), lavender celebration (`#e1a3ee`), teal
info (`#12cfc0`).
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`.
Outlined glyphs for diamonds & clubs are *always on*; the
Settings "color-blind mode" toggle swaps red → lime `#acc267`
(was red → cyan pre-v0.21.0; lime is the next-best non-red
base16-eighties accent now that the primary itself is red).
- **Card glyphs render upright in both corners** — no 180°
inverted-corner-indicator rotation. Single-orientation
digital play doesn't benefit from the traditional flip-
readback convention. `design-system.md` § Game Cards
documents this deliberate deviation.
## Resume prompt
```
You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine>.
Branch: master. v0.21.5 is tagged at a2432df (cut 2026-05-08, a
patch release rolling up replay-overlay scrubbing affordances +
accessibility: scrub-bar notches with percentage labels, keybind-
hint footer, ESC + ← / → keyboard accelerators, and HC-mode
coverage for the footer top border). v0.21.4 stays at 23ff62c,
v0.21.3 at 3d92a91, v0.21.2 at f23df3b, v0.21.1 at daa655a,
v0.21.0 at 04f9bf9. Working tree clean. See CHANGELOG.md §
[0.21.5] for full detail.
State: HEAD locally — see `git rev-parse HEAD`. The cut commit
is a2432df; any post-cut docs edits ride on top of that.
Workspace tests: 1250 total / 1249 passing / 1 pre-existing
time-dependent flake (`daily_challenge` warning, fails when UTC
is within 30 min of midnight; verified not introduced by recent
work). Clippy clean.
READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.21.5] section is the most recent cut
3. CLAUDE.md — unified-3.0 rule set
4. CLAUDE_SPEC.md — formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow
6. docs/ui-mockups/ — design system + 24-mockup library +
desktop-adaptation.md (the rules-based
companion to the mockups; read this
before any plugin port)
7. docs/android/* — Android setup + build runbook
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
— saved feedback / project context
(machine-local; may be missing on a
fresh machine)
DECISION TO ASK THE PLAYER FIRST:
A. APK launch verification on AVD / device — `adb install` +
`adb logcat` to shake out runtime bugs the build / unit
tests can't catch. Likely surfaces JNI ClipboardManager
and Android Keystore stubs that need real bridges. Larger
scope; needs an Android device or emulator running.
B. Replay-overlay screen-takeover redesign — multi-session
work. v0.21.4 shipped WIN MOVE marker, pause / resume /
step + Space accelerator, plus the floating-MOVE-chip
piece from v0.21.2 (`2fb2d63`). v0.21.5 shipped scrub
notches + percentage labels + keybind-hint footer + ESC
and ← / → accelerators + HC marker for the footer top
border (six commits across CHANGELOG § [0.21.5]). Banner
height grew 60 → 76 → 92 px across two layout-changing
commits in v0.21.5; geometry is now mutable. Keyboard
accelerator coverage is complete. Natural next finite
steps:
1. **HC-mode coverage** for the scrub-track / notches /
WIN MOVE marker (render via `BackgroundColor` not
`BorderColor`, so `HighContrastBorder` doesn't apply
— needs a settings-aware paint, precedent
`radial_rim_outline`). Smallest next commit.
2. **Continuous scrub on key-held ← / →** instead of
single-step. Needs a key-held event source. Matches
the mockup's `[← →] scrub` terminology.
3. **Move-log scroller / mini-tableau preview** — both
need a much larger banner-height grow (effectively
the takeover container itself). Multi-session arcs
that close B-2.
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`.
C. Phase 8 (sync) — local storage scaffolding, self-hosted
Axum server, `SolitaireServerClient` impl, GPGS stub
wired into Settings. The biggest open arc by scope; rolls
up several Phase Android dependencies (Keystore,
ClipboardManager).
WORKFLOW NOTES:
- Use the system git config (already correct).
- When attributing playtester feedback in commits/docs, use
"Quat" not "Rhys" (saved feedback memory).
- Sub-agents stage + verify only; orchestrator commits.
- Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — gh auth setup-git wired on
primary dev box; verify on laptop before first push.
- Token-port pattern: when migrating tokens, walk every
concrete artifact downstream of the token (PNG textures,
embedded SVGs, hardcoded literals, comment color names),
not just the token name. v0.21.0 surfaced three "the
migration walked past this" follow-ups that all matched
this shape — codified here so future similar work can
pattern-match instead of rediscovering.
- Doc-vs-implementation drift pattern: v0.21.1's pile-marker
visibility fix (`4d48cad`) implemented an invariant that
had been declared in a module doc comment but was never
enforced in code. When future work touches a module with
a "this does X" doc comment, verify the code actually does
X and add a test if not. Two layers, two checks.
OPEN AT THE START: ask which of AC. Don't pick unilaterally.
Note: every remaining option is multi-session by nature (A is
gated on Android tooling, B and C are explicitly multi-session
arcs). A fresh session is a better fit for any of them than the
tail of a long working stretch.
```
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
with `input.release(key); input.clear()` between updates.
+28
View File
@@ -0,0 +1,28 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: solitaire-server
namespace: argocd
spec:
project: default
source:
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
targetRevision: deploy
path: deploy
destination:
server: https://kubernetes.default.svc
namespace: solitaire
# Secrets are applied manually and must not be pruned by ArgoCD.
ignoreDifferences:
- group: ""
kind: Secret
name: matomo-secret
namespace: solitaire
jsonPointers:
- /data
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
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

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