Compare commits

...

59 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
78 changed files with 3373 additions and 701 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"
}
+12 -1
View File
@@ -4,6 +4,12 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v0.36.2)'
required: true
default: 'v0.36.2'
env: env:
APK_OUT: target/release/apk/ferrous-solitaire.apk APK_OUT: target/release/apk/ferrous-solitaire.apk
@@ -42,7 +48,12 @@ jobs:
- name: Get tag name - name: Get tag name
id: tag id: tag
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" 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 - name: Decode release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
+9 -11
View File
@@ -60,19 +60,17 @@ jobs:
curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz | tar xz 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 sudo mv kustomize /usr/local/bin/kustomize
- name: Pin image tag in deploy manifests - name: Pin image tag and push to deploy branch
run: |
cd deploy
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
- name: Commit and push updated kustomization
run: | run: |
git config user.email "ci@gitea.local" git config user.email "ci@gitea.local"
git config user.name "Gitea CI" 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 add deploy/kustomization.yaml
git diff --cached --quiet && exit 0 # nothing to commit — skip push git diff --cached --quiet && exit 0
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]"
for i in 1 2 3; do git push origin deploy
git pull --rebase origin master && git push && break
sleep 5
done
+4
View File
@@ -8,6 +8,10 @@
data/ data/
.claude/ .claude/
# ruflo runtime state
agentdb.rvf
agentdb.rvf.lock
# IDE project files # IDE project files
.idea/ .idea/
+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
}
}
}
+1 -1
View File
@@ -355,7 +355,7 @@ Must always be handled explicitly:
* The gesture/navigation bar at the bottom (≈132px physical on common * The gesture/navigation bar at the bottom (≈132px physical on common
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
avoid placing interactive elements in that zone avoid placing interactive elements in that zone
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop; * `HUD_BAND_HEIGHT` is 112px on Android vs 64px on desktop;
layout constants are `#[cfg(target_os = "android")]` gated layout constants are `#[cfg(target_os = "android")]` gated
* JNI calls must use `attach_current_thread_permanently` — not * JNI calls must use `attach_current_thread_permanently` — not
`attach_current_thread` — to avoid detach-on-drop panics `attach_current_thread` — to avoid detach-on-drop panics
+130
View File
@@ -0,0 +1,130 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
---
## Current state
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
- **Latest tag:** `v0.35.1`
- **Working tree:** clean
- **Build:** `cargo clippy --workspace -- -D warnings` clean
- **Tests:** 1277 passing / 0 failing across the workspace
---
## What shipped since the last handoff (v0.23.0 → v0.35.1)
### v0.34.0 — Android polish + code-quality sweep (2026-05-16/17)
| Commit | Summary |
|--------|---------|
| `9623bde` | Wire FiraMono to Android corner label; CardImageSet load tests |
| `980312c` | Fix wrong bottom-right suit symbol on JS/QS/KS card assets |
| `04e99a8` | Correct Android waste fan overlap and resume layout desync |
| `3bb3ddb` | Eliminate panics, fix dismiss hit-test scope, guard home respawn |
| `f8f1f26` | Adaptive drop zones, touch event correctness, modal lifecycle guards |
| `1eb4043` | Auth-guard avatar serving; atomic write; user_id assertion in merge |
| `69c6e88` | Deterministic pile serialization, undo skip, url-encode bytes, merge_at |
| `aa7b0f6` | Gate frame-hot ECS systems on resource changes (perf) |
| `6727126` | Consolidate APP_DIR_NAME; add `#[must_use]` on pure fns |
| `a4dfb0c` | Differentiate leaderboard opt-in vs opt-out error toasts (M-12) |
| `7fc98f8` | WASM: state() and step() return Result, errors throw JS exceptions (CR-6) |
| `ffed6b2` | Share Tokio runtime across all network tasks (M-16) |
| `fa84152` | Correct Android help hint label `→` to `!` (M-17) |
| `18d7937` | Derive Copy for DrawMode; drop redundant .clone() calls (M-18) |
| `132fea9` | Use saturating_add for move_count increments (M-19) |
| `0ecc1a9` | Add missing derives to AchievementContext (M-20) |
| `2301cc6` | Align android_keystore temp extension with cleanup glob (M-21) |
| `2e52f54` | Enforce 32-char display_name limit at sync client boundary (M-22) |
| `c8878d6` | Fix stale FOCUS_RING colour comment (M-23) |
| `4aafc0a` | Name HUD popover Z-layers; replace raw Z arithmetic (M-24) |
### v0.35.0 — Accessibility + sync reliability (2026-05-18)
| Commit | Summary |
|--------|---------|
| `eb6c93f` | Silence B0004 by adding Transform to ModalScrim |
| `6f5cebd` | Fire WarningToastEvent on sync pull failure (was InfoToastEvent) |
| `87aec5b` | Gate all decorative motion animations under `reduce_motion_mode` |
`reduce_motion_mode` now gates: score pulse, score floater, streak flourish
(hud_plugin), card-shake on rejected move, foundation completion flourish
(feedback_anim_plugin). Pattern: gate at the trigger/start system, never at
the tick system — if the component isn't inserted, the tick path never runs.
### v0.35.1 — Leaderboard bug fixes (2026-05-18)
| Commit | Summary |
|--------|---------|
| `8f86d66` | Fix three leaderboard bugs: wrong toast type, stale label, name not synced |
Three bugs fixed:
1. **Wrong toast type on error**`poll_opt_in_task` / `poll_opt_out_task` error
branches now fire `WarningToastEvent` instead of `InfoToastEvent`.
2. **Display name not pushed to server on change**`Settings` gains
`leaderboard_opted_in: bool` (serde-defaulted `false`). Set `true`/`false` when
opt-in/out tasks succeed and persisted to `settings.json`. `handle_display_name_confirm`
now spawns an `opt_in_leaderboard` task when already opted in — the server's upsert
endpoint updates only `display_name` without re-opting-in.
3. **"Public name" label stale after name change** — `LeaderboardPublicNameText` marker
component added to the label node. `update_leaderboard_public_name_label` system
rewrites the text each frame the panel is open; O(0) cost when panel is closed.
5 new regression tests cover all three bugs.
---
## Open punch list
### 1. CHANGELOG documentation debt
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
are missing. Low priority (git log is authoritative) but worth closing before the
next release.
### 2. Android APK launch verification (Option A)
Physical device test: install the latest APK on a real Android device (not AVD),
confirm:
- App launches without crash
- Safe area insets arrive and shift HUD correctly after ~3 frames
- All modal Done buttons are above the gesture bar
- Drag-and-drop works on all pile types
- Leaderboard panel opens and the "Public name" label updates correctly after
using "Set Name"
This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real
touch events, so physical-device smoke testing is the only gate.
### 3. Matomo analytics wiring
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
engine code consumes them — the analytics toggle in Settings is a no-op. If
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
and wired to `GameStateResource` events.
---
## Architectural notes for next session
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
(the trigger), not the `tick_*` system. If the component is never inserted, the
tick path never runs. See `hud_plugin.rs::detect_score_change` and
`feedback_anim_plugin.rs::start_shake_anim` for the canonical pattern.
- **Leaderboard server upsert:** `POST /api/leaderboard/opt-in` is idempotent —
calling it when already opted in just updates `display_name`. Safe to call from
`handle_display_name_confirm` without tracking a separate "needs update" flag.
- **`Messages<T>` API (Bevy 0.18.1):** write with
`resource_mut::<Messages<T>>().write(value)`; read in tests with
`msgs.get_cursor()` + `cursor.read(msgs).next()`.
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
with `input.release(key); input.clear()` between updates.
+1 -1
View File
@@ -7,7 +7,7 @@ spec:
project: default project: default
source: source:
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
targetRevision: master targetRevision: deploy
path: deploy path: deploy
destination: destination:
server: https://kubernetes.default.svc server: https://kubernetes.default.svc
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images: images:
- name: solitaire-server - name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server newName: git.aleshym.co/funman300/solitaire-server
newTag: 858012d9 newTag: da601beb
@@ -96,7 +96,7 @@ fn main() {
continue; continue;
} }
let cfg = SolverConfig { move_budget, state_budget }; let cfg = SolverConfig { move_budget, state_budget };
match try_solve(seed, draw_mode.clone(), &cfg) { match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable => { SolverResult::Winnable => {
buckets[i].push(seed); buckets[i].push(seed);
eprintln!( eprintln!(
+1 -1
View File
@@ -73,7 +73,7 @@ fn main() {
while found.len() < count { while found.len() < count {
tried += 1; tried += 1;
if matches!( if matches!(
try_solve(seed, draw_mode.clone(), &cfg), try_solve(seed, draw_mode, &cfg),
SolverResult::Winnable SolverResult::Winnable
) { ) {
found.push(seed); found.push(seed);
+3 -1
View File
@@ -8,9 +8,11 @@
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an //! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
//! unlock event for any `AchievementDef` whose record is not yet unlocked. //! unlock event for any `AchievementDef` whose record is not yet unlocked.
use serde::{Deserialize, Serialize};
/// Fields needed by achievement conditions. Constructed by the engine from /// Fields needed by achievement conditions. Constructed by the engine from
/// `StatsSnapshot`, the final `GameState`, and wall-clock time. /// `StatsSnapshot`, the final `GameState`, and wall-clock time.
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AchievementContext { pub struct AchievementContext {
/// Total number of games played (after this win has been recorded). /// Total number of games played (after this win has been recorded).
pub games_played: u32, pub games_played: u32,
+88 -33
View File
@@ -10,6 +10,9 @@ pub enum Suit {
} }
impl Suit { impl Suit {
/// All four suits in declaration order.
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
/// Returns `true` for red suits (Diamonds, Hearts). /// Returns `true` for red suits (Diamonds, Hearts).
pub fn is_red(self) -> bool { pub fn is_red(self) -> bool {
matches!(self, Suit::Diamonds | Suit::Hearts) matches!(self, Suit::Diamonds | Suit::Hearts)
@@ -24,38 +27,63 @@ impl Suit {
/// Card rank, Ace through King. /// Card rank, Ace through King.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Rank { pub enum Rank {
Ace, Ace = 1,
Two, Two = 2,
Three, Three = 3,
Four, Four = 4,
Five, Five = 5,
Six, Six = 6,
Seven, Seven = 7,
Eight, Eight = 8,
Nine, Nine = 9,
Ten, Ten = 10,
Jack, Jack = 11,
Queen, Queen = 12,
King, King = 13,
} }
impl Rank { impl Rank {
/// All thirteen ranks in ascending order.
pub const RANKS: [Self; 13] = [
Self::Ace, Self::Two, Self::Three, Self::Four, Self::Five,
Self::Six, Self::Seven, Self::Eight, Self::Nine, Self::Ten,
Self::Jack, Self::Queen, Self::King,
];
/// Numeric value: Ace = 1, King = 13. /// Numeric value: Ace = 1, King = 13.
pub fn value(self) -> u8 { pub fn value(self) -> u8 {
match self { self as u8
Rank::Ace => 1, }
Rank::Two => 2,
Rank::Three => 3, const fn new(n: u8) -> Option<Self> {
Rank::Four => 4, match n {
Rank::Five => 5, 1 => Some(Self::Ace),
Rank::Six => 6, 2 => Some(Self::Two),
Rank::Seven => 7, 3 => Some(Self::Three),
Rank::Eight => 8, 4 => Some(Self::Four),
Rank::Nine => 9, 5 => Some(Self::Five),
Rank::Ten => 10, 6 => Some(Self::Six),
Rank::Jack => 11, 7 => Some(Self::Seven),
Rank::Queen => 12, 8 => Some(Self::Eight),
Rank::King => 13, 9 => Some(Self::Nine),
10 => Some(Self::Ten),
11 => Some(Self::Jack),
12 => Some(Self::Queen),
13 => Some(Self::King),
_ => None,
}
}
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
pub const fn checked_add(self, n: u8) -> Option<Self> {
Self::new((self as u8).saturating_add(n))
}
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
pub const fn checked_sub(self, n: u8) -> Option<Self> {
match (self as u8).checked_sub(n) {
Some(v) => Self::new(v),
None => None,
} }
} }
} }
@@ -79,16 +107,43 @@ mod tests {
#[test] #[test]
fn rank_values_are_sequential() { fn rank_values_are_sequential() {
let ranks = [ for (i, r) in Rank::RANKS.iter().enumerate() {
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
];
for (i, r) in ranks.iter().enumerate() {
assert_eq!(r.value(), (i + 1) as u8); assert_eq!(r.value(), (i + 1) as u8);
} }
} }
#[test]
fn rank_as_u8_matches_value() {
for r in Rank::RANKS {
assert_eq!(r as u8, r.value());
}
}
#[test]
fn rank_checked_add_boundary() {
assert_eq!(Rank::King.checked_add(1), None);
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
}
#[test]
fn rank_checked_sub_boundary() {
assert_eq!(Rank::Ace.checked_sub(1), None);
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
}
#[test]
fn suit_suits_contains_all_four() {
assert_eq!(Suit::SUITS.len(), 4);
assert!(Suit::SUITS.contains(&Suit::Clubs));
assert!(Suit::SUITS.contains(&Suit::Diamonds));
assert!(Suit::SUITS.contains(&Suit::Hearts));
assert!(Suit::SUITS.contains(&Suit::Spades));
}
#[test] #[test]
fn suit_red_and_black_are_complementary() { fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
+413 -42
View File
@@ -5,7 +5,7 @@ use crate::deck::{deal_klondike, Deck};
use crate::error::MoveError; use crate::error::MoveError;
use crate::pile::{Pile, PileType}; use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo}; use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_flip, score_move, score_recycle, score_undo as scoring_undo};
const MAX_UNDO_STACK: usize = 64; const MAX_UNDO_STACK: usize = 64;
@@ -31,7 +31,8 @@ mod pile_map_serde {
use crate::pile::{Pile, PileType}; use crate::pile::{Pile, PileType};
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> { pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
let entries: Vec<(&PileType, &Pile)> = map.iter().collect(); let mut entries: Vec<(&PileType, &Pile)> = map.iter().collect();
entries.sort_by_key(|(k, _)| *k);
entries.serialize(s) entries.serialize(s)
} }
@@ -42,7 +43,7 @@ mod pile_map_serde {
} }
/// Whether cards are drawn one at a time or three at a time from the stock. /// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode { pub enum DrawMode {
/// Draw one card from stock per turn. /// Draw one card from stock per turn.
DrawOne, DrawOne,
@@ -154,6 +155,7 @@ pub struct GameState {
/// [`GAME_STATE_SCHEMA_VERSION`]. /// [`GAME_STATE_SCHEMA_VERSION`].
#[serde(default = "schema_v1")] #[serde(default = "schema_v1")]
pub schema_version: u32, pub schema_version: u32,
#[serde(skip)]
undo_stack: VecDeque<StateSnapshot>, undo_stack: VecDeque<StateSnapshot>,
} }
@@ -224,10 +226,10 @@ impl GameState {
return Err(MoveError::GameAlreadyWon); return Err(MoveError::GameAlreadyWon);
} }
let stock_len = self.piles[&PileType::Stock].cards.len(); let stock_len = self.piles.get(&PileType::Stock).ok_or(MoveError::InvalidSource)?.cards.len();
if stock_len == 0 { if stock_len == 0 {
let waste_len = self.piles[&PileType::Waste].cards.len(); let waste_len = self.piles.get(&PileType::Waste).ok_or(MoveError::InvalidSource)?.cards.len();
if waste_len == 0 { if waste_len == 0 {
return Err(MoveError::StockEmpty); return Err(MoveError::StockEmpty);
} }
@@ -245,7 +247,14 @@ impl GameState {
stock.cards.push(card); stock.cards.push(card);
} }
self.recycle_count = self.recycle_count.saturating_add(1); self.recycle_count = self.recycle_count.saturating_add(1);
self.move_count += 1; if self.mode != GameMode::Zen {
let penalty = score_recycle(
self.recycle_count,
self.draw_mode == DrawMode::DrawThree,
);
self.score = (self.score + penalty).max(0);
}
self.move_count = self.move_count.saturating_add(1);
return Ok(()); return Ok(());
} }
@@ -271,7 +280,7 @@ impl GameState {
waste.cards.push(card); waste.cards.push(card);
} }
self.move_count += 1; self.move_count = self.move_count.saturating_add(1);
Ok(()) Ok(())
} }
@@ -306,6 +315,11 @@ impl GameState {
match &to { match &to {
PileType::Foundation(_) => { PileType::Foundation(_) => {
if matches!(&from, PileType::Foundation(_)) {
return Err(MoveError::RuleViolation(
"cannot move between foundation slots".into(),
));
}
if count != 1 { if count != 1 {
return Err(MoveError::RuleViolation( return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(), "only one card can move to foundation at a time".into(),
@@ -329,6 +343,11 @@ impl GameState {
)); ));
} }
} }
if matches!(&from, PileType::Waste) && count != 1 {
return Err(MoveError::RuleViolation(
"only the top waste card may be moved".into(),
));
}
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
if !can_place_on_tableau(&bottom_card, dest) { if !can_place_on_tableau(&bottom_card, dest) {
return Err(MoveError::RuleViolation("invalid tableau placement".into())); return Err(MoveError::RuleViolation("invalid tableau placement".into()));
@@ -365,7 +384,8 @@ impl GameState {
.cards .cards
.split_off(move_start); .split_off(move_start);
// Flip the newly exposed top card of the source pile // Flip the newly exposed top card of the source pile; award +5 per Windows scoring.
let mut flipped = false;
if let Some(top) = self.piles if let Some(top) = self.piles
.get_mut(&from) .get_mut(&from)
.ok_or(MoveError::InvalidSource)? .ok_or(MoveError::InvalidSource)?
@@ -374,12 +394,14 @@ impl GameState {
&& !top.face_up && !top.face_up
{ {
top.face_up = true; top.face_up = true;
flipped = true;
} }
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved); self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
self.score = (self.score + score_delta).max(0); let flip_bonus = if flipped && self.mode != GameMode::Zen { score_flip() } else { 0 };
self.move_count += 1; self.score = (self.score + score_delta + flip_bonus).max(0);
self.move_count = self.move_count.saturating_add(1);
self.is_won = self.check_win(); self.is_won = self.check_win();
if !self.is_won { if !self.is_won {
@@ -414,31 +436,132 @@ impl GameState {
Ok(()) Ok(())
} }
/// Returns `true` when all four foundation slots each contain 13 cards. /// Returns `true` when all four foundation slots each contain a valid A→K
/// sequence of a single suit.
///
/// Counting 13 cards is not sufficient — a corrupt save could produce 13
/// arbitrary cards per pile and permanently lock the game via `GameAlreadyWon`.
pub fn check_win(&self) -> bool { pub fn check_win(&self) -> bool {
(0..4_u8).all(|slot| { (0..4_u8).all(|slot| self.is_valid_foundation_pile(slot))
self.piles }
.get(&PileType::Foundation(slot))
.is_some_and(|p| p.cards.len() == 13) fn is_valid_foundation_pile(&self, slot: u8) -> bool {
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else {
return false;
};
if pile.cards.len() != 13 {
return false;
}
let suit = pile.cards[0].suit;
pile.cards.iter().enumerate().all(|(i, card)| {
card.suit == suit && card.rank.value() == (i as u8 + 1)
}) })
} }
/// Returns `true` when stock and waste are empty and all tableau cards are face-up. /// Returns `true` when stock and waste are empty and all tableau cards are face-up.
/// At that point the game can be completed without further player input. /// At that point the game can be completed without further player input.
pub fn check_auto_complete(&self) -> bool { pub fn check_auto_complete(&self) -> bool {
// Stock must be empty; waste may still have cards (they are resolved // All three conditions must hold: stock empty, waste empty, and all
// by draw() calls inside next_auto_complete_move / auto_complete_step). // tableau cards face-up. Requiring waste empty avoids the deadlock
if !self.piles[&PileType::Stock].cards.is_empty() { // where the waste top cannot reach a foundation directly.
if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) {
return false;
}
if self.piles.get(&PileType::Waste).is_none_or(|p| !p.cards.is_empty()) {
return false; return false;
} }
(0..7).all(|i| { (0..7).all(|i| {
self.piles[&PileType::Tableau(i)] self.piles
.cards .get(&PileType::Tableau(i))
.iter() .is_some_and(|p| p.cards.iter().all(|c| c.face_up))
.all(|c| c.face_up)
}) })
} }
/// Returns all currently valid `move_cards` calls as `(from, to, count)` triples.
///
/// Does not include stock draws — callers check `piles[&PileType::Stock]` directly.
/// Every returned triple is guaranteed to succeed when passed to `move_cards`.
pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> {
if self.is_won {
return Vec::new();
}
let mut moves = Vec::new();
// Waste top card → foundation or tableau
if let Some(waste_top) = self.piles.get(&PileType::Waste).and_then(|p| p.cards.last()) {
for slot in 0..4_u8 {
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(waste_top, f)
{
moves.push((PileType::Waste, PileType::Foundation(slot), 1));
}
}
for dst in 0..7_usize {
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(waste_top, t)
{
moves.push((PileType::Waste, PileType::Tableau(dst), 1));
}
}
}
// Tableau sources
for src in 0..7_usize {
let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { continue };
if src_pile.cards.is_empty() {
continue;
}
let run_len = src_pile.cards.iter().rev().take_while(|c| c.face_up).count();
if run_len == 0 {
continue;
}
for count in 1..=run_len {
let seq_start = src_pile.cards.len() - count;
if !is_valid_tableau_sequence(&src_pile.cards[seq_start..]) {
continue;
}
let bottom = &src_pile.cards[seq_start];
if count == 1 {
for slot in 0..4_u8 {
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(bottom, f)
{
moves.push((PileType::Tableau(src), PileType::Foundation(slot), 1));
}
}
}
for dst in 0..7_usize {
if dst == src {
continue;
}
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(bottom, t)
{
moves.push((PileType::Tableau(src), PileType::Tableau(dst), count));
}
}
}
}
// Foundation top → tableau (only when house rule is enabled)
if self.take_from_foundation {
for slot in 0..4_u8 {
let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { continue };
let Some(top) = f.cards.last() else { continue };
for dst in 0..7_usize {
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(top, t)
{
moves.push((PileType::Foundation(slot), PileType::Tableau(dst), 1));
}
}
}
}
moves
}
/// Returns the next `(from, to)` move that advances auto-complete, or /// Returns the next `(from, to)` move that advances auto-complete, or
/// `None` if no such move exists (or `is_auto_completable` is not set). /// `None` if no such move exists (or `is_auto_completable` is not set).
/// ///
@@ -449,11 +572,10 @@ impl GameState {
/// # Precondition /// # Precondition
/// ///
/// This function is only called when `is_auto_completable` is `true`. /// This function is only called when `is_auto_completable` is `true`.
/// Auto-completability requires the waste pile to be empty, as enforced by /// Auto-completability requires both stock and waste to be empty, as
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false` /// enforced by [`check_auto_complete`](Self::check_auto_complete). The
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile /// waste-pile check in this function is therefore a safety net only; under
/// in this scan is intentional and correct: by the time this function is /// normal operation the waste is guaranteed empty when this is reached.
/// reached, there are guaranteed to be no cards there to move.
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> { pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
if !self.is_auto_completable || self.is_won { if !self.is_auto_completable || self.is_won {
return None; return None;
@@ -461,7 +583,8 @@ impl GameState {
// Check waste top first — when stock is exhausted the waste may still // Check waste top first — when stock is exhausted the waste may still
// contain cards that can go directly to a foundation. // contain cards that can go directly to a foundation.
let waste = PileType::Waste; let waste = PileType::Waste;
if let Some((card, slot)) = self.piles[&waste].cards.last() if let Some((card, slot)) = self.piles.get(&waste)
.and_then(|p| p.cards.last())
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s))) .and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
{ {
let _ = card; // borrow ends here let _ = card; // borrow ends here
@@ -469,7 +592,8 @@ impl GameState {
} }
for i in 0..7 { for i in 0..7 {
let tableau = PileType::Tableau(i); let tableau = PileType::Tableau(i);
if let Some(slot) = self.piles[&tableau].cards.last() if let Some(slot) = self.piles.get(&tableau)
.and_then(|p| p.cards.last())
.and_then(|c| self.foundation_slot_for(c)) .and_then(|c| self.foundation_slot_for(c))
{ {
return Some((tableau, PileType::Foundation(slot))); return Some((tableau, PileType::Foundation(slot)));
@@ -487,7 +611,7 @@ impl GameState {
let mut candidate: Option<u8> = None; let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None; let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 { for slot in 0..4_u8 {
let pile = &self.piles[&PileType::Foundation(slot)]; let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { continue };
if pile.cards.is_empty() { if pile.cards.is_empty() {
if empty_slot.is_none() { if empty_slot.is_none() {
empty_slot = Some(slot); empty_slot = Some(slot);
@@ -501,7 +625,8 @@ impl GameState {
if card.rank.value() == 1 { empty_slot } else { None } if card.rank.value() == 1 { empty_slot } else { None }
}); });
target.filter(|&slot| { target.filter(|&slot| {
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)]) self.piles.get(&PileType::Foundation(slot))
.is_some_and(|p| can_place_on_foundation(card, p))
}) })
} }
@@ -1032,10 +1157,11 @@ mod tests {
} }
#[test] #[test]
fn auto_complete_true_when_stock_empty_waste_has_cards() { fn auto_complete_blocked_when_waste_has_cards() {
// Waste no longer blocks auto-complete — draw() drains it during // Waste must also be empty for auto-complete to engage. A non-empty
// auto-complete steps. Only stock-not-empty and face-down tableau // waste pile — even with all tableau cards face-up and stock empty —
// cards block the flag. // must return false to prevent a deadlock where the waste top cannot
// reach a foundation directly.
let mut g = new_game(); let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
@@ -1049,7 +1175,7 @@ mod tests {
c.face_up = true; c.face_up = true;
} }
} }
assert!(g.check_auto_complete()); assert!(!g.check_auto_complete());
} }
#[test] #[test]
@@ -1306,12 +1432,9 @@ mod tests {
} }
#[test] #[test]
fn take_from_foundation_allowed_by_default() { fn take_from_foundation_enabled_by_default() {
let mut g = setup_take_from_foundation_game(); let g = setup_take_from_foundation_game();
assert!(g.take_from_foundation, "standard Klondike allows take-from-foundation by default"); assert!(g.take_from_foundation, "take_from_foundation is on by default (standard Klondike rule)");
g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap();
assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1);
assert_eq!(g.piles[&PileType::Tableau(0)].cards.len(), 2);
} }
#[test] #[test]
@@ -1362,4 +1485,252 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!(err, MoveError::RuleViolation(_))); assert!(matches!(err, MoveError::RuleViolation(_)));
} }
// --- possible_instructions ---
#[test]
fn possible_instructions_empty_when_won() {
let mut g = new_game();
g.is_won = true;
assert!(g.possible_instructions().is_empty());
}
#[test]
fn possible_instructions_includes_ace_to_foundation() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
let moves = g.possible_instructions();
assert!(
moves.contains(&(PileType::Tableau(0), PileType::Foundation(0), 1)),
"Ace must be moveable to empty foundation slot 0; got {moves:?}"
);
}
#[test]
fn possible_instructions_all_valid_on_fresh_game() {
// Every triple returned must actually succeed when applied to a clone of the state.
let g = new_game();
for (from, to, count) in g.possible_instructions() {
let mut clone = g.clone();
assert!(
clone.move_cards(from.clone(), to.clone(), count).is_ok(),
"instruction ({from:?}, {to:?}, {count}) from possible_instructions must succeed"
);
}
}
#[test]
fn possible_instructions_no_face_down_sources() {
let g = new_game();
for (from, _, count) in g.possible_instructions() {
if let PileType::Tableau(i) = from {
let pile = &g.piles[&PileType::Tableau(i)];
let run_len = pile.cards.iter().rev().take_while(|c| c.face_up).count();
assert!(
count <= run_len,
"count {count} exceeds face-up run {run_len} for Tableau({i})"
);
}
}
}
// --- Flip bonus (+5) ---
#[test]
fn flip_bonus_awarded_when_face_down_card_exposed() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
// Tableau(0): hidden Ace under a face-up 5♠
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: false },
Card { id: 2, suit: Suit::Spades, rank: Rank::Five, face_up: true },
];
// Tableau(1): 6♥ — 5♠ can land here
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![
Card { id: 3, suit: Suit::Hearts, rank: Rank::Six, face_up: true },
];
let score_before = g.score;
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
assert_eq!(g.score, score_before + 5, "flip bonus must be +5 when a face-down card is exposed");
assert!(g.piles[&PileType::Tableau(0)].cards[0].face_up, "exposed card must now be face-up");
}
#[test]
fn flip_bonus_not_awarded_when_source_pile_empties() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
// Only a King in Tableau(0); moving it leaves pile empty — nothing to flip
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
];
let score_before = g.score;
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
assert_eq!(g.score, score_before, "no flip bonus when source pile becomes empty");
}
#[test]
fn flip_bonus_suppressed_in_zen_mode() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: false },
Card { id: 2, suit: Suit::Spades, rank: Rank::Five, face_up: true },
];
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![
Card { id: 3, suit: Suit::Hearts, rank: Rank::Six, face_up: true },
];
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
assert_eq!(g.score, 0, "zen mode must suppress flip bonus");
}
// --- Recycle penalty ---
#[test]
fn recycle_penalty_draw1_first_pass_free() {
let mut g = new_game(); // DrawOne
g.score = 200;
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap(); // first recycle — free
assert_eq!(g.recycle_count, 1);
assert_eq!(g.score, 200, "first recycle in Draw-1 must be free");
}
#[test]
fn recycle_penalty_draw1_second_pass_costs_100() {
let mut g = new_game(); // DrawOne
g.score = 200;
// First recycle (free)
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
// Second recycle (-100)
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
assert_eq!(g.recycle_count, 2);
assert_eq!(g.score, 100, "second recycle in Draw-1 must cost -100");
}
#[test]
fn recycle_penalty_draw3_three_passes_free() {
let mut g = GameState::new(42, DrawMode::DrawThree);
g.score = 200;
for _ in 0..3 {
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
}
assert_eq!(g.recycle_count, 3);
assert_eq!(g.score, 200, "first 3 recycles in Draw-3 must be free");
}
#[test]
fn recycle_penalty_draw3_fourth_pass_costs_20() {
let mut g = GameState::new(42, DrawMode::DrawThree);
g.score = 200;
for _ in 0..3 {
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
}
// Fourth recycle (-20)
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
assert_eq!(g.recycle_count, 4);
assert_eq!(g.score, 180, "fourth recycle in Draw-3 must cost -20");
}
#[test]
fn recycle_penalty_suppressed_in_zen_mode() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
// Two recycles — second would normally cost -100 in classic mode
for _ in 0..2 {
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
}
assert_eq!(g.recycle_count, 2);
assert_eq!(g.score, 0, "zen mode must suppress recycle penalty");
}
#[test]
fn possible_instructions_waste_top_included() {
let mut g = new_game();
// Clear board, put a King on waste, and an empty tableau pile — waste→tableau must appear.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: 99, suit: Suit::Spades, rank: Rank::King, face_up: true,
});
let moves = g.possible_instructions();
// King goes on any of the 7 empty tableau piles
assert!(
(0..7).any(|dst| moves.contains(&(PileType::Waste, PileType::Tableau(dst), 1))),
"King on waste must be moveable to an empty tableau column"
);
}
// --- P2: waste multi-card move must be rejected ---
#[test]
fn waste_multi_card_move_returns_rule_violation() {
let mut g = new_game();
g.piles.get_mut(&PileType::Waste).unwrap().cards = vec![
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true },
Card { id: 2, suit: Suit::Spades, rank: Rank::King, face_up: true },
];
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
let result = g.move_cards(PileType::Waste, PileType::Tableau(0), 2);
assert!(matches!(result, Err(MoveError::RuleViolation(_))),
"moving 2 cards from waste must be rejected");
}
// --- P3: foundation-to-foundation move must be rejected ---
#[test]
fn foundation_to_foundation_move_returns_rule_violation() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
// Place Ace of Clubs on Foundation(0), leave Foundation(1) empty.
g.piles.get_mut(&PileType::Foundation(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
];
// Attempting to move Ace from Foundation(0) to Foundation(1) must fail.
let result = g.move_cards(PileType::Foundation(0), PileType::Foundation(1), 1);
assert!(matches!(result, Err(MoveError::RuleViolation(_))),
"moving between foundation slots must be rejected");
}
// --- P4: undo must not retain points from the undone move ---
#[test]
fn undo_does_not_retain_score_from_undone_move() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
// Place an Ace on Tableau(0) — moving it to Foundation earns +10.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
];
assert_eq!(g.score, 0);
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
assert_eq!(g.score, 10, "moving Ace to foundation earns +10");
// Undo must roll back to snapshot.score (0) minus the penalty, not keep the +10.
g.undo().unwrap();
// snapshot.score was 0, so result is max(0, 0 - 15) = 0
assert_eq!(g.score, 0, "undo must not retain points from the undone move");
}
} }
+1 -1
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use crate::card::{Card, Suit}; use crate::card::{Card, Suit};
/// Identifies which pile on the board a set of cards belongs to. /// Identifies which pile on the board a set of cards belongs to.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum PileType { pub enum PileType {
/// The face-down draw pile. /// The face-down draw pile.
Stock, Stock,
+9 -6
View File
@@ -1,4 +1,4 @@
use crate::card::Card; use crate::card::{Card, Rank};
use crate::pile::Pile; use crate::pile::Pile;
/// Returns `true` if `card` can be placed on the foundation `pile`. /// Returns `true` if `card` can be placed on the foundation `pile`.
@@ -9,22 +9,24 @@ use crate::pile::Pile;
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)). /// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
/// - When the pile is non-empty, the next card must match the top card's /// - When the pile is non-empty, the next card must match the top card's
/// suit and be exactly one rank higher. /// suit and be exactly one rank higher.
#[must_use]
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool { pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() { match pile.cards.last() {
None => card.rank.value() == 1, None => card.rank == Rank::Ace,
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1, Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
} }
} }
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau. /// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
/// ///
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower. /// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
#[must_use]
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool { pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() { match pile.cards.last() {
None => card.rank.value() == 13, None => card.rank == Rank::King,
Some(top) => { Some(top) => {
top.face_up top.face_up
&& card.rank.value() + 1 == top.rank.value() && card.rank.checked_add(1) == Some(top.rank)
&& card.suit.is_red() != top.suit.is_red() && card.suit.is_red() != top.suit.is_red()
} }
} }
@@ -36,9 +38,10 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
/// only validates the sequence's *internal* structure, which the tableau /// only validates the sequence's *internal* structure, which the tableau
/// move path must enforce so a player can't smuggle an arbitrary stack /// move path must enforce so a player can't smuggle an arbitrary stack
/// onto another column when the bottom card happens to land legally. /// onto another column when the bottom card happens to land legally.
#[must_use]
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool { pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
cards.windows(2).all(|w| { cards.windows(2).all(|w| {
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red() w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
}) })
} }
+53 -8
View File
@@ -5,13 +5,19 @@ use crate::pile::PileType;
/// Windows XP Standard scoring: /// Windows XP Standard scoring:
/// - +10 for any card reaching a foundation pile /// - +10 for any card reaching a foundation pile
/// - +5 for a waste → tableau move /// - +5 for a waste → tableau move
/// - -15 for a foundation → tableau (take-from-foundation) move
/// - 0 for all other moves /// - 0 for all other moves
///
/// Note: the +5 flip bonus for exposing a face-down tableau card is applied
/// separately in `game_state::move_cards` because it depends on post-move state.
pub fn score_move(from: &PileType, to: &PileType) -> i32 { pub fn score_move(from: &PileType, to: &PileType) -> i32 {
match to { match to {
PileType::Foundation(_) => 10, PileType::Foundation(_) => 10,
PileType::Tableau(_) => { PileType::Tableau(_) => match from {
if matches!(from, PileType::Waste) { 5 } else { 0 } PileType::Waste => 5,
} PileType::Foundation(_) => -15,
_ => 0,
},
_ => 0, _ => 0,
} }
} }
@@ -21,6 +27,21 @@ pub fn score_undo() -> i32 {
-15 -15
} }
/// Score bonus awarded when a face-down tableau card is flipped face-up: +5.
pub fn score_flip() -> i32 {
5
}
/// Score penalty for recycling the waste pile back to stock.
///
/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3).
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
/// `recycle_count` is the new total count **after** this recycle.
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
let (free, penalty) = if is_draw_three { (3_u32, -20_i32) } else { (1_u32, -100_i32) };
if recycle_count > free { penalty } else { 0 }
}
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`. /// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero. /// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 { pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
@@ -71,13 +92,12 @@ mod tests {
} }
#[test] #[test]
fn non_waste_to_tableau_scores_zero() { fn foundation_to_tableau_penalises_fifteen() {
// Foundation → Tableau is impossible in practice but must score 0. // Moving a card back off a foundation (take_from_foundation rule) costs -15.
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0); assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), -15);
// Tableau → Tableau (restack) scores 0.
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
} }
#[test] #[test]
fn move_to_stock_or_waste_scores_zero() { fn move_to_stock_or_waste_scores_zero() {
// These destinations are illegal moves in practice, but the function // These destinations are illegal moves in practice, but the function
@@ -92,4 +112,29 @@ mod tests {
let bonus = compute_time_bonus(1); let bonus = compute_time_bonus(1);
assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast"); assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
} }
#[test]
fn flip_bonus_is_five() {
assert_eq!(score_flip(), 5);
}
#[test]
fn recycle_draw1_first_pass_free() {
assert_eq!(score_recycle(1, false), 0);
}
#[test]
fn recycle_draw1_second_pass_penalised() {
assert_eq!(score_recycle(2, false), -100);
}
#[test]
fn recycle_draw3_third_pass_free() {
assert_eq!(score_recycle(3, true), 0);
}
#[test]
fn recycle_draw3_fourth_pass_penalised() {
assert_eq!(score_recycle(4, true), -20);
}
} }
+10 -3
View File
@@ -298,9 +298,16 @@ impl SolverState {
} }
} }
/// True when every foundation slot has 13 cards. /// True when every foundation slot holds a complete Ace-through-King sequence.
fn is_won(&self) -> bool { fn is_won(&self) -> bool {
self.foundation.iter().all(|f| f.len() == 13) self.foundation.iter().all(|pile| {
pile.len() == 13
&& pile[0].rank == crate::card::Rank::Ace
&& pile.windows(2).all(|w| {
w[0].suit == w[1].suit
&& w[1].rank.value() == w[0].rank.value() + 1
})
})
} }
/// Returns the foundation slot that already claims `suit`, or the /// Returns the foundation slot that already claims `suit`, or the
@@ -665,7 +672,7 @@ impl SolverState {
foundation, foundation,
stock, stock,
waste, waste,
draw_mode: game.draw_mode.clone(), draw_mode: game.draw_mode,
just_drew: false, just_drew: false,
consecutive_draws: 0, consecutive_draws: 0,
} }
+1 -2
View File
@@ -10,12 +10,11 @@ use std::path::{Path, PathBuf};
pub use solitaire_sync::AchievementRecord; pub use solitaire_sync::AchievementRecord;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const FILE_NAME: &str = "achievements.json"; const FILE_NAME: &str = "achievements.json";
/// Platform-specific default path for `achievements.json`. /// Platform-specific default path for `achievements.json`.
pub fn achievements_file_path() -> Option<PathBuf> { pub fn achievements_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME)) crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
} }
/// Load achievements from an explicit path. Returns `Vec::new()` if the file /// Load achievements from an explicit path. Returns `Vec::new()` if the file
+137 -36
View File
@@ -2,7 +2,10 @@
/// ///
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a /// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
/// device-bound key from the Android Keystore, and written atomically to /// device-bound key from the Android Keystore, and written atomically to
/// `{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`. /// `{data_dir}/ferrous_solitaire/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`.
///
/// The file stores a `HashMap<String, TokenBlob>` (keyed by username) so that
/// multiple accounts can coexist without silently overwriting each other.
/// ///
/// The Keystore key survives app restarts but is destroyed on uninstall (or if /// The Keystore key survives app restarts but is destroyed on uninstall (or if
/// the user changes biometric/lock credentials, in which case decryption fails /// the user changes biometric/lock credentials, in which case decryption fails
@@ -15,6 +18,7 @@ use jni::{
JNIEnv, JavaVM, JNIEnv, JavaVM,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use crate::auth_tokens::TokenError; use crate::auth_tokens::TokenError;
@@ -280,51 +284,119 @@ fn decrypt_gcm(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn token_file_path() -> Option<PathBuf> { fn token_file_path() -> Option<PathBuf> {
crate::platform::data_dir()
.map(|d| d.join(crate::APP_DIR_NAME).join("auth_tokens.bin"))
}
/// Path where the token file lived before the APP_DIR_NAME subdirectory was
/// introduced. Used only during the one-time migration in `read_map`.
fn legacy_token_file_path() -> Option<PathBuf> {
crate::platform::data_dir().map(|d| d.join("auth_tokens.bin")) crate::platform::data_dir().map(|d| d.join("auth_tokens.bin"))
} }
fn read_file_bytes() -> Result<Vec<u8>, TokenError> { fn read_file_bytes_from(path: &PathBuf) -> Result<Vec<u8>, TokenError> {
let path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
if !path.exists() { if !path.exists() {
return Err(TokenError::NotFound(String::new())); return Err(TokenError::NotFound(String::new()));
} }
std::fs::read(&path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}"))) std::fs::read(path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}")))
} }
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> { fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
let path = token_file_path() let path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?; .ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
let tmp = path.with_extension("tmp"); if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| TokenError::Keyring(format!("create dir: {e}")))?;
}
let tmp = path.with_extension("bin.tmp");
std::fs::write(&tmp, data) std::fs::write(&tmp, data)
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?; .map_err(|e| TokenError::Keyring(format!("write auth_tokens.bin.tmp: {e}")))?;
std::fs::rename(&tmp, &path) std::fs::rename(&tmp, &path)
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}"))) .map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
} }
fn load_blob(username: &str) -> Result<TokenBlob, TokenError> { /// Decrypt raw bytes from the file and deserialise as `HashMap<String, TokenBlob>`.
let data = read_file_bytes().map_err(|e| match e { ///
TokenError::NotFound(_) => TokenError::NotFound(username.to_string()), /// Migration strategy:
/// 1. If the new-path file exists, read and decrypt it.
/// - Try to deserialise as `HashMap<String, TokenBlob>`.
/// - On parse failure (old single-blob format), try `TokenBlob` and convert.
/// 2. If the new-path file does NOT exist but the legacy-path file does, migrate:
/// - Read and decrypt the legacy file.
/// - Deserialise as `TokenBlob` (the only format the legacy path ever used).
/// - Write the result to the new path as a single-entry map.
/// - Delete the legacy file (best-effort; leave it if removal fails).
/// 3. If neither file exists, return an empty map.
fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
let new_path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
let legacy_path = legacy_token_file_path();
// --- 1. New path exists ---
if new_path.exists() {
let data = read_file_bytes_from(&new_path).map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
other => other, other => other,
})?; })?;
if data.len() < 12 { if data.len() < 12 {
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into())); return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
} }
let plaintext = with_jvm(|env| { let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?; let key = load_or_create_key(env)?;
decrypt_gcm(env, &key, &data) decrypt_gcm(env, &key, &data)
})?; })?;
// Try the current multi-user format first.
let blob: TokenBlob = serde_json::from_slice(&plaintext) if let Ok(map) = serde_json::from_slice::<HashMap<String, TokenBlob>>(&plaintext) {
.map_err(|e| TokenError::Keyring(format!("JSON decode: {e}")))?; return Ok(map);
}
if blob.username != username { // Fall back: old single-blob format written by an earlier binary.
return Err(TokenError::NotFound(username.to_string())); if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
let mut map = HashMap::new();
map.insert(blob.username.clone(), blob);
return Ok(map);
}
return Err(TokenError::Keyring("auth_tokens.bin unrecognised format".into()));
} }
Ok(blob) // --- 2. Legacy path migration ---
if let Some(ref lpath) = legacy_path {
if lpath.exists() {
let data = read_file_bytes_from(lpath).map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
other => other,
})?;
if data.len() >= 12 {
let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?;
decrypt_gcm(env, &key, &data)
})?;
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
let mut map = HashMap::new();
map.insert(blob.username.clone(), blob);
// Write to the new location, then remove the legacy file.
if write_map_inner(&map).is_ok() {
let _ = std::fs::remove_file(lpath);
}
return Ok(map);
}
}
// Legacy file corrupt or unrecognised — treat as empty.
}
}
// --- 3. No file found ---
Ok(HashMap::new())
}
/// Serialise and encrypt a map, then write it atomically.
fn write_map_inner(map: &HashMap<String, TokenBlob>) -> Result<(), TokenError> {
let plaintext = serde_json::to_vec(map)
.map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
let encrypted = with_jvm(|env| {
let key = load_or_create_key(env)?;
encrypt_gcm(env, &key, &plaintext)
})?;
write_file_bytes(&encrypted)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -333,46 +405,71 @@ fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
/// Encrypt and store `access_token` and `refresh_token` for `username`. /// Encrypt and store `access_token` and `refresh_token` for `username`.
/// ///
/// Overwrites any previously stored tokens. /// If tokens already exist for other usernames they are preserved.
/// Any previously stored tokens for `username` are silently replaced.
pub fn store_tokens( pub fn store_tokens(
username: &str, username: &str,
access_token: &str, access_token: &str,
refresh_token: &str, refresh_token: &str,
) -> Result<(), TokenError> { ) -> Result<(), TokenError> {
let blob = TokenBlob { let mut map = match read_map() {
Ok(m) => m,
// If the file is missing or corrupt, start with an empty map so we
// do not block a fresh login.
Err(TokenError::NotFound(_)) => HashMap::new(),
Err(e) => return Err(e),
};
map.insert(
username.to_string(),
TokenBlob {
username: username.to_string(), username: username.to_string(),
access_token: access_token.to_string(), access_token: access_token.to_string(),
refresh_token: refresh_token.to_string(), refresh_token: refresh_token.to_string(),
}; },
let plaintext = serde_json::to_vec(&blob) );
.map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
let encrypted = with_jvm(|env| { write_map_inner(&map)
let key = load_or_create_key(env)?;
encrypt_gcm(env, &key, &plaintext)
})?;
write_file_bytes(&encrypted)
} }
/// Return the stored access token for `username`. /// Return the stored access token for `username`.
/// ///
/// Returns [`TokenError::NotFound`] if no token has been stored yet. /// Returns [`TokenError::NotFound`] if no token has been stored for this username.
pub fn load_access_token(username: &str) -> Result<String, TokenError> { pub fn load_access_token(username: &str) -> Result<String, TokenError> {
load_blob(username).map(|b| b.access_token) let mut map = read_map()?;
map.remove(username)
.map(|b| b.access_token)
.ok_or_else(|| TokenError::NotFound(username.to_string()))
} }
/// Return the stored refresh token for `username`. /// Return the stored refresh token for `username`.
/// ///
/// Returns [`TokenError::NotFound`] if no token has been stored yet. /// Returns [`TokenError::NotFound`] if no token has been stored for this username.
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> { pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
load_blob(username).map(|b| b.refresh_token) let mut map = read_map()?;
map.remove(username)
.map(|b| b.refresh_token)
.ok_or_else(|| TokenError::NotFound(username.to_string()))
} }
/// Delete stored tokens and remove the Keystore key for `username`. /// Delete stored tokens for `username`.
///
/// If other usernames have stored tokens they are left untouched.
/// When this is the last entry in the map the Keystore key is also removed so
/// a future re-login generates a fresh key.
/// ///
/// Missing file or missing Keystore entry are silently ignored. /// Missing file or missing Keystore entry are silently ignored.
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> { pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
let mut map = match read_map() {
Ok(m) => m,
Err(TokenError::NotFound(_)) => return Ok(()), // nothing to delete
Err(e) => return Err(e),
};
map.remove(username);
if map.is_empty() {
// No more users — remove the file and the Keystore key.
if let Some(path) = token_file_path() { if let Some(path) = token_file_path() {
if path.exists() { if path.exists() {
std::fs::remove_file(&path) std::fs::remove_file(&path)
@@ -406,4 +503,8 @@ pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])? env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
.v() .v()
}) })
} else {
// Other users still exist — just rewrite the map without this user.
write_map_inner(&map)
}
} }
+3
View File
@@ -168,3 +168,6 @@ pub use matomo_client::MatomoClient;
pub mod platform; pub mod platform;
pub use platform::data_dir; pub use platform::data_dir;
/// Application data subdirectory name, shared by all persistence modules.
pub(crate) const APP_DIR_NAME: &str = "ferrous_solitaire";
+5 -5
View File
@@ -111,12 +111,12 @@ impl MatomoClient {
} }
fn url_encode(s: &str) -> String { fn url_encode(s: &str) -> String {
s.chars() s.bytes()
.flat_map(|c| match c { .flat_map(|b| match b {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
vec![c] vec![b as char]
} }
c => format!("%{:02X}", c as u32).chars().collect(), b => format!("%{b:02X}").chars().collect(),
}) })
.collect() .collect()
} }
+1 -2
View File
@@ -14,7 +14,6 @@ use chrono::{Datelike, NaiveDate};
pub use solitaire_sync::progress::level_for_xp; pub use solitaire_sync::progress::level_for_xp;
pub use solitaire_sync::PlayerProgress; pub use solitaire_sync::PlayerProgress;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const FILE_NAME: &str = "progress.json"; const FILE_NAME: &str = "progress.json";
/// Deterministic seed derived from a date, identical for all players globally. /// Deterministic seed derived from a date, identical for all players globally.
@@ -46,7 +45,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
/// Platform-specific default path for `progress.json`. /// Platform-specific default path for `progress.json`.
pub fn progress_file_path() -> Option<PathBuf> { pub fn progress_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME)) crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
} }
/// Load progress from an explicit path. Returns `default()` if missing/corrupt. /// Load progress from an explicit path. Returns `default()` if missing/corrupt.
+2 -3
View File
@@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json"; const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -279,14 +278,14 @@ impl ReplayHistory {
in migrate_legacy_latest_replay" in migrate_legacy_latest_replay"
)] )]
pub fn latest_replay_path() -> Option<PathBuf> { pub fn latest_replay_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME)) crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
} }
/// Returns the platform-specific path to `replays.json`, the rolling /// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g. /// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
/// minimal Linux containers). /// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> { pub fn replay_history_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME)) crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
} }
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` → /// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
+8 -2
View File
@@ -11,7 +11,6 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DifficultyLevel, DrawMode}; use solitaire_core::game_state::{DifficultyLevel, DrawMode};
const APP_DIR_NAME: &str = "ferrous_solitaire";
const SETTINGS_FILE_NAME: &str = "settings.json"; const SETTINGS_FILE_NAME: &str = "settings.json";
/// Animation playback speed for card transitions. /// Animation playback speed for card transitions.
@@ -239,6 +238,12 @@ pub struct Settings {
/// field existed deserialize cleanly to `None` via `#[serde(default)]`. /// field existed deserialize cleanly to `None` via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub leaderboard_display_name: Option<String>, pub leaderboard_display_name: Option<String>,
/// `true` once the player has successfully opted in to the leaderboard on
/// the server. Used to decide whether a display-name change should also
/// push an update via `opt_in_leaderboard`. Older `settings.json` files
/// deserialize cleanly to `false` via `#[serde(default)]`.
#[serde(default)]
pub leaderboard_opted_in: bool,
/// When `true`, the player may drag the top card of a foundation pile back /// When `true`, the player may drag the top card of a foundation pile back
/// onto a compatible tableau column. Enabled by default (standard Klondike /// onto a compatible tableau column. Enabled by default (standard Klondike
/// rules). Older `settings.json` files without this key deserialize to /// rules). Older `settings.json` files without this key deserialize to
@@ -388,6 +393,7 @@ impl Default for Settings {
replay_move_interval_secs: default_replay_move_interval_secs(), replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None, last_difficulty: None,
leaderboard_display_name: None, leaderboard_display_name: None,
leaderboard_opted_in: false,
take_from_foundation: true, take_from_foundation: true,
analytics_enabled: false, analytics_enabled: false,
matomo_url: None, matomo_url: None,
@@ -479,7 +485,7 @@ impl Settings {
/// Returns the platform-specific path to `settings.json`, or `None` if /// Returns the platform-specific path to `settings.json`, or `None` if
/// the platform's data directory is unavailable. /// the platform's data directory is unavailable.
pub fn settings_file_path() -> Option<PathBuf> { pub fn settings_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME)) crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(SETTINGS_FILE_NAME))
} }
/// Load settings from an explicit path. Returns `Settings::default()` if the /// Load settings from an explicit path. Returns `Settings::default()` if the
+7 -8
View File
@@ -13,7 +13,6 @@ use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use crate::stats::StatsSnapshot; use crate::stats::StatsSnapshot;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const STATS_FILE_NAME: &str = "stats.json"; const STATS_FILE_NAME: &str = "stats.json";
const GAME_STATE_FILE_NAME: &str = "game_state.json"; const GAME_STATE_FILE_NAME: &str = "game_state.json";
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json"; const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
@@ -21,7 +20,7 @@ const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
/// Returns the platform-specific path to `stats.json`, or `None` if /// Returns the platform-specific path to `stats.json`, or `None` if
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers). /// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
pub fn stats_file_path() -> Option<PathBuf> { pub fn stats_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME)) crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(STATS_FILE_NAME))
} }
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if /// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
@@ -71,7 +70,7 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
/// Returns the platform-specific path to `game_state.json`, or `None` if /// Returns the platform-specific path to `game_state.json`, or `None` if
/// `crate::data_dir()` is unavailable. /// `crate::data_dir()` is unavailable.
pub fn game_state_file_path() -> Option<PathBuf> { pub fn game_state_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME)) crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
} }
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is /// Load an in-progress `GameState` from `path`. Returns `None` if the file is
@@ -123,14 +122,14 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
} }
} }
/// Remove any leftover `*.json.tmp` files in the app data directory. /// Remove any leftover `*.tmp` files in the app data directory.
/// ///
/// These can be left behind if the process crashes between the write and rename /// These can be left behind if the process crashes between the write and rename
/// in an atomic save. Safe to call on startup; missing or unreadable entries /// in an atomic save. Safe to call on startup; missing or unreadable entries
/// are silently skipped. /// are silently skipped.
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> { pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
let dir = match crate::data_dir() { let dir = match crate::data_dir() {
Some(d) => d.join(APP_DIR_NAME), Some(d) => d.join(crate::APP_DIR_NAME),
None => return Ok(()), None => return Ok(()),
}; };
@@ -181,7 +180,7 @@ pub struct TimeAttackSession {
/// Returns the platform-specific path to `time_attack_session.json`, or /// Returns the platform-specific path to `time_attack_session.json`, or
/// `None` if `crate::data_dir()` is unavailable. /// `None` if `crate::data_dir()` is unavailable.
pub fn time_attack_session_path() -> Option<PathBuf> { pub fn time_attack_session_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME)) crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
} }
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s /// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
@@ -267,7 +266,7 @@ pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttac
} }
} }
/// Inner helper: delete `*.json.tmp` entries inside `dir`. /// Inner helper: delete `*.tmp` entries inside `dir`.
/// ///
/// Per-file errors (already deleted, permission denied) are silently ignored. /// Per-file errors (already deleted, permission denied) are silently ignored.
fn cleanup_tmp_files_in(dir: &Path) { fn cleanup_tmp_files_in(dir: &Path) {
@@ -277,7 +276,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
if path if path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".json.tmp")) .is_some_and(|n| n.ends_with(".tmp"))
{ {
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
+3
View File
@@ -309,6 +309,9 @@ impl SyncProvider for SolitaireServerClient {
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> { async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
let token = self.access_token()?; let token = self.access_token()?;
let url = format!("{}/api/leaderboard/opt-in", self.base_url); let url = format!("{}/api/leaderboard/opt-in", self.base_url);
// Enforce the server's 32-char column limit at the client boundary so
// the server never receives an over-length name regardless of caller.
let display_name: String = display_name.chars().take(32).collect();
let resp = self let resp = self
.client .client
+6 -7
View File
@@ -32,7 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible, ModalScrim, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
@@ -162,10 +162,7 @@ fn evaluate_on_win(
mut achievements: ResMut<AchievementsResource>, mut achievements: ResMut<AchievementsResource>,
mut progress: ResMut<ProgressResource>, mut progress: ResMut<ProgressResource>,
) { ) {
let Some(ev) = wins.read().last() else { for ev in wins.read() {
return;
};
let ctx = AchievementContext { let ctx = AchievementContext {
games_played: stats.0.games_played, games_played: stats.0.games_played,
games_won: stats.0.games_won, games_won: stats.0.games_won,
@@ -184,7 +181,7 @@ fn evaluate_on_win(
let hits = check_achievements(&ctx); let hits = check_achievements(&ctx);
if hits.is_empty() { if hits.is_empty() {
return; continue;
} }
let now = Utc::now(); let now = Utc::now();
@@ -250,6 +247,7 @@ fn evaluate_on_win(
warn!("failed to save progress after reward: {e}"); warn!("failed to save progress after reward: {e}");
} }
} }
}
/// Cinephile unlock observer. /// Cinephile unlock observer.
/// ///
@@ -391,6 +389,7 @@ fn toggle_achievements_screen(
achievements: Res<AchievementsResource>, achievements: Res<AchievementsResource>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<AchievementsScreen>>, screens: Query<Entity, With<AchievementsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked { if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
@@ -398,7 +397,7 @@ fn toggle_achievements_screen(
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else if other_modal_scrims.is_empty() {
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref()); spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
} }
} }
+22 -16
View File
@@ -12,7 +12,7 @@ use solitaire_core::game_state::GameMode;
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings}; use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent}; use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
use crate::resources::GameStateResource; use crate::resources::{GameStateResource, TokioRuntimeResource};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -50,13 +50,24 @@ impl Plugin for AnalyticsPlugin {
Update, Update,
( (
react_to_settings_change, react_to_settings_change,
on_game_won,
on_forfeit,
on_new_game, on_new_game,
on_achievement_unlocked, on_achievement_unlocked,
tick_flush_timer,
), ),
); );
// Build the shared Tokio runtime; skip network flush systems if the OS
// refuses to create threads (resource-limited / sandboxed environments).
match TokioRuntimeResource::new() {
Ok(rt) => {
app.insert_resource(rt).add_systems(
Update,
(on_game_won, on_forfeit, tick_flush_timer),
);
}
Err(e) => {
bevy::log::warn!("analytics_plugin: Tokio runtime unavailable — analytics flush disabled: {e}");
}
}
} }
} }
@@ -80,28 +91,28 @@ fn react_to_settings_change(
fn on_game_won( fn on_game_won(
mut wins: MessageReader<GameWonEvent>, mut wins: MessageReader<GameWonEvent>,
analytics: Res<AnalyticsResource>, analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>, rt: Res<TokioRuntimeResource>,
) { ) {
let Some(client) = analytics.client.clone() else { let Some(client) = analytics.client.clone() else {
return; return;
}; };
for ev in wins.read() { for ev in wins.read() {
client.event("Game", "Won", None, Some(ev.score as f64)); client.event("Game", "Won", None, Some(ev.score as f64));
fire_flush(client.clone(), &settings.0); fire_flush(client.clone(), rt.0.clone());
} }
} }
fn on_forfeit( fn on_forfeit(
mut forfeits: MessageReader<ForfeitEvent>, mut forfeits: MessageReader<ForfeitEvent>,
analytics: Res<AnalyticsResource>, analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>, rt: Res<TokioRuntimeResource>,
) { ) {
let Some(client) = analytics.client.clone() else { let Some(client) = analytics.client.clone() else {
return; return;
}; };
for _ev in forfeits.read() { for _ev in forfeits.read() {
client.event("Game", "Forfeit", None, None); client.event("Game", "Forfeit", None, None);
fire_flush(client.clone(), &settings.0); fire_flush(client.clone(), rt.0.clone());
} }
} }
@@ -137,14 +148,14 @@ fn on_achievement_unlocked(
fn tick_flush_timer( fn tick_flush_timer(
time: Res<Time>, time: Res<Time>,
mut analytics: ResMut<AnalyticsResource>, mut analytics: ResMut<AnalyticsResource>,
settings: Res<SettingsResource>, rt: Res<TokioRuntimeResource>,
) { ) {
analytics.flush_timer.tick(time.delta()); analytics.flush_timer.tick(time.delta());
if !analytics.flush_timer.just_finished() { if !analytics.flush_timer.just_finished() {
return; return;
} }
if let Some(client) = analytics.client.clone() { if let Some(client) = analytics.client.clone() {
fire_flush(client, &settings.0); fire_flush(client, rt.0.clone());
} }
} }
@@ -164,15 +175,10 @@ fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid))) Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
} }
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) { fn fire_flush(client: Arc<MatomoClient>, rt: Arc<tokio::runtime::Runtime>) {
AsyncComputeTaskPool::get() AsyncComputeTaskPool::get()
.spawn(async move { .spawn(async move {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
rt.block_on(client.flush()); rt.block_on(client.flush());
}
}) })
.detach(); .detach();
} }
+23 -3
View File
@@ -72,6 +72,17 @@ const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
const CHALLENGE_TOAST_SECS: f32 = 3.0; const CHALLENGE_TOAST_SECS: f32 = 3.0;
const VOLUME_TOAST_SECS: f32 = 1.4; const VOLUME_TOAST_SECS: f32 = 1.4;
/// Z added to a card's render depth while its `CardAnim` is in-flight.
///
/// Foundation and tableau cards share x,y during the slide (destination equals
/// a slot that already holds a card). Without this lift the incoming card's
/// bottom-right corner overlaps the stationary card's top-left, which the
/// player perceives as a single card with mismatched rank/suit indices.
///
/// 50.0 sits comfortably above the highest pile depth (~1.04) and well below
/// `DRAG_Z` (500), so a dragged card always renders above an animated one.
const CARD_ANIM_Z_LIFT: f32 = 50.0;
/// Per-card stagger interval for the win cascade at Normal speed (seconds). /// Per-card stagger interval for the win cascade at Normal speed (seconds).
/// ///
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing /// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
@@ -247,6 +258,11 @@ fn advance_card_anims(
anim.delay = (anim.delay - dt).max(0.0); anim.delay = (anim.delay - dt).max(0.0);
continue; continue;
} }
if anim.duration <= 0.0 {
transform.translation = anim.target;
commands.entity(entity).remove::<CardAnim>();
continue;
}
anim.elapsed += dt; anim.elapsed += dt;
let t = (anim.elapsed / anim.duration).min(1.0); let t = (anim.elapsed / anim.duration).min(1.0);
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out // Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
@@ -254,7 +270,11 @@ fn advance_card_anims(
// shared `CardAnim` struct stays a simple linear-tween container — the // shared `CardAnim` struct stays a simple linear-tween container — the
// upgrade is one extra `sample_curve` call per advancing animation. // upgrade is one extra `sample_curve` call per advancing animation.
let s = sample_curve(MotionCurve::SmoothSnap, t); let s = sample_curve(MotionCurve::SmoothSnap, t);
transform.translation = anim.start.lerp(anim.target, s); let mut pos = anim.start.lerp(anim.target, s);
// Elevate z during transit so the moving card always renders in front
// of any card already resting at the destination position.
pos.z = anim.target.z + CARD_ANIM_Z_LIFT;
transform.translation = pos;
if t >= 1.0 { if t >= 1.0 {
transform.translation = anim.target; transform.translation = anim.target;
commands.entity(entity).remove::<CardAnim>(); commands.entity(entity).remove::<CardAnim>();
@@ -454,8 +474,8 @@ fn handle_settings_toast(
for ev in events.read() { for ev in events.read() {
let sfx = ev.0.sfx_volume; let sfx = ev.0.sfx_volume;
let music = ev.0.music_volume; let music = ev.0.music_volume;
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > f32::EPSILON); let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > 0.001);
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > f32::EPSILON); let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > 0.001);
*last_sfx = Some(sfx); *last_sfx = Some(sfx);
*last_music = Some(music); *last_music = Some(music);
if sfx_changed { if sfx_changed {
+12 -2
View File
@@ -13,6 +13,7 @@ use bevy::prelude::*;
use crate::audio_plugin::{AudioState, SoundLibrary}; use crate::audio_plugin::{AudioState, SoundLibrary};
use crate::events::{MoveRequestEvent, StateChangedEvent}; use crate::events::{MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
/// Volume amplitude used for the auto-complete activation chime. /// Volume amplitude used for the auto-complete activation chime.
@@ -72,9 +73,14 @@ fn detect_auto_complete(
if game.0.is_auto_completable && !state.active { if game.0.is_auto_completable && !state.active {
state.active = true; state.active = true;
state.cooldown = 0.0; // fire first move immediately state.cooldown = 0.0; // fire first move immediately
} else if !game.0.is_auto_completable {
state.active = false;
} }
// Intentionally no `else if !is_auto_completable` branch here.
// Deactivating on every frame where `is_auto_completable` is false
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
// transiently returns `None` (e.g. while the previous move is still
// in-flight). The `is_won` check above already handles the definitive
// end-of-game case; `drive_auto_complete` simply retries next tick
// when no move is available yet.
} }
/// Plays a distinct chime the moment auto-complete first activates. /// Plays a distinct chime the moment auto-complete first activates.
@@ -106,11 +112,15 @@ fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>, mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut moves: MessageWriter<MoveRequestEvent>, mut moves: MessageWriter<MoveRequestEvent>,
) { ) {
if !state.active { if !state.active {
return; return;
} }
if paused.is_some_and(|p| p.0) {
return;
}
state.cooldown -= time.delta_secs(); state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 { if state.cooldown > 0.0 {
+18 -6
View File
@@ -21,6 +21,8 @@ use bevy::asset::RenderAssetUsages;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use crate::resources::TokioRuntimeResource;
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar /// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
/// has been fetched yet (new account, no internet, or fetch in progress). /// has been fetched yet (new account, no internet, or fetch in progress).
#[derive(Resource, Default)] #[derive(Resource, Default)]
@@ -48,23 +50,33 @@ impl Plugin for AvatarPlugin {
app.add_message::<AvatarFetchEvent>() app.add_message::<AvatarFetchEvent>()
.init_resource::<AvatarResource>() .init_resource::<AvatarResource>()
.init_resource::<PendingAvatarTask>() .init_resource::<PendingAvatarTask>()
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task)); .add_systems(Update, poll_avatar_task);
// Build the shared Tokio runtime; skip avatar download if the OS
// refuses to create threads (resource-limited / sandboxed environments).
match TokioRuntimeResource::new() {
Ok(rt) => {
app.insert_resource(rt)
.add_systems(Update, handle_avatar_fetch);
}
Err(e) => {
bevy::log::warn!("avatar_plugin: Tokio runtime unavailable — avatar fetch disabled: {e}");
}
}
} }
} }
fn handle_avatar_fetch( fn handle_avatar_fetch(
mut events: MessageReader<AvatarFetchEvent>, mut events: MessageReader<AvatarFetchEvent>,
rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingAvatarTask>, mut pending: ResMut<PendingAvatarTask>,
) { ) {
for ev in events.read() { for ev in events.read() {
// Cancel any in-flight task and restart with the new URL. // Cancel any in-flight task and restart with the new URL.
let url = ev.url.clone(); let url = ev.url.clone();
let rt = rt.0.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move { pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread() rt.block_on(async move {
.enable_all()
.build()
.ok()?
.block_on(async move {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let bytes = client let bytes = client
.get(&url) .get(&url)
@@ -142,6 +142,13 @@ impl Plugin for CardAnimationPlugin {
update_frame_time_diagnostics, update_frame_time_diagnostics,
// Advance active animations. // Advance active animations.
advance_card_animations, advance_card_animations,
// Flush deferred commands so `CardAnimation` removals from
// `advance_card_animations` are visible before the chain
// system runs. Without this, the chain sees the component
// still present in the same frame it was removed (deferred
// commands aren't applied until the next ApplyDeferred
// point), causing a 1-frame gap between every chain step.
ApplyDeferred,
// After each animation finishes, pop the next chain segment. // After each animation finishes, pop the next chain segment.
advance_animation_chains, advance_animation_chains,
// Interaction visuals (run after animation for final positions). // Interaction visuals (run after animation for final positions).
+154 -29
View File
@@ -41,7 +41,9 @@ use crate::ui_theme::{
}; };
/// Fraction of card height used as vertical offset between face-up tableau cards. /// Fraction of card height used as vertical offset between face-up tableau cards.
pub const TABLEAU_FAN_FRAC: f32 = 0.25; /// Must match `layout::TABLEAU_FAN_FRAC` so the initial layout and the first
/// dynamic update from `update_tableau_fan_frac` produce identical spacing.
pub const TABLEAU_FAN_FRAC: f32 = 0.18;
/// Per-card vertical step for face-down tableau cards, as a fraction of /// Per-card vertical step for face-down tableau cards, as a fraction of
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards /// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
@@ -51,18 +53,22 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
/// renderer creates a visible offset between the card face and where /// renderer creates a visible offset between the card face and where
/// clicks land. /// clicks land.
/// ///
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must /// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
/// stay in sync; the layout constant drives the adaptive LayoutResource value /// stay in sync; the layout constant drives the adaptive LayoutResource value
/// used at runtime, while this one is the minimum floor used by /// used at runtime, while this one is the minimum floor used by
/// `update_tableau_fan_frac` when computing proportional updates. /// `update_tableau_fan_frac` when computing proportional updates.
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20; pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// Fraction of card height used as a tiny offset between stacked cards in /// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible. Public so other plugins /// non-tableau piles, so stacking is visible. Public so other plugins
/// (e.g. input_plugin's drag-rejection tween) can compute the resting /// (e.g. input_plugin's drag-rejection tween) can compute the resting
/// `Transform.translation.z` for a card at a given stack index without /// `Transform.translation.z` for a card at a given stack index without
/// drifting from the value used by [`card_positions`]. /// drifting from the value used by [`card_positions`].
pub const STACK_FAN_FRAC: f32 = 0.003; // Must exceed the highest child local-z of any card entity (0.02 for the
// Android corner label) so every card's sprite covers all children of the
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
// foundation piles bleeding through when a 2 sits on an Ace.
pub const STACK_FAN_FRAC: f32 = 0.025;
/// Font size as a fraction of card width. /// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28; const FONT_SIZE_FRAC: f32 = 0.28;
@@ -178,8 +184,8 @@ pub struct CardLabel;
/// readable at phone scale. Only exists when `CardImageSet` is present /// readable at phone scale. Only exists when `CardImageSet` is present
/// (the fallback solid-colour path uses a plain `CardLabel` instead). /// (the fallback solid-colour path uses a plain `CardLabel` instead).
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[derive(Component, Debug, Clone, Copy)] #[derive(Component, Debug, Clone)]
struct AndroidCornerLabel; struct AndroidCornerLabel(pub String);
/// Solid-colour background sprite behind [`AndroidCornerLabel`]. /// Solid-colour background sprite behind [`AndroidCornerLabel`].
/// ///
@@ -451,7 +457,9 @@ impl Plugin for CardPlugin {
clear_right_click_highlights_on_state_change.after(GameMutation), clear_right_click_highlights_on_state_change.after(GameMutation),
clear_right_click_highlights_on_pause, clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation), update_stock_empty_indicator.after(GameMutation),
update_stock_count_badge.after(GameMutation), update_stock_count_badge
.after(GameMutation)
.run_if(resource_changed::<crate::GameStateResource>),
collect_resize_events.after(LayoutSystem::UpdateOnResize), collect_resize_events.after(LayoutSystem::UpdateOnResize),
snap_cards_on_window_resize.after(collect_resize_events), snap_cards_on_window_resize.after(collect_resize_events),
), ),
@@ -689,15 +697,36 @@ fn sync_cards(
) { ) {
let positions = card_positions(game, layout); let positions = card_positions(game, layout);
// Map card_id -> (Entity, current_translation, has_card_animation) for // The waste buffer card exists only to keep its entity alive while the new
// in-place updates. The `has_card_animation` flag lets `update_card_entity` // top card's slide animation plays — it must never be visible to the player.
// skip the snap/slide path on cards that are already being driven by a // Without this, the buffer sits at waste_base uncovered during the animation
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween // and its rank/suit peek behind the incoming card.
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that let waste_buffer_id: Option<u32> = {
// accompanies a rejection would race the tween and the card would jump. let visible = match game.draw_mode {
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new(); DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
game.piles
.get(&PileType::Waste)
.filter(|w| w.cards.len() > visible)
.and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1)))
.map(|c| c.id)
};
// Map card_id -> (Entity, current_translation, anim_end) for in-place
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
// is currently driving the card (e.g. a drag-rejection return tween).
//
// In the position loop below we compare `anim_end` against the new game-
// state target position to decide whether to honour or cancel the tween:
// • end ≈ target → animation is still heading to the right place; let
// it finish (skip the snap/slide path).
// • end ≠ target → the game state has changed (e.g. a new game started
// while the win-cascade was mid-flight); cancel the
// stale `CardAnimation` and apply the new position.
let mut existing: HashMap<u32, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
for (entity, marker, transform, anim) in entities.iter() { for (entity, marker, transform, anim) in entities.iter() {
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some())); existing.insert(marker.card_id, (entity, transform.translation, anim.map(|a| a.end)));
} }
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect(); let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
@@ -709,17 +738,38 @@ fn sync_cards(
} }
} }
// For each card in the current state: spawn or update its entity. // For each card in the current state: spawn or update its entity, then
// apply visibility. The waste buffer card is hidden so it cannot peek
// behind the incoming top card during the draw slide animation.
for (card, position, z) in positions { for (card, position, z) in positions {
match existing.get(&card.id) { let entity = match existing.get(&card.id) {
Some(&(entity, cur, has_anim)) => { Some(&(entity, cur, anim_end)) => {
// If a CardAnimation is in flight, check whether its destination
// still matches the game-state target. If the game moved the card
// elsewhere (e.g. new game started during a win-cascade scatter),
// cancel the stale tween so the card snaps/slides to its new home.
let has_anim = match anim_end {
Some(end_xy) if (end_xy - position).length() > 2.0 => {
commands.entity(entity).remove::<CardAnimation>();
false
}
Some(_) => true,
None => false,
};
update_card_entity( update_card_entity(
&mut commands, entity, card, position, z, layout, &mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle, slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
) );
entity
} }
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle), None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle),
} };
let visibility = if waste_buffer_id == Some(card.id) {
Visibility::Hidden
} else {
Visibility::Inherited
};
commands.entity(entity).insert(visibility);
} }
} }
@@ -829,7 +879,7 @@ fn spawn_card_entity(
card_images: Option<&CardImageSet>, card_images: Option<&CardImageSet>,
selected_back: usize, selected_back: usize,
font_handle: Option<&Handle<Font>>, font_handle: Option<&Handle<Font>>,
) { ) -> Entity {
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back); let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
let mut entity = commands.spawn(( let mut entity = commands.spawn((
@@ -838,6 +888,7 @@ fn spawn_card_entity(
Transform::from_xyz(pos.x, pos.y, z), Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(), Visibility::default(),
)); ));
let entity_id = entity.id();
// Every card gets a subtle drop-shadow child so the play surface reads // Every card gets a subtle drop-shadow child so the play surface reads
// as physical instead of flat. Spawned in idle state; the drag-tracking // as physical instead of flat. Spawned in idle state; the drag-tracking
// system retunes its offset / alpha when this card joins the dragged // system retunes its offset / alpha when this card joins the dragged
@@ -878,6 +929,7 @@ fn spawn_card_entity(
// Suppress unused-variable warning when not building for Android. // Suppress unused-variable warning when not building for Android.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
let _ = font_handle; let _ = font_handle;
entity_id
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -1095,11 +1147,13 @@ fn add_android_corner_label(
let bg_w = font_size * 2.0; let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25; let bg_h = font_size * 1.25;
// Solid background that hides the card art's small corner label. // Background covers the PNG's baked-in small corner text (top-left).
// Classic PNG cards have a white face, so the background must be white too.
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
parent.spawn(( parent.spawn((
AndroidCornerBg, AndroidCornerBg,
Sprite { Sprite {
color: CARD_FACE_COLOUR, color: Color::WHITE,
custom_size: Some(Vec2::new(bg_w, bg_h)), custom_size: Some(Vec2::new(bg_w, bg_h)),
..default() ..default()
}, },
@@ -1109,20 +1163,51 @@ fn add_android_corner_label(
0.015, 0.015,
), ),
)); ));
// Cover the matching rotated baked-in text at the bottom-right corner.
parent.spawn((
AndroidCornerBg,
Sprite {
color: Color::WHITE,
custom_size: Some(Vec2::new(bg_w, bg_h)),
..default()
},
Transform::from_xyz(
card_size.x / 2.0 - inset - bg_w / 2.0,
-card_size.y / 2.0 + inset + bg_h / 2.0,
0.015,
),
));
// Large rank+suit text drawn on top of the background. FiraMono must be // Large rank+suit text drawn on top of the background. FiraMono must be
// wired here explicitly — the suit glyphs (U+2660U+2666) are not in // wired here explicitly — the suit glyphs (U+2660U+2666) are not in
// Bevy's built-in font and render as a coloured rectangle without it. // Bevy's built-in font and render as a coloured rectangle without it.
//
// Classic PNG cards have a white face: red suits stay the same saturated
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
// Terminal theme background.
let text_col = if card.suit.is_red() {
if color_blind {
RED_SUIT_COLOUR_CBM
} else if high_contrast {
RED_SUIT_COLOUR_HC
} else {
RED_SUIT_COLOUR
}
} else {
CARD_FACE_COLOUR
};
let label_text = mobile_label_for(card);
parent.spawn(( parent.spawn((
AndroidCornerLabel, AndroidCornerLabel(label_text.clone()),
CardLabel, CardLabel,
Text2d::new(mobile_label_for(card)), Text2d::new(label_text),
TextFont { TextFont {
font: font_handle.cloned().unwrap_or_default(), font: font_handle.cloned().unwrap_or_default(),
font_size, font_size,
..default() ..default()
}, },
TextColor(text_colour(card, color_blind, high_contrast)), TextColor(text_col),
Anchor::TOP_LEFT, Anchor::TOP_LEFT,
Transform::from_xyz( Transform::from_xyz(
-card_size.x / 2.0 + inset, -card_size.x / 2.0 + inset,
@@ -1598,6 +1683,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>, pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>, label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
layout: &Layout, layout: &Layout,
font: Handle<Font>,
) { ) {
let stock_empty = game let stock_empty = game
.piles .piles
@@ -1623,7 +1709,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
b.spawn(( b.spawn((
StockEmptyLabel, StockEmptyLabel,
Text2d::new(""), Text2d::new(""),
TextFont { font_size, ..default() }, TextFont { font: font.clone(), font_size, ..default() },
TextColor(TEXT_PRIMARY.with_alpha(0.7)), TextColor(TEXT_PRIMARY.with_alpha(0.7)),
Transform::from_xyz(0.0, 0.0, 0.1), Transform::from_xyz(0.0, 0.0, 0.1),
)); ));
@@ -1649,16 +1735,19 @@ fn update_stock_empty_indicator_startup(
mut commands: Commands, mut commands: Commands,
game: Res<GameStateResource>, game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
font_res: Option<Res<FontResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>, mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>, label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) { ) {
let Some(layout) = layout else { return }; let Some(layout) = layout else { return };
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator( apply_stock_empty_indicator(
&mut commands, &mut commands,
&game.0, &game.0,
&mut pile_markers, &mut pile_markers,
&label_children, &label_children,
&layout.0, &layout.0,
font,
); );
} }
@@ -1669,6 +1758,7 @@ fn update_stock_empty_indicator(
mut commands: Commands, mut commands: Commands,
game: Res<GameStateResource>, game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
font_res: Option<Res<FontResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>, mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>, label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) { ) {
@@ -1676,12 +1766,14 @@ fn update_stock_empty_indicator(
return; return;
} }
let Some(layout) = layout else { return }; let Some(layout) = layout else { return };
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator( apply_stock_empty_indicator(
&mut commands, &mut commands,
&game.0, &game.0,
&mut pile_markers, &mut pile_markers,
&label_children, &label_children,
&layout.0, &layout.0,
font,
); );
} }
@@ -1892,6 +1984,7 @@ fn snap_cards_on_window_resize(
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
card_images: Option<Res<CardImageSet>>, card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
entities: Query< entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform), (Entity, &CardEntity, &mut Sprite, &mut Transform),
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>), (Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
@@ -1940,12 +2033,14 @@ fn snap_cards_on_window_resize(
frame_query, frame_query,
); );
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator( apply_stock_empty_indicator(
&mut commands, &mut commands,
&game.0, &game.0,
&mut pile_markers, &mut pile_markers,
&label_children, &label_children,
&layout.0, &layout.0,
font,
); );
throttle.last_applied_secs = now; throttle.last_applied_secs = now;
@@ -2050,7 +2145,7 @@ fn resize_cards_in_place(
fn resize_android_corner_labels( fn resize_android_corner_labels(
layout: Res<LayoutResource>, layout: Res<LayoutResource>,
card_images: Option<Res<CardImageSet>>, card_images: Option<Res<CardImageSet>>,
mut text_query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>, mut text_query: Query<(&AndroidCornerLabel, &mut Text2d, &mut TextFont, &mut Transform)>,
mut bg_query: Query< mut bg_query: Query<
(&mut Sprite, &mut Transform), (&mut Sprite, &mut Transform),
(With<AndroidCornerBg>, Without<AndroidCornerLabel>), (With<AndroidCornerBg>, Without<AndroidCornerLabel>),
@@ -2066,7 +2161,8 @@ fn resize_android_corner_labels(
let text_x = -layout.0.card_size.x / 2.0 + inset; let text_x = -layout.0.card_size.x / 2.0 + inset;
let text_y = layout.0.card_size.y / 2.0 - inset; let text_y = layout.0.card_size.y / 2.0 - inset;
for (mut font, mut transform) in text_query.iter_mut() { for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
text2d.0 = label.0.clone();
font.font_size = font_size; font.font_size = font_size;
transform.translation.x = text_x; transform.translation.x = text_x;
transform.translation.y = text_y; transform.translation.y = text_y;
@@ -2345,6 +2441,35 @@ mod tests {
} }
} }
/// The waste buffer card (slot below top) must be at the *same* XY as the
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
#[test]
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawOne);
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> =
g.piles[&PileType::Waste].cards.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Buffer (slot 0) + top (slot 1) = 2 rendered waste cards.
assert_eq!(waste_rendered.len(), 2, "Draw-One with 3 waste cards must render exactly 2");
// Both must share the same XY so that hiding the buffer leaves no gap.
let (_, pos0, _) = waste_rendered[0];
let (_, pos1, _) = waste_rendered[1];
assert!(
(pos0.x - pos1.x).abs() < 1e-3 && (pos0.y - pos1.y).abs() < 1e-3,
"buffer and top card must be at the same XY; got buffer={pos0:?} top={pos1:?}"
);
}
#[test] #[test]
fn card_positions_tableau_cards_are_fanned_downward() { fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
+13 -10
View File
@@ -38,7 +38,7 @@ use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC}; use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR}; use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
Update, Update,
( (
update_cursor_icon, update_cursor_icon,
update_drop_highlights, update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
update_drop_target_overlays, update_drop_target_overlays,
), ),
); );
@@ -382,24 +382,24 @@ fn update_drop_target_overlays(
/// for everything else it is card-sized. Replicated here rather than /// for everything else it is card-sized. Replicated here rather than
/// imported because `pile_drop_rect` is private to `input_plugin` and /// imported because `pile_drop_rect` is private to `input_plugin` and
/// this overlay is the only other consumer. /// this overlay is the only other consumer.
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) { fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
let centre = layout.pile_positions[pile]; let centre = layout.pile_positions.get(pile).copied()?;
if matches!(pile, PileType::Tableau(_)) { if matches!(pile, PileType::Tableau(_)) {
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len()); let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
if card_count > 1 { if card_count > 1 {
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC; let fan = -layout.card_size.y * layout.tableau_fan_frac;
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32; let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
let top_edge = centre.y + layout.card_size.y / 2.0; let top_edge = centre.y + layout.card_size.y / 2.0;
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0; let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
let span_height = top_edge - bottom_edge; let span_height = top_edge - bottom_edge;
let new_centre_y = (top_edge + bottom_edge) / 2.0; let new_centre_y = (top_edge + bottom_edge) / 2.0;
return ( return Some((
Vec2::new(centre.x, new_centre_y), Vec2::new(centre.x, new_centre_y),
Vec2::new(layout.card_size.x, span_height), Vec2::new(layout.card_size.x, span_height),
); ));
} }
} }
(centre, layout.card_size) Some((centre, layout.card_size))
} }
/// Spawns one overlay parent (fill) plus four edge sprites (outline) at /// Spawns one overlay parent (fill) plus four edge sprites (outline) at
@@ -410,7 +410,10 @@ fn spawn_drop_target_overlay(
layout: &Layout, layout: &Layout,
game: &GameState, game: &GameState,
) { ) {
let (centre, size) = drop_overlay_rect(pile, layout, game); let Some((centre, size)) = drop_overlay_rect(pile, layout, game) else {
warn!("drop_overlay_rect: pile {pile:?} not in layout, skipping overlay");
return;
};
let edge = DROP_TARGET_OUTLINE_PX; let edge = DROP_TARGET_OUTLINE_PX;
commands commands
@@ -478,7 +481,7 @@ fn tableau_or_stack_pos(
if is_tableau { if is_tableau {
Vec2::new( Vec2::new(
base.x, base.x,
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32), base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
) )
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree { } else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len()); let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
+109 -2
View File
@@ -210,10 +210,15 @@ impl Plugin for FeedbackAnimPlugin {
start_shake_anim.after(GameMutation), start_shake_anim.after(GameMutation),
tick_shake_anim, tick_shake_anim,
start_settle_anim.after(GameMutation), start_settle_anim.after(GameMutation),
// tick_foundation_flourish writes the full Transform.scale
// (Vec3); tick_settle_anim writes only scale.y on top of
// it. Ordering ensures the settle's y-only write always
// applies last so it wins on the ~0.15 s overlap when both
// components are present on the same King entity.
tick_foundation_flourish.before(tick_settle_anim),
tick_settle_anim, tick_settle_anim,
start_deal_anim.after(GameMutation), start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation), start_foundation_flourish.after(GameMutation),
tick_foundation_flourish,
), ),
); );
} }
@@ -228,10 +233,15 @@ impl Plugin for FeedbackAnimPlugin {
fn start_shake_anim( fn start_shake_anim(
mut events: MessageReader<MoveRejectedEvent>, mut events: MessageReader<MoveRejectedEvent>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity, &Transform)>, card_entities: Query<(Entity, &CardEntity, &Transform)>,
mut commands: Commands, mut commands: Commands,
) { ) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
for ev in events.read() { for ev in events.read() {
if reduce_motion {
continue;
}
let dest_pile = &ev.to; let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile. // Collect the card ids that belong to the destination pile.
let Some(pile) = game.0.piles.get(dest_pile) else { continue }; let Some(pile) = game.0.piles.get(dest_pile) else { continue };
@@ -489,11 +499,16 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
fn start_foundation_flourish( fn start_foundation_flourish(
mut events: MessageReader<FoundationCompletedEvent>, mut events: MessageReader<FoundationCompletedEvent>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity)>, card_entities: Query<(Entity, &CardEntity)>,
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>, mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
mut commands: Commands, mut commands: Commands,
) { ) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
for ev in events.read() { for ev in events.read() {
if reduce_motion {
continue;
}
let pile_type = PileType::Foundation(ev.slot); let pile_type = PileType::Foundation(ev.slot);
// Top card of the completed foundation is the King. // Top card of the completed foundation is the King.
let Some(king_id) = game let Some(king_id) = game
@@ -785,7 +800,7 @@ mod tests {
#[test] #[test]
fn deal_stagger_jitter_varies_across_card_ids() { fn deal_stagger_jitter_varies_across_card_ids() {
// 52 cards should produce more than a couple distinct jitter factors; // 52 cards should produce more than a couple distinct jitter factors;
// a constant function would return one value for all ids. // a constant function would return one function for all ids.
use std::collections::HashSet; use std::collections::HashSet;
let unique: HashSet<u64> = (0u32..52) let unique: HashSet<u64> = (0u32..52)
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64) .map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
@@ -796,4 +811,96 @@ mod tests {
unique.len() unique.len()
); );
} }
// -----------------------------------------------------------------------
// Reduce-motion gates — ShakeAnim, FoundationFlourish
// -----------------------------------------------------------------------
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
/// is on, even when the event targets a pile that has card entities present.
#[test]
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
// Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = PileType::Tableau(0);
let card_id = app
.world()
.resource::<GameStateResource>()
.0
.piles
.get(&dest_pile)
.and_then(|p| p.cards.last())
.map(|c| c.id)
.expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that id so the system would
// find it and insert ShakeAnim if the gate were absent.
app.world_mut().spawn((
CardEntity { card_id },
Transform::default(),
));
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()
.write(MoveRejectedEvent {
from: PileType::Stock,
to: dest_pile,
count: 1,
});
app.update();
let shake_count = app
.world_mut()
.query::<&ShakeAnim>()
.iter(app.world())
.count();
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
}
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
/// `reduce_motion_mode` is on.
#[test]
fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
app.world_mut()
.resource_mut::<Messages<FoundationCompletedEvent>>()
.write(FoundationCompletedEvent {
slot: 0,
suit: solitaire_core::card::Suit::Spades,
});
app.update();
let flourish_count = app
.world_mut()
.query::<&FoundationFlourish>()
.iter(app.world())
.count();
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
}
} }
+9 -2
View File
@@ -31,8 +31,15 @@ fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
// Assets<Font>). FontPlugin in that context is a no-op — consumers // Assets<Font>). FontPlugin in that context is a no-op — consumers
// already query `Option<Res<FontResource>>` and degrade cleanly. // already query `Option<Res<FontResource>>` and degrade cleanly.
let Some(mut fonts) = fonts else { return }; let Some(mut fonts) = fonts else { return };
let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec()) let font = match Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec()) {
.expect("bundled FiraMono failed to parse — binary is corrupt"); Ok(f) => f,
Err(e) => {
// A corrupt embedded font is unusual but should not crash the
// process — UI will render without glyphs rather than panicking.
warn!("bundled FiraMono failed to parse ({e}); UI text may be invisible");
return;
}
};
let handle = fonts.add(font); let handle = fonts.add(font);
commands.insert_resource(FontResource(handle)); commands.insert_resource(FontResource(handle));
} }
+54 -49
View File
@@ -32,7 +32,7 @@ use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource}; use crate::resources::{DragState, GameStateResource, SyncStatusResource};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant, spawn_modal_header, ButtonVariant, ModalScrim,
}; };
use crate::ui_theme; use crate::ui_theme;
@@ -202,6 +202,8 @@ impl Plugin for GamePlugin {
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<AppLifecycle>() .add_message::<AppLifecycle>()
// add_message is idempotent; SettingsPlugin also registers this.
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
.add_systems( .add_systems(
Update, Update,
poll_pending_new_game_seed.before(GameMutation), poll_pending_new_game_seed.before(GameMutation),
@@ -228,6 +230,7 @@ impl Plugin for GamePlugin {
// GameMutation flow. // GameMutation flow.
.add_systems(Update, spawn_restore_prompt_if_pending) .add_systems(Update, spawn_restore_prompt_if_pending)
.add_systems(Update, handle_restore_prompt.before(GameMutation)) .add_systems(Update, handle_restore_prompt.before(GameMutation))
.add_systems(Update, sync_settings_to_game.before(GameMutation))
.init_resource::<AutoSaveTimer>() .init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time) .add_systems(Update, tick_elapsed_time)
.add_systems(Update, auto_save_game_state) .add_systems(Update, auto_save_game_state)
@@ -235,6 +238,23 @@ impl Plugin for GamePlugin {
} }
} }
/// Forwards `take_from_foundation` from [`SettingsResource`] to the live
/// [`GameStateResource`] every time [`SettingsChangedEvent`] fires.
///
/// This covers two cases that the new-game path misses:
/// 1. The initial settings load at startup: saves on disk default to `false`
/// but `Settings` defaults to `true`; the event fires once when the
/// settings file is first read.
/// 2. A user toggling the setting mid-session in the Settings panel.
fn sync_settings_to_game(
mut events: MessageReader<crate::settings_plugin::SettingsChangedEvent>,
mut game: ResMut<GameStateResource>,
) {
for ev in events.read() {
game.0.take_from_foundation = ev.0.take_from_foundation;
}
}
/// Pure, testable helper. Updates `elapsed_seconds` and drains the /// Pure, testable helper. Updates `elapsed_seconds` and drains the
/// fractional accumulator into whole-second ticks. No-op when `is_won`. /// fractional accumulator into whole-second ticks. No-op when `is_won`.
pub fn advance_elapsed( pub fn advance_elapsed(
@@ -380,11 +400,11 @@ fn poll_pending_new_game_seed(
/// Pure helper extracted for testability — `new_game_with_solver_*` /// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path. /// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 { pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
let cfg = SolverConfig::default(); let cfg = SolverConfig::default();
let mut seed = initial_seed; let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP { for _ in 0..SOLVER_DEAL_RETRY_CAP {
match try_solve(seed, draw_mode.clone(), &cfg) { match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable | SolverResult::Inconclusive => return seed, SolverResult::Winnable | SolverResult::Inconclusive => return seed,
SolverResult::Unwinnable => { SolverResult::Unwinnable => {
seed = seed.wrapping_add(1); seed = seed.wrapping_add(1);
@@ -411,6 +431,7 @@ fn handle_new_game(
game_over_screens: Query<Entity, With<GameOverScreen>>, game_over_screens: Query<Entity, With<GameOverScreen>>,
layout: Option<Res<crate::layout::LayoutResource>>, layout: Option<Res<crate::layout::LayoutResource>>,
mut card_transforms: Query<&mut Transform, With<crate::card_plugin::CardEntity>>, mut card_transforms: Query<&mut Transform, With<crate::card_plugin::CardEntity>>,
scrims: Query<(), With<ModalScrim>>,
) { ) {
for ev in new_game.read() { for ev in new_game.read() {
// If an active game is in progress, intercept and show a confirm dialog. // If an active game is in progress, intercept and show a confirm dialog.
@@ -420,8 +441,12 @@ fn handle_new_game(
// duplicates) or if the event itself was already confirmed by the // duplicates) or if the event itself was already confirmed by the
// player pressing Y on the modal — without the `confirmed` check the // player pressing Y on the modal — without the `confirmed` check the
// modal would be respawned the frame after the despawn flushes. // modal would be respawned the frame after the despawn flushes.
// Also skip if any other modal scrim is currently open (global guard).
let confirm_already_open = !confirm_screens.is_empty(); let confirm_already_open = !confirm_screens.is_empty();
if needs_confirm && !confirm_already_open && !ev.confirmed { if needs_confirm && !confirm_already_open && !ev.confirmed {
if !scrims.is_empty() {
return;
}
// Despawn any stale game-over overlay before showing confirm dialog. // Despawn any stale game-over overlay before showing confirm dialog.
for entity in &game_over_screens { for entity in &game_over_screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
@@ -451,7 +476,7 @@ fn handle_new_game(
// where SettingsPlugin is not installed. // where SettingsPlugin is not installed.
let draw_mode = settings let draw_mode = settings
.as_ref() .as_ref()
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone()); .map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode);
let mode = ev.mode.unwrap_or(game.0.mode); let mode = ev.mode.unwrap_or(game.0.mode);
// Solver-backed retry: when the player has opted in to // Solver-backed retry: when the player has opted in to
@@ -473,9 +498,8 @@ fn handle_new_game(
.as_ref() .as_ref()
.is_some_and(|s| s.0.winnable_deals_only); .is_some_and(|s| s.0.winnable_deals_only);
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() { if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
let dm = draw_mode.clone();
let task = AsyncComputeTaskPool::get() let task = AsyncComputeTaskPool::get()
.spawn(async move { choose_winnable_seed(initial_seed, &dm) }); .spawn(async move { choose_winnable_seed(initial_seed, draw_mode) });
pending_seed.inner = Some(PendingSeedTask { pending_seed.inner = Some(PendingSeedTask {
handle: task, handle: task,
mode: ev.mode, mode: ev.mode,
@@ -557,10 +581,14 @@ fn spawn_restore_prompt_if_pending(
splash: Query<(), With<crate::splash_plugin::SplashRoot>>, splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
existing: Query<(), With<RestorePromptScreen>>, existing: Query<(), With<RestorePromptScreen>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
scrims: Query<(), With<ModalScrim>>,
) { ) {
if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() { if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() {
return; return;
} }
if !scrims.is_empty() {
return;
}
spawn_modal( spawn_modal(
&mut commands, &mut commands,
RestorePromptScreen, RestorePromptScreen,
@@ -615,6 +643,7 @@ fn handle_restore_prompt(
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>, new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
mut pending: ResMut<PendingRestoredGame>, mut pending: ResMut<PendingRestoredGame>,
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>, mut new_game: MessageWriter<NewGameRequestEvent>,
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>, mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
@@ -640,6 +669,10 @@ fn handle_restore_prompt(
let resolved = if key_continue || click_continue { let resolved = if key_continue || click_continue {
if let Some(restored) = pending.0.take() { if let Some(restored) = pending.0.take() {
game.0 = restored; game.0 = restored;
// Patch setting that serialized with the old core default of `false`.
if let Some(s) = settings.as_ref() {
game.0.take_from_foundation = s.0.take_from_foundation;
}
changed.write(StateChangedEvent); changed.write(StateChangedEvent);
} }
for entity in &screens { for entity in &screens {
@@ -970,7 +1003,7 @@ pub fn record_replay_on_win(
let win_move_index = recording.moves.len().checked_sub(1); let win_move_index = recording.moves.len().checked_sub(1);
let replay = Replay::new( let replay = Replay::new(
game.0.seed, game.0.seed,
game.0.draw_mode.clone(), game.0.draw_mode,
game.0.mode, game.0.mode,
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
@@ -1012,9 +1045,7 @@ pub fn record_replay_on_win(
/// previous heuristic incorrectly did (Quat hit this with 4 cards /// previous heuristic incorrectly did (Quat hit this with 4 cards
/// remaining and the game just sat there). /// remaining and the game just sat there).
pub fn has_legal_moves(game: &GameState) -> bool { pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::card::Card;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
// Drawing from a non-empty stock, and recycling a non-empty waste back to // Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles). // stock, are always legal moves in standard Klondike (unlimited recycles).
@@ -1025,40 +1056,14 @@ pub fn has_legal_moves(game: &GameState) -> bool {
return true; return true;
} }
// Stock and waste exhausted — check whether any visible card can be placed. // Stock and waste both exhausted — delegate to the authoritative move
let mut sources: Vec<Card> = Vec::new(); // enumeration in core, which validates tableau sequence structure and
// Top waste card (waste is empty here, but included for completeness). // foundation placement correctly. The previous hand-rolled loop only
if let Some(p) = game.piles.get(&PileType::Waste) // checked can_place_on_tableau(card, dest) for individual face-up cards
&& let Some(top) = p.cards.last() // without verifying that the cards above them form a valid alternating run,
{ // causing false positives when a useful-looking card was buried under an
sources.push(top.clone()); // invalid sequence.
} !game.possible_instructions().is_empty()
// Any face-up card in a tableau column can be the base of a movable run.
for i in 0..7_usize {
if let Some(t) = game.piles.get(&PileType::Tableau(i)) {
for card in t.cards.iter().filter(|c| c.face_up) {
sources.push(card.clone());
}
}
}
for card in &sources {
for slot in 0..4_u8 {
if let Some(dest) = game.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(card, dest)
{
return true;
}
}
for i in 0..7_usize {
if let Some(dest) = game.piles.get(&PileType::Tableau(i))
&& can_place_on_tableau(card, dest)
{
return true;
}
}
}
false
} }
/// After each `StateChangedEvent`, check if the game has no legal moves. /// After each `StateChangedEvent`, check if the game has no legal moves.
@@ -1076,12 +1081,11 @@ fn check_no_moves(
mut already_fired: Local<bool>, mut already_fired: Local<bool>,
game_over_screens: Query<Entity, With<GameOverScreen>>, game_over_screens: Query<Entity, With<GameOverScreen>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
scrims: Query<(), With<ModalScrim>>,
) { ) {
// Reset the debounce flag on every state change so if something changes // Reset the debounce flag on every state change so if something changes
// we re-evaluate on the next state change. // we re-evaluate on the next state change.
let had_event = events.read().next().is_some(); let had_event = events.read().count() > 0;
// Drain remaining events to avoid leaking.
events.clear();
if !had_event { if !had_event {
return; return;
@@ -1109,8 +1113,9 @@ fn check_no_moves(
let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game"; let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game";
toast.write(InfoToastEvent(no_moves_msg.to_string())); toast.write(InfoToastEvent(no_moves_msg.to_string()));
*already_fired = true; *already_fired = true;
// Only spawn the overlay if one does not already exist. // Only spawn the overlay if one does not already exist, and no other
if game_over_screens.is_empty() { // modal scrim is currently open (global ModalScrim guard).
if game_over_screens.is_empty() && scrims.is_empty() {
spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref()); spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
} }
} }
@@ -2649,7 +2654,7 @@ mod tests {
// resolves as Inconclusive — the engine treats Inconclusive // resolves as Inconclusive — the engine treats Inconclusive
// as winnable (see `choose_winnable_seed` doc), so the // as winnable (see `choose_winnable_seed` doc), so the
// helper must return 395 when started at 394. // helper must return 395 when started at 394.
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne); let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
assert_eq!( assert_eq!(
chosen, 395, chosen, 395,
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted" "seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
+29 -3
View File
@@ -9,9 +9,11 @@ use bevy::prelude::*;
use crate::events::HelpRequestEvent; use crate::events::HelpRequestEvent;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
#[cfg(target_os = "android")]
use crate::hud_plugin::ANDROID_HINT_LABEL;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible, ModalScrim, ScrimDismissible,
}; };
use crate::ui_theme::{SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL}; use crate::ui_theme::{SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@@ -65,6 +67,7 @@ fn toggle_help_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<HelpRequestEvent>, mut requests: MessageReader<HelpRequestEvent>,
screens: Query<Entity, With<HelpScreen>>, screens: Query<Entity, With<HelpScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<HelpScreen>)>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
// Either F1 or a click on the HUD "Help" button (which fires // Either F1 or a click on the HUD "Help" button (which fires
@@ -75,7 +78,7 @@ fn toggle_help_screen(
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else if other_modal_scrims.is_empty() {
spawn_help_screen(&mut commands, font_res.as_deref()); spawn_help_screen(&mut commands, font_res.as_deref());
} }
} }
@@ -158,7 +161,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlRow { keys: "", description: "Undo last move" }, ControlRow { keys: "", description: "Undo last move" },
ControlRow { keys: "||", description: "Pause / resume" }, ControlRow { keys: "||", description: "Pause / resume" },
ControlRow { keys: "?", description: "This help screen" }, ControlRow { keys: "?", description: "This help screen" },
ControlRow { keys: "", description: "Show a hint" }, ControlRow { keys: ANDROID_HINT_LABEL, description: "Show a hint" },
ControlRow { keys: "", description: "Open menu (Stats, Settings, Profile...)" }, ControlRow { keys: "", description: "Open menu (Stats, Settings, Profile...)" },
], ],
}, },
@@ -346,6 +349,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
mod tests { mod tests {
use super::*; use super::*;
/// Regression test for M-17: Android help screen showed "→" (right-arrow)
/// for the Hint button when the actual HUD button label is "!".
/// Verifies that the HUD Buttons section contains exactly one row whose
/// `keys` matches `ANDROID_HINT_LABEL`.
#[cfg(target_os = "android")]
#[test]
fn android_hint_row_matches_hud_label() {
use crate::hud_plugin::ANDROID_HINT_LABEL;
let hud_section = CONTROL_SECTIONS
.iter()
.find(|s| s.title == "HUD buttons")
.expect("HUD buttons section must exist");
let hint_row = hud_section
.rows
.iter()
.find(|r| r.description == "Show a hint")
.expect("hint row must exist");
assert_eq!(
hint_row.keys, ANDROID_HINT_LABEL,
"help hint row must match the HUD button label"
);
}
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin); app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
+17 -3
View File
@@ -35,6 +35,7 @@ use crate::stats_plugin::StatsResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable}; use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalButton,
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
@@ -373,6 +374,7 @@ fn toggle_home_screen(
daily: Option<Res<DailyChallengeResource>>, daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>, screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
diff_expanded: Res<DifficultyExpanded>, diff_expanded: Res<DifficultyExpanded>,
) { ) {
if !keys.just_pressed(KeyCode::KeyM) { if !keys.just_pressed(KeyCode::KeyM) {
@@ -380,7 +382,7 @@ fn toggle_home_screen(
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else if other_modal_scrims.is_empty() {
spawn_home_screen( spawn_home_screen(
&mut commands, &mut commands,
build_home_context( build_home_context(
@@ -428,7 +430,7 @@ fn build_home_context<'a>(
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score), challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today, daily_today,
draw_mode: settings draw_mode: settings
.map(|s| s.0.draw_mode.clone()) .map(|s| s.0.draw_mode)
.unwrap_or(DrawMode::DrawOne), .unwrap_or(DrawMode::DrawOne),
font_res, font_res,
difficulty_expanded, difficulty_expanded,
@@ -589,6 +591,7 @@ fn handle_home_draw_mode_buttons(
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>, one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>, three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>, screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut settings: Option<ResMut<SettingsResource>>, mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>, storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>, mut changed: MessageWriter<SettingsChangedEvent>,
@@ -601,6 +604,12 @@ fn handle_home_draw_mode_buttons(
if screens.is_empty() { if screens.is_empty() {
return; return;
} }
// Don't respawn while another modal sits on top — the despawn queues
// immediately but executes at end of frame, so a respawn in the same
// frame would create a second concurrent ModalScrim.
if !other_modal_scrims.is_empty() {
return;
}
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed); let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed); let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
if !want_one && !want_three { if !want_one && !want_three {
@@ -658,6 +667,7 @@ fn handle_home_difficulty_toggle(
mut commands: Commands, mut commands: Commands,
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>, toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>, screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut diff_expanded: ResMut<DifficultyExpanded>, mut diff_expanded: ResMut<DifficultyExpanded>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
@@ -668,6 +678,9 @@ fn handle_home_difficulty_toggle(
if screens.is_empty() { if screens.is_empty() {
return; return;
} }
if !other_modal_scrims.is_empty() {
return;
}
if !toggles.iter().any(|i| *i == Interaction::Pressed) { if !toggles.iter().any(|i| *i == Interaction::Pressed) {
return; return;
} }
@@ -1103,7 +1116,7 @@ fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() }; let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() }; let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
let chevron = if ctx.difficulty_expanded { "" } else { "" }; let chevron = if ctx.difficulty_expanded { "v" } else { ">" };
// Header row — click to toggle expand/collapse. // Header row — click to toggle expand/collapse.
parent parent
@@ -1337,6 +1350,7 @@ fn spawn_mode_card(
// bevy::ui — the click handler queries on `&Interaction` // bevy::ui — the click handler queries on `&Interaction`
// which Button drives. // which Button drives.
Button, Button,
ModalButton(ButtonVariant::Secondary),
Node { Node {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2, row_gap: VAL_SPACE_2,
+137 -30
View File
@@ -20,7 +20,7 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT; use crate::layout::HUD_BAND_HEIGHT;
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop, SafeAreaInsets}; use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
use crate::ui_theme::SPACE_2; use crate::ui_theme::SPACE_2;
use crate::ui_theme::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
@@ -45,7 +45,7 @@ use crate::layout::LayoutSystem;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use crate::resources::DragState; use crate::resources::{DragState, GameInputConsumedResource};
use crate::selection_plugin::SelectionState; use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable}; use crate::ui_focus::{FocusGroup, Focusable};
@@ -140,6 +140,12 @@ pub struct HudColumn;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HudActionBar; pub struct HudActionBar;
/// Marker on the text node inside each action-bar button (Android only).
/// Used by `resize_action_bar_labels` to update font size on window resize.
#[cfg(target_os = "android")]
#[derive(Component, Debug)]
struct ActionButtonLabel;
/// Marker on the circular profile-picture button anchored to the /// Marker on the circular profile-picture button anchored to the
/// top-right of the HUD band. Pressing it opens the Profile overlay. /// top-right of the HUD band. Pressing it opens the Profile overlay.
/// Shows the server avatar image when loaded; falls back to the player's /// Shows the server avatar image when loaded; falls back to the player's
@@ -298,6 +304,11 @@ pub struct HelpButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HintButton; pub struct HintButton;
/// Android HUD label for the Hint button — shared with the help screen's
/// controls reference so both always agree.
#[cfg(target_os = "android")]
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`] /// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
/// (a small dropdown panel) below the action bar. Each popover row starts /// (a small dropdown panel) below the action bar. Each popover row starts
/// the corresponding game mode. /// the corresponding game mode.
@@ -366,6 +377,9 @@ pub enum MenuOption {
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module /// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
/// can use it as a `const` without a non-const expression in `ZIndex(...)`. /// can use it as a `const` without a non-const expression in `ZIndex(...)`.
const Z_HUD: i32 = crate::ui_theme::Z_HUD; const Z_HUD: i32 = crate::ui_theme::Z_HUD;
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
/// Idle / hover / pressed colours shared by every action button. Aliased /// Idle / hover / pressed colours shared by every action button. Aliased
/// to the theme tokens so the HUD picks up palette changes for free. /// to the theme tokens so the HUD picks up palette changes for free.
@@ -417,7 +431,13 @@ impl Plugin for HudPlugin {
.add_systems(Update, (update_hud_avatar, handle_avatar_button)) .add_systems(Update, (update_hud_avatar, handle_avatar_button))
.add_systems(Update, update_won_previously.after(GameMutation)) .add_systems(Update, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud) .add_systems(
Update,
update_selection_hud.run_if(
resource_exists_and_changed::<SelectionState>
.or(resource_exists_and_changed::<GameStateResource>),
),
)
.add_systems(Update, update_hud_typography) .add_systems(Update, update_hud_typography)
.add_systems( .add_systems(
Update, Update,
@@ -475,6 +495,11 @@ impl Plugin for HudPlugin {
.after(TouchDragSet::AfterStartDrag) .after(TouchDragSet::AfterStartDrag)
.in_set(TouchDragSet::BeforeEndDrag), .in_set(TouchDragSet::BeforeEndDrag),
); );
app.add_systems(
Update,
resize_action_bar_labels
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
);
} }
} }
} }
@@ -486,13 +511,12 @@ impl Plugin for HudPlugin {
/// The entity carries no `BackgroundColor` — the green felt shows through. /// The entity carries no `BackgroundColor` — the green felt shows through.
/// A slim grey background is handled by each content section individually /// A slim grey background is handled by each content section individually
/// (the bottom action bar has its own `BG_HUD_BAND` background). /// (the bottom action bar has its own `BG_HUD_BAND` background).
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) { fn spawn_hud_band(mut commands: Commands) {
const BASE_TOP: f32 = 0.0; const BASE_TOP: f32 = 0.0;
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
commands.spawn(( commands.spawn((
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
top: Val::Px(BASE_TOP + top_inset), top: Val::Px(BASE_TOP),
left: Val::Px(0.0), left: Val::Px(0.0),
width: Val::Percent(100.0), width: Val::Percent(100.0),
height: Val::Px(HUD_BAND_HEIGHT), height: Val::Px(HUD_BAND_HEIGHT),
@@ -525,10 +549,8 @@ fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
/// make Score the visual protagonist. /// make Score the visual protagonist.
fn spawn_hud( fn spawn_hud(
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont { let font_score = TextFont {
font: font_handle.clone(), font: font_handle.clone(),
@@ -568,7 +590,7 @@ fn spawn_hud(
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
left: VAL_SPACE_3, left: VAL_SPACE_3,
top: Val::Px(SPACE_2 + top_inset), top: Val::Px(SPACE_2),
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
// Cap the column at 50% of viewport so on narrow // Cap the column at 50% of viewport so on narrow
// (mobile) widths the inner tier rows have a bounded // (mobile) widths the inner tier rows have a bounded
@@ -701,13 +723,11 @@ fn spawn_hud(
/// `AvatarResource` or `SettingsResource` later changes. /// `AvatarResource` or `SettingsResource` later changes.
fn spawn_hud_avatar( fn spawn_hud_avatar(
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
avatar: Option<Res<AvatarResource>>, avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
const SIZE: f32 = 32.0; const SIZE: f32 = 32.0;
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let id = commands let id = commands
.spawn(( .spawn((
HudAvatar, HudAvatar,
@@ -715,7 +735,7 @@ fn spawn_hud_avatar(
Tooltip::new("Your profile — tap to open."), Tooltip::new("Your profile — tap to open."),
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
top: Val::Px(SPACE_2 + top_inset), top: Val::Px(SPACE_2),
right: VAL_SPACE_3, right: VAL_SPACE_3,
width: Val::Px(SIZE), width: Val::Px(SIZE),
height: Val::Px(SIZE), height: Val::Px(SIZE),
@@ -834,13 +854,25 @@ fn handle_avatar_button(
/// on its own visual edge. /// on its own visual edge.
fn spawn_action_buttons( fn spawn_action_buttons(
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>, windows: Query<&Window>,
mut commands: Commands, mut commands: Commands,
) { ) {
let bottom_inset = insets.as_deref().copied().unwrap_or_default().bottom; // On Android the glyph labels must scale with the viewport so they remain
// legible on any screen density. Use the window width at startup; the
// resize_action_bar_labels system keeps this current on window changes.
#[cfg(target_os = "android")]
let action_font_size = {
let w = windows.iter().next().map_or(900.0, |win| win.width());
action_bar_font_size(w)
};
#[cfg(not(target_os = "android"))]
let action_font_size = TYPE_BODY;
#[cfg(not(target_os = "android"))]
let _windows = windows;
let font = TextFont { let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY, font_size: action_font_size,
..default() ..default()
}; };
@@ -857,29 +889,29 @@ fn spawn_action_buttons(
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono) /* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono /* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
/* help */ "?", /* help */ "?",
/* hint */ "!", // ! attention/alert — semantically: "look here" /* hint */ ANDROID_HINT_LABEL,
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono /* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
/* new */ "+", /* new */ "+",
); );
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
let labels = ( let labels = (
"Menu \u{25BE}", "Menu \u{2193}",
"Undo", "Undo",
"Pause", "Pause",
"Help", "Help",
"Hint", "Hint",
"Modes \u{25BE}", "Modes \u{2193}",
"New Game", "New Game",
); );
// Bottom bar: full-width, centered, sits above the gesture-navigation zone. // Bottom bar: full-width, centered, sits above the gesture-navigation zone.
// `bottom` is set to `bottom_inset` initially; `SafeAreaAnchoredBottom` keeps // `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
// it correct as Android insets arrive in later frames. // Android reports it (frames 1-3); initial value is 0.0.
commands commands
.spawn(( .spawn((
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
bottom: Val::Px(bottom_inset), bottom: Val::Px(0.0),
left: Val::Px(0.0), left: Val::Px(0.0),
width: Val::Percent(100.0), width: Val::Percent(100.0),
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
@@ -957,7 +989,7 @@ fn spawn_action_button<M: Component>(
// centred with room to breathe. On desktop, keep the comfortable 48 dp // centred with room to breathe. On desktop, keep the comfortable 48 dp
// floor and 8 dp side padding. // floor and 8 dp side padding.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0)); let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0));
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0)); let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
@@ -985,6 +1017,9 @@ fn spawn_action_button<M: Component>(
HighContrastBorder::with_default(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
#[cfg(target_os = "android")]
b.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color)));
#[cfg(not(target_os = "android"))]
b.spawn((Text::new(label), font.clone(), TextColor(text_color))); b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
if let Some(key) = hotkey { if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label — // Hotkey hint rendered as a dim caption next to the label —
@@ -1180,7 +1215,7 @@ fn spawn_modes_popover(
..default() ..default()
}, },
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5), ZIndex(Z_HUD_POPOVER),
)) ))
.with_children(|panel| { .with_children(|panel| {
for (option, label, tooltip) in rows { for (option, label, tooltip) in rows {
@@ -1207,8 +1242,8 @@ fn spawn_modes_popover(
} }
}); });
// Fullscreen transparent backdrop at Z_HUD+4 (below the popover at // Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
// Z_HUD+5) so tapping outside the panel light-dismisses it. // popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
commands.spawn(( commands.spawn((
ModesPopoverBackdrop, ModesPopoverBackdrop,
Button, Button,
@@ -1221,7 +1256,7 @@ fn spawn_modes_popover(
..default() ..default()
}, },
BackgroundColor(Color::NONE), BackgroundColor(Color::NONE),
ZIndex(Z_HUD + 4), ZIndex(Z_HUD_POPOVER_BACKDROP),
)); ));
} }
@@ -1378,7 +1413,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
..default() ..default()
}, },
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5), ZIndex(Z_HUD_POPOVER),
)) ))
.with_children(|panel| { .with_children(|panel| {
for (option, label, tooltip) in rows { for (option, label, tooltip) in rows {
@@ -1419,7 +1454,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
..default() ..default()
}, },
BackgroundColor(Color::NONE), BackgroundColor(Color::NONE),
ZIndex(Z_HUD + 4), ZIndex(Z_HUD_POPOVER_BACKDROP),
)); ));
} }
@@ -1748,6 +1783,11 @@ fn detect_score_change(
return; return;
} }
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
if reduce_motion {
return;
}
let speed = settings let speed = settings
.as_ref() .as_ref()
.map(|s| s.0.animation_speed) .map(|s| s.0.animation_speed)
@@ -1793,7 +1833,7 @@ fn detect_score_change(
top: Val::Px(0.0), top: Val::Px(0.0),
..default() ..default()
}, },
ZIndex(Z_HUD + 10), ZIndex(Z_HUD_TOP),
Text::new(format!("+{delta}")), Text::new(format!("+{delta}")),
font, font,
TextColor(ACCENT_PRIMARY), TextColor(ACCENT_PRIMARY),
@@ -1921,6 +1961,9 @@ fn start_streak_flourish(
let Some(latest) = events.read().last() else { let Some(latest) = events.read().last() else {
return; return;
}; };
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
return;
}
let speed = settings let speed = settings
.as_ref() .as_ref()
.map(|s| s.0.animation_speed) .map(|s| s.0.animation_speed)
@@ -2468,6 +2511,32 @@ fn restore_hud_on_modal(
} }
} }
/// Returns the action-bar glyph font size for a given logical window width.
/// Scales linearly so glyphs remain legible at any phone density.
#[cfg(target_os = "android")]
fn action_bar_font_size(window_width: f32) -> f32 {
// ~1/40 of the window width gives ~22 px on a 900 logical-px phone.
// Clamped so it never goes too tiny on narrow viewports or too large
// on landscape tablets.
(window_width / 40.0).clamp(16.0, 30.0)
}
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
/// current viewport width whenever [`LayoutResource`] changes (orientation
/// change or window resize).
#[cfg(target_os = "android")]
fn resize_action_bar_labels(
layout: Res<crate::layout::LayoutResource>,
windows: Query<&Window>,
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
) {
let w = windows.iter().next().map_or(layout.0.card_size.x * 7.25, |win| win.width());
let new_size = action_bar_font_size(w);
for mut font in &mut labels {
font.font_size = new_size;
}
}
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
fn toggle_hud_on_tap( fn toggle_hud_on_tap(
mut touch_events: MessageReader<bevy::input::touch::TouchInput>, mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
@@ -2477,6 +2546,7 @@ fn toggle_hud_on_tap(
mut tracker: ResMut<HudTapTracker>, mut tracker: ResMut<HudTapTracker>,
mut hud_vis: ResMut<HudVisibility>, mut hud_vis: ResMut<HudVisibility>,
buttons: Query<&Interaction, With<ActionButton>>, buttons: Query<&Interaction, With<ActionButton>>,
mut game_consumed: ResMut<GameInputConsumedResource>,
) { ) {
use bevy::input::touch::TouchPhase; use bevy::input::touch::TouchPhase;
if !scrims.is_empty() || paused.is_some_and(|p| p.0) { if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
@@ -2487,6 +2557,7 @@ fn toggle_hud_on_tap(
for _ in touch_events.read() {} for _ in touch_events.read() {}
tracker.start_pos = None; tracker.start_pos = None;
tracker.started_on_button = false; tracker.started_on_button = false;
game_consumed.0 = false;
return; return;
} }
for event in touch_events.read() { for event in touch_events.read() {
@@ -2500,7 +2571,13 @@ fn toggle_hud_on_tap(
buttons.iter().any(|i| *i != Interaction::None); buttons.iter().any(|i| *i != Interaction::None);
} }
TouchPhase::Ended if drag.is_idle() => { TouchPhase::Ended if drag.is_idle() => {
let on_button = tracker.started_on_button; // Also treat taps where game logic consumed the touch (e.g.
// drawing from stock) as "on button" so they don't toggle
// the HUD. The flag is set on TouchPhase::Started by the
// input system that consumed the tap and must be cleared here
// regardless of whether we toggle.
let on_button = tracker.started_on_button || game_consumed.0;
game_consumed.0 = false;
if let Some(start) = tracker.start_pos.take() { if let Some(start) = tracker.start_pos.take() {
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX { if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX {
*hud_vis = match *hud_vis { *hud_vis = match *hud_vis {
@@ -2517,6 +2594,7 @@ fn toggle_hud_on_tap(
TouchPhase::Canceled => { TouchPhase::Canceled => {
tracker.start_pos = None; tracker.start_pos = None;
tracker.started_on_button = false; tracker.started_on_button = false;
game_consumed.0 = false;
} }
_ => {} _ => {}
} }
@@ -3004,6 +3082,35 @@ mod tests {
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5); assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
} }
// -----------------------------------------------------------------------
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
// -----------------------------------------------------------------------
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
#[test]
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
use solitaire_data::Settings;
let mut app = headless_app();
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
0,
"ScorePulse must not spawn under reduce-motion"
);
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"ScoreFloater must not spawn under reduce-motion"
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — HUD action bar // Phase 2: keyboard focus ring — HUD action bar
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+27 -11
View File
@@ -47,7 +47,8 @@ use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource, HintCycleIndex}; use crate::replay_playback::ReplayPlaybackState;
use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex};
use crate::selection_plugin::SelectionState; use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
@@ -95,6 +96,7 @@ impl Plugin for InputPlugin {
app.init_resource::<HintCycleIndex>() app.init_resource::<HintCycleIndex>()
.init_resource::<HintSolverConfig>() .init_resource::<HintSolverConfig>()
.init_resource::<crate::pending_hint::PendingHintTask>() .init_resource::<crate::pending_hint::PendingHintTask>()
.init_resource::<GameInputConsumedResource>()
.add_message::<StartZenRequestEvent>() .add_message::<StartZenRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ForfeitRequestEvent>() .add_message::<ForfeitRequestEvent>()
@@ -174,11 +176,20 @@ fn handle_keyboard_core(
mut zen_requests: MessageReader<StartZenRequestEvent>, mut zen_requests: MessageReader<StartZenRequestEvent>,
confirm_screens: Query<(), With<ConfirmNewGameScreen>>, confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
restore_prompts: Query<(), With<RestorePromptScreen>>, restore_prompts: Query<(), With<RestorePromptScreen>>,
replay_state: Option<Res<ReplayPlaybackState>>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// During replay playback (Playing or Completed) all game-input shortcuts
// are suppressed. The replay overlay owns Space (pause/resume) and the
// arrow keys (step). Letting game input through would mutate
// `GameStateResource` and corrupt replay determinism.
if replay_state.is_some_and(|r| !matches!(*r, ReplayPlaybackState::Inactive)) {
return;
}
if keys.just_pressed(KeyCode::KeyU) { if keys.just_pressed(KeyCode::KeyU) {
ev.undo.write(UndoRequestEvent); ev.undo.write(UndoRequestEvent);
} }
@@ -501,6 +512,7 @@ fn handle_touch_stock_tap(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
drag: Res<DragState>, drag: Res<DragState>,
mut draw: MessageWriter<DrawRequestEvent>, mut draw: MessageWriter<DrawRequestEvent>,
mut game_consumed: ResMut<GameInputConsumedResource>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
@@ -522,6 +534,7 @@ fn handle_touch_stock_tap(
}; };
if point_in_rect(world, stock_pos, layout.0.card_size) { if point_in_rect(world, stock_pos, layout.0.card_size) {
draw.write(DrawRequestEvent); draw.write(DrawRequestEvent);
game_consumed.0 = true;
break; // one draw per tap frame break; // one draw per tap frame
} }
} }
@@ -717,13 +730,12 @@ fn end_drag(
let ok = match &target { let ok = match &target {
PileType::Foundation(_) => { PileType::Foundation(_) => {
count == 1 count == 1
&& can_place_on_foundation( && game.0.piles.get(&target)
&bottom_card, .is_some_and(|p| can_place_on_foundation(&bottom_card, p))
&game.0.piles[&target],
)
} }
PileType::Tableau(_) => { PileType::Tableau(_) => {
can_place_on_tableau(&bottom_card, &game.0.piles[&target]) game.0.piles.get(&target)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
} }
_ => false, _ => false,
}; };
@@ -941,10 +953,10 @@ fn touch_end_drag(
continue; continue;
} }
// Uncommitted tap — cancel cleanly. // Uncommitted tap — cancel cleanly. No StateChangedEvent: nothing
// changed. The mouse path (end_drag) follows the same convention.
if !drag.committed { if !drag.committed {
drag.clear(); drag.clear();
changed.write(StateChangedEvent);
return; return;
} }
@@ -972,10 +984,12 @@ fn touch_end_drag(
let ok = match &target { let ok = match &target {
PileType::Foundation(_) => { PileType::Foundation(_) => {
count == 1 count == 1
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target]) && game.0.piles.get(&target)
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
} }
PileType::Tableau(_) => { PileType::Tableau(_) => {
can_place_on_tableau(&bottom_card, &game.0.piles[&target]) game.0.piles.get(&target)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
} }
_ => false, _ => false,
}; };
@@ -1161,7 +1175,9 @@ fn find_draggable_at(
(i, pile_cards.cards.len()) (i, pile_cards.cards.len())
} else { } else {
if i != pile_cards.cards.len() - 1 { if i != pile_cards.cards.len() - 1 {
return None; // Non-top card on a non-tableau pile — not draggable; skip
// this pile and continue searching remaining piles.
break;
} }
(i, i + 1) (i, i + 1)
}; };
+31 -7
View File
@@ -96,13 +96,33 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
/// below this band so the HUD doesn't bleed into the play surface. /// below this band so the HUD doesn't bleed into the play surface.
/// ///
/// Desktop: 64 px fits the score/moves/time + mode badge rows. /// Desktop: 64 px fits the score/moves/time + mode badge rows.
/// Android: 80 px gives the same content rows comfortable clearance. /// Android: 112 px — the HUD column has 4 flex tiers with 3 inter-tier
/// (Previously 128 px when action buttons lived in the top band; those are /// gaps (4 px each) plus a SPACE_2 = 8 px top offset. With empty tiers
/// now in the bottom bar so the larger reserve is no longer needed.) /// still contributing gap height in Bevy's flex layout, the actual HUD
/// height can reach ~80 px before the grid starts; 112 px gives ~28 px
/// of clearance between the HUD bottom and the top card edge, preventing
/// the overlap seen with the previous 80 px value.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub const HUD_BAND_HEIGHT: f32 = 64.0; pub const HUD_BAND_HEIGHT: f32 = 64.0;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub const HUD_BAND_HEIGHT: f32 = 80.0; pub const HUD_BAND_HEIGHT: f32 = 112.0;
/// Height of the bottom action-bar (the row of ≡ ← || ? ! M + buttons).
///
/// The action bar sits *above* the OS gesture/navigation zone, so it is NOT
/// covered by `safe_area_bottom`. `compute_layout` adds this constant to
/// `safe_area_bottom` before computing the height-based card-size candidate
/// and the available tableau height, ensuring the deepest fanned column
/// never scrolls behind the button row.
///
/// Derivation (Android): `min_height 44 px` buttons
/// + `padding.top 8 px` + `padding.bottom 8 px` outer bar padding = **60 px**.
///
/// Desktop: no persistent bottom bar, so 0.
#[cfg(not(target_os = "android"))]
const BOTTOM_BAR_HEIGHT: f32 = 0.0;
#[cfg(target_os = "android")]
const BOTTOM_BAR_HEIGHT: f32 = 60.0;
/// Table background colour (dark green felt). /// Table background colour (dark green felt).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196]; pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -123,7 +143,7 @@ pub struct Layout {
pub pile_positions: HashMap<PileType, Vec2>, pub pile_positions: HashMap<PileType, Vec2>,
/// Per-step vertical offset fraction for face-up tableau cards, as a /// Per-step vertical offset fraction for face-up tableau cards, as a
/// fraction of `card_size.y`. On height-limited (desktop) windows this /// fraction of `card_size.y`. On height-limited (desktop) windows this
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone) /// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
/// windows it expands to fill the available vertical space so the tableau /// windows it expands to fill the available vertical space so the tableau
/// stretches to the bottom of the screen. Card rendering (`card_plugin`) /// stretches to the bottom of the screen. Card rendering (`card_plugin`)
/// and hit testing (`input_plugin`) both read from this field so they /// and hit testing (`input_plugin`) both read from this field so they
@@ -187,9 +207,13 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the // Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
// largest w that fits gives: // largest w that fits gives:
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT) // (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
// Reserve space for both the OS gesture/nav bar and the app's own action
// bar, which sits above it and is invisible to safe_area_bottom.
let effective_safe_bottom = safe_area_bottom + BOTTOM_BAR_HEIGHT;
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC; let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT; let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - band_h).max(0.0) / height_denom; let card_width_height_based = (window.y - safe_area_top - effective_safe_bottom - band_h).max(0.0) / height_denom;
let card_width = card_width_width_based.min(card_width_height_based); let card_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT; let card_height = card_width * CARD_ASPECT;
@@ -238,7 +262,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
// //
// avail = distance from the top of the first tableau card to the bottom // avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps. // margin — i.e. the space available for 12 fan steps.
let avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + h_gap) - card_height / 2.0).max(0.0); let avail = (tableau_y - (-window.y / 2.0 + effective_safe_bottom + h_gap) - card_height / 2.0).max(0.0);
let ideal_fan_frac = if card_height > 0.0 { let ideal_fan_frac = if card_height > 0.0 {
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height) avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
} else { } else {
+323 -10
View File
@@ -15,13 +15,13 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::{save_settings_to, settings::SyncBackend}; use solitaire_data::{save_settings_to, settings::SyncBackend};
use solitaire_sync::LeaderboardEntry; use solitaire_sync::LeaderboardEntry;
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent}; use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent, WarningToastEvent};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible, ModalScrim, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
@@ -138,6 +138,8 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<OptOutTask>() .init_resource::<OptOutTask>()
.init_resource::<DisplayNameBuffer>() .init_resource::<DisplayNameBuffer>()
.add_message::<ToggleLeaderboardRequestEvent>() .add_message::<ToggleLeaderboardRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<InfoToastEvent>()
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input // `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
// plugin under `DefaultPlugins`; register them explicitly so all // plugin under `DefaultPlugins`; register them explicitly so all
// leaderboard systems run cleanly under `MinimalPlugins` in tests. // leaderboard systems run cleanly under `MinimalPlugins` in tests.
@@ -159,6 +161,7 @@ impl Plugin for LeaderboardPlugin {
handle_display_name_text_input, handle_display_name_text_input,
handle_display_name_confirm, handle_display_name_confirm,
handle_display_name_cancel, handle_display_name_cancel,
update_leaderboard_public_name_label,
) )
.chain(), .chain(),
) )
@@ -350,7 +353,7 @@ fn handle_opt_in_button(
None None
} }
}) })
.map(str::to_string) .map(|n| n.chars().take(32).collect::<String>())
}) })
.unwrap_or_else(|| "Player".to_string()); .unwrap_or_else(|| "Player".to_string());
@@ -361,10 +364,13 @@ fn handle_opt_in_button(
} }
} }
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure. /// Polls the opt-in task; fires a toast and persists opted-in state on completion.
fn poll_opt_in_task( fn poll_opt_in_task(
mut task_res: ResMut<OptInTask>, mut task_res: ResMut<OptInTask>,
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
mut warn_toast: MessageWriter<WarningToastEvent>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) { ) {
let Some(task) = task_res.0.as_mut() else { return }; let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return }; let Some(result) = future::block_on(future::poll_once(task)) else { return };
@@ -372,10 +378,18 @@ fn poll_opt_in_task(
match result { match result {
Ok(()) => { Ok(()) => {
toast.write(InfoToastEvent("Opted in to leaderboard".to_string())); toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
if let Some(mut s) = settings {
s.0.leaderboard_opted_in = true;
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &s.0)
{
warn!("failed to save settings after opt-in: {e}");
}
}
} }
Err(e) => { Err(e) => {
warn!("leaderboard opt-in failed: {e}"); warn!("leaderboard opt-in failed: {e}");
toast.write(InfoToastEvent("Leaderboard update failed".to_string())); warn_toast.write(WarningToastEvent("Failed to join leaderboard".to_string()));
} }
} }
} }
@@ -401,10 +415,13 @@ fn handle_opt_out_button(
} }
} }
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure. /// Polls the opt-out task; fires a toast and clears opted-in state on completion.
fn poll_opt_out_task( fn poll_opt_out_task(
mut task_res: ResMut<OptOutTask>, mut task_res: ResMut<OptOutTask>,
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
mut warn_toast: MessageWriter<WarningToastEvent>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) { ) {
let Some(task) = task_res.0.as_mut() else { return }; let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return }; let Some(result) = future::block_on(future::poll_once(task)) else { return };
@@ -412,10 +429,18 @@ fn poll_opt_out_task(
match result { match result {
Ok(()) => { Ok(()) => {
toast.write(InfoToastEvent("Opted out of leaderboard".to_string())); toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
if let Some(mut s) = settings {
s.0.leaderboard_opted_in = false;
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &s.0)
{
warn!("failed to save settings after opt-out: {e}");
}
}
} }
Err(e) => { Err(e) => {
warn!("leaderboard opt-out failed: {e}"); warn!("leaderboard opt-out failed: {e}");
toast.write(InfoToastEvent("Leaderboard update failed".to_string())); warn_toast.write(WarningToastEvent("Failed to leave leaderboard".to_string()));
} }
} }
} }
@@ -428,6 +453,12 @@ fn poll_opt_out_task(
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct LeaderboardCloseButton; pub struct LeaderboardCloseButton;
/// Marker on the "Public name: …" label inside the leaderboard panel so it
/// can be updated reactively when the player changes their display name
/// without a full panel rebuild.
#[derive(Component, Debug)]
struct LeaderboardPublicNameText;
fn spawn_leaderboard_screen( fn spawn_leaderboard_screen(
commands: &mut Commands, commands: &mut Commands,
data: &LeaderboardResource, data: &LeaderboardResource,
@@ -481,6 +512,7 @@ fn spawn_leaderboard_screen(
None => "Public name: (same as username)".to_string(), None => "Public name: (same as username)".to_string(),
}; };
row.spawn(( row.spawn((
LeaderboardPublicNameText,
Text::new(label), Text::new(label),
font_caption.clone(), font_caption.clone(),
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
@@ -683,6 +715,7 @@ fn data_cell(
fn handle_set_display_name_button( fn handle_set_display_name_button(
button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>, button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>,
existing: Query<(), With<DisplayNameModal>>, existing: Query<(), With<DisplayNameModal>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<DisplayNameModal>)>,
mut commands: Commands, mut commands: Commands,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
@@ -694,6 +727,9 @@ fn handle_set_display_name_button(
if !existing.is_empty() { if !existing.is_empty() {
return; // already open return; // already open
} }
if !other_modal_scrims.is_empty() {
return; // Another modal is already visible.
}
buf.0 = settings buf.0 = settings
.as_ref() .as_ref()
.and_then(|s| s.0.leaderboard_display_name.clone()) .and_then(|s| s.0.leaderboard_display_name.clone())
@@ -733,7 +769,9 @@ fn handle_display_name_text_input(
} }
} }
/// Saves the typed display name to `SettingsResource` and closes the modal. /// Saves the typed display name to `SettingsResource`, closes the modal, and
/// pushes the new name to the server when the player is already opted in.
#[allow(clippy::too_many_arguments)]
fn handle_display_name_confirm( fn handle_display_name_confirm(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>, button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
screens: Query<Entity, With<DisplayNameModal>>, screens: Query<Entity, With<DisplayNameModal>>,
@@ -741,22 +779,58 @@ fn handle_display_name_confirm(
buf: Res<DisplayNameBuffer>, buf: Res<DisplayNameBuffer>,
settings: Option<ResMut<SettingsResource>>, settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>, settings_path: Option<Res<SettingsStoragePath>>,
provider: Option<Res<SyncProviderResource>>,
mut task_res: ResMut<OptInTask>,
) { ) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) { if !button_q.iter().any(|i| *i == Interaction::Pressed) {
return; return;
} }
if let Some(mut settings) = settings { if let Some(mut settings) = settings {
let trimmed = buf.0.trim().to_string(); let trimmed: String = buf.0.trim().chars().take(32).collect();
settings.0.leaderboard_display_name = if trimmed.is_empty() { settings.0.leaderboard_display_name = if trimmed.is_empty() {
None None
} else { } else {
Some(trimmed) Some(trimmed.clone())
}; };
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref()) if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &settings.0) && let Err(e) = save_settings_to(path, &settings.0)
{ {
warn!("failed to save settings: {e}"); warn!("failed to save settings: {e}");
} }
// Push updated name to the server when already opted in and no task
// is in flight. The server's opt-in endpoint is an upsert, so calling
// it a second time only updates the display_name column.
let is_remote = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
if settings.0.leaderboard_opted_in && is_remote && task_res.0.is_none() {
let display_name = settings
.0
.leaderboard_display_name
.clone()
.unwrap_or_else(|| {
if let solitaire_data::settings::SyncBackend::SolitaireServer {
ref username,
..
} = settings.0.sync_backend
{
username.chars().take(32).collect()
} else {
"Player".to_string()
}
});
if let Some(p) = provider {
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.opt_in_leaderboard(&display_name)
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
} }
for entity in &screens { for entity in &screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
@@ -857,6 +931,25 @@ fn spawn_display_name_modal(
}); });
} }
/// Keeps the "Public name: …" label in the leaderboard panel in sync with
/// `SettingsResource` after the player saves a new display name. No-op when
/// the panel is closed (`labels.is_empty()` exits immediately).
fn update_leaderboard_public_name_label(
settings: Option<Res<SettingsResource>>,
mut labels: Query<&mut Text, With<LeaderboardPublicNameText>>,
) {
if labels.is_empty() {
return;
}
let new_label = match settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()) {
Some(n) => format!("Public name: {n}"),
None => "Public name: (same as username)".to_string(),
};
for mut text in &mut labels {
text.0 = new_label.clone();
}
}
/// Accepts printable ASCII characters (0x200x7e) for the display-name field. /// Accepts printable ASCII characters (0x200x7e) for the display-name field.
fn printable_char_dn(text: &str) -> Option<char> { fn printable_char_dn(text: &str) -> Option<char> {
let ch = text.chars().next()?; let ch = text.chars().next()?;
@@ -1048,4 +1141,224 @@ mod tests {
// 65 seconds = 1:05, not 1:5 // 65 seconds = 1:05, not 1:5
assert_eq!(format_secs(65), "1:05"); assert_eq!(format_secs(65), "1:05");
} }
// -------------------------------------------------------------------------
// Bug-fix regression tests
// -------------------------------------------------------------------------
fn headless_app_with_settings() -> App {
let mut app = headless_app();
app.insert_resource(SettingsResource(solitaire_data::settings::Settings::default()));
app
}
/// Bug 1: opt-in errors must fire `WarningToastEvent`, not `InfoToastEvent`.
#[test]
fn opt_in_error_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with_settings();
// Inject a pre-resolved failed task directly into OptInTask.
let failed_task = AsyncComputeTaskPool::get()
.spawn(async { Err::<(), String>("network error".to_string()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
// Pump until the task is polled or a deadline elapses. A fixed
// update count is unreliable under parallel `cargo test --workspace`
// load — the AsyncComputeTaskPool background threads can be starved
// long enough that 5 updates finish before the task completes.
// Mirrors the deadline-loop pattern used in sync_plugin tests.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
if cursor.read(msgs).next().is_some() {
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
}
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
assert!(
cursor.read(msgs).next().is_some(),
"WarningToastEvent must be fired when opt-in fails"
);
}
/// Bug 1: opt-out errors must fire `WarningToastEvent`, not `InfoToastEvent`.
#[test]
fn opt_out_error_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with_settings();
let failed_task = AsyncComputeTaskPool::get()
.spawn(async { Err::<(), String>("network error".to_string()) });
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
if cursor.read(msgs).next().is_some() {
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
}
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
assert!(
cursor.read(msgs).next().is_some(),
"WarningToastEvent must be fired when opt-out fails"
);
}
/// Bug 2: successful opt-in must set `leaderboard_opted_in = true` in Settings.
#[test]
fn opt_in_success_sets_opted_in_flag() {
let mut app = headless_app_with_settings();
// Confirm the flag starts false.
assert!(!app
.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in);
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
if app
.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in
{
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
}
assert!(
app.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in,
"leaderboard_opted_in must be true after successful opt-in"
);
}
/// Bug 2: successful opt-out must clear `leaderboard_opted_in`.
#[test]
fn opt_out_success_clears_opted_in_flag() {
let mut app = headless_app_with_settings();
// Seed as opted in.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.leaderboard_opted_in = true;
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
if !app
.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in
{
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
}
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in,
"leaderboard_opted_in must be false after successful opt-out"
);
}
/// Bug 3: `LeaderboardPublicNameText` label must reflect a display-name
/// change applied to `SettingsResource` without a panel rebuild.
#[test]
fn public_name_label_updates_reactively() {
let mut app = headless_app_with_settings();
// Open the panel.
press(&mut app, KeyCode::KeyL);
app.update();
// Verify the label starts with the default copy.
let initial: String = app
.world_mut()
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
.iter(app.world())
.next()
.expect("LeaderboardPublicNameText must exist while panel is open")
.0
.clone();
assert!(
initial.contains("same as username"),
"initial label should say '(same as username)' when no display name is set"
);
// Clear just-pressed state so `toggle_leaderboard_screen` doesn't
// re-fire in the next frame (MinimalPlugins has no input-tick system).
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyL);
input.clear();
}
// Update the display name in SettingsResource.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.leaderboard_display_name = Some("TestPlayer".to_string());
app.update();
let updated: String = app
.world_mut()
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
.iter(app.world())
.next()
.expect("LeaderboardPublicNameText must still exist")
.0
.clone();
assert!(
updated.contains("TestPlayer"),
"label must reflect new display name after settings change"
);
}
} }
+9 -2
View File
@@ -235,7 +235,7 @@ fn toggle_pause(
// Snapshot current level and streak at pause time. // Snapshot current level and streak at pause time.
let level = progress.as_deref().map(|p| p.0.level); let level = progress.as_deref().map(|p| p.0.level);
let streak = stats.as_deref().map(|s| s.0.win_streak_current); let streak = stats.as_deref().map(|s| s.0.win_streak_current);
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode.clone()); let draw_mode = settings.as_deref().map(|s| s.0.draw_mode);
spawn_pause_screen( spawn_pause_screen(
&mut commands, &mut commands,
level, level,
@@ -437,10 +437,15 @@ fn close_forfeit_modal(
/// The player reaches these overlays via the HUD menu while paused, which /// The player reaches these overlays via the HUD menu while paused, which
/// causes both the pause modal and the overlay to be live simultaneously. /// causes both the pause modal and the overlay to be live simultaneously.
/// That is always unintentional — the overlay should own the screen. /// That is always unintentional — the overlay should own the screen.
/// Query filter for modals that are not part of the pause flow.
/// Excludes both `PauseScreen` (the pause modal itself) and
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
type NonPauseFamilyScrim = (With<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
fn auto_resume_on_overlay( fn auto_resume_on_overlay(
mut commands: Commands, mut commands: Commands,
pause_screens: Query<Entity, With<PauseScreen>>, pause_screens: Query<Entity, With<PauseScreen>>,
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>, other_modal_scrims: Query<Entity, NonPauseFamilyScrim>,
mut paused: ResMut<PausedResource>, mut paused: ResMut<PausedResource>,
) { ) {
if pause_screens.is_empty() || other_modal_scrims.is_empty() { if pause_screens.is_empty() || other_modal_scrims.is_empty() {
@@ -449,8 +454,10 @@ fn auto_resume_on_overlay(
for entity in &pause_screens { for entity in &pause_screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
if paused.0 {
paused.0 = false; paused.0 = false;
} }
}
/// Spawns the pause modal using the standard `ui_modal` scaffold — /// Spawns the pause modal using the standard `ui_modal` scaffold —
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary /// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
+12 -5
View File
@@ -138,12 +138,13 @@ fn handle_open_dialog(
mut requests: MessageReader<StartPlayBySeedRequestEvent>, mut requests: MessageReader<StartPlayBySeedRequestEvent>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
existing: Query<(), With<PlayBySeedScreen>>, existing: Query<(), With<PlayBySeedScreen>>,
other_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<PlayBySeedScreen>)>,
) { ) {
if requests.read().count() == 0 { if requests.read().count() == 0 {
return; return;
} }
// Guard against double-spawn (e.g. two events in one frame). // Guard against double-spawn (e.g. two events in one frame) or stacking over another modal.
if !existing.is_empty() { if !existing.is_empty() || !other_scrims.is_empty() {
return; return;
} }
let font = font_res.as_deref(); let font = font_res.as_deref();
@@ -338,7 +339,7 @@ fn tick_debounce_and_spawn_solver_task(
let draw_mode = settings let draw_mode = settings
.as_ref() .as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode.clone()); .map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let cfg = SolverConfig::default(); let cfg = SolverConfig::default();
let task = AsyncComputeTaskPool::get() let task = AsyncComputeTaskPool::get()
.spawn(async move { try_solve(seed, draw_mode, &cfg) }); .spawn(async move { try_solve(seed, draw_mode, &cfg) });
@@ -411,7 +412,11 @@ fn handle_confirm(
new_game.write(NewGameRequestEvent { new_game.write(NewGameRequestEvent {
seed: Some(seed), seed: Some(seed),
mode: None, mode: None,
confirmed: false, // The player explicitly clicked Play (or pressed Enter) after typing
// a seed — treat this as an affirmative confirmation so the
// abandon-current-game dialog is not shown on top of the already-
// dismissed seed dialog.
confirmed: true,
}); });
for entity in &screen { for entity in &screen {
@@ -566,7 +571,9 @@ mod tests {
assert_eq!(fired.len(), 1); assert_eq!(fired.len(), 1);
assert_eq!(fired[0].seed, Some(42)); assert_eq!(fired[0].seed, Some(42));
assert_eq!(fired[0].mode, None); assert_eq!(fired[0].mode, None);
assert!(!fired[0].confirmed); // confirmed: true — the player explicitly clicked Play, so no
// abandon-current-game dialog should appear.
assert!(fired[0].confirmed);
// Dialog should be gone. // Dialog should be gone.
assert!(!dialog_present(&mut app)); assert!(!dialog_present(&mut app));
+6 -3
View File
@@ -473,8 +473,11 @@ fn radial_open_on_long_press(
mut state: ResMut<RightClickRadialState>, mut state: ResMut<RightClickRadialState>,
) { ) {
// Guard: only count while a touch is down, uncommitted, and radial is idle. // Guard: only count while a touch is down, uncommitted, and radial is idle.
let active_id = drag.active_touch_id; let Some(active_id) = drag.active_touch_id else {
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.0) { *hold_timer = 0.0;
return;
};
if drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
*hold_timer = 0.0; *hold_timer = 0.0;
return; return;
} }
@@ -487,7 +490,7 @@ fn radial_open_on_long_press(
// Resolve current touch world position. // Resolve current touch world position.
let Some(touches) = touches else { return }; let Some(touches) = touches else { return };
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else { let Some(touch) = touches.iter().find(|t| t.id() == active_id) else {
return; return;
}; };
let Some((camera, cam_xf)) = cameras.single().ok() else { return }; let Some((camera, cam_xf)) = cameras.single().ok() else { return };
+32 -4
View File
@@ -28,7 +28,7 @@ use chrono::Datelike;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::replay_playback::{ use crate::replay_playback::{
step_backwards_replay_playback, step_replay_playback, stop_replay_playback, step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
toggle_pause_replay_playback, ReplayPlaybackState, toggle_pause_replay_playback, ReplayPlaybackState,
@@ -476,6 +476,7 @@ impl Plugin for ReplayOverlayPlugin {
.add_message::<MoveRequestEvent>() .add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>() .add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>() .add_message::<UndoRequestEvent>()
.add_message::<StateChangedEvent>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -1884,6 +1885,7 @@ fn handle_pause_keyboard(
/// resets to 0 on key release so the next fresh press fires /// resets to 0 on key release so the next fresh press fires
/// immediately. This matches the mockup's `[← →] scrub` /// immediately. This matches the mockup's `[← →] scrub`
/// terminology while keeping single-press = single-step semantics. /// terminology while keeping single-press = single-step semantics.
#[allow(clippy::too_many_arguments)]
fn handle_arrow_keyboard( fn handle_arrow_keyboard(
keys: Option<Res<ButtonInput<KeyCode>>>, keys: Option<Res<ButtonInput<KeyCode>>>,
time: Res<Time>, time: Res<Time>,
@@ -1892,10 +1894,22 @@ fn handle_arrow_keyboard(
mut moves_writer: MessageWriter<MoveRequestEvent>, mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>, mut draws_writer: MessageWriter<DrawRequestEvent>,
mut undo_writer: MessageWriter<UndoRequestEvent>, mut undo_writer: MessageWriter<UndoRequestEvent>,
mut state_changed: MessageReader<StateChangedEvent>,
// `true` while a backward step is in-flight: cursor was decremented and
// `UndoRequestEvent` was written, but `handle_undo` hasn't applied it yet.
// Cleared when `StateChangedEvent` confirms the game state has caught up.
// Prevents rapid ← presses from accumulating multiple cursor decrements
// before any undo is applied (Bug #16).
mut back_pending: Local<bool>,
) { ) {
let Some(keys) = keys else { return }; let Some(keys) = keys else { return };
let dt = time.delta_secs(); let dt = time.delta_secs();
// Clear the in-flight flag once the game confirms the undo landed.
if state_changed.read().count() > 0 {
*back_pending = false;
}
// Right (forward step) — initial press fires immediately; // Right (forward step) — initial press fires immediately;
// held repeats fire when the accumulator crosses the interval. // held repeats fire when the accumulator crosses the interval.
if keys.just_pressed(KeyCode::ArrowRight) { if keys.just_pressed(KeyCode::ArrowRight) {
@@ -1911,14 +1925,28 @@ fn handle_arrow_keyboard(
hold.right_held_secs = 0.0; hold.right_held_secs = 0.0;
} }
// Left (backwards step) — symmetric to the right path. // Left (backwards step) — gate on `back_pending` so at most one undo
// is in-flight at a time. The cursor is only decremented inside
// `step_backwards_replay_playback`, which also writes `UndoRequestEvent`.
// `back_pending` is set after a successful step and cleared above when
// `StateChangedEvent` confirms the undo was applied.
if keys.just_pressed(KeyCode::ArrowLeft) { if keys.just_pressed(KeyCode::ArrowLeft) {
step_backwards_replay_playback(&mut state, &mut undo_writer); if !*back_pending {
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
if fired {
*back_pending = true;
}
}
hold.left_held_secs = 0.0; hold.left_held_secs = 0.0;
} else if keys.pressed(KeyCode::ArrowLeft) { } else if keys.pressed(KeyCode::ArrowLeft) {
hold.left_held_secs += dt; hold.left_held_secs += dt;
if hold.left_held_secs >= SCRUB_REPEAT_INTERVAL_SECS { if hold.left_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
step_backwards_replay_playback(&mut state, &mut undo_writer); if !*back_pending {
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
if fired {
*back_pending = true;
}
}
hold.left_held_secs = 0.0; hold.left_held_secs = 0.0;
} }
} else { } else {
+2 -1
View File
@@ -190,7 +190,7 @@ pub fn start_replay_playback(
) { ) {
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode); let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
commands.insert_resource(GameStateResource(fresh)); commands.insert_resource(GameStateResource(fresh));
// Initial `secs_to_next` uses the constant rather than reading // Initial `secs_to_next` uses the constant rather than reading
@@ -512,6 +512,7 @@ pub struct ReplayPlaybackPlugin;
impl Plugin for ReplayPlaybackPlugin { impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>() app.init_resource::<ReplayPlaybackState>()
.add_message::<StateChangedEvent>()
.add_systems( .add_systems(
Update, Update,
( (
+35
View File
@@ -1,5 +1,7 @@
//! Bevy resources owned by the engine crate. //! Bevy resources owned by the engine crate.
use std::sync::Arc;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@@ -111,3 +113,36 @@ pub struct HintCycleIndex(pub usize);
/// returns to the same position in the list without re-scrolling. /// returns to the same position in the list without re-scrolling.
#[derive(Resource, Debug, Clone, Default)] #[derive(Resource, Debug, Clone, Default)]
pub struct SettingsScrollPos(pub f32); pub struct SettingsScrollPos(pub f32);
/// Set to `true` by an input system when a touch tap is consumed by game logic
/// (e.g. drawing from stock). `toggle_hud_on_tap` checks this flag on
/// `TouchPhase::Ended` and skips the HUD visibility toggle when set, then
/// resets it to `false` so subsequent taps behave normally.
#[derive(Resource, Debug, Clone, Default)]
pub struct GameInputConsumedResource(pub bool);
/// Shared Tokio runtime used by all async-task closures that need HTTP I/O.
///
/// Bevy's `AsyncComputeTaskPool` uses `async-executor` (not Tokio), so spawned
/// closures that call `reqwest`/`hyper` need a Tokio reactor. A single
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply
/// into every network task — safe for concurrent `block_on` calls from multiple
/// worker threads.
#[derive(Resource, Clone)]
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
impl TokioRuntimeResource {
/// Attempts to build the shared multi-threaded Tokio runtime.
///
/// Returns `Err` if the OS refuses to create worker threads (e.g. resource
/// limits on Android). Callers should log the error and disable sync
/// features rather than panicking.
pub fn new() -> Result<Self, tokio::io::Error> {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()?;
Ok(Self(Arc::new(rt)))
}
}
+7 -1
View File
@@ -156,7 +156,13 @@ impl Plugin for SelectionPlugin {
.in_set(SelectionKeySet) .in_set(SelectionKeySet)
.before(GameMutation), .before(GameMutation),
clear_selection_on_state_change.after(GameMutation), clear_selection_on_state_change.after(GameMutation),
update_selection_highlight.after(GameMutation), update_selection_highlight
.after(GameMutation)
.run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>),
),
), ),
); );
} }
+14 -5
View File
@@ -94,7 +94,7 @@ pub struct SettingsChangedEvent(pub Settings);
/// Marker on the root Settings panel entity. /// Marker on the root Settings panel entity.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsPanel; pub struct SettingsPanel;
/// Marks the `Text` node showing the live SFX volume value. /// Marks the `Text` node showing the live SFX volume value.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -401,8 +401,10 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text, update_anim_speed_text,
update_color_blind_text, update_color_blind_text,
update_high_contrast_text, update_high_contrast_text,
update_high_contrast_borders, update_high_contrast_borders
update_high_contrast_backgrounds, .run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds
.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text, update_reduce_motion_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
@@ -508,6 +510,7 @@ fn toggle_settings_screen(
fn sync_settings_panel_visibility( fn sync_settings_panel_visibility(
screen: Res<SettingsScreen>, screen: Res<SettingsScreen>,
panels: Query<Entity, With<SettingsPanel>>, panels: Query<Entity, With<SettingsPanel>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>, scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
mut scroll_pos: ResMut<SettingsScrollPos>, mut scroll_pos: ResMut<SettingsScrollPos>,
mut commands: Commands, mut commands: Commands,
@@ -523,7 +526,7 @@ fn sync_settings_panel_visibility(
return; return;
} }
if screen.0 { if screen.0 {
if panels.is_empty() { if panels.is_empty() && other_modal_scrims.is_empty() {
let status_label = sync_status let status_label = sync_status
.map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0)); .map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0));
let unlocked_backs = progress let unlocked_backs = progress
@@ -1134,6 +1137,7 @@ fn handle_sync_buttons(
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>, mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>, mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>, mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
mut screen: ResMut<SettingsScreen>,
) { ) {
for (interaction, button) in &interaction_query { for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
@@ -1141,7 +1145,12 @@ fn handle_sync_buttons(
} }
match button { match button {
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); } SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); } SettingsButton::ConnectSync => {
// Close settings before the sync-setup modal opens so the
// guard in open_sync_setup_modal doesn't block on our own scrim.
screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent);
}
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); } SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); } SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
_ => {} _ => {}
+7 -1
View File
@@ -220,7 +220,13 @@ impl Plugin for StatsPlugin {
) )
.add_systems( .add_systems(
Update, Update,
handle_forfeit.before(GameMutation), // handle_forfeit must run before update_stats_on_new_game so
// the NewGameRequestEvent it emits is not visible to
// update_stats_on_new_game in the same frame — otherwise
// record_abandoned() fires twice on every forfeit (#21).
handle_forfeit
.before(GameMutation)
.before(update_stats_on_new_game),
) )
.add_systems(Update, toggle_stats_screen.after(GameMutation)) .add_systems(Update, toggle_stats_screen.after(GameMutation))
.add_systems(Update, handle_stats_close_button) .add_systems(Update, handle_stats_close_button)
+57 -40
View File
@@ -26,12 +26,12 @@ use solitaire_sync::{merge, SyncPayload, SyncResponse};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath}; use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{ use crate::events::{
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent, GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
SyncConfigureRequestEvent, WarningToastEvent,
}; };
use crate::game_plugin::RecordingReplay; use crate::game_plugin::RecordingReplay;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource}; use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource};
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath}; use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -108,7 +108,14 @@ impl Plugin for SyncPlugin {
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<SyncCompleteEvent>() .add_message::<SyncCompleteEvent>()
.add_message::<SyncConfigureRequestEvent>() .add_message::<SyncConfigureRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<WarningToastEvent>();
// Build the shared Tokio runtime; disable all network sync if the OS
// refuses to create threads (resource-limited environments, sandboxed
// Android builds, etc.).
match TokioRuntimeResource::new() {
Ok(rt) => {
app.insert_resource(rt)
.add_systems(Startup, start_pull) .add_systems(Startup, start_pull)
.add_systems( .add_systems(
Update, Update,
@@ -121,6 +128,11 @@ impl Plugin for SyncPlugin {
) )
.add_systems(Last, push_on_exit); .add_systems(Last, push_on_exit);
} }
Err(e) => {
warn!("sync: failed to create Tokio runtime — network sync disabled: {e}");
}
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -130,19 +142,14 @@ impl Plugin for SyncPlugin {
/// Startup system: spawns the async pull task and sets status to `Syncing`. /// Startup system: spawns the async pull task and sets status to `Syncing`.
fn start_pull( fn start_pull(
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
mut task_res: ResMut<PullTask>, mut task_res: ResMut<PullTask>,
mut status: ResMut<SyncStatusResource>, mut status: ResMut<SyncStatusResource>,
) { ) {
let provider = provider.0.clone(); let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move { let task = AsyncComputeTaskPool::get().spawn(async move {
// Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but rt.block_on(provider.pull())
// reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide
// a short-lived single-threaded runtime for this network round-trip.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.pull())
}); });
task_res.0 = Some(task); task_res.0 = Some(task);
status.0 = SyncStatus::Syncing; status.0 = SyncStatus::Syncing;
@@ -153,6 +160,7 @@ fn start_pull(
fn handle_manual_sync_request( fn handle_manual_sync_request(
mut events: MessageReader<ManualSyncRequestEvent>, mut events: MessageReader<ManualSyncRequestEvent>,
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
mut task_res: ResMut<PullTask>, mut task_res: ResMut<PullTask>,
mut status: ResMut<SyncStatusResource>, mut status: ResMut<SyncStatusResource>,
) { ) {
@@ -164,12 +172,9 @@ fn handle_manual_sync_request(
return; // Already pulling — ignore. return; // Already pulling — ignore.
} }
let provider = provider.0.clone(); let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move { let task = AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread() rt.block_on(provider.pull())
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.pull())
}); });
task_res.0 = Some(task); task_res.0 = Some(task);
status.0 = SyncStatus::Syncing; status.0 = SyncStatus::Syncing;
@@ -197,7 +202,7 @@ fn poll_pull_result(
progress_path: Res<ProgressStoragePath>, progress_path: Res<ProgressStoragePath>,
mut complete_writer: MessageWriter<SyncCompleteEvent>, mut complete_writer: MessageWriter<SyncCompleteEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>, mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>, mut warning_toast: MessageWriter<WarningToastEvent>,
) { ) {
let Some(task) = task_res.0.as_mut() else { let Some(task) = task_res.0.as_mut() else {
return; return;
@@ -251,13 +256,13 @@ fn poll_pull_result(
SyncError::Serialization(_) => format!("Unexpected server response: {e}"), SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
SyncError::UnsupportedPlatform => unreachable!("handled above"), SyncError::UnsupportedPlatform => unreachable!("handled above"),
}; };
warning_toast.write(WarningToastEvent(msg.clone()));
// On auth failure, reopen the Connect modal so the player can // On auth failure, reopen the Connect modal so the player can
// re-enter credentials without having to navigate through Settings. // re-enter credentials without having to navigate through Settings.
// `open_sync_setup_modal` is idempotent — it ignores the event when // `open_sync_setup_modal` is idempotent — it ignores the event when
// the modal is already on screen, so repeated pull failures don't // the modal is already on screen, so repeated pull failures don't
// stack multiple modals. // stack multiple modals.
if matches!(e, SyncError::Auth(_)) { if matches!(e, SyncError::Auth(_)) {
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
configure_sync.write(SyncConfigureRequestEvent); configure_sync.write(SyncConfigureRequestEvent);
} }
status.0 = SyncStatus::Error(msg.clone()); status.0 = SyncStatus::Error(msg.clone());
@@ -274,6 +279,7 @@ fn poll_pull_result(
fn push_on_exit( fn push_on_exit(
mut exit_events: MessageReader<AppExit>, mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
stats: Res<StatsResource>, stats: Res<StatsResource>,
achievements: Res<AchievementsResource>, achievements: Res<AchievementsResource>,
progress: Res<ProgressResource>, progress: Res<ProgressResource>,
@@ -284,21 +290,7 @@ fn push_on_exit(
exit_events.clear(); exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0); let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone(); let result = rt.0.block_on(provider.0.push(&payload));
// Prefer an existing tokio runtime; fall back to a temporary one for
// environments (e.g. tests, Android's non-Tokio async executor) where
// reqwest/hyper would otherwise panic with "no reactor running".
let result = match tokio::runtime::Handle::try_current() {
Ok(handle) => handle.block_on(provider.push(&payload)),
Err(_) => match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt.block_on(provider.push(&payload)),
Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))),
},
};
match result { match result {
Ok(_) => {} Ok(_) => {}
// `UnsupportedPlatform` is the expected response of // `UnsupportedPlatform` is the expected response of
@@ -327,6 +319,7 @@ fn push_on_exit(
fn push_replay_on_win( fn push_replay_on_win(
mut wins: MessageReader<GameWonEvent>, mut wins: MessageReader<GameWonEvent>,
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
recording: Res<RecordingReplay>, recording: Res<RecordingReplay>,
mut pending: ResMut<PendingReplayUpload>, mut pending: ResMut<PendingReplayUpload>,
@@ -340,7 +333,7 @@ fn push_replay_on_win(
} }
let replay = Replay::new( let replay = Replay::new(
game.0.seed, game.0.seed,
game.0.draw_mode.clone(), game.0.draw_mode,
game.0.mode, game.0.mode,
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
@@ -348,12 +341,9 @@ fn push_replay_on_win(
recording.moves.clone(), recording.moves.clone(),
); );
let provider = provider.0.clone(); let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move { let task = AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread() rt.block_on(provider.push_replay(&replay))
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.push_replay(&replay))
}); });
// If a previous upload is still in flight, drop it — the most // If a previous upload is still in flight, drop it — the most
// recent win is the one whose share link the player will care // recent win is the one whose share link the player will care
@@ -571,6 +561,33 @@ mod tests {
); );
} }
#[test]
fn pull_failure_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with(FailingProvider);
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
if matches!(
app.world().resource::<SyncStatusResource>().0,
SyncStatus::Error(_)
) {
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
}
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
assert!(
cursor.read(msgs).next().is_some(),
"a WarningToastEvent must fire when the pull fails"
);
}
#[test] #[test]
fn build_payload_sets_nil_user_id() { fn build_payload_sets_nil_user_id() {
let payload = build_payload( let payload = build_payload(
+48 -19
View File
@@ -52,9 +52,10 @@ use crate::events::{
SyncLogoutRequestEvent, SyncLogoutRequestEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath}; use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath};
use crate::resources::TokioRuntimeResource;
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::spawn_modal; use crate::ui_modal::{spawn_modal, ModalScrim};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI,
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED,
@@ -204,9 +205,14 @@ impl Plugin for SyncSetupPlugin {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received. /// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
#[allow(clippy::type_complexity)]
fn open_sync_setup_modal( fn open_sync_setup_modal(
mut events: MessageReader<SyncConfigureRequestEvent>, mut events: MessageReader<SyncConfigureRequestEvent>,
existing: Query<(), With<SyncSetupScreen>>, existing: Query<(), With<SyncSetupScreen>>,
// Exclude SettingsPanel: the Connect button closes settings in the same
// frame it fires SyncConfigureRequestEvent, but Bevy despawns are deferred
// so the settings scrim still exists in the world during this system.
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>, Without<SettingsPanel>)>,
mut commands: Commands, mut commands: Commands,
mut focused: ResMut<SyncFocusedField>, mut focused: ResMut<SyncFocusedField>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
@@ -218,6 +224,9 @@ fn open_sync_setup_modal(
if !existing.is_empty() { if !existing.is_empty() {
return; // Already open. return; // Already open.
} }
if !other_modal_scrims.is_empty() {
return; // Another modal is already visible.
}
*focused = SyncFocusedField::Url; *focused = SyncFocusedField::Url;
spawn_sync_setup_modal(&mut commands, font_res.as_deref()); spawn_sync_setup_modal(&mut commands, font_res.as_deref());
} }
@@ -300,7 +309,8 @@ fn update_field_borders(
fn handle_auth_button( fn handle_auth_button(
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>, login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>, register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>, mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer)>,
rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingAuthTask>, mut pending: ResMut<PendingAuthTask>,
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>, mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>, mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
@@ -352,9 +362,10 @@ fn handle_auth_button(
return; return;
} }
// Clear error and show busy indicator. // Clear previous error and show busy indicator.
for (mut text, _) in &mut error_nodes { for (mut text, mut color) in &mut error_nodes {
text.0 = "Connecting…".to_string(); text.0 = String::new();
color.0 = TEXT_SECONDARY;
} }
for mut vis in &mut busy_nodes { for mut vis in &mut busy_nodes {
*vis = Visibility::Visible; *vis = Visibility::Visible;
@@ -363,13 +374,10 @@ fn handle_auth_button(
let is_register = register_clicked; let is_register = register_clicked;
let client = SolitaireServerClient::new(url.clone(), username.clone()); let client = SolitaireServerClient::new(url.clone(), username.clone());
let pw = password.clone(); let pw = password.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move { let task = AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread() rt.block_on(async {
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(async {
let (access_token, refresh_token) = if is_register { let (access_token, refresh_token) = if is_register {
client.register(&pw).await? client.register(&pw).await?
} else { } else {
@@ -388,6 +396,14 @@ fn handle_auth_button(
pending.task = Some(task); pending.task = Some(task);
pending.url = url; pending.url = url;
pending.username = username; pending.username = username;
// Zero the password buffer immediately — it must not linger in ECS
// components after the credential has been handed off to the async task.
for (kind, mut buf) in &mut fields {
if *kind == SyncFieldKind::Password {
buf.0.clear();
}
}
} }
/// Polls the in-flight auth task. On success updates settings + provider. /// Polls the in-flight auth task. On success updates settings + provider.
@@ -541,6 +557,7 @@ fn handle_logout(
fn open_delete_confirm_modal( fn open_delete_confirm_modal(
mut events: MessageReader<DeleteAccountRequestEvent>, mut events: MessageReader<DeleteAccountRequestEvent>,
existing: Query<(), With<DeleteConfirmScreen>>, existing: Query<(), With<DeleteConfirmScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<DeleteConfirmScreen>)>,
mut commands: Commands, mut commands: Commands,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -551,6 +568,9 @@ fn open_delete_confirm_modal(
if !existing.is_empty() { if !existing.is_empty() {
return; return;
} }
if !other_modal_scrims.is_empty() {
return; // Another modal is already visible.
}
spawn_delete_confirm_modal(&mut commands, font_res.as_deref()); spawn_delete_confirm_modal(&mut commands, font_res.as_deref());
} }
@@ -575,6 +595,7 @@ fn handle_delete_cancel(
fn handle_delete_confirm( fn handle_delete_confirm(
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>, confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingDeleteTask>, mut pending: ResMut<PendingDeleteTask>,
screen: Query<Entity, With<DeleteConfirmScreen>>, screen: Query<Entity, With<DeleteConfirmScreen>>,
mut commands: Commands, mut commands: Commands,
@@ -587,12 +608,9 @@ fn handle_delete_confirm(
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
let provider = provider.0.clone(); let provider = provider.0.clone();
let rt = rt.0.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move { pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread() rt.block_on(provider.delete_account())
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.delete_account())
})); }));
} }
@@ -678,20 +696,31 @@ fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResourc
font_res, font_res,
); );
// Error / status line. // Error / status line — two distinct children so visibility and
// text can be controlled independently.
body.spawn(Node { body.spawn(Node {
min_height: Val::Px(18.0), min_height: Val::Px(18.0),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default() ..default()
}) })
.with_children(|row| { .with_children(|row| {
// Busy indicator: shown while the auth task is in flight.
row.spawn(( row.spawn((
SyncAuthError,
SyncBusyOverlay, SyncBusyOverlay,
Text::new(String::new()), Text::new(""),
make_font(font_res, TYPE_CAPTION), make_font(font_res, TYPE_CAPTION),
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
Visibility::Hidden, Visibility::Hidden,
)); ));
// Error / status text: always laid out, empty when idle.
row.spawn((
SyncAuthError,
Text::new(String::new()),
make_font(font_res, TYPE_CAPTION),
TextColor(TEXT_SECONDARY),
));
}); });
// Tab hint — desktop only; no Tab key on Android. // Tab hint — desktop only; no Tab key on Android.
+20
View File
@@ -182,12 +182,16 @@ fn sync_card_image_set_with_active_theme(
mut events: MessageReader<AssetEvent<CardTheme>>, mut events: MessageReader<AssetEvent<CardTheme>>,
active: Option<Res<ActiveTheme>>, active: Option<Res<ActiveTheme>>,
themes: Res<Assets<CardTheme>>, themes: Res<Assets<CardTheme>>,
asset_server: Option<Res<AssetServer>>,
mut card_image_set: Option<ResMut<CardImageSet>>, mut card_image_set: Option<ResMut<CardImageSet>>,
mut state_events: MessageWriter<StateChangedEvent>, mut state_events: MessageWriter<StateChangedEvent>,
) { ) {
let Some(active) = active else { return }; let Some(active) = active else { return };
let active_id = active.0.id(); let active_id = active.0.id();
let mut should_sync = false; let mut should_sync = false;
// Consume asset events — covers the normal first-load path.
for ev in events.read() { for ev in events.read() {
let id = match ev { let id = match ev {
AssetEvent::LoadedWithDependencies { id } AssetEvent::LoadedWithDependencies { id }
@@ -198,6 +202,22 @@ fn sync_card_image_set_with_active_theme(
should_sync = true; should_sync = true;
} }
} }
// A→B→A switch: Bevy does not re-fire LoadedWithDependencies for a
// handle whose asset is already cached. Detect this by checking that
// `ActiveTheme` itself changed this frame (the resource was just
// replaced by `react_to_settings_theme_change`) and the underlying
// asset is already fully loaded. If so, sync immediately rather than
// waiting for an event that will never arrive.
if !should_sync
&& active.is_changed()
&& asset_server
.as_ref()
.is_some_and(|as_| as_.is_loaded_with_dependencies(active.0.id()))
{
should_sync = true;
}
if !should_sync { if !should_sync {
return; return;
} }
+5 -4
View File
@@ -172,14 +172,15 @@ fn advance_time_attack(
paused: Option<Res<crate::pause_plugin::PausedResource>>, paused: Option<Res<crate::pause_plugin::PausedResource>>,
path: Option<Res<TimeAttackSessionPath>>, path: Option<Res<TimeAttackSessionPath>>,
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>, home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
win_overlays: Query<(), With<crate::win_summary_plugin::WinSummaryOverlay>>,
) { ) {
if !session.active { if !session.active {
return; return;
} }
// Mirrors `tick_elapsed_time`: pause while the launch / mode-picker // Pause the countdown while Home, the Pause overlay, or the Win Summary
// Home modal is up so the countdown doesn't burn while the player // overlay is visible — the player should not lose time while reading results
// is choosing what to play next. // or navigating menus.
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() { if paused.is_some_and(|p| p.0) || !home_screens.is_empty() || !win_overlays.is_empty() {
return; return;
} }
session.remaining_secs -= time.delta_secs(); session.remaining_secs -= time.delta_secs();
+14 -3
View File
@@ -212,6 +212,13 @@ where
// modal at `Z_PAUSE` (220) in some scenes. // modal at `Z_PAUSE` (220) in some scenes.
GlobalZIndex(z_panel), GlobalZIndex(z_panel),
ZIndex(z_panel), ZIndex(z_panel),
// B0004: ModalCard carries Transform (for the scale animation).
// Bevy's GlobalTransform hook fires B0004 when a child has
// GlobalTransform but the parent does not. Adding Identity
// Transform here gives the scrim GlobalTransform so the check
// passes. UI layout still uses UiTransform; this has no layout
// effect.
Transform::default(),
)) ))
.with_children(|root| { .with_children(|root| {
root.spawn(( root.spawn((
@@ -603,7 +610,7 @@ pub fn dismiss_modal_on_scrim_click(
mut commands: Commands, mut commands: Commands,
mouse: Option<Res<ButtonInput<MouseButton>>>, mouse: Option<Res<ButtonInput<MouseButton>>>,
windows: Query<&Window, With<PrimaryWindow>>, windows: Query<&Window, With<PrimaryWindow>>,
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>, scrims: Query<(Entity, &Children), (With<ModalScrim>, With<ScrimDismissible>)>,
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>, cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
) { ) {
let Some(mouse) = mouse else { return }; let Some(mouse) = mouse else { return };
@@ -620,15 +627,19 @@ pub fn dismiss_modal_on_scrim_click(
// Topmost-only: bail after the first dismissible scrim. Stacked // Topmost-only: bail after the first dismissible scrim. Stacked
// dismissible modals are not currently a real case, but this guard // dismissible modals are not currently a real case, but this guard
// keeps the behaviour predictable if they ever arise. // keeps the behaviour predictable if they ever arise.
let Some(scrim_entity) = scrims.iter().next() else { let Some((scrim_entity, scrim_children)) = scrims.iter().next() else {
return; return;
}; };
let cursor_over_card = cards.iter().any(|(transform, computed)| { // Only test the ModalCard(s) that belong to THIS scrim, not cards
// from any other concurrently-open modal.
let cursor_over_card = scrim_children.iter().any(|child| {
cards.get(child).is_ok_and(|(transform, computed)| {
let inv = computed.inverse_scale_factor; let inv = computed.inverse_scale_factor;
let size_logical = computed.size() * inv; let size_logical = computed.size() * inv;
let centre_logical = transform.translation * inv; let centre_logical = transform.translation * inv;
cursor_is_inside_rect(cursor, centre_logical, size_logical) cursor_is_inside_rect(cursor, centre_logical, size_logical)
})
}); });
if !cursor_over_card { if !cursor_over_card {
+11 -4
View File
@@ -313,10 +313,10 @@ impl HighContrastBackground {
/// `outline` from the design system. `#505050`. /// `outline` from the design system. `#505050`.
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0); pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
/// 2 px ring drawn around the focused interactive element. Cyan /// 2 px ring drawn around the focused interactive element. Brick-red
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible /// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
/// against both elevated surfaces and the modal scrim backdrop. /// against both elevated surfaces and the modal scrim backdrop.
/// `rgba(111, 194, 239, 0.85)`. /// `rgba(165, 66, 66, 0.85)`.
pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85); pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -401,8 +401,13 @@ pub const Z_BACKGROUND: i32 = -10;
pub const Z_PILE_MARKER: i32 = -1; pub const Z_PILE_MARKER: i32 = -1;
/// Base layer for HUD readouts (top-left). /// Base layer for HUD readouts (top-left).
pub const Z_HUD: i32 = 50; pub const Z_HUD: i32 = 50;
/// Action bar + popovers — above HUD readouts so dropdowns can overlap. /// Fullscreen transparent dismiss-backdrop spawned behind a HUD popover so
pub const Z_HUD_TOP: i32 = 60; /// tapping outside it light-dismisses the panel without blocking other input.
pub const Z_HUD_POPOVER_BACKDROP: i32 = Z_HUD + 4;
/// HUD popovers (Modes dropdown, etc.) — above the dismiss backdrop.
pub const Z_HUD_POPOVER: i32 = Z_HUD + 5;
/// Transient HUD annotations (score-delta floaters) — above popovers.
pub const Z_HUD_TOP: i32 = Z_HUD + 10;
pub const Z_MODAL_SCRIM: i32 = 200; pub const Z_MODAL_SCRIM: i32 = 200;
pub const Z_MODAL_PANEL: i32 = 210; pub const Z_MODAL_PANEL: i32 = 210;
/// Pause overlay outranks normal modals — pausing should always be on top. /// Pause overlay outranks normal modals — pausing should always be on top.
@@ -648,6 +653,8 @@ mod tests {
Z_BACKGROUND, Z_BACKGROUND,
Z_PILE_MARKER, Z_PILE_MARKER,
Z_HUD, Z_HUD,
Z_HUD_POPOVER_BACKDROP,
Z_HUD_POPOVER,
Z_HUD_TOP, Z_HUD_TOP,
Z_MODAL_SCRIM, Z_MODAL_SCRIM,
Z_MODAL_PANEL, Z_MODAL_PANEL,
+1 -1
View File
@@ -82,7 +82,7 @@ fn evaluate_weekly_goals(
let ctx = WeeklyGoalContext { let ctx = WeeklyGoalContext {
time_seconds: ev.time_seconds, time_seconds: ev.time_seconds,
used_undo: game.0.undo_count > 0, used_undo: game.0.undo_count > 0,
draw_mode: game.0.draw_mode.clone(), draw_mode: game.0.draw_mode,
}; };
for def in WEEKLY_GOALS { for def in WEEKLY_GOALS {
if !def.matches(&ctx) { if !def.matches(&ctx) {
+7 -3
View File
@@ -24,6 +24,7 @@ use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS, scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS, MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
@@ -507,7 +508,7 @@ fn collect_session_achievements(
) { ) {
// Reset on any new-game request (including mode switches via Z/X/C/T) so // Reset on any new-game request (including mode switches via Z/X/C/T) so
// achievements from the previous session are not carried into the next one. // achievements from the previous session are not carried into the next one.
if new_games.read().last().is_some() { if new_games.read().next().is_some() {
session.names.clear(); session.names.clear();
} }
for ev in unlocks.read() { for ev in unlocks.read() {
@@ -538,6 +539,7 @@ fn spawn_win_summary_after_delay(
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
time: Res<Time>, time: Res<Time>,
overlays: Query<Entity, With<WinSummaryOverlay>>, overlays: Query<Entity, With<WinSummaryOverlay>>,
other_scrims: Query<(), (With<ModalScrim>, Without<WinSummaryOverlay>)>,
mut delay: Local<Option<f32>>, mut delay: Local<Option<f32>>,
) { ) {
// Process new win events. // Process new win events.
@@ -568,8 +570,8 @@ fn spawn_win_summary_after_delay(
*remaining -= time.delta_secs(); *remaining -= time.delta_secs();
if *remaining <= 0.0 { if *remaining <= 0.0 {
*delay = None; *delay = None;
// Only spawn if there is no overlay already. // Only spawn if no overlay of any kind is already visible.
if overlays.is_empty() { if overlays.is_empty() && other_scrims.is_empty() {
// Drain any XpAwardedEvents that arrived this frame but were // Drain any XpAwardedEvents that arrived this frame but were
// not yet consumed by `cache_win_data` (which may run later in // not yet consumed by `cache_win_data` (which may run later in
// the same schedule). Accumulating here ensures the modal // the same schedule). Accumulating here ensures the modal
@@ -757,6 +759,7 @@ fn spawn_overlay(
commands commands
.spawn(( .spawn((
WinSummaryOverlay, WinSummaryOverlay,
ModalScrim,
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
left: Val::Percent(0.0), left: Val::Percent(0.0),
@@ -769,6 +772,7 @@ fn spawn_overlay(
..default() ..default()
}, },
BackgroundColor(SCRIM), BackgroundColor(SCRIM),
GlobalZIndex(Z_WIN_CASCADE),
ZIndex(Z_WIN_CASCADE), ZIndex(Z_WIN_CASCADE),
)) ))
.with_children(|root| { .with_children(|root| {
+20 -21
View File
@@ -336,13 +336,11 @@ pub async fn get_me(
Ok(Json(MeResponse { Ok(Json(MeResponse {
id: user.user_id, id: user.user_id,
username: row.username.unwrap_or_default(), username: row.username.ok_or(AppError::Unauthorized)?,
avatar_url: row.avatar_url, avatar_url: row.avatar_url,
})) }))
} }
/// Allowed MIME types for uploaded avatars.
const ALLOWED_IMAGE_TYPES: &[&str] = &["image/jpeg", "image/png", "image/webp", "image/gif"];
/// Maximum avatar upload size in bytes (1 MB). /// Maximum avatar upload size in bytes (1 MB).
const AVATAR_MAX_BYTES: usize = 1024 * 1024; const AVATAR_MAX_BYTES: usize = 1024 * 1024;
@@ -361,23 +359,15 @@ pub async fn upload_avatar(
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let ext = if mime.contains("jpeg") || mime.contains("jpg") { let ext = match mime.as_str() {
"jpg" "image/jpeg" | "image/jpg" => "jpg",
} else if mime.contains("png") { "image/png" => "png",
"png" "image/webp" => "webp",
} else if mime.contains("webp") { "image/gif" => "gif",
"webp" _ => return Err(AppError::BadRequest(
} else if mime.contains("gif") {
"gif"
} else {
return Err(AppError::BadRequest(
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(), "avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
)); )),
}; };
if !ALLOWED_IMAGE_TYPES.iter().any(|t| mime.starts_with(t)) {
return Err(AppError::BadRequest("unsupported image type".into()));
}
if body.len() > AVATAR_MAX_BYTES { if body.len() > AVATAR_MAX_BYTES {
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into())); return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
} }
@@ -386,13 +376,22 @@ pub async fn upload_avatar(
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?; std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
let filename = format!("{}.{}", user.user_id, ext); let filename = format!("{}.{}", user.user_id, ext);
let path = std::path::Path::new("avatars").join(&filename); let path = std::path::Path::new("avatars").join(&filename);
// Remove stale files with other extensions first. let tmp_path = std::path::Path::new("avatars").join(format!("{}.{}.tmp", user.user_id, ext));
// Write to a temp file then atomically rename so concurrent readers never
// see a partially-written avatar.
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
if let Err(e) = std::fs::rename(&tmp_path, &path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(AppError::Internal(e.to_string()));
}
// Remove stale files with other extensions after the atomic rename.
for old_ext in &["jpg", "png", "webp", "gif"] { for old_ext in &["jpg", "png", "webp", "gif"] {
if *old_ext != ext {
let _ = std::fs::remove_file( let _ = std::fs::remove_file(
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)), std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
); );
} }
std::fs::write(&path, &body).map_err(|e| AppError::Internal(e.to_string()))?; }
let avatar_url = format!("/avatars/{filename}"); let avatar_url = format!("/avatars/{filename}");
sqlx::query!( sqlx::query!(
@@ -412,7 +411,7 @@ pub async fn upload_avatar(
Ok(Json(MeResponse { Ok(Json(MeResponse {
id: user.user_id, id: user.user_id,
username: username.unwrap_or_default(), username: username.ok_or(AppError::Unauthorized)?,
avatar_url: Some(avatar_url), avatar_url: Some(avatar_url),
})) }))
} }
+2 -2
View File
@@ -197,7 +197,8 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
.route("/api/daily-challenge", get(challenge::daily_challenge)) .route("/api/daily-challenge", get(challenge::daily_challenge))
.route("/api/replays/recent", get(replays::recent)) .route("/api/replays/recent", get(replays::recent))
.route("/api/replays/{id}", get(replays::get_by_id)) .route("/api/replays/{id}", get(replays::get_by_id))
.route("/health", get(health)); .route("/health", get(health))
.nest_service("/avatars", ServeDir::new("avatars"));
// Replay web UI: a single HTML page served at `/replays/:id` plus a // Replay web UI: a single HTML page served at `/replays/:id` plus a
// ServeDir for the static assets (`web/index.html`, `web/replay.css`, // ServeDir for the static assets (`web/index.html`, `web/replay.css`,
@@ -231,7 +232,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
) )
.nest_service("/web", ServeDir::new("solitaire_server/web")) .nest_service("/web", ServeDir::new("solitaire_server/web"))
.nest_service("/assets", ServeDir::new("assets")) .nest_service("/assets", ServeDir::new("assets"))
.nest_service("/avatars", ServeDir::new("avatars"))
.layer(axum_middleware::from_fn(security_headers)); .layer(axum_middleware::from_fn(security_headers));
Router::new() Router::new()
+6 -3
View File
@@ -35,7 +35,7 @@ use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
/// the desktop client's transitive dependencies. /// the desktop client's transitive dependencies.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ReplayHeader { struct ReplayHeader {
seed: i64, seed: u64,
draw_mode: String, draw_mode: String,
mode: String, mode: String,
time_seconds: i64, time_seconds: i64,
@@ -94,6 +94,9 @@ pub async fn upload(
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let received_at = Utc::now().to_rfc3339(); let received_at = Utc::now().to_rfc3339();
let replay_json = serde_json::to_string(&payload)?; let replay_json = serde_json::to_string(&payload)?;
// SQLite INTEGER columns bind as i64. Reinterpret the u64 bits — the
// database stores the same 8 bytes; high-bit seeds round-trip correctly.
let seed_i64 = header.seed as i64;
sqlx::query!( sqlx::query!(
r#"INSERT INTO replays ( r#"INSERT INTO replays (
@@ -102,7 +105,7 @@ pub async fn upload(
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
id, id,
user.user_id, user.user_id,
header.seed, seed_i64,
header.draw_mode, header.draw_mode,
header.mode, header.mode,
header.time_seconds, header.time_seconds,
@@ -116,7 +119,7 @@ pub async fn upload(
// Update leaderboard best score/time for opted-in users when this replay // Update leaderboard best score/time for opted-in users when this replay
// beats their existing best. Only classic mode counts for the leaderboard. // beats their existing best. Only classic mode counts for the leaderboard.
if header.mode == "classic" { if header.mode == "Classic" {
sqlx::query!( sqlx::query!(
r#"UPDATE leaderboard r#"UPDATE leaderboard
SET best_score = ?, SET best_score = ?,
+123
View File
@@ -230,6 +230,68 @@ main {
pointer-events: none; pointer-events: none;
} }
/* ── Resume overlay ──────────────────────────────────────────────────── */
#resume-overlay {
position: fixed;
inset: 0;
background: rgba(21, 21, 21, 0.92);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
#resume-overlay.hidden { display: none; }
.resume-card {
background: var(--panel);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px;
padding: 40px 48px;
text-align: center;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
max-width: 360px;
}
.resume-title {
font-size: 28px;
font-weight: 700;
color: var(--accent);
}
.resume-detail {
font-size: 14px;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.resume-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 8px;
}
.resume-actions button {
padding: 12px 24px;
font-size: 15px;
}
.resume-actions button.secondary {
background: transparent;
border: 1px solid rgba(255,255,255,0.2);
color: var(--text-muted);
}
.resume-actions button.secondary:hover {
background: rgba(255,255,255,0.05);
}
/* ── Win overlay ─────────────────────────────────────────────────────── */ /* ── Win overlay ─────────────────────────────────────────────────────── */
#win-overlay { #win-overlay {
@@ -293,6 +355,67 @@ main {
animation: illegal-shake 320ms ease; animation: illegal-shake 320ms ease;
} }
/* ── No-moves banner ─────────────────────────────────────────────────── */
#no-moves-banner {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 900;
animation: slide-up 240ms ease;
}
#no-moves-banner.hidden { display: none; }
.no-moves-card {
background: var(--panel);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 12px;
padding: 20px 32px;
text-align: center;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.7);
min-width: 300px;
}
.no-moves-title {
font-size: 18px;
font-weight: 700;
color: var(--accent);
}
.no-moves-detail {
font-size: 13px;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.no-moves-actions {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 4px;
}
.no-moves-actions button.secondary {
background: transparent;
border: 1px solid rgba(255,255,255,0.2);
color: var(--text-muted);
}
.no-moves-actions button.secondary:hover {
background: rgba(255,255,255,0.05);
}
@keyframes slide-up {
from { opacity: 0; transform: translateX(-50%) translateY(12px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ── Foundation slot suit hints ──────────────────────────────────────────── */ /* ── Foundation slot suit hints ──────────────────────────────────────────── */
.slot-hint { .slot-hint {
+22
View File
@@ -56,6 +56,17 @@
</section> </section>
</main> </main>
<div id="resume-overlay" class="hidden">
<div class="resume-card">
<div class="resume-title">Resume Game?</div>
<p class="resume-detail">You have an unfinished game saved. Would you like to continue where you left off?</p>
<div class="resume-actions">
<button id="btn-resume">↩ Resume</button>
<button id="btn-resume-new" class="secondary">↺ New Game</button>
</div>
</div>
</div>
<div id="win-overlay" class="hidden"> <div id="win-overlay" class="hidden">
<div class="win-card"> <div class="win-card">
<div class="win-title">You Won!</div> <div class="win-title">You Won!</div>
@@ -66,6 +77,17 @@
</div> </div>
</div> </div>
<div id="no-moves-banner" class="hidden">
<div class="no-moves-card">
<div class="no-moves-title">No Moves Available</div>
<p class="no-moves-detail">No legal moves remain. Undo to go back or start a new game.</p>
<div class="no-moves-actions">
<button id="btn-no-moves-undo">↩ Undo</button>
<button id="btn-no-moves-new" class="secondary">↺ New Game</button>
</div>
</div>
</div>
<script type="module" src="/web/game.js"></script> <script type="module" src="/web/game.js"></script>
</body> </body>
</html> </html>
+102 -8
View File
@@ -69,6 +69,34 @@ function preloadTheme(theme) {
preloadTheme("classic"); preloadTheme("classic");
preloadTheme("dark"); preloadTheme("dark");
// ── Persistence ──────────────────────────────────────────────────────────────
const LS_SAVE_KEY = "fs_game_save";
function saveState() {
if (!game) return;
try {
const gameState = game.serialize();
if (typeof gameState !== "string") return;
localStorage.setItem(LS_SAVE_KEY, JSON.stringify({ gameState, elapsedSecs, drawThree }));
} catch (e) {
// localStorage may be unavailable (private browsing quota, etc.) — never block gameplay.
console.warn("fs: save failed", e);
}
}
function clearSave() {
try { localStorage.removeItem(LS_SAVE_KEY); } catch { /* ignore */ }
}
function loadSave() {
try {
const raw = localStorage.getItem(LS_SAVE_KEY);
if (!raw) return null;
const save = JSON.parse(raw);
return save?.gameState ? save : null;
} catch { return null; }
}
// ── State ──────────────────────────────────────────────────────────────────── // ── State ────────────────────────────────────────────────────────────────────
let game = null; let game = null;
let snap = null; // last rendered GameSnapshot let snap = null; // last rendered GameSnapshot
@@ -113,6 +141,7 @@ const winScore = document.getElementById("win-score");
const winMoves = document.getElementById("win-moves"); const winMoves = document.getElementById("win-moves");
const winTime = document.getElementById("win-time"); const winTime = document.getElementById("win-time");
const btnWinNew = document.getElementById("btn-win-new"); const btnWinNew = document.getElementById("btn-win-new");
const noMovesBanner = document.getElementById("no-moves-banner");
// ── Scale to fit ───────────────────────────────────────────────────────────── // ── Scale to fit ─────────────────────────────────────────────────────────────
// Scales #card-area to fill #board without overflowing either dimension. // Scales #card-area to fill #board without overflowing either dimension.
@@ -138,16 +167,72 @@ async function bootstrap() {
await init(); await init();
syncThemeButton(); syncThemeButton();
buildSlots();
scaleBoard();
window.addEventListener("resize", scaleBoard);
attachHandlers();
const saved = loadSave();
if (saved) {
showResumeDialog(saved);
} else {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed(); const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
drawThree = params.has("draw3"); drawThree = params.has("draw3");
chkDraw3.checked = drawThree; chkDraw3.checked = drawThree;
buildSlots();
scaleBoard();
window.addEventListener("resize", scaleBoard);
startGame(urlSeed); startGame(urlSeed);
attachHandlers(); }
}
function showResumeDialog(saved) {
const overlay = document.getElementById("resume-overlay");
if (overlay) overlay.classList.remove("hidden");
document.getElementById("btn-resume").onclick = () => {
if (overlay) overlay.classList.add("hidden");
resumeGame(saved);
};
document.getElementById("btn-resume-new").onclick = () => {
clearSave();
if (overlay) overlay.classList.add("hidden");
drawThree = false;
chkDraw3.checked = false;
startGame(randomSeed());
};
}
function resumeGame(saved) {
let restored;
try {
restored = SolitaireGame.from_saved(saved.gameState);
} catch (e) {
console.warn("fs: restore failed, starting new game", e);
clearSave();
startGame(randomSeed());
return;
}
game = restored;
drawThree = !!saved.drawThree;
elapsedSecs = saved.elapsedSecs || 0;
chkDraw3.checked = drawThree;
const displaySeed = Math.round(game.seed());
hudSeed.textContent = `seed ${displaySeed}`;
winOverlay.classList.add("hidden");
cardEls.clear();
board.querySelectorAll(".card, .recycle-label").forEach(el => el.remove());
const url = new URL(window.location);
url.searchParams.set("seed", displaySeed);
if (drawThree) url.searchParams.set("draw3", "");
else url.searchParams.delete("draw3");
history.replaceState(null, "", url);
const s = game.state();
snap = s;
render(s);
if (!s.is_won) startTimer();
} }
function randomSeed() { function randomSeed() {
@@ -300,12 +385,19 @@ function render(s) {
board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target")); board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target"));
if (s.is_auto_completable && !s.is_won && !acTimer) { if (s.is_auto_completable && !s.is_won && !acTimer) {
stopTimer(); // freeze elapsed time at the moment the player's last move completes
acTimer = setInterval(doAutoCompleteStep, 380); acTimer = setInterval(doAutoCompleteStep, 380);
} }
if (s.is_won) { if (s.is_won) {
clearSave();
stopTimer(); stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; } if (acTimer) { clearInterval(acTimer); acTimer = null; }
if (noMovesBanner) noMovesBanner.classList.add("hidden");
showWin(s); showWin(s);
} else {
saveState();
const noMoves = !s.has_moves && !s.is_auto_completable;
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
} }
} }
@@ -343,12 +435,12 @@ async function submitReplay(s) {
const payload = { const payload = {
schema_version: 1, schema_version: 1,
seed: Math.round(game.seed()), seed: Math.round(game.seed()),
draw_mode: drawThree ? "draw_three" : "draw_one", draw_mode: drawThree ? "DrawThree" : "DrawOne",
mode: "classic", mode: "Classic",
time_seconds: elapsedSecs, time_seconds: elapsedSecs,
final_score: s.score, final_score: s.score,
move_count: s.move_count, move_count: s.move_count,
recorded_at: new Date().toISOString(), recorded_at: new Date().toISOString().slice(0, 10),
moves: [], moves: [],
}; };
try { try {
@@ -391,6 +483,8 @@ function attachHandlers() {
btnUndo.addEventListener("click", doUndo); btnUndo.addEventListener("click", doUndo);
btnBoardUndo.addEventListener("click", doUndo); btnBoardUndo.addEventListener("click", doUndo);
btnNew.addEventListener("click", () => startGame(randomSeed())); btnNew.addEventListener("click", () => startGame(randomSeed()));
document.getElementById("btn-no-moves-undo")?.addEventListener("click", doUndo);
document.getElementById("btn-no-moves-new")?.addEventListener("click", () => startGame(randomSeed()));
btnWinNew.addEventListener("click", () => startGame(randomSeed())); btnWinNew.addEventListener("click", () => startGame(randomSeed()));
chkDraw3.addEventListener("change", () => { chkDraw3.addEventListener("change", () => {
drawThree = chkDraw3.checked; drawThree = chkDraw3.checked;
+27 -3
View File
@@ -40,20 +40,32 @@ export class ReplayPlayer {
} }
/** /**
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`). * Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
*
* Throws a JS string exception on serialisation failure (should never
* occur in practice `StateSnapshot` contains only primitive types).
* @returns {any} * @returns {any}
*/ */
state() { state() {
const ret = wasm.replayplayer_state(this.__wbg_ptr); const ret = wasm.replayplayer_state(this.__wbg_ptr);
return ret; if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
} }
/** /**
* Apply the next move; returns the post-step snapshot, or `null` * Apply the next move; returns the post-step snapshot, or `null`
* once the move list is exhausted. * once the move list is exhausted.
*
* Returns `null` (not an exception) when the replay is finished.
* Throws a JS string exception on serialisation failure.
* @returns {any} * @returns {any}
*/ */
step() { step() {
const ret = wasm.replayplayer_step(this.__wbg_ptr); const ret = wasm.replayplayer_step(this.__wbg_ptr);
return ret; if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
} }
/** /**
* 0-indexed position of the next move to apply. * 0-indexed position of the next move to apply.
@@ -157,11 +169,16 @@ export class SolitaireGame {
} }
/** /**
* Full pile snapshot as a JS object. * Full pile snapshot as a JS object.
*
* Throws a JS string exception on serialisation failure.
* @returns {any} * @returns {any}
*/ */
state() { state() {
const ret = wasm.solitairegame_state(this.__wbg_ptr); const ret = wasm.solitairegame_state(this.__wbg_ptr);
return ret; if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
} }
/** /**
* Undo the last move. Returns `{ok, error?, snapshot?}`. * Undo the last move. Returns `{ok, error?, snapshot?}`.
@@ -180,6 +197,13 @@ function __wbg_get_imports() {
const ret = Error(getStringFromWasm0(arg0, arg1)); const ret = Error(getStringFromWasm0(arg0, arg1));
return ret; return ret;
}, },
__wbg_String_8564e559799eccda: function(arg0, arg1) {
const ret = String(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) { __wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1)); throw new Error(getStringFromWasm0(arg0, arg1));
}, },
Binary file not shown.
+1 -1
View File
@@ -12,7 +12,7 @@ pub mod progress;
pub mod stats; pub mod stats;
pub use achievements::AchievementRecord; pub use achievements::AchievementRecord;
pub use merge::merge; pub use merge::{merge, merge_at};
pub use progress::{level_for_xp, PlayerProgress}; pub use progress::{level_for_xp, PlayerProgress};
pub use stats::StatsSnapshot; pub use stats::StatsSnapshot;
+132 -39
View File
@@ -3,13 +3,18 @@
//! All functions are free of I/O and side effects — safe to call from any //! All functions are free of I/O and side effects — safe to call from any
//! context including unit tests and the Bevy main thread. //! context including unit tests and the Bevy main thread.
use chrono::{NaiveDate, Utc}; use chrono::{DateTime, NaiveDate, Utc};
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload}; use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP}; use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
/// Merge two [`SyncPayload`]s into a single authoritative result. /// Merge two [`SyncPayload`]s into a single authoritative result.
/// ///
/// `resolved_at` is recorded as `last_modified` on the merged payload and all
/// sub-structs. Pass an explicit timestamp when the caller needs deterministic
/// output (e.g. server handlers); use [`merge`] as a convenience wrapper that
/// captures the current time automatically.
///
/// The merge strategy is additive and conflict-free for most fields: /// The merge strategy is additive and conflict-free for most fields:
/// - Counters: take the maximum (games_played, games_won, etc.) /// - Counters: take the maximum (games_played, games_won, etc.)
/// - Best records: take the minimum for times, maximum for scores/xp /// - Best records: take the minimum for times, maximum for scores/xp
@@ -20,6 +25,38 @@ use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
/// Fields that cannot be merged deterministically (e.g. diverged streak /// Fields that cannot be merged deterministically (e.g. diverged streak
/// counts) are recorded in [`ConflictReport`] entries returned alongside /// counts) are recorded in [`ConflictReport`] entries returned alongside
/// the merged payload. Data is never silently discarded. /// the merged payload. Data is never silently discarded.
pub fn merge_at(
local: &SyncPayload,
remote: &SyncPayload,
resolved_at: DateTime<Utc>,
) -> (SyncPayload, Vec<ConflictReport>) {
let mut conflicts = Vec::new();
if local.user_id != remote.user_id {
conflicts.push(ConflictReport {
field: "user_id".to_string(),
local_value: local.user_id.to_string(),
remote_value: remote.user_id.to_string(),
});
return (local.clone(), conflicts);
}
let stats = merge_stats(&local.stats, &remote.stats, resolved_at, &mut conflicts);
let achievements = merge_achievements(&local.achievements, &remote.achievements);
let progress = merge_progress(&local.progress, &remote.progress, resolved_at, &mut conflicts);
let merged = SyncPayload {
user_id: local.user_id,
stats,
achievements,
progress,
last_modified: resolved_at,
};
(merged, conflicts)
}
/// Convenience wrapper around [`merge_at`] that captures the current UTC time.
/// ///
/// # Examples /// # Examples
/// ``` /// ```
@@ -45,21 +82,7 @@ use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
/// assert!(conflicts.is_empty()); /// assert!(conflicts.is_empty());
/// ``` /// ```
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<ConflictReport>) { pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<ConflictReport>) {
let mut conflicts = Vec::new(); merge_at(local, remote, Utc::now())
let stats = merge_stats(&local.stats, &remote.stats, &mut conflicts);
let achievements = merge_achievements(&local.achievements, &remote.achievements);
let progress = merge_progress(&local.progress, &remote.progress, &mut conflicts);
let merged = SyncPayload {
user_id: local.user_id,
stats,
achievements,
progress,
last_modified: Utc::now(),
};
(merged, conflicts)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -69,6 +92,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<Con
fn merge_stats( fn merge_stats(
local: &StatsSnapshot, local: &StatsSnapshot,
remote: &StatsSnapshot, remote: &StatsSnapshot,
resolved_at: DateTime<Utc>,
conflicts: &mut Vec<ConflictReport>, conflicts: &mut Vec<ConflictReport>,
) -> StatsSnapshot { ) -> StatsSnapshot {
// win_streak_current cannot be merged deterministically — record conflict // win_streak_current cannot be merged deterministically — record conflict
@@ -84,31 +108,48 @@ fn merge_stats(
let merged_games_won = local.games_won.max(remote.games_won); let merged_games_won = local.games_won.max(remote.games_won);
let merged_games_played = local.games_played.max(remote.games_played); let merged_games_played = local.games_played.max(remote.games_played);
// Recompute average time from the merged totals. If no wins yet, keep 0. // Carry the average time from whichever side contributed merged_games_won.
// Taking max(total_time)/max(wins) misattributes time when the side with
// more wins has a lower total — use the winning side's average directly.
let avg_time_seconds = if merged_games_won == 0 { let avg_time_seconds = if merged_games_won == 0 {
0 0
} else if local.games_won >= remote.games_won {
local.avg_time_seconds
} else { } else {
// Use whichever side has more wins to approximate total time, then blend. remote.avg_time_seconds
// We don't have total_time stored, so we reconstruct it from avg * count.
let local_total = local.avg_time_seconds as u128 * local.games_won as u128;
let remote_total = remote.avg_time_seconds as u128 * remote.games_won as u128;
// Take max total time (conservative — avoids underestimating total play time).
let best_total = local_total.max(remote_total);
(best_total / merged_games_won as u128) as u64
}; };
// Derive games_lost from the merged played/won counts so the invariant
// games_won + games_lost <= games_played is always satisfied. Computing
// max(local.games_lost, remote.games_lost) independently can push
// games_won + games_lost above games_played after a divergent merge.
let merged_games_lost = merged_games_played.saturating_sub(merged_games_won);
StatsSnapshot { StatsSnapshot {
games_played: merged_games_played, games_played: merged_games_played,
games_won: merged_games_won, games_won: merged_games_won,
games_lost: local.games_lost.max(remote.games_lost), games_lost: merged_games_lost,
win_streak_current: local.win_streak_current.max(remote.win_streak_current), win_streak_current: local.win_streak_current.max(remote.win_streak_current),
win_streak_best: local.win_streak_best.max(remote.win_streak_best), win_streak_best: local.win_streak_best.max(remote.win_streak_best),
avg_time_seconds, avg_time_seconds,
fastest_win_seconds: local.fastest_win_seconds.min(remote.fastest_win_seconds), fastest_win_seconds: local.fastest_win_seconds.min(remote.fastest_win_seconds),
lifetime_score: local.lifetime_score.max(remote.lifetime_score), lifetime_score: local.lifetime_score.max(remote.lifetime_score),
best_single_score: local.best_single_score.max(remote.best_single_score), best_single_score: local.best_single_score.max(remote.best_single_score),
draw_one_wins: local.draw_one_wins.max(remote.draw_one_wins), // Take per-mode win counts from whichever side contributed `games_won`
draw_three_wins: local.draw_three_wins.max(remote.draw_three_wins), // (the side with the higher total). Independent max() calls can push
// draw_one_wins + draw_three_wins above games_won when the two sides
// have complementary win histories (e.g. local has 20 draw-one wins,
// remote has 20 draw-three wins, each with games_won = 20).
draw_one_wins: if local.games_won >= remote.games_won {
local.draw_one_wins
} else {
remote.draw_one_wins
},
draw_three_wins: if local.games_won >= remote.games_won {
local.draw_three_wins
} else {
remote.draw_three_wins
},
// Per-mode bests. Bests take max; fastest times take a *zero-aware* // Per-mode bests. Bests take max; fastest times take a *zero-aware*
// min — see [`min_ignore_zero`] for the rationale (0 means "no win // min — see [`min_ignore_zero`] for the rationale (0 means "no win
// yet" for these fields, unlike the lifetime `fastest_win_seconds` // yet" for these fields, unlike the lifetime `fastest_win_seconds`
@@ -128,7 +169,7 @@ fn merge_stats(
local.challenge_fastest_win_seconds, local.challenge_fastest_win_seconds,
remote.challenge_fastest_win_seconds, remote.challenge_fastest_win_seconds,
), ),
last_modified: Utc::now(), last_modified: resolved_at,
} }
} }
@@ -213,6 +254,7 @@ fn merge_achievements(
fn merge_progress( fn merge_progress(
local: &PlayerProgress, local: &PlayerProgress,
remote: &PlayerProgress, remote: &PlayerProgress,
resolved_at: DateTime<Utc>,
conflicts: &mut Vec<ConflictReport>, conflicts: &mut Vec<ConflictReport>,
) -> PlayerProgress { ) -> PlayerProgress {
// daily_challenge_streak cannot be merged deterministically. // daily_challenge_streak cannot be merged deterministically.
@@ -303,7 +345,7 @@ fn merge_progress(
challenge_index, challenge_index,
daily_challenge_history, daily_challenge_history,
daily_challenge_longest_streak, daily_challenge_longest_streak,
last_modified: Utc::now(), last_modified: resolved_at,
} }
} }
@@ -429,14 +471,28 @@ mod tests {
} }
#[test] #[test]
fn stats_games_lost_takes_max() { fn stats_games_lost_derived_from_played_minus_won() {
// games_lost must equal games_played - games_won so the invariant
// games_won + games_lost <= games_played is always satisfied.
let mut local = default_payload(); let mut local = default_payload();
local.stats.games_played = 20;
local.stats.games_won = 8;
local.stats.games_lost = 12; local.stats.games_lost = 12;
let mut remote = default_payload(); let mut remote = default_payload();
remote.stats.games_lost = 8; remote.stats.games_played = 15;
remote.stats.games_won = 10;
remote.stats.games_lost = 5;
// merged: games_played = max(20, 15) = 20; games_won = max(8, 10) = 10
// games_lost must be 20 - 10 = 10, NOT max(12, 5) = 12
let (merged, _) = merge(&local, &remote); let (merged, _) = merge(&local, &remote);
assert_eq!(merged.stats.games_lost, 12); assert_eq!(merged.stats.games_played, 20);
assert_eq!(merged.stats.games_won, 10);
assert_eq!(merged.stats.games_lost, 10);
assert!(
merged.stats.games_won + merged.stats.games_lost <= merged.stats.games_played,
"games_won + games_lost must never exceed games_played"
);
} }
#[test] #[test]
@@ -462,26 +518,63 @@ mod tests {
} }
#[test] #[test]
fn stats_draw_mode_wins_take_max() { fn stats_draw_mode_wins_taken_from_winning_side() {
// Both sides have equal games_won (default 0), so local is chosen (>=).
// Per-mode counts come entirely from that one side — no cross-side max.
let mut local = default_payload(); let mut local = default_payload();
local.stats.games_won = 25;
local.stats.draw_one_wins = 20; local.stats.draw_one_wins = 20;
local.stats.draw_three_wins = 5; local.stats.draw_three_wins = 5;
let mut remote = default_payload(); let mut remote = default_payload();
remote.stats.games_won = 15;
remote.stats.draw_one_wins = 15; remote.stats.draw_one_wins = 15;
remote.stats.draw_three_wins = 8; remote.stats.draw_three_wins = 8;
// local has more wins, so local's per-mode counts are used.
let (merged, _) = merge(&local, &remote); let (merged, _) = merge(&local, &remote);
assert_eq!(merged.stats.games_won, 25);
assert_eq!(merged.stats.draw_one_wins, 20); assert_eq!(merged.stats.draw_one_wins, 20);
assert_eq!(merged.stats.draw_three_wins, 8); assert_eq!(merged.stats.draw_three_wins, 5);
assert!(
merged.stats.draw_one_wins + merged.stats.draw_three_wins
<= merged.stats.games_won,
"draw-mode win counts must not exceed total wins"
);
}
#[test]
fn merge_stats_draw_mode_wins_do_not_exceed_total() {
// local: 20 draw-one wins, 0 draw-three, games_won = 20
// remote: 0 draw-one wins, 20 draw-three, games_won = 20
// Without the fix, independent max() calls yield draw_one=20, draw_three=20,
// games_won=20 — the breakdown sums to 40, double the actual total.
let mut local = default_payload();
local.stats.games_won = 20;
local.stats.draw_one_wins = 20;
local.stats.draw_three_wins = 0;
let mut remote = default_payload();
remote.stats.games_won = 20;
remote.stats.draw_one_wins = 0;
remote.stats.draw_three_wins = 20;
let (merged, _) = merge(&local, &remote);
assert!(
merged.stats.draw_one_wins + merged.stats.draw_three_wins <= merged.stats.games_won,
"draw-mode win counts must not exceed total wins after merge: \
draw_one={}, draw_three={}, games_won={}",
merged.stats.draw_one_wins,
merged.stats.draw_three_wins,
merged.stats.games_won,
);
} }
#[test] #[test]
fn stats_avg_time_recomputed_from_merged_totals() { fn stats_avg_time_recomputed_from_merged_totals() {
// local: 4 wins averaging 100s each (total = 400s) // local: 4 wins averaging 100s each
// remote: 6 wins averaging 200s each (total = 1200s) // remote: 6 wins averaging 200s each
// merged_games_won = max(4, 6) = 6 // merged_games_won = max(4, 6) = 6 → remote contributed the wins
// best_total = max(400, 1200) = 1200 // avg_time_seconds must be remote's 200s, not a blend of totals
// avg = 1200 / 6 = 200
let mut local = default_payload(); let mut local = default_payload();
local.stats.games_won = 4; local.stats.games_won = 4;
local.stats.avg_time_seconds = 100; local.stats.avg_time_seconds = 100;
+51 -8
View File
@@ -119,7 +119,7 @@ impl ReplayPlayer {
let replay: Replay = let replay: Replay =
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?; serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
let game = let game =
GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode); GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
Ok(Self { Ok(Self {
game, game,
moves: replay.moves, moves: replay.moves,
@@ -193,16 +193,24 @@ impl ReplayPlayer {
} }
/// Snapshot the current `GameState` as a JS object (see `StateSnapshot`). /// Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
pub fn state(&self) -> JsValue { ///
serde_wasm_bindgen::to_value(&self.snapshot()).unwrap_or(JsValue::NULL) /// Throws a JS string exception on serialisation failure (should never
/// occur in practice — `StateSnapshot` contains only primitive types).
pub fn state(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.snapshot())
.map_err(|e| JsValue::from_str(&e.to_string()))
} }
/// Apply the next move; returns the post-step snapshot, or `null` /// Apply the next move; returns the post-step snapshot, or `null`
/// once the move list is exhausted. /// once the move list is exhausted.
pub fn step(&mut self) -> JsValue { ///
/// Returns `null` (not an exception) when the replay is finished.
/// Throws a JS string exception on serialisation failure.
pub fn step(&mut self) -> Result<JsValue, JsValue> {
match self.step_native() { match self.step_native() {
Some(snap) => serde_wasm_bindgen::to_value(&snap).unwrap_or(JsValue::NULL), Some(snap) => serde_wasm_bindgen::to_value(&snap)
None => JsValue::NULL, .map_err(|e| JsValue::from_str(&e.to_string())),
None => Ok(JsValue::NULL),
} }
} }
@@ -233,6 +241,8 @@ pub struct GameSnapshot {
pub move_count: u32, pub move_count: u32,
pub is_won: bool, pub is_won: bool,
pub is_auto_completable: bool, pub is_auto_completable: bool,
/// `false` when stock, waste, and all pile-to-pile moves are exhausted.
pub has_moves: bool,
pub undo_count: u32, pub undo_count: u32,
/// Number of snapshots currently on the undo stack; 0 means undo is unavailable. /// Number of snapshots currently on the undo stack; 0 means undo is unavailable.
pub undo_stack_len: usize, pub undo_stack_len: usize,
@@ -271,11 +281,17 @@ impl SolitaireGame {
.map(|p| p.cards.iter().map(CardSnapshot::from).collect()) .map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default() .unwrap_or_default()
}; };
let has_moves = {
let stock_empty = self.game.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty());
let waste_empty = self.game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty());
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
};
GameSnapshot { GameSnapshot {
score: self.game.score, score: self.game.score,
move_count: self.game.move_count, move_count: self.game.move_count,
is_won: self.game.is_won, is_won: self.game.is_won,
is_auto_completable: self.game.is_auto_completable, is_auto_completable: self.game.is_auto_completable,
has_moves,
undo_count: self.game.undo_count, undo_count: self.game.undo_count,
undo_stack_len: self.game.undo_stack_len(), undo_stack_len: self.game.undo_stack_len(),
stock: cards(PileType::Stock), stock: cards(PileType::Stock),
@@ -364,8 +380,11 @@ impl SolitaireGame {
} }
/// Full pile snapshot as a JS object. /// Full pile snapshot as a JS object.
pub fn state(&self) -> JsValue { ///
serde_wasm_bindgen::to_value(&self.snap()).unwrap_or(JsValue::NULL) /// Throws a JS string exception on serialisation failure.
pub fn state(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.snap())
.map_err(|e| JsValue::from_str(&e.to_string()))
} }
/// The seed used to deal this game. /// The seed used to deal this game.
@@ -411,6 +430,30 @@ impl SolitaireGame {
} }
} }
/// Serialise the full game state as a JSON string for `localStorage`.
///
/// Use [`SolitaireGame::from_saved`] to restore it. The returned string is
/// opaque — callers should treat it as a blob and store/restore it verbatim.
pub fn serialize(&self) -> Result<String, JsValue> {
serde_json::to_string(&self.game)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
///
/// Returns an error string if the JSON is malformed or describes a state
/// that can't be deserialised (e.g. from a future schema version).
pub fn from_saved(json: &str) -> Result<SolitaireGame, JsValue> {
serde_json::from_str::<GameState>(json)
.map(|mut game| {
// Older saves serialised with take_from_foundation=false (the core default).
// The web client has no settings layer, so enforce the standard rule here.
game.take_from_foundation = true;
SolitaireGame { game }
})
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Apply one auto-complete move (only valid when `is_auto_completable`). /// Apply one auto-complete move (only valid when `is_auto_completable`).
/// ///
/// If no card can go directly to a foundation this step, advances the /// If no card can go directly to a foundation this step, advances the