Compare commits

..

135 Commits

Author SHA1 Message Date
funman300 7dbf34c163 fix(server): move bcrypt to spawn_blocking, async file I/O, validate JWT_SECRET
Build and Deploy / build-and-push (push) Successful in 5m20s
Web E2E / web-e2e (push) Failing after 3m23s
Three independent hardening changes:

1. bcrypt on a blocking thread: hash() and verify() are CPU-bound
   (~300 ms at cost 12). Running them directly on an async task starved
   the Tokio runtime under concurrent load. Wrapped in spawn_blocking.

2. Async avatar file I/O: std::fs::write/rename/remove_file in an async
   handler blocks the executor. Replaced with tokio::fs equivalents.

3. JWT_SECRET minimum length: a secret shorter than 32 bytes is fatally
   weak. validate_jwt_secret() now rejects it at startup with a clear
   message rather than silently accepting it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:05:45 -07:00
funman300 7fa91b6fb4 data(seeds): regenerate difficulty seeds (2026-06-04)
Replace the 2026-05-09 seed lists with seeds regenerated on 2026-06-04.
All seeds remain verified winnable within their respective solver budgets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:05:37 -07:00
funman300 becfda0f6c fix(android): auto-discover SDK/NDK in build script, strip native libs
build_android_apk.sh no longer requires all four env vars to be set
manually. It probes common SDK paths and uses the newest installed
build-tools/NDK/platform when vars are absent. Also adds llvm-strip
pass to strip debug symbols from .so files before packaging (controlled
by STRIP_NATIVE_LIBS, default 1), moves the debug keystore to a stable
target/android/debug.keystore path, and prints resolved paths at start.

Also adds scripts/ANDROID_TESTING.md and scripts/android_smoke.sh for
on-device smoke testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:05:31 -07:00
funman300 fa786bafcf feat(android): wire Android Keystore JNI via OnceLock
Remove the dependency on bevy::android::ANDROID_APP inside
android_keystore.rs. Instead, solitaire_data owns a process-wide
OnceLock<JavaVM> initialised by a new pub fn init_android_jvm().
solitaire_app calls it from android_main before run() so JNI is
ready before any auth-token operation can execute.

- android_keystore: drop ANDROID_APP import; add ANDROID_JVM OnceLock
  and init_android_jvm(vm_ptr: *mut c_void)
- solitaire_data/lib.rs: re-export init_android_jvm for android target
- auth_tokens.rs: update doc comment (Android backend is now complete)
- solitaire_app/lib.rs: call init_android_jvm from android_main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:05:23 -07:00
funman300 d864d985c8 refactor(engine,wasm,data): route all klondike/card_game imports through solitaire_core
Build and Deploy / build-and-push (push) Failing after 53s
Web E2E / web-e2e (push) Failing after 4m16s
All downstream crates now import Foundation, KlondikePile, Tableau,
Klondike, Session, Suit, Rank exclusively from solitaire_core.
solitaire_core is the single version-pin point for the upstream crates.

- solitaire_engine: 19 files updated, klondike direct dep removed
- solitaire_wasm: use statement updated, klondike direct dep removed
- solitaire_data: unused klondike dep removed
- Cargo.lock: klondike no longer a direct dep of engine/wasm/data
- Full workspace clippy clean, all tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:04:05 -07:00
funman300 ae1ecc8559 refactor(core): unify Suit/Rank with card_game upstream types
Build and Deploy / build-and-push (push) Failing after 56s
Web E2E / web-e2e (push) Failing after 4m23s
Replace the parallel solitaire_core::Suit and solitaire_core::Rank
definitions with pub-use re-exports from card_game. card_game upstream
gained serde, is_black(), and value() to make this clean.

- card.rs: remove Suit/Rank enums and impls; add pub use card_game::{Suit,Rank}
- klondike_adapter.rs: remove From<card_game::Suit/Rank> bridges (now same type)
- Simplify card_from_kl: .into() calls become direct assignment
- Cargo.toml: switch to git deps (serde feature), Cargo.lock updated

All 62 solitaire_core tests pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:57:49 -07:00
funman300 5e8735886f refactor(core): integrate card_game/klondike deps cleanly
Build and Deploy / build-and-push (push) Failing after 56s
Web E2E / web-e2e (push) Failing after 3m14s
Wire card_game 0.4.0 and klondike 0.3.0 as workspace deps in
solitaire_core and clean the integration seam across five areas:

- Move From<card_game::Suit/Rank> bridge impls out of card.rs and into
  klondike_adapter.rs so the product-type module is upstream-dep-free
- Add `use crate::card` alias to adapter; rename card_from_kl parameter
  to avoid shadowing; correct score_for_undo doc (it is Ferrous policy,
  not an upstream default — the solver explicitly passes undo_penalty=0)
- Mark Pile as a read-only projection / data-transfer type in its doc
  comment so game logic isn't accidentally routed through it
- Add GameState::session() read accessor exposing the underlying
  Session<Klondike> for replay history and solver use by external crates;
  update solver.rs to use the accessor instead of the pub(crate) field
- Re-export Foundation, Klondike, KlondikePile, Session, Tableau from
  solitaire_core::lib so downstream crates (engine, wasm) can import
  from one place without a direct klondike/card_game dep
- Add proptest property tests: card conservation (52 unique IDs always
  present), deal determinism, undo pile-layout invariant, legal moves
  always succeed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:46:29 -07:00
funman300 8bd2fb89eb test: expand WASM unit tests and add web behavior e2e specs
solitaire_wasm/src/lib.rs — 5 new unit tests (9 total, was 4):
- serialize_from_saved_round_trip: board key matches after JSON round-trip
- undo_reverts_to_prior_state: state + history length restored after undo
- draw_one_advances_waste_by_one: DrawOne takes exactly 1 card from stock
- draw_three_advances_waste_by_three: DrawThree takes up to 3 cards
- debug_apply_move_json_stock_click: JSON DebugMove path via native method

solitaire_server/e2e/tests/game_behaviors.spec.js — 5 new Playwright tests:
- resume overlay shows when localStorage save exists; seed() returns null
  until user interacts (before bootstrap completes a game)
- clicking New Game on overlay clears history and starts fresh (0 moves)
- clicking Resume restores saved move history length exactly
- HUD new-game button resets history to 0 and score to 0
- tab-visibility timer: timer freezes during hidden, resumes when visible
  (tests the visibilitychange fix from the 500-game UX audit); uses
  page.clock.install() to control setInterval without real-time delay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:12:42 -07:00
funman300 2b1ad2161a test(e2e): add Playwright spec for /play Bevy canvas route
play_canvas.spec.js covers the window.__FERROUS_DEBUG__ bridge on the
/play route (five tests): bridge availability + seed param, draw3 URL
param, applyLegalMove/undo round-trip, failureReport schema, and
autonomous autoplay invariant batch across 7 seeds.

All tests drive exclusively through the debug bridge — no DOM selectors,
because the Bevy canvas is a single <canvas> element with no HTML
controls.

Also update SESSION_HANDOFF.md to reflect post-v0.35.1 work (10 commits
since 2026-05-18 handoff), new e2e architecture notes, and HiDPI fix doc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:03:25 -07:00
funman300 2cf728210e feat(e2e): add window.__FERROUS_DEBUG__ bridge to /play for automation
Build and Deploy / build-and-push (push) Successful in 4m42s
Web E2E / web-e2e (push) Successful in 4m10s
play.html now loads solitaire_wasm.js alongside the Bevy canvas and
exposes the same window.__FERROUS_DEBUG__ object as /play-classic.
The bridge runs an independent SolitaireGame (WASM logic layer) seeded
from ?seed= / ?draw3= URL params; Bevy renders the visual game in
parallel without coupling.

Methods exposed: seed, state, legalMoves, moveHistory, snapshot,
applyLegalMove, applyMove, draw, undo, serialize, fromSaved, newGame,
failureReport, replayPayload, runAutoplay — matching the /play-classic
contract so the shared Playwright harness targets either route without
modification.

cycle_metrics.js: add --route play-classic|play flag (default
play-classic). Routes to /${route}?seed=N. The resume-overlay clear
step is skipped for /play since the Bevy build uses localStorage-backed
WasmStorage, not a #resume-overlay element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:41:07 -07:00
funman300 8b262afcd2 fix(web): clamp wgpu surface to CSS pixels on HiDPI to prevent wasm panic
Build and Deploy / build-and-push (push) Successful in 4m52s
Web E2E / web-e2e (push) Successful in 4m12s
Root cause: fit_canvas_to_parent requests a wgpu surface sized in
physical pixels (CSS pixels × devicePixelRatio). On HiDPI displays
(DPR ≈ 2) the physical size (e.g. 2612×1469) exceeds WebGL2's per-
dimension texture limit of 2048, triggering a wgpu validation panic
that kills the WASM thread immediately on the first window resize.

Fix: add `resolution: WindowResolution::default().with_scale_factor_override(1.0)`
to the primary window so Bevy uses CSS/logical pixels as the surface
dimensions. For a 1306×734 CSS viewport this keeps the framebuffer well
within 2048 regardless of devicePixelRatio.

Also remove the temporary [drag] console logging added in the previous
commit — the panic was causing drag to never run, not a hit-test bug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:28:25 -07:00
funman300 8b736cae3c debug(input): log drag failures to browser console for diagnosis
Build and Deploy / build-and-push (push) Successful in 5m12s
Web E2E / web-e2e (push) Successful in 3m40s
Add warn!/info! calls to start_drag so every click that doesn't produce
a drag emits a console line with the cursor world position, stock/waste
sizes, and per-tableau pile lengths. This lets us see in browser DevTools
whether find_draggable_at is returning None (wrong hit position) or
something earlier in the pipeline is blocking.

Remove once root cause is identified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:22:42 -07:00
funman300 de7ae16830 fix(onboarding): delay first-run modal until splash screen despawns
Build and Deploy / build-and-push (push) Successful in 4m35s
Web E2E / web-e2e (push) Successful in 4m25s
OnboardingPlugin previously used PostStartup which fires before the
first Update tick — guaranteeing the onboarding modal and the launch
splash (MOTION_SPLASH_TOTAL_SECS = 1.6 s) overlap for the entire
splash duration. The splash sits at Z_SPLASH (the highest UI z-index),
so the two screens fought visually and the user saw a confusing frozen
composite before the splash faded out.

Fix: move spawn_if_first_run to Update and gate it on
`splashes.is_empty()` (no SplashRoot entity alive). A Local<bool>
ensures the spawn fires at most once per session. Cost: ~one frame of
latency after the splash clears, which is imperceptible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:59:58 -07:00
funman300 d45b7cb82b feat(e2e): add Playwright browser test suite for web routes
Build and Deploy / build-and-push (push) Successful in 1m6s
Web E2E / web-e2e (push) Successful in 4m40s
solitaire_server/e2e/:
- smoke.spec.js: verifies /play-classic loads, exposes window.__FERROUS_DEBUG__
  bridge, keyboard parity (Space=draw, U=undo), debug failure report, and
  replay payload builder exports schema-v2 moves.
- gameplay_review.spec.js: HUD/controls render check, stock-click + undo
  player flow, draw-mode toggle, autonomous play invariant batch, and
  cycle-detection regression guard.
- cycle_metrics.js: headless cycle-rate analysis tool; run via
  `npm run review:cycles` with configurable policy, game count, and
  thresholds. Regression gate baked into package.json scripts.
- playwright.config.js: targets the local server at http://localhost:8080.
- package.json / package-lock.json: @playwright/test 1.60.0.

.gitea/workflows/web-e2e.yml:
- Runs on pushes to solitaire_server/, solitaire_wasm/, solitaire_core/,
  or Cargo changes. Starts the server binary, waits for /health, runs
  the full Playwright suite, uploads test-results/ on failure.

docs/testing-architecture.md: documents the three-tier test strategy
  (unit → Playwright smoke → cycle regression) and the __FERROUS_DEBUG__
  bridge contract.

scripts/update_quaternions_deps.sh: helper to bump the Quaternions
  registry deps (klondike, card_game) by version and run the full
  safety gate including deterministic replay checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:40:30 -07:00
funman300 763fdb486f fix(input): hit-test deck at correct position; accept waste click too
Build and Deploy / build-and-push (push) Successful in 4m36s
pile_positions[KlondikePile::Stock] stores the waste column position
(col_x(1)).  card_plugin renders the face-down deck one column to the
left (col_x(0) = Tableau1 x) via `base.x -= tableau_col_step`.

handle_stock_click and handle_touch_stock_tap were using pile_positions
[Stock] directly, so the click hotspot was on the waste card (right
column) instead of the deck (left column).  Result: clicking the
visible face-down deck did nothing, while clicking the waste pile
triggered draw.

Fix: compute deck_pos = Vec2::new(tableau1.x, waste_pos.y) and hit-test
both the deck column AND the waste slot.  Accepting waste clicks matches
standard Klondike UX where either card acts as the draw trigger.

Touch tap handler receives the same fix.

Also rebuild canvas_bg.wasm with the corrected engine source and
-O2 optimisation (replacing the previous -Oz that caused grey screen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:39:01 -07:00
funman300 1cdb78caf2 chore: cargo fmt across workspace; add analytics domain to CSP
Build and Deploy / build-and-push (push) Successful in 4m46s
- Apply cargo fmt to solitaire_engine, solitaire_server formatting.
- solitaire_server/src/lib.rs: add https://analytics.aleshym.co to
  script-src, img-src, and connect-src so the analytics beacon loads
  without a CSP violation.
- docs and README updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:32 -07:00
funman300 baf524ec75 fix(web): rebuild Bevy canvas WASM; add SolitaireGame interactive API
Grey screen fix (canvas_bg.wasm):
- Rebuilt Bevy WASM from refactored solitaire_core that removes the
  per-game KlondikeAdapter field from GameState. The old binary was
  built with wasm-opt -Oz; the large adapter allocation pattern appears
  to trigger an over-aggressive wasm-opt optimisation that corrupts
  Bevy's render pipeline, causing a permanent grey screen on /play.
- build_wasm.sh: change wasm-opt -Oz → -O2. Speed-optimised level avoids
  the size-focused transforms that miscompile Bevy's deep render stacks.

solitaire_core refactoring:
- game_state.rs: remove adapter: KlondikeAdapter field; use static
  KlondikeAdapter::config_for() instead of a per-instance allocation.
  Gate test_pile_state behind #[cfg(feature = "test-support")] so
  production builds carry no test-only heap state.
  Add instruction_history() public accessor (delegates to saved_moves()).
- card.rs: add Card::new(), face_up(), face_down() const constructors
  for more ergonomic test and wasm code.
- pile.rs, solver.rs: cargo fmt.

solitaire_wasm interactive API:
- lib.rs: add SolitaireGame wasm-bindgen struct with draw(), move_cards(),
  undo(), auto_complete_step(), serialize(), from_saved() — the full
  player-action surface used by game.js.
  Add DebugSnapshot, DebugMove, DebugInvariantReport structs and
  debug_snapshot(), debug_legal_moves(), debug_apply_move_json()
  methods for e2e test automation (window.__FERROUS_DEBUG__ bridge).
  Add replay_moves() to export the current game as a Replay v2 payload.
- solitaire_wasm.js + solitaire_wasm_bg.wasm: rebuilt with new API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:20 -07:00
funman300 9ff0585454 fix(ci): remove Quaternions registry auth; add canvas WASM drift guard
Dockerfile:
- Drop --mount=type=secret,id=cargo_token: the Quaternions private
  registry has been migrated to the public Cargo.io path so the build
  secret is no longer needed. Removes the requirement for CI_TOKEN to
  carry registry credentials.

CI workflow (docker-build.yml):
- Add solitaire_wasm/** and solitaire_web/** to the push-trigger paths
  so changes to either WASM crate actually fire the build job.
- Add wasm drift check for solitaire_wasm artifacts (solitaire_wasm.js,
  solitaire_wasm_bg.wasm) — exits 1 if solitaire_wasm/ or solitaire_core/
  changed without updating the committed pkg files.
- Add hard canvas drift check: solitaire_web/ changes MUST update
  canvas_bg.wasm or the deploy gets a stale Bevy binary.
- Add advisory notice for solitaire_engine/ / solitaire_core/ changes
  that omit a canvas_bg.wasm rebuild (non-blocking; formatting commits
  should not fail CI).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:20:56 -07:00
funman300 64f975ed6d fix(ux): 14 cross-platform UX/UI fixes from 500-game audit
Web client (game.js):
- Restart game timer after undo exits auto-complete sequence
- Pause timer while browser tab is hidden (visibilitychange)
- Validate URL seed — NaN / negative falls back to randomSeed()
- Guard onBoardClick/onBoardDblClick during win (snap.is_won)
- Delay win overlay 320 ms so last card CSS transition finishes
- Force reflow in flashIllegal() to restart shake on rapid re-trigger

Android (safe_area.rs):
- Preserve last-known insets on app resume instead of zeroing them;
  eliminates double layout flash on every foreground cycle

All clients — Bevy engine:
- Radial menu: clamp icon anchors to viewport bounds so icons are
  never placed off-screen on narrow phones
- Auto-complete: deactivate state.active when is_auto_completable
  goes false (undo mid-sequence) to stop perpetual background retry
- Touch selection: gate highlight rebuild on is_changed() — was
  despawning/respawning entities every frame unnecessarily
- Input: fire "Tap a pile to move" InfoToast on first tap in
  TapToSelect mode; document cursor_world 1:1 viewport invariant
- Drag threshold: raise desktop from 4 → 6 px to prevent accidental
  drags from cursor jitter on HiDPI displays

Desktop / Android (solitaire_app):
- Call cleanup_orphaned_tmp_files() at startup to remove .tmp files
  left by crashes between atomic write and rename

Design clarification (klondike_adapter.rs):
- Doc comment: Draw-1 recycling is penalty-only by design (never
  blocked) to avoid creating unwinnable positions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:23:52 -07:00
funman300 20e5222148 fix(engine): send confirmed:true from game-over screen New Game handlers
Build and Deploy / build-and-push (push) Successful in 4m51s
The 'No Moves Available' dialog's New Game button and keyboard shortcut
were firing NewGameRequestEvent::default() (confirmed: false). When the
player has made moves, handle_new_game sees needs_confirm = true, then
hits the scrims.is_empty() guard — which is false because the GameOver-
Screen itself is a ModalScrim — and silently returns without starting a
new game or showing the confirm dialog.

Fix: set confirmed: true in both handle_game_over_input (N/Escape key)
and handle_game_over_button_input (click). The game is already stuck so
the abandon-confirmation guard does not apply, as the doc comment on the
button handler has always said.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:26:07 -07:00
funman300 44e90ff582 fix(ci): pass Quaternions registry token as Docker build secret
Build and Deploy / build-and-push (push) Successful in 4m39s
cargo fetch --locked was failing with "failed to parse manifest" because
.cargo/config.toml (which registers the Quaternions sparse index) was
never copied into the build image, and the registry's auth token was
never supplied.

Changes:
- COPY .cargo/config.toml into the builder stage so Cargo knows the
  Quaternions registry URL.
- Replace bare `cargo fetch` and `cargo build` with
  `--mount=type=secret,id=cargo_token` variants that set
  CARGO_REGISTRIES_QUATERNIONS_TOKEN from the mounted secret — token
  never appears in image layers or docker history.
- Workflow: pass CI_TOKEN as the `cargo_token` build secret.
- Add solitaire_engine/** and solitaire_server/Dockerfile to trigger
  paths so engine changes and Dockerfile edits kick off rebuilds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:58:25 -07:00
funman300 0bae839e3b fix(wasm): gate wasm32-only imports behind cfg, add binaryen wasm-opt pass
Build and Deploy / build-and-push (push) Failing after 1m12s
- Gate `Startup` and `user_theme_dir` imports in theme/registry.rs
  behind `#[cfg(not(target_arch = "wasm32"))]` — they are only used
  in the non-wasm code path, eliminating two unused-import warnings
  in the WASM release build.
- Rebuild canvas_bg.wasm and solitaire_wasm_bg.wasm with wasm-opt -Oz
  (binaryen v129); canvas_bg.wasm drops from 57 MB → 30 MB.
- Add solitaire_web/Cargo.toml stub to server Dockerfile so
  `cargo fetch --locked` resolves all workspace members.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:53:36 -07:00
funman300 c68cf96488 fix(web): add WgpuSettingsPriority::WebGL2 for Chromium shader compatibility
Build and Deploy / build-and-push (push) Failing after 43s
Without this setting, wgpu's naga SPIR-V→GLSL translator uses features
unsupported by ANGLE (Chromium's WebGL2 implementation): storage buffers,
tight inter-stage component limits, etc. ANGLE rejects these shaders with
a fatal "Shader translation error" and a context-lost event.

WgpuSettingsPriority::WebGL2 constrains naga to emit GLES 300es-compatible
GLSL (same limits as WebGL2 spec: no storage buffers, max 31 inter-stage
components, max 255-byte vertex stride). Firefox was already permissive
enough to work without this; Chromium required it.

Result: game renders correctly in both Chromium (ANGLE/SwiftShader) and
Firefox (native WebGL2), with zero JS errors in both environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:24:27 -07:00
funman300 a92ac066a6 fix(web): resolve wasm32 runtime panics; game boots and renders in Firefox
Build and Deploy / build-and-push (push) Failing after 1m6s
Fixes found while testing the Bevy WASM build in a real browser:

1. chrono wasmbind: add `wasmbind` feature to workspace chrono dep so
   Local::now()/Utc::now() use js-sys::Date on wasm32 (previously
   fell through to std::time::SystemTime which panics on wasm32).

2. std::time::SystemTime: replace all remaining direct SystemTime::now()
   calls (4 sites across game_plugin, difficulty_plugin, time_attack_plugin,
   solitaire_data/storage) with chrono::Utc::now() which is wasm32-safe.

3. user_dir: return empty PathBuf (instead of panicking) when data_dir()
   is None on wasm32; there is no filesystem in the browser so user themes
   are unsupported and a benign empty path is correct.

4. ThemeRegistryPlugin: gate build_registry_on_startup to non-wasm32
   (the filesystem scan for user themes has nothing to scan in the browser;
   only the bundled embedded themes are available).

5. AssetMetaCheck::Never: configure AssetPlugin in solitaire_web to skip
   `.meta` sidecar fetches — we don't ship .meta files, so the default
   AssetMetaCheck::Always produced a 404 flood on every card/background asset.

Result: `http://localhost:<port>/play` boots in Firefox with zero errors
and renders the full Bevy game — home screen, onboarding modal, HUD all
visible. Assets load correctly from /assets/. Chromium has a separate
wgpu-27/ANGLE/GLES shader translation bug (not in our code); Firefox works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:16:19 -07:00
funman300 f464aab543 fix(web): clean up wasm32 build warnings and wire /play route to Bevy canvas
Build and Deploy / build-and-push (push) Failing after 44s
- solitaire_data/sync_client.rs: fix SyncPayload/SyncResponse import split
  (SyncResponse is needed by LocalOnlyProvider which compiles on wasm32)
- solitaire_engine/assets/sources.rs: cfg-gate AssetApp/AssetSourceBuilder
  imports (only used in the non-wasm FileAssetReader block)
- solitaire_engine/auto_complete_plugin.rs: cfg-gate AUTO_COMPLETE_CHIME_VOLUME
- solitaire_engine/daily_challenge_plugin.rs: cfg-gate Task/AsyncComputeTaskPool
  imports and DailyChallengeTask struct (server fetch systems are non-wasm only)
- solitaire_engine/resources.rs: cfg-gate std::sync::Arc (TokioRuntimeResource
  is non-wasm only)
- solitaire_engine/settings_plugin.rs: cfg-gate ScanThemes variant, pill_button,
  and their match arms; fix refresh_registry import placement
- solitaire_server/src/lib.rs: point /play route at play.html (Bevy canvas);
  keep /play-classic serving game.html during transition period
- build_wasm.sh: add --no-typescript to wasm-bindgen call for canvas build
- solitaire_server/web/pkg: add canvas.js + canvas_bg.wasm build artifacts

wasm32 build and native clippy --workspace -D warnings both clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:55:39 -07:00
funman300 835a48fe9d feat(web): add solitaire_web Bevy WASM build targeting play.html canvas
Build and Deploy / build-and-push (push) Failing after 58s
Adds a new `solitaire_web` crate that compiles the full `solitaire_engine`
to `wasm32-unknown-unknown` and renders to a `<canvas id="bevy-canvas">`
element in `play.html` — the same ECS code path as desktop and Android.

Changes to enable the WASM target:
- .cargo/config.toml: add wasm32-unknown-unknown rustflags for getrandom
- Workspace Cargo.toml: add solitaire_web member
- solitaire_data/Cargo.toml: gate tokio/reqwest/dirs/keyring to non-wasm
- solitaire_data/src: add wasm32 branch to data_dir() (returns None);
  cfg-gate sync_client network types, auth_tokens, matomo_client
- solitaire_engine/Cargo.toml: gate tokio/reqwest/kira/arboard/dirs/zip
  to non-wasm (mio/cpal/arboard don't compile for wasm32-unknown-unknown)
- solitaire_engine/src/lib.rs: cfg-gate module declarations and re-exports
  for analytics, audio, sync, sync_setup, avatar, leaderboard plugins
- solitaire_engine/src/core_game_plugin.rs: cfg-gate plugin registrations
  that require TokioRuntime (audio, sync, analytics, leaderboard, avatar)
- solitaire_engine/src/resources.rs: cfg-gate TokioRuntimeResource
- solitaire_engine/src/game_plugin.rs: cfg-gate std::fs::remove_file (x10)
- solitaire_engine/src/theme/mod.rs: cfg-gate importer module (uses dirs+zip)
- solitaire_engine/src/settings_plugin.rs: cfg-gate theme ZIP import UI
- solitaire_engine/src/assets/sources.rs: cfg-gate FileAssetReader/user_theme_dir
- solitaire_engine/src/auto_complete_plugin.rs: cfg-gate audio system
- solitaire_engine/src/daily_challenge_plugin.rs: cfg-gate server fetch
- solitaire_engine/src/hud_plugin.rs: cfg-gate AvatarResource import
- solitaire_engine/src/profile_plugin.rs: cfg-gate AvatarResource import
- solitaire_server/web/play.html: minimal HTML canvas shell
- solitaire_web/: new crate (Cargo.toml + src/lib.rs)
- build_wasm.sh: add Bevy WASM build step (cargo + wasm-bindgen + wasm-opt)

All tests pass; clippy --workspace -- -D warnings clean; native build
(solitaire_engine, solitaire_app) unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:46:45 -07:00
funman300 9260ca7994 refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s
- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
  throughout solitaire_core, solitaire_wasm, and solitaire_engine;
  ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
  input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:13:35 -07:00
funman300 ca612f51f1 Revert "refactor(core): split game_state.rs into submodule directory"
Build and Deploy / build-and-push (push) Failing after 50s
This reverts commit dba154cf92.
2026-05-29 18:51:59 -07:00
funman300 dba154cf92 refactor(core): split game_state.rs into submodule directory
Build and Deploy / build-and-push (push) Failing after 44s
1692-line monolith → 4 focused files:
- mod.rs (580): types, constructors, instruction mapping, core game actions
- serde_impl.rs (119): PersistedGameState + Serialize/Deserialize/PartialEq impls
- hints.rs (141): auto-complete detection and move-hint queries
- tests.rs (866): all 118 unit tests

No logic changes; all tests pass; clippy clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 18:24:47 -07:00
funman300 258abd198e chore(core): remove dead card_to_kl / suit_to_kl / rank_to_kl helpers
Build and Deploy / build-and-push (push) Failing after 56s
These were scaffolded for a future KlondikeState::from_piles() path
that never materialised. card_from_kl (used by sync_piles_from_session)
is retained; suit_from_kl / rank_from_kl are narrowed to pub(crate).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 18:11:54 -07:00
funman300 389fdd1fb0 docs: add card-game integration guide (closes #76)
Full gap analysis between Quaternions/card_game and solitaire_core,
integration steps 1-7 (all now complete), and references.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 18:04:41 -07:00
funman300 6309d3325f fix(engine): auto-complete delay + right-click shake on no legal move
Closes #80: add AUTO_COMPLETE_INITIAL_DELAY (0.75 s) before the first
auto-complete move fires. Previously cooldown was 0.0, causing the
sequence to hijack the board the same frame the condition was met.

Closes #81: fire MoveRejectedEvent in radial_open_on_right_click when
the right-clicked card has no legal destinations, so the shake
animation and invalid-move sound play consistently on desktop/web.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 18:01:34 -07:00
funman300 862f7e4b48 chore(core): delete deck.rs and scoring.rs
Build and Deploy / build-and-push (push) Failing after 1m18s
- deck.rs (193 lines) — Deck/deal_klondike replaced by Klondike::with_seed()
- scoring.rs (152 lines) — scoring fns superseded by KlondikeAdapter; move
  compute_time_bonus to klondike_adapter.rs, update win_summary_plugin import
- Remove rand dep from solitaire_core (only used by deck.rs)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 17:42:30 -07:00
funman300 6496e130f3 feat(core): Step 2 — replace pile management with Session<Klondike>
Build and Deploy / build-and-push (push) Failing after 29s
- Delete rules.rs (228 lines) — move validation now handled by klondike engine
- Delete SolverState DFS from solver.rs (~900 lines) — replaced by session.solve()
- Rewrite GameState::new_with_mode() using Klondike::with_seed() (removes deck.rs dep)
- Rewrite move_cards/draw/undo to use Session<Klondike> as move executor
- Remove internal undo_stack (VecDeque<StateSnapshot>) — session owns history
- Sync piles from KlondikeState after each move via sync_piles_from_session()
- Update engine layer (game_plugin, input_plugin, card_plugin, etc.) to new API
- Net: 821 insertions, 3872 deletions (-3051 lines)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 17:31:09 -07:00
funman300 d4796fa252 feat(core): integrate klondike v0.3.0 / card_game v0.4.0 — solver + serde newtypes
Build and Deploy / build-and-push (push) Failing after 29s
Step 6: replace 767-line DFS seed-solver with Session<Klondike>::solve().
- try_solve_with_first_move() now delegates to card_game::Session::solve()
  with solve_moves_budget/solve_states_budget from SolverConfig
- Maps Ok(Some) → Winnable, Ok(None) → Unwinnable, Err → Inconclusive
- try_solve_from_state() retains the DFS (pile mapping pending, step 2)
- Removed dead SolverState::initial() — no longer needed for seed path
- Updated tests: session solver returns no Unwinnable in 0..500 range
  (all non-Winnable deals are Inconclusive); updated engine seed-retry test

Step 7: SavedInstruction serde newtypes in klondike_adapter.
- SavedInstruction mirrors KlondikeInstruction with Serialize+Deserialize
- Sub-types: SavedDstFoundation, SavedDstTableau, SavedKlondikePile,
  SavedKlondikePileStack, SavedTableauStack, SavedTableau, SavedFoundation,
  SavedSkipCards — all with serde derives
- From<KlondikeInstruction> for SavedInstruction (infallible)
- TryFrom<SavedInstruction> for KlondikeInstruction (InvalidSavedInstruction
  on out-of-range u8 values)
- InvalidSavedInstruction error type via thiserror

Also: chore(deps): bump klondike to v0.3.0, card_game to v0.4.0 (Cargo.toml/lock)

All 1399 tests pass; clippy clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 15:43:32 -07:00
funman300 57c4b5aacf feat(core): card/pile conversion utils and GameMode-aware scoring (steps 2-prep, 5)
Build and Deploy / build-and-push (push) Failing after 55s
Step 2 prep — card_game dep + type-conversion utilities:
- Add card_game = "0.3.0" (registry Quaternions) to workspace + core
- suit_to_kl / suit_from_kl, rank_to_kl / rank_from_kl
- card_to_kl (drops id, Deck1), card_from_kl (reconstructs stable id
  from Clubs-first suit×13+rank ordering matching deck.rs)
- Ready to wire into KlondikeState pile projection once upstream
  adds KlondikeState::from_piles()

Step 5 — GameMode-aware scoring in the adapter:
- score_for_move_with_mode, score_for_flip_with_mode (return 0 in Zen)
- apply_undo_score (static, handles Zen + −15 penalty + clamp)
- score_for_recycle_with_mode (return 0 in Zen)
- game_state.rs: all inline GameMode::Zen checks replaced with
  adapter calls; adapter is now the single source of truth for
  "what score does this action give in this mode"

192 tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:38:41 -07:00
funman300 f1914b4398 feat(core): add klondike v0.2.0 dep and KlondikeAdapter (integration steps 1, 3, 4)
Build and Deploy / build-and-push (push) Failing after 1m0s
Step 1 — Cargo & registry:
- Add .cargo/config.toml with Quaternions sparse registry
  (https://git.aleshym.co/api/packages/Quaternions/cargo/)
- Add klondike = "0.2.0" to workspace deps (+ card_game v0.3.0,
  arrayvec v0.7.6 as transitives via the Quaternions registry)
- Add klondike as a solitaire_core dep

Step 3 — KlondikeConfig / MoveFromFoundationConfig:
- KlondikeAdapter::new(draw_mode, take_from_foundation) builds a
  KlondikeConfig with the correct DrawStockConfig and
  MoveFromFoundationConfig (Allowed/Disallowed); exposes it via
  klondike_config() for future solver and pile-mapping steps

Step 4 — Scoring via ScoringConfig:
- GameState.adapter (serde(skip)) owns the authoritative KlondikeConfig
  with ScoringConfig::DEFAULT (WXP values)
- score_for_move/flip/undo/recycle replace direct scoring.rs calls;
  scoring.rs retained for reference and future deletion
- score_for_recycle implements the WXP free-recycle allowance rule
  that ScoringConfig::recycle cannot express (flat delta)
- PartialEq/Eq for KlondikeAdapter compare draw_stock and
  move_from_foundation only (scoring is always DEFAULT)

All 192 solitaire_core tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:34:22 -07:00
funman300 0a6eb8c610 Revert "docs: update integration doc to reflect klondike v0.2.0 / card_game v0.3.0"
This reverts commit bb92bb333b.
2026-05-29 14:06:00 -07:00
funman300 bb92bb333b docs: update integration doc to reflect klondike v0.2.0 / card_game v0.3.0
Both upstream issues are now merged:
- PR #13 (closes #10): ScoringConfig with 5 configurable deltas lands
  in KlondikeConfig; KlondikeStats gains flip_up_bonus_count and
  move_from_foundation_count; score() takes &ScoringConfig
- PR #12 (closes #11): MoveFromFoundationConfig (Allowed/Disallowed)
  lands in KlondikeConfig; is_instruction_valid enforces it

Doc changes:
- "Already has" table updated with ScoringConfig, MoveFromFoundationConfig,
  richer KlondikeStats counters, and version numbers (v0.3.0 / v0.2.0)
- Gap 1 scoring table gains a "Handled by" column showing which deltas
  upstream now owns vs. which remain in our adapter (undo penalty,
  recycle-with-free-allowance, score floor, time bonus)
- Gap 1 adds note that ScoringConfig::recycle is a flat delta and cannot
  express the "N free recycles then penalty" WXP rule
- Gap 4 marked as upstream merged; notes that upstream default is
  MoveFromFoundationConfig::Allowed — we must explicitly set Disallowed
- Integration path: steps renumbered (8→7), step 3 now configures
  MoveFromFoundationConfig, step 4 splits upstream-handled vs.
  adapter-owned scoring; dependency versions pinned
- References updated with PR links and release commit hashes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:05:35 -07:00
funman300 38e4c0341e feat(engine): reactive render — animations drive RequestRedraw, focused_mode reactive on Android
All per-frame animation tick systems now write MessageWriter<RequestRedraw>
each frame they have active work, allowing WinitSettings focused_mode to
switch from Continuous to reactive_low_power(100 ms) on Android.

Systems updated:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish (FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step interval keepalive)

The 100 ms low-power ceiling means the game timer still ticks ~10×/s
with no input; animations self-sustain via the redraw chain at full
frame rate while active; and the GPU is completely idle between frames
when the board is static.

Each plugin registers add_message::<RequestRedraw>() so the message
type is available under MinimalPlugins in unit tests.

Closes #78, #79

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 13:54:54 -07:00
funman300 ccf280ea50 fix(engine): add missing modal scrim guard to leaderboard panel
Android Release / build-apk (push) Successful in 4m29s
toggle_leaderboard_screen was missing the other_modal_scrims guard that
all other panel-toggle systems have. Pressing L (or the HUD button) while
any other modal was open would spawn a second ModalScrim on top of the
existing one, breaking z-ordering and leaving the first modal un-dismissable.

Adds:
  other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>
and the early-return guard before spawn_leaderboard_screen is called.

Closes #77

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:52:47 -07:00
funman300 f1d96012f1 fix(engine): add modal scrim guard to toggle_stats_screen (#75)
Pressing S (or the Stats HUD button) while another modal was open
(Settings, Profile, Leaderboard, etc.) would spawn a second ModalScrim
on top of the existing one, violating the one-scrim-at-a-time invariant.

Add other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>
matching the guard pattern used by every other modal-spawning system.
Also import ModalScrim which was previously not imported in this file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:19:24 -07:00
funman300 7eb1181e50 fix(server): accept nil user_id placeholder in push; use received_at for leaderboard (#73, #74)
Build and Deploy / build-and-push (push) Successful in 3m37s
- sync.rs: replace Uuid::nil() placeholder with the authenticated
  user's real UUID before the mismatch check so desktop client pushes
  no longer fail with 400 user_id mismatch (#73)
- replays.rs: use server-computed received_at instead of client-supplied
  header.recorded_at when updating leaderboard recorded_at to prevent
  timestamp spoofing (#74)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:41:02 -07:00
funman300 f444378184 fix(engine): toast on challenge exhaustion, block input during auto-complete (#71, #72)
- challenge_plugin: replace silent warn+return with InfoToast when all
  challenges are completed so the player gets clear feedback (#72)
- input_plugin: add AutoCompleteState guard to start_drag,
  touch_start_drag, and handle_double_tap so player input cannot race
  with the auto-complete move sequence (#71)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:24:09 -07:00
funman300 927598202e feat(engine,data): add tap-to-select touch input mode (#70)
- Add TouchInputMode enum (OneTap | TapToSelect) to solitaire_data settings
- Create TouchSelectionPlugin with TouchSelectionState resource and highlight
- Branch handle_double_tap: OneTap → existing auto-move, TapToSelect → two-tap flow
- Add Settings UI toggle row (Touch Input Mode) with TouchInputModeText marker
- Register TouchSelectionPlugin in CoreGamePlugin

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:04:40 -07:00
funman300 6e407a3ea7 fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 13:07:22 -07:00
funman300 8cb4c9808e fix(wasm,stats): surface replay errors to JS, deduplicate win events per frame (#65, #69)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 21:53:15 -07:00
funman300 dbe728fef7 refactor(engine): deduplicate TABLEAU_FAN_FRAC constant (#59)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 19:57:49 -07:00
funman300 0437c36463 fix(assets,theme): remove assert in svg_loader, log theme failures, fix default theme id (#58, #63, #64)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 19:36:05 -07:00
funman300 35fde160fa fix(ui): add modal guard to profile, make modal dimensions responsive (#57, #67)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 19:21:09 -07:00
funman300 cfdf27c8c7 fix(time-attack): clamp timer to zero and pause during overlays (#54, #55)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 19:13:42 -07:00
funman300 bd49364553 fix(android): replace forbidden Unicode chars in win_summary and splash (#52, #53)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 19:03:11 -07:00
funman300 a3b9293cd9 chore(engine): final cleanup after platform abstraction refactor (closes #51)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 18:08:00 -07:00
funman300 ce536b0176 refactor(engine): audit and rationalize platform cfg gates (closes #49)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 18:00:57 -07:00
funman300 561395fca6 feat(data,engine): implement NativeStorage and WasmStorage backends (closes #48)
Build and Deploy / build-and-push (push) Successful in 3m59s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 17:30:35 -07:00
funman300 a8ceed97a9 refactor(engine): migrate gameplay plugins into CoreGamePlugin (closes #45, closes #46)
All engine plugin registrations now live in CoreGamePlugin::build().
build_app() is reduced to DefaultPlugins setup + CoreGamePlugin registration.
sync_provider is threaded through CoreGamePlugin::new() via Mutex<Option<...>>.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 17:08:54 -07:00
funman300 86bafdd679 feat(engine): add platform abstraction trait skeleton (closes #47)
Adds solitaire_engine::platform::{StorageBackend, PlatformTime} traits.
No implementations yet — native and WASM impls follow in #48.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 16:58:42 -07:00
funman300 3885b334ec refactor(app): extract build_app(), add CoreGamePlugin placeholder (closes #42, closes #44)
- Split run() into build_app(sync_provider) -> App and run()
- Add empty CoreGamePlugin registered in build_app()
- Issue #43 closed via API (main.rs already satisfies it)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 16:41:40 -07:00
funman300 5a71e2bc0a fix(engine): ensure dragged card stack z-order is above all piles (closes #35)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 16:29:01 -07:00
funman300 04aea8595a docs(claude): add dealsbe.com AI tools directory to user resources
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 15:13:06 -07:00
funman300 25c43db61e fix(ci): use git switch to avoid deploy dir/branch ambiguity
Build and Deploy / build-and-push (push) Successful in 20s
'git checkout deploy' is ambiguous because the repo contains both a
deploy/ directory and a deploy remote tracking branch. Switch to
'git switch' which is branch-only and unambiguous.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 14:44:35 -07:00
funman300 c2eff2ed96 ci: add comment to retrigger docker build
Build and Deploy / build-and-push (push) Failing after 21s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 14:38:37 -07:00
funman300 099ceab47c ci: re-trigger docker build after transient failure
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 14:36:54 -07:00
funman300 22661eac66 fix(wasm): rebuild pkg with take_from_foundation fix (closes #36)
Build and Deploy / build-and-push (push) Failing after 4m31s
The binary in pkg/ was built on May 18, predating commit 3322fd4
(fix(wasm): enable take-from-foundation in web game client, May 19).
Dragging Foundation cards to Tableau was silently rejected because
take_from_foundation was false in the stale binary.

Rebuilt with ./build_wasm.sh against current solitaire_core.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:41:24 -07:00
funman300 a5a81ccc8e test(core): possible_instructions Foundation→Tableau coverage
Add two tests verifying that possible_instructions includes
Foundation→Tableau moves when take_from_foundation is enabled,
and excludes them when it is disabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:26:42 -07:00
funman300 e3188faddc fix(engine): foundation→tableau drag hints, z-lift, and Android battery drain
Fixes #34, #35, #36

- all_hints: add Foundation as source for Tableau hints (guarded by
  take_from_foundation); previously H key never suggested Foundation→Tableau
- end_drag / touch_end_drag: enforce take_from_foundation at input layer
  so a rejected-by-core MoveRequestEvent is never fired
- animation_plugin: pub CARD_ANIM_Z_LIFT so card_plugin can consume it
- update_card_entity: set CardAnim start.z = z + CARD_ANIM_Z_LIFT to
  eliminate 1-frame z artifact where animated card appeared behind resting cards
- solitaire_app: use AutoVsync on Android (caps GPU at display Hz vs
  spinning at 200+ fps); add WinitSettings unfocused reactive_low_power
  so app draws ~1fps when backgrounded

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:17:28 -07:00
funman300 a2f02e1cbc ci(argocd): watch deploy branch for kustomization updates
Android Release / build-apk (push) Successful in 4m50s
targetRevision changed from master to deploy so Argo CD tracks the
image-tag commits the CI bot writes there, not the source branch.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Same fix applied to SolitaireGame::state().

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:24:25 -07:00
funman300 d8f67dcad3 fix(ci): collapse multi-line Python to one-liner to fix YAML block scalar indentation error
Android Release / build-apk (push) Successful in 4m3s
2026-05-16 12:34:40 -07:00
funman300 ccb77f76b8 chore(release): promote Unreleased to 0.30.0 2026-05-16 12:31:51 -07:00
funman300 da54faf8e2 feat(engine): tighten tableau card fan offset (0.25→0.18, 0.20→0.14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:31:18 -07:00
funman300 f3d01b5890 fix(ci): delete existing APK assets before upload to avoid duplicates on re-runs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:20:10 -07:00
179 changed files with 25186 additions and 13092 deletions
+5
View File
@@ -0,0 +1,5 @@
[registries.Quaternions]
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
+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"
}
+25 -3
View File
@@ -4,6 +4,12 @@ on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v0.36.2)'
required: true
default: 'v0.36.2'
env:
APK_OUT: target/release/apk/ferrous-solitaire.apk
@@ -42,7 +48,12 @@ jobs:
- name: Get tag name
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
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
@@ -92,7 +103,18 @@ jobs:
- name: Upload APK to release
run: |
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
RELEASE_ID="${{ steps.release.outputs.id }}"
# Remove any existing APK assets so re-runs don't accumulate duplicates.
curl -sf -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets" \
| python3 -c "import sys,json; [print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
| while read AID; do
curl -sf -X DELETE -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets/$AID"
done
curl -sf -X POST \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
-H "$AUTH" \
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
"${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}/releases/${{ steps.release.outputs.id }}/assets"
"$BASE/releases/$RELEASE_ID/assets"
+61 -11
View File
@@ -1,3 +1,4 @@
# Build and deploy the solitaire server Docker image.
name: Build and Deploy
on:
@@ -5,10 +6,14 @@ on:
branches: [master]
paths:
- 'solitaire_server/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_sync/**'
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'solitaire_server/Dockerfile'
- '.gitea/workflows/docker-build.yml'
env:
@@ -31,6 +36,48 @@ jobs:
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: Check wasm pkg drift
run: |
set -euo pipefail
BASE_SHA="${{ github.event.before }}"
HEAD_SHA="${{ github.sha }}"
if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
RANGE="$BASE_SHA..$HEAD_SHA"
else
RANGE="HEAD~1..HEAD"
fi
CHANGED="$(git diff --name-only "$RANGE")"
echo "Changed files:"
echo "$CHANGED"
if echo "$CHANGED" | grep -Eq '^(solitaire_wasm/|solitaire_core/|Cargo\.toml|Cargo\.lock)$|^(solitaire_wasm/|solitaire_core/)'; then
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/solitaire_wasm\.js$|^solitaire_server/web/pkg/solitaire_wasm_bg\.wasm$'; then
echo "error: wasm/core/Cargo changed but committed web pkg artifacts are missing."
echo "Run: wasm-pack build --target web --out-dir solitaire_server/web/pkg --no-typescript solitaire_wasm"
exit 1
fi
fi
# Hard check: solitaire_web/ is the direct Bevy WASM source — any
# change there MUST rebuild canvas_bg.wasm or the binary goes stale.
if echo "$CHANGED" | grep -Eq '^solitaire_web/'; then
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
echo "error: solitaire_web/ changed but canvas_bg.wasm not updated."
echo "Run: ./build_wasm.sh (requires wasm-bindgen-cli + wasm32-unknown-unknown target)"
exit 1
fi
fi
# Advisory notice: solitaire_engine/ and solitaire_core/ changes often
# require a Bevy WASM rebuild but are not enforced (formatting-only
# commits should not be blocked).
if echo "$CHANGED" | grep -Eq '^(solitaire_engine/|solitaire_core/)' && \
! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
echo "notice: solitaire_engine/core changed without a canvas_bg.wasm rebuild."
echo " If the change affects gameplay run ./build_wasm.sh before pushing."
fi
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
@@ -60,19 +107,22 @@ 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
sudo mv kustomize /usr/local/bin/kustomize
- name: Pin image tag in deploy manifests
run: |
cd deploy
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
- name: Commit and push updated kustomization
- name: Pin image tag and push to deploy branch
run: |
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
# Switch to the deploy branch, creating it from the current HEAD if absent.
# Use 'git switch' (branch-only) to avoid ambiguity with the deploy/ directory.
if git fetch origin deploy 2>/dev/null; then
git switch deploy
else
git switch -c deploy
fi
# Update the pinned image tag.
cd deploy
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
cd ..
git add deploy/kustomization.yaml
git diff --cached --quiet && exit 0 # 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]"
for i in 1 2 3; do
git pull --rebase origin master && git push && break
sleep 5
done
git push origin deploy
+49
View File
@@ -0,0 +1,49 @@
name: Web E2E
on:
push:
branches: [master]
paths:
- 'solitaire_server/web/**'
- 'solitaire_server/src/**'
- 'solitaire_server/e2e/**'
- 'solitaire_wasm/**'
- 'solitaire_core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/web-e2e.yml'
workflow_dispatch:
jobs:
web-e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: solitaire_server/e2e/package-lock.json
- name: Install e2e dependencies
working-directory: solitaire_server/e2e
run: npm ci
- name: Install Playwright browser
working-directory: solitaire_server/e2e
run: npx playwright install --with-deps chromium
- name: Run web e2e tests
working-directory: solitaire_server/e2e
run: npm test
- name: Run cycle regression gate
working-directory: solitaire_server/e2e
run: npm run review:cycles:regression
+9
View File
@@ -8,9 +8,18 @@
data/
.claude/
# ruflo runtime state
agentdb.rvf
agentdb.rvf.lock
# IDE project files
.idea/
# Browser e2e harness artifacts
solitaire_server/e2e/node_modules/
solitaire_server/e2e/playwright-report/
solitaire_server/e2e/test-results/
# Android signing keystores — never commit
*.jks
*.jks.bak
+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
}
}
}
+54
View File
@@ -6,6 +6,60 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.33.0] — 2026-05-16
### Fixed
- **Face-down cards render as tiny red squares (startup ordering bug)**. The
`load_initial_theme` system fell back to `"dark"` when `SettingsResource` was
not yet available at `Startup`, which happens on every fresh run before the
settings file is read. The dark theme's near-black card back (#151515) renders
as fully-off pixels on AMOLED screens, leaving only a 24×32 px red badge
visible. Changed the fallback to `"classic"` so startup behaviour matches the
`default_theme_id()` set in v0.31.0. Cascade-collapse and top-row legibility
issues were visual consequences of the same invisible-card-back problem, not
separate layout bugs.
## [0.32.0] — 2026-05-16
### Fixed
- **Stock-count badge overlaps waste pile on Android** (Bug 1). The badge was
centred 12 px inward from the stock pile's right edge, but its half-width of
17 px pushed it 5 px past the edge. On Android (`H_GAP_DIVISOR = 32`) the
inter-pile gap is only ~4 px, so the badge's top-right corner covered the
left edge of the adjacent waste card at `Z_STOCK_BADGE = 30` (above the
card's Z ≈ 1). Fixed by moving the inset to 20 px so the badge right edge
sits 3 px inside the stock card on every device.
- **Oversized grey header bar** (Bug 2). The top HUD band was a full-width
`Node` with an opaque dark-grey `BackgroundColor` sized to `HUD_BAND_HEIGHT`
(64 px desktop / 80 px Android). Typical gameplay only shows one tier of
score text (~30 px), leaving a large empty grey block. Removed the
`BackgroundColor` from the band entity; the green felt now shows through and
only the score text and avatar button are visible in the header area.
## [0.31.0] — 2026-05-16
### Fixed
- **Face-down cards rendered as solid red squares on AMOLED phones** (Bug 1).
The dark theme's card back (`back.svg`) uses a near-black background
(`#151515`) which AMOLED screens render as fully-off pixels, leaving only a
tiny `#a54242` red badge visible — exactly what was reported. Fixed by
changing the fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly readable on all display types).
Also corrected stale asset paths in `load_card_images` (`cards/backs/back_N`
`cards/backs/classic/back_N`, `cards/faces/XY``cards/faces/classic/XY`)
so the PNG fallback loads correctly when the embedded theme hasn't arrived yet.
## [0.30.0] — 2026-05-16
### Changed
- **Tableau card spacing tightened.** Face-up card fan reduced from 25% to 18%
of card height; face-down from 20% to 14%. Cards on tableau piles sit closer
together while still showing enough of each card to read the pile depth.
## [0.29.0] — 2026-05-16
### Fixed
+12 -1
View File
@@ -355,7 +355,7 @@ Must always be handled explicitly:
* The gesture/navigation bar at the bottom (≈132px physical on common
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
avoid placing interactive elements in that zone
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
* `HUD_BAND_HEIGHT` is 112px on Android vs 64px on desktop;
layout constants are `#[cfg(target_os = "android")]` gated
* JNI calls must use `attach_current_thread_permanently` — not
`attach_current_thread` — to avoid detach-on-drop panics
@@ -691,3 +691,14 @@ Claude should behave as if it constructed:
---
# END CONTEXT INJECTION SYSTEM
---
# 17. User Resources
## 17.1 AI Tools Directory
**dealsbe.com** — https://dealsbe.com/
Curated directory of 128+ AI tools across 8 categories: writing, coding assistants,
image generation, video/audio, research, productivity, design, and marketing.
Use this when the user asks for tool recommendations or wants to discover new AI products.
Generated
+416 -15
View File
@@ -364,6 +364,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4"
[[package]]
name = "as-raw-xcb-connection"
version = "1.0.1"
@@ -717,6 +723,28 @@ dependencies = [
"android-activity",
]
[[package]]
name = "bevy_anti_alias"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_core_pipeline",
"bevy_derive",
"bevy_diagnostic",
"bevy_ecs",
"bevy_image",
"bevy_math",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_utils",
"tracing",
]
[[package]]
name = "bevy_app"
version = "0.18.1"
@@ -878,6 +906,35 @@ dependencies = [
"syn",
]
[[package]]
name = "bevy_dev_tools"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_diagnostic",
"bevy_ecs",
"bevy_image",
"bevy_input",
"bevy_math",
"bevy_picking",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_state",
"bevy_text",
"bevy_time",
"bevy_transform",
"bevy_ui",
"bevy_ui_render",
"bevy_window",
"tracing",
]
[[package]]
name = "bevy_diagnostic"
version = "0.18.1"
@@ -901,7 +958,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
dependencies = [
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bevy_ecs_macros",
"bevy_platform",
"bevy_ptr",
@@ -945,6 +1002,36 @@ dependencies = [
"encase_derive_impl",
]
[[package]]
name = "bevy_feathers"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6"
dependencies = [
"accesskit",
"bevy_a11y",
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_derive",
"bevy_ecs",
"bevy_input_focus",
"bevy_log",
"bevy_math",
"bevy_picking",
"bevy_platform",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_text",
"bevy_ui",
"bevy_ui_render",
"bevy_ui_widgets",
"bevy_window",
"smol_str",
]
[[package]]
name = "bevy_gizmos"
version = "0.18.1"
@@ -1067,14 +1154,17 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754"
dependencies = [
"bevy_a11y",
"bevy_android",
"bevy_anti_alias",
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_core_pipeline",
"bevy_derive",
"bevy_dev_tools",
"bevy_diagnostic",
"bevy_ecs",
"bevy_feathers",
"bevy_gizmos_render",
"bevy_image",
"bevy_input",
@@ -1082,6 +1172,7 @@ dependencies = [
"bevy_log",
"bevy_math",
"bevy_mesh",
"bevy_pbr",
"bevy_platform",
"bevy_ptr",
"bevy_reflect",
@@ -1101,6 +1192,27 @@ dependencies = [
"bevy_winit",
]
[[package]]
name = "bevy_light"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_ecs",
"bevy_image",
"bevy_math",
"bevy_mesh",
"bevy_platform",
"bevy_reflect",
"bevy_transform",
"bevy_utils",
"tracing",
]
[[package]]
name = "bevy_log"
version = "0.18.1"
@@ -1138,7 +1250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
dependencies = [
"approx",
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bevy_reflect",
"derive_more",
"glam 0.30.10",
@@ -1161,7 +1273,9 @@ dependencies = [
"bevy_asset",
"bevy_derive",
"bevy_ecs",
"bevy_image",
"bevy_math",
"bevy_mikktspace",
"bevy_platform",
"bevy_reflect",
"bevy_transform",
@@ -1174,6 +1288,71 @@ dependencies = [
"wgpu-types",
]
[[package]]
name = "bevy_mikktspace"
version = "0.17.0-dev"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59"
[[package]]
name = "bevy_pbr"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_core_pipeline",
"bevy_derive",
"bevy_diagnostic",
"bevy_ecs",
"bevy_image",
"bevy_light",
"bevy_log",
"bevy_math",
"bevy_mesh",
"bevy_platform",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_transform",
"bevy_utils",
"bitflags 2.11.1",
"bytemuck",
"derive_more",
"fixedbitset",
"nonmax",
"offset-allocator",
"smallvec",
"static_assertions",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "bevy_picking"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_derive",
"bevy_ecs",
"bevy_input",
"bevy_math",
"bevy_platform",
"bevy_reflect",
"bevy_time",
"bevy_transform",
"bevy_window",
"tracing",
"uuid",
]
[[package]]
name = "bevy_platform"
version = "0.18.1"
@@ -1500,6 +1679,7 @@ dependencies = [
"bevy_input",
"bevy_input_focus",
"bevy_math",
"bevy_picking",
"bevy_platform",
"bevy_reflect",
"bevy_sprite",
@@ -1512,6 +1692,7 @@ dependencies = [
"taffy",
"thiserror 2.0.18",
"tracing",
"uuid",
]
[[package]]
@@ -1545,6 +1726,26 @@ dependencies = [
"tracing",
]
[[package]]
name = "bevy_ui_widgets"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712"
dependencies = [
"accesskit",
"bevy_a11y",
"bevy_app",
"bevy_camera",
"bevy_ecs",
"bevy_input",
"bevy_input_focus",
"bevy_log",
"bevy_math",
"bevy_picking",
"bevy_reflect",
"bevy_ui",
]
[[package]]
name = "bevy_utils"
version = "0.18.1"
@@ -1672,6 +1873,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"bytemuck",
"serde_core",
]
@@ -1703,7 +1905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"cc",
"cfg-if",
"constant_time_eq",
@@ -1879,6 +2081,15 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "card_game"
version = "0.4.0"
source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3"
dependencies = [
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
"serde",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -1939,6 +2150,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -3457,6 +3679,17 @@ dependencies = [
"weezl",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
dependencies = [
"khronos_api",
"log",
"xml-rs",
]
[[package]]
name = "glam"
version = "0.30.10"
@@ -3485,6 +3718,27 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "glow"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
dependencies = [
"js-sys",
"slotmap",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
dependencies = [
"gl_generator",
]
[[package]]
name = "governor"
version = "0.10.4"
@@ -4051,7 +4305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
"quick-error 2.0.1",
]
[[package]]
@@ -4309,6 +4563,23 @@ dependencies = [
"uuid",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
"libloading",
"pkg-config",
]
[[package]]
name = "khronos_api"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kira"
version = "0.12.0"
@@ -4326,13 +4597,22 @@ dependencies = [
"triple_buffer",
]
[[package]]
name = "klondike"
version = "0.3.0"
source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3"
dependencies = [
"card_game",
"rand 0.10.1",
]
[[package]]
name = "kurbo"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
dependencies = [
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"euclid",
"smallvec",
]
@@ -4740,7 +5020,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
dependencies = [
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bit-set",
"bitflags 2.11.1",
"cfg-if",
@@ -5778,6 +6058,25 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags 2.11.1",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "prost"
version = "0.14.3"
@@ -5822,6 +6121,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -5947,6 +6252,16 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -5985,6 +6300,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
version = "0.5.1"
@@ -6004,6 +6325,15 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -6493,6 +6823,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -6980,7 +7322,9 @@ dependencies = [
name = "solitaire_core"
version = "0.1.0"
dependencies = [
"rand 0.9.4",
"card_game",
"klondike",
"proptest",
"serde",
"thiserror 2.0.18",
]
@@ -6991,7 +7335,6 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"bevy",
"chrono",
"dirs",
"jni 0.21.1",
@@ -7015,9 +7358,11 @@ version = "0.1.0"
dependencies = [
"arboard",
"async-trait",
"base64",
"bevy",
"chrono",
"dirs",
"getrandom 0.3.4",
"image",
"jni 0.21.1",
"kira",
@@ -7035,6 +7380,8 @@ dependencies = [
"tokio",
"usvg",
"uuid",
"wasm-bindgen",
"web-sys",
"zip",
]
@@ -7083,6 +7430,19 @@ dependencies = [
"serde_json",
"solitaire_core",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "solitaire_web"
version = "0.1.0"
dependencies = [
"bevy",
"console_error_panic_hook",
"getrandom 0.3.4",
"solitaire_data",
"solitaire_engine",
"wasm-bindgen",
]
[[package]]
@@ -7497,7 +7857,7 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
@@ -7596,7 +7956,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
dependencies = [
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"grid",
"serde",
"slotmap",
@@ -7865,7 +8225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bytemuck",
"cfg-if",
"log",
@@ -7879,7 +8239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bytemuck",
"cfg-if",
"log",
@@ -8528,6 +8888,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "uncased"
version = "0.9.10"
@@ -8734,6 +9100,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -9039,12 +9414,13 @@ version = "27.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
dependencies = [
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"js-sys",
"log",
"naga",
"portable-atomic",
@@ -9052,6 +9428,8 @@ dependencies = [
"raw-window-handle",
"smallvec",
"static_assertions",
"wasm-bindgen",
"web-sys",
"wgpu-core",
"wgpu-hal",
"wgpu-types",
@@ -9063,7 +9441,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
dependencies = [
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bit-set",
"bit-vec",
"bitflags 2.11.1",
@@ -9083,6 +9461,7 @@ dependencies = [
"smallvec",
"thiserror 2.0.18",
"wgpu-core-deps-apple",
"wgpu-core-deps-wasm",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-types",
@@ -9097,6 +9476,15 @@ dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-wasm"
version = "27.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "27.0.0"
@@ -9113,7 +9501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
dependencies = [
"android_system_properties",
"arrayvec",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"ash",
"bit-set",
"bitflags 2.11.1",
@@ -9122,15 +9510,20 @@ dependencies = [
"cfg-if",
"cfg_aliases",
"core-graphics-types 0.2.0",
"glow",
"glutin_wgl_sys",
"gpu-alloc",
"gpu-allocator",
"gpu-descriptor",
"hashbrown 0.16.1",
"js-sys",
"khronos-egl",
"libc",
"libloading",
"log",
"metal",
"naga",
"ndk-sys",
"objc",
"once_cell",
"ordered-float",
@@ -9143,6 +9536,8 @@ dependencies = [
"renderdoc-sys",
"smallvec",
"thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
"wgpu-types",
"windows 0.58.0",
"windows-core 0.58.0",
@@ -10025,6 +10420,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "xmlwriter"
version = "0.1.0"
+4 -1
View File
@@ -8,6 +8,7 @@ members = [
"solitaire_app",
"solitaire_assetgen",
"solitaire_wasm",
"solitaire_web",
]
resolver = "2"
@@ -21,7 +22,7 @@ rust-version = "1.95"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
thiserror = "2"
rand = "0.9"
async-trait = "0.1"
@@ -37,6 +38,8 @@ solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" }
klondike = { git = "https://git.aleshym.co/Quaternions/card_game" }
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", features = ["serde"] }
# Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
+20
View File
@@ -118,8 +118,28 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
# Lint
cargo clippy --workspace --all-targets -- -D warnings
# Browser e2e smoke (starts solitaire_server automatically)
cd solitaire_server/e2e
npm ci
npx playwright install chromium
npm test
# Seed-batch cycle regression gate (thresholded)
npm run review:cycles:regression
# Loop-aware candidate benchmark (writes test-results/cycle-candidate.json)
npm run review:cycles:candidate
```
For layered engine-vs-UI automation design (Rust unit tests, wasm debug-API
integration tests, and Playwright UI validation), see
[docs/testing-architecture.md](docs/testing-architecture.md).
For Quaternions (`klondike` / `card_game`) dependency upgrades, use
[`scripts/update_quaternions_deps.sh`](scripts/update_quaternions_deps.sh) and
the runbook in [docs/card-game-integration.md](docs/card-game-integration.md).
## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
+173
View File
@@ -0,0 +1,173 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-02 — Web e2e test suite complete; `/play` canvas bridge added and tested. All commits on origin/master.
---
## Current state
- **HEAD:** `play_canvas.spec.js` added (Playwright tests for `/play` Bevy canvas route)
- **Latest tag:** `v0.35.1`
- **Working tree:** clean
- **Build:** `cargo clippy --workspace -- -D warnings` clean
- **Tests:** 1243 Rust tests passing; Playwright suite in `solitaire_server/e2e/`
---
## What shipped since the last handoff (v0.35.1 → present, 2026-06-02)
| Commit | Summary |
|--------|---------|
| `64f975e` | 14 cross-platform UX/UI fixes from 500-game audit |
| `763fdb4` | Fix input: hit-test deck at correct position; accept waste click |
| `1cdb78c` | cargo fmt; add analytics domain to CSP |
| `baf524e` | Rebuild Bevy canvas WASM; add SolitaireGame interactive API |
| `9ff0585` | Remove Quaternions registry auth; canvas WASM drift guard |
| `de7ae16` | Delay first-run modal until splash screen despawns |
| `8b736ca` | Debug drag failures (temp logging, removed in next commit) |
| `8b262af` | Clamp wgpu surface to CSS pixels on HiDPI (prevented WASM panic) |
| `d45b7cb` | Add Playwright e2e test suite for web routes |
| `2cf7282` | Add `window.__FERROUS_DEBUG__` bridge to `/play` for automation |
**Key audit bugs fixed (all 7 from 500-game UX audit):** timer-after-undo, radial-menu clamping, Android resume flash, tab-hidden timer, orphaned tmp files, drag threshold 4→6px, Draw-1 recycle doc comment.
**HiDPI wgpu fix:** `WindowResolution::default().with_scale_factor_override(1.0)` added to the Bevy canvas app. Root cause was physical pixels (CSS×DPR) exceeding WebGL2's 2048px per-dimension limit on HiDPI displays.
**E2E test architecture:** three-tier — Rust unit tests → Playwright smoke/review specs → cycle regression gate. Debug bridge contract in `docs/testing-architecture.md`.
---
## What shipped before v0.35.1
See git log. CHANGELOG.md currently ends at v0.33.0 (documentation debt, low priority).
---
## 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. All post-v0.33.0 work is in git log. Low
priority — git log is authoritative.
### 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.
- **`/play` debug bridge design:** `play.html` runs two independent WASM instances in
`Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__`
(logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge
operates its own `SolitaireGame` — moves applied through the bridge do NOT affect
the Bevy visual game. This is intentional for automation/invariant checking.
- **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)`
is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit
on HiDPI displays, causing an immediate wgpu panic on the first resize event.
- **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js`
target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play`
using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js`
supports both via `--route play-classic|play`.
+1 -1
View File
@@ -7,7 +7,7 @@ spec:
project: default
source:
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
targetRevision: master
targetRevision: deploy
path: deploy
destination:
server: https://kubernetes.default.svc
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

+48 -7
View File
@@ -1,18 +1,21 @@
#!/usr/bin/env bash
# Rebuild the solitaire_wasm crate and install the output into
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
# Rebuild WASM artifacts and install them into solitaire_server/web/pkg/.
#
# Two artifacts are produced:
# solitaire_wasm.* — thin replay-viewer + interactive JS API (wasm-pack)
# canvas.* — full Bevy WASM app for play.html (cargo + wasm-bindgen)
#
# Prerequisites:
# cargo install wasm-pack
# cargo install wasm-pack wasm-bindgen-cli
# rustup target add wasm32-unknown-unknown
# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm
#
# Run from the repo root:
# ./build_wasm.sh
#
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
# committed to git so self-hosters who don't touch the WASM crate can
# skip this step. Regenerate after any change to solitaire_wasm/ or
# solitaire_core/.
# The generated pkg/ files are committed to git so self-hosters who don't
# touch the WASM crates can skip this step. Regenerate after any change to
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
set -euo pipefail
@@ -36,5 +39,43 @@ wasm-pack build \
# Remove them — we manage the output directory ourselves.
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
# ---------------------------------------------------------------------------
# Bevy WASM app (solitaire_web → canvas.js + canvas_bg.wasm)
# ---------------------------------------------------------------------------
if ! command -v wasm-bindgen &> /dev/null; then
echo "error: wasm-bindgen not found." >&2
echo " Install with: cargo install wasm-bindgen-cli" >&2
echo " The CLI version must match the wasm-bindgen crate dep." >&2
exit 1
fi
echo "Building solitaire_web (Bevy WASM app)..."
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
echo "Running wasm-bindgen for solitaire_web..."
wasm-bindgen \
--out-dir "$OUT_DIR" \
--out-name canvas \
--target web \
--no-typescript \
"$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm"
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
# wasm-opt passes are skipped silently when the tool is not installed.
if command -v wasm-opt &> /dev/null; then
echo "Running wasm-opt on canvas_bg.wasm..."
# Use -O2 (not -Oz): Bevy's render pipeline uses deep call stacks and
# complex memory patterns that wasm-opt -Oz can miscompile, resulting
# in a grey screen on first load. -O2 is speed-optimised and avoids
# the size-focused transforms that trigger the regression.
wasm-opt -O2 \
-o "$OUT_DIR/canvas_bg.wasm" \
"$OUT_DIR/canvas_bg.wasm"
else
echo "note: wasm-opt not found; skipping size optimisation."
echo " Install with: cargo install wasm-opt (or via binaryen)"
fi
echo "Done. Output:"
ls -lh "$OUT_DIR"
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images:
- name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server
newTag: 858012d9
newTag: da601beb
+195
View File
@@ -0,0 +1,195 @@
# Integrating `card_game` / `klondike` as the Solitaire Core
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
---
## What `card_game` + `klondike` Already Has
### `card_game` crate (generic primitives) — v0.4.0
| Feature | Notes |
|---|---|
| `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation |
| `Suit`, `Rank`, `Deck` enums | Full A→K, 4 suits, up to 4 deck IDs |
| `Stack<CAP>` | Const-generic `ArrayVec` wrapper |
| `Pile<DN, UP>` | Face-down + face-up stacks; `flip_up`, `pop_flip_up` |
| `Game` trait | `possible_instructions`, `is_instruction_valid`, `process_instruction`, `is_win` |
| `Session` | Wraps a `Game`; snapshot-based undo (O(1)), score including undo penalty |
| `Session::solve()` | Built-in DFS solver with move/state budgets; returns `Solution<G>` or `SolveError` |
| `StateSnapshot<G>` | Pre-move state + instruction; used by snapshot history and `Solution` |
| `SessionState::score()` | = `game_score + undos × undo_penalty` (15 by default via `SessionConfig`) |
| `SessionConfig` | `undo_penalty`, `solve_moves_budget`, `solve_states_budget` |
### `klondike` crate (Klondike rules) — v0.3.0
| Feature | Notes |
|---|---|
| 7 tableau + 4 foundation + 1 stock | Fully dealt from a seeded RNG |
| Draw-1 / Draw-3 config | `KlondikeConfig::draw_stock` (`DrawStockConfig`) |
| `MoveFromFoundationConfig` | `Allowed` (upstream default) / `Disallowed`; controls foundation → tableau rule |
| `ScoringConfig` | Configurable deltas: `move_to_foundation` (+10), `flip_up_bonus` (+5), `move_to_tableau` (+5), `move_from_foundation` (15), `recycle` (0 by default) |
| `KlondikeStats::score(&config)` | Computes score from per-event counters × `ScoringConfig` deltas |
| `KlondikeStats` counters | `move_to_foundation_count`, `flip_up_bonus_count`, `move_to_tableau_count`, `move_from_foundation_count`, `recycle_count`, `moves` |
| Foundation placement (Ace start, suit-matched A→K) | ✅ |
| Tableau placement (alternating colour, K on empty) | ✅ |
| Multi-card stack moves (via `SkipCards`) | ✅ |
| `RotateStock` (recycle waste → stock) | ✅ |
| `is_win_trivial` (all face-down cards cleared) | Auto-complete trigger |
| `get_auto_move` / `get_sorted_moves` | Priority-ranked move suggestion (take `&KlondikeConfig`) |
| Benchmark suite (`klondike-bench`) | 1 000-game throughput test |
| CLI display (`klondike-cli`) | Terminal renderer |
---
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
### 1. Scoring — remaining adapter responsibilities
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
| Event | Delta | Handled by |
|---|---|---|
| Any card → foundation | +10 | `KlondikeStats` / `ScoringConfig::move_to_foundation` ✅ |
| Waste → tableau | +5 | `KlondikeStats` / `ScoringConfig::move_to_tableau` ✅ |
| Flip face-down tableau card | +5 | `KlondikeStats` / `ScoringConfig::flip_up_bonus` ✅ |
| Foundation → tableau | 15 | `KlondikeStats` / `ScoringConfig::move_from_foundation` ✅ |
| Undo | 15 | `SessionStats` / `SessionConfig::undo_penalty` ✅ |
| Recycle (Draw-1, after 1st free) | 100 | **Our adapter** — see below |
| Recycle (Draw-3, after 3rd free) | 20 | **Our adapter** — see below |
| Score floor | `score.max(0)` always | **Our adapter** |
| Time bonus on win | `700_000 / elapsed_seconds` | **Our adapter** (not wasm-portable) |
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The 15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
### 2. Game Modes
Ferrous has three modes that alter scoring and undo behaviour:
| Mode | Scoring | Undo |
|---|---|---|
| **Classic** | Full WXP scoring (table above) | Allowed (15 penalty) |
| **Zen** | All deltas suppressed — score stays 0 | Allowed (no penalty) |
| **Challenge** | Full WXP scoring | **Disabled** — returns an error |
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
- `MovesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
- `StatesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
**Important:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in. Our adapter explicitly sets `Disallowed` in the default `KlondikeConfig` and switches to `Allowed` only when the user toggles the house-rule option.
**In our wrapper:** Construct `KlondikeConfig { move_from_foundation: MoveFromFoundationConfig::Disallowed, .. }` by default; mirror the user's settings toggle to `Allowed`. No custom intercept needed — `klondike` enforces the rule automatically.
### 5. JSON Serialisation / Persistence
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
**Current verification (2026-06-01):** `klondike v0.3.0` and `card_game v0.4.0`
crate manifests expose no `serde` dependency/feature, and source exports no
serde derives for instruction/state snapshot types. Keep Ferrous'
`SavedInstruction` bridge in place.
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
### 6. Typed Move Errors
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
```
GameAlreadyWon
UndoStackEmpty
StockEmpty
InvalidSource
InvalidDestination
RuleViolation(String)
```
`KlondikeInstruction` is always constructed by game code from valid entity layout, so invalid moves are only detectable at `solitaire_core`'s construction boundary — the error lives there, not inside `klondike`.
**In our wrapper:** `MoveError` variants are generated when `solitaire_core` fails to construct a `KlondikeInstruction` from the player's requested move. No translation of `is_instruction_valid`'s bool return is required; by the time an instruction reaches `klondike`, it is already known to be structurally valid.
### 7. Waste Pile as Separate Concept
Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into `Stock` (the face-up half of the stock `Pile`). The engine's UI and scoring logic reference the waste pile directly; the mapping needs to be explicit.
**In our wrapper:** Project the face-up half of `klondike`'s stock `Pile` as `PileType::Waste` when building pile snapshots for the engine.
### 8. Undo Stack Approach *(resolved — not an issue)*
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
---
## Integration Path (All work in `solitaire_core`)
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
1.**Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
3.**Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
4.**Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
5.**Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
7. **Implement `serde`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
---
## Quaternions Upgrade Runbook
Use this sequence whenever upgrading `klondike` / `card_game` from the
Quaternions registry:
1. Review upstream changes/releases:
- <https://git.aleshym.co/Quaternions/card_game>
- <https://git.aleshym.co/Quaternions/klondike>
2. Run:
```bash
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
```
3. If the script passes, inspect the resulting `Cargo.lock` diff and land the
upgrade with the normal PR flow.
The script enforces:
- lockfile update to requested versions
- `cargo test --workspace`
- `cargo clippy --workspace -- -D warnings`
- deterministic replay/debug-API smoke tests in `solitaire_wasm`
---
## What Does NOT Need to Change
- The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`.
- The `solitaire_sync` merge logic — operates on a `SyncPayload` DTO, independent of core card types.
- The `solitaire_server` — speaks only `SyncPayload` JSON, unaffected.
---
## References
- Quaternions' repo: <https://git.aleshym.co/Quaternions/card_game>
- `card_game v0.4.0` release commit: `fa098f0d`
- `klondike v0.3.0` release commit: `f4c4e350`
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
- Upstream solver PR: #14
- `solitaire_core` source: `solitaire_core/src/`
- Scoring spec: `solitaire_core/src/scoring.rs`
- Architecture overview: `ARCHITECTURE.md`
+115
View File
@@ -0,0 +1,115 @@
# Testing Architecture — Engine-first Validation
Ferrous Solitaire validation is split into three layers with clear ownership:
1. **Rust unit tests (`solitaire_core`)**
- move generation and legality
- deal generation determinism
- scoring and penalties
- undo semantics
- win detection
2. **Engine integration tests (`solitaire_wasm` debug API)**
- autonomous game execution without UI/pointer simulation
- invariant checks after every move
- deterministic seed replay
- high-volume seeded runs (including long-running soak tests)
3. **Playwright UI tests**
- verify rendering vs engine state
- drag/drop and keyboard UX behavior
- responsive layout behavior
- browser-compatibility checks
## Source of truth
The Rust engine is authoritative. Browser tests must interact with the game via
debug API hooks, not via pixel/OCR solving or hardcoded screen coordinates.
## Debug API surfaces
Two automation surfaces are exposed:
- `solitaire_wasm::SolitaireGame` methods:
- `debug_snapshot()`
- `debug_legal_moves()`
- `debug_move_history()`
- `debug_apply_legal_move(index)`
- `debug_apply_move_json(json)`
- Browser bridge on `game.html`:
- `window.__FERROUS_DEBUG__.snapshot()`
- `window.__FERROUS_DEBUG__.legalMoves()`
- `window.__FERROUS_DEBUG__.moveHistory()`
- `window.__FERROUS_DEBUG__.applyLegalMove(index)`
- `window.__FERROUS_DEBUG__.applyMove(move)`
- `window.__FERROUS_DEBUG__.failureReport()`
- `window.__FERROUS_DEBUG__.runAutoplay(options)`
## Required failure payload
Every automation failure should capture:
- seed
- move history
- current game state
- screenshot
- browser trace
- console logs
`failureReport()` provides the engine-side fields (`seed`, `moveHistory`,
`currentState`) so UI harnesses only need to attach browser artifacts.
## Execution guidance
- Fast verification:
- `cargo test -p solitaire_core -p solitaire_wasm`
- Full verification:
- `cargo test --workspace`
- `cargo clippy --workspace -- -D warnings`
- Long unattended soak:
- `cargo test -p solitaire_wasm debug_api_autonomous_thousands_seed_soak -- --ignored`
### Browser e2e harness
The Playwright suite lives under `solitaire_server/e2e/` and boots
`solitaire_server` via Playwright `webServer` config.
- Install + run:
- `cd solitaire_server/e2e`
- `npm ci`
- `npx playwright install chromium`
- `npm test`
- Cycle metrics batch run:
- `cd solitaire_server/e2e`
- `npm run review:cycles -- --games 1000 --steps 350 --policy baseline --max-visits 1 --out /tmp/cycle-baseline.json`
- `npm run review:cycles -- --games 1000 --steps 350 --policy loop_aware --max-visits 2 --out /tmp/cycle-loop-aware.json`
- `npm run review:cycles:regression` (thresholded gate, writes `test-results/cycle-regression.json`)
- `npm run review:cycles:candidate` (loop-aware candidate run, writes `test-results/cycle-candidate.json`)
### Cycle-risk regression baseline and guardrails
- Current regression gate command:
- `npm run review:cycles:regression`
- config: `games=240`, `steps=350`, `policy=baseline`, `max-visits=1`
- Current guardrail thresholds:
- `all.cycle_rate_pct <= 86`
- `draw1.cycle_rate_pct <= 76`
- `draw3.cycle_rate_pct <= 95`
- `all.win_rate_pct >= 14`
- zero invariant/apply/page/console issue counts
- Baseline sample (240 games):
- overall: `win_rate=15.8%`, `cycle_rate=84.2%`
- draw-one: `win_rate=25.8%`, `cycle_rate=74.2%`
- draw-three: `win_rate=5.8%`, `cycle_rate=94.2%`
- Candidate loop-aware sample (240 games, lookahead via simulated move + restore):
- overall: `win_rate=20.4%`, `cycle_rate=32.5%`
- draw-one: `win_rate=33.3%`, `cycle_rate=16.7%`
- draw-three: `win_rate=7.5%`, `cycle_rate=48.3%`
- no invariant/apply/page/console issues in the sampled run
- Additional 500-game candidate soak:
- overall: `win_rate=20.2%`, `cycle_rate=28.6%`, `step_budget=51.2%`
- draw-three remains the dominant risk (`cycle_rate=45.2%`)
- Fix applied: cycle metrics regression now supports explicit
`max_step_budget_rate_*` thresholds. Candidate command now enforces
`max_step_budget_rate_all <= 60` to prevent silent drift from cycles into
step-budget stalls.
+228
View File
@@ -0,0 +1,228 @@
# Android testing
This directory contains lightweight Android test helpers for Ferrous Solitaire.
They are intended to run against either a physical Android device or an emulator
connected through `adb`. When no device is connected the smoke script can
automatically launch an AVD for you.
## Prerequisites
- Android SDK and NDK installed.
- `adb` available on `PATH`.
- One device/emulator visible in `adb devices`, **or** at least one AVD created
(the script will launch one automatically if `LAUNCH_AVD=1`, which is the default).
- If multiple devices are connected, set `ADB_SERIAL` to the target device serial.
- Environment variables required by `scripts/build_android_apk.sh` when building:
```sh
export ANDROID_HOME=/path/to/android-sdk
export ANDROID_NDK_HOME=/path/to/android-ndk
export BUILD_TOOLS_VERSION=34.0.0
export PLATFORM=android-34
```
## Smoke test
From the workspace root (`Rusty_Solitaire/`):
```sh
scripts/android_smoke.sh
```
The smoke test first checks whether `adb` can see a ready device. If no device
is connected and `LAUNCH_AVD=1` (default), it:
1. locates the `emulator` binary under `ANDROID_HOME` or `PATH`,
2. picks the first available AVD (or uses `AVD_NAME`),
3. launches the emulator in the foreground (or headless with `AVD_HEADLESS=1`),
4. waits for `sys.boot_completed=1` before proceeding,
5. dismisses the lock screen so the screenshot shows the app.
Once a device is ready (auto-launched or pre-existing) the script:
1. builds the APK using `scripts/build_android_apk.sh`,
2. installs it with `adb install -r -d` so debug smoke builds can replace newer local builds,
3. force-stops the package by default for a clean launch,
4. clears `logcat`,
5. launches `com.ferrousapp.solitaire/android.app.NativeActivity`,
6. waits for the app to settle,
7. verifies the process is still running,
8. captures a screenshot and `logcat`, and
9. fails on fatal log patterns such as native crashes, JNI fatal errors, real ANRs,
and Rust panics.
On exit the script kills any emulator it launched (`SHUTDOWN_AVD_ON_EXIT=1` by
default). Set `SHUTDOWN_AVD_ON_EXIT=0` to keep the emulator open for inspection.
Artifacts are written to `target/android-smoke/<timestamp>/` by default. A successful run includes:
- `device.txt` — selected device and display metadata,
- `df-data-before.txt` / `df-data-after.txt` — emulator/device storage snapshots,
- `emulator.log` — stdout/stderr from the emulator process (AVD runs only),
- `emulator.pid` — PID of the emulator process (AVD runs only),
- `launch.png` — screenshot after the wait period,
- `logcat.txt` — full captured log,
- `log-summary.txt` — grep summary for warnings, errors, JNI, safe-area, and crash terms, and
- `pid.txt` — running app process id.
## Creating an AVD
If no AVDs exist, create one before running the smoke test:
```sh
# Install a system image
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
'system-images;android-34;google_apis;x86_64'
# Create the AVD
"$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd \
-n Pixel_7_API_34 \
-k 'system-images;android-34;google_apis;x86_64' \
--device 'pixel_7'
```
Then run the smoke test — it will pick `Pixel_7_API_34` automatically:
```sh
scripts/android_smoke.sh
```
## Faster iteration
If you already built the APK and only want to reinstall/relaunch:
```sh
BUILD_APK=0 scripts/android_smoke.sh
```
If the APK is already installed and you only want to relaunch/capture logs:
```sh
BUILD_APK=0 INSTALL_APK=0 scripts/android_smoke.sh
```
By default the script force-stops the package before launch so logcat and screenshots represent a clean app start. To test warm-launch behavior instead:
```sh
BUILD_APK=0 INSTALL_APK=0 FORCE_STOP=0 scripts/android_smoke.sh
```
This is also useful when an already-installed build is good enough for launch/log checks. On install failure, the script writes `adb-install.txt`, storage snapshots, and installed-package diagnostics to the output directory.
If install fails with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, the smoke script uninstalls the package and retries once by default (`RESET_ON_SIGNATURE_MISMATCH=1`). This resets app data on the device/emulator. Disable it with:
```sh
RESET_ON_SIGNATURE_MISMATCH=0 scripts/android_smoke.sh
```
To write artifacts to a stable path:
```sh
OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
```
When reusing an output directory, previous files are removed by default so stale artifacts do not contaminate the latest result. To keep existing files:
```sh
CLEAN_OUT_DIR=0 OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
```
To target a specific device when more than one is attached:
```sh
ADB_SERIAL=emulator-5554 scripts/android_smoke.sh
```
To wait longer for safe-area inset polling or slow devices:
```sh
WAIT_SECS=8 scripts/android_smoke.sh
```
## AVD options
To pick a specific AVD by name instead of auto-selecting the first one:
```sh
AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
```
To run headless (no emulator window) — useful in CI or on a display-less machine:
```sh
AVD_HEADLESS=1 scripts/android_smoke.sh
```
To give a slow machine more time to boot the emulator (default is 120 s):
```sh
AVD_BOOT_TIMEOUT=180 scripts/android_smoke.sh
```
To keep the emulator running after the test (useful for manual inspection):
```sh
SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh
```
To pass extra flags to the emulator (e.g. disable snapshot for a completely
cold boot, or change GPU mode):
```sh
AVD_EXTRA_ARGS="-gpu swiftshader_indirect" scripts/android_smoke.sh
```
To disable AVD auto-launch entirely and fail immediately if no device is
connected:
```sh
LAUNCH_AVD=0 scripts/android_smoke.sh
```
For build-only validation without requiring a connected device, use the lower-level APK builder directly:
```sh
scripts/build_android_apk.sh
```
For smoke testing, `scripts/android_smoke.sh` defaults to the connected device's primary ABI when `BUILD_APK=1`, which keeps emulator APKs much smaller than the full multi-ABI default. You can still override it explicitly:
```sh
ABIS=x86_64 scripts/android_smoke.sh
```
For build-only validation, `scripts/build_android_apk.sh` still defaults to all configured ABIs unless you set `ABIS` yourself:
```sh
ABIS=x86_64 scripts/build_android_apk.sh
```
The APK builder signs debug builds with a persistent keystore at `target/android/debug.keystore` by default. This avoids signature churn across smoke-test runs.
The APK builder also strips native debug symbols by default before packaging (`STRIP_NATIVE_LIBS=1`). This keeps debug APKs installable on emulators with limited `/data` storage. To preserve native debug symbols for low-level debugging:
```sh
STRIP_NATIVE_LIBS=0 ABIS=x86_64 scripts/build_android_apk.sh
```
## Device checklist
The script is only a smoke test. Before shipping Android builds, also verify:
- safe-area insets arrive and shift the HUD after a few seconds,
- HUD does not overlap the top status bar,
- modal Done buttons are above the gesture/navigation bar,
- stock tap works,
- drag-and-drop works on tableau, waste, and foundation piles,
- Settings/Help/Profile modals open and close,
- login tokens persist after app restart, and
- `target/android-smoke/.../logcat.txt` contains no fatal JNI/native crash output.
## Notes
- `adb shell input tap X Y` uses physical pixels, not Bevy logical pixels.
- The projects common test device mapping is physical `1080×2400`, Bevy logical
`900×2000`, scale factor `1.20`; multiply logical coordinates by `1.20` for
scripted `adb shell input` commands on that device.
- Keep generated screenshots/logs under `target/android-smoke/` so they stay out
of source control.
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env bash
# Android smoke test for Ferrous Solitaire.
#
# Builds (optional), installs, launches, captures logcat + screenshot, and
# fails on fatal Android log patterns. Designed as a lightweight device/emulator
# sanity check rather than a full UI automation suite.
#
# Required:
# adb on PATH
# Android SDK/NDK env required by scripts/build_android_apk.sh when BUILD_APK=1
#
# Optional environment:
# BUILD_APK=1|0 Build APK before install (default: 1)
# INSTALL_APK=1|0 Install APK before launch (default: 1)
# RESET_ON_SIGNATURE_MISMATCH=1|0
# Uninstall/retry if debug signatures differ (default: 1)
# LAUNCH_APP=1|0 Launch app before checks (default: 1)
# FORCE_STOP=1|0 Force-stop package before launch for clean logs (default: 1)
# CAPTURE_SCREENSHOT=1|0 Capture screenshot (default: 1)
# ADB_SERIAL=... Device serial to use when multiple devices are connected
# APK_PATH=... APK to install (default: target/debug/apk/ferrous-solitaire.apk)
# PACKAGE=... Android package (default: com.ferrousapp.solitaire)
# ACTIVITY=... Activity class (default: android.app.NativeActivity)
# OUT_DIR=... Artifact directory (default: target/android-smoke/<timestamp>)
# CLEAN_OUT_DIR=1|0 Remove prior artifacts from OUT_DIR first (default: 1)
# WAIT_SECS=... Seconds to wait after launch (default: 5)
# ABIS=... Passed to build script. If unset and BUILD_APK=1,
# defaults to the connected device's primary ABI.
#
# AVD auto-launch (used when no device/emulator is already connected):
# LAUNCH_AVD=1|0 Auto-launch an AVD when no device is ready (default: 1)
# AVD_NAME=... AVD name to launch (default: first from `emulator -list-avds`)
# AVD_BOOT_TIMEOUT=... Seconds to wait for the emulator to finish booting (default: 120)
# AVD_HEADLESS=1|0 Run with -no-window -no-audio for CI/no-display environments (default: 0)
# AVD_EXTRA_ARGS=... Extra arguments appended verbatim to the emulator command line
# SHUTDOWN_AVD_ON_EXIT=1|0
# Kill the AVD this script launched on exit (default: 1).
# Set to 0 to leave the emulator running after the test.
#
# Examples:
# scripts/android_smoke.sh
# BUILD_APK=0 scripts/android_smoke.sh
# LAUNCH_AVD=0 scripts/android_smoke.sh # error out if no device, never auto-launch
# AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
# AVD_HEADLESS=1 scripts/android_smoke.sh # CI / no-display
# SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh # keep emulator open after test
# OUT_DIR=target/android-smoke/latest WAIT_SECS=8 scripts/android_smoke.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
BUILD_APK="${BUILD_APK:-1}"
INSTALL_APK="${INSTALL_APK:-1}"
RESET_ON_SIGNATURE_MISMATCH="${RESET_ON_SIGNATURE_MISMATCH:-1}"
LAUNCH_APP="${LAUNCH_APP:-1}"
FORCE_STOP="${FORCE_STOP:-1}"
CAPTURE_SCREENSHOT="${CAPTURE_SCREENSHOT:-1}"
APK_PATH="${APK_PATH:-target/debug/apk/ferrous-solitaire.apk}"
PACKAGE="${PACKAGE:-com.ferrousapp.solitaire}"
ACTIVITY="${ACTIVITY:-android.app.NativeActivity}"
WAIT_SECS="${WAIT_SECS:-5}"
OUT_DIR="${OUT_DIR:-target/android-smoke/$(date +%Y%m%d-%H%M%S)}"
CLEAN_OUT_DIR="${CLEAN_OUT_DIR:-1}"
REMOTE_SCREENSHOT="/sdcard/ferrous-solitaire-smoke.png"
LAUNCH_AVD="${LAUNCH_AVD:-1}"
AVD_NAME="${AVD_NAME:-}"
AVD_BOOT_TIMEOUT="${AVD_BOOT_TIMEOUT:-120}"
AVD_HEADLESS="${AVD_HEADLESS:-0}"
AVD_EXTRA_ARGS="${AVD_EXTRA_ARGS:-}"
SHUTDOWN_AVD_ON_EXIT="${SHUTDOWN_AVD_ON_EXIT:-1}"
ADB=(adb)
if [ -n "${ADB_SERIAL:-}" ]; then
ADB+=( -s "$ADB_SERIAL" )
fi
# PID of any emulator we start so the EXIT trap can clean it up.
_LAUNCHED_EMULATOR_PID=""
_cleanup_emulator() {
if [ -n "$_LAUNCHED_EMULATOR_PID" ] && [ "$SHUTDOWN_AVD_ON_EXIT" = "1" ]; then
echo ">>> shutdown emulator (PID $_LAUNCHED_EMULATOR_PID)"
kill "$_LAUNCHED_EMULATOR_PID" 2>/dev/null || true
fi
}
trap _cleanup_emulator EXIT
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 1
}
}
mkdir -p "$OUT_DIR"
if [ "$CLEAN_OUT_DIR" = "1" ]; then
find "$OUT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
fi
require_cmd adb
# ---------------------------------------------------------------------------
# Device / emulator availability
# ---------------------------------------------------------------------------
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$DEVICE_STATE" != "device" ]; then
if [ "$LAUNCH_AVD" != "1" ]; then
adb devices > "$OUT_DIR/adb-devices.txt" 2>&1 || true
if [ -n "${ADB_SERIAL:-}" ]; then
echo "Android device '$ADB_SERIAL' is not connected/ready (state: ${DEVICE_STATE:-unknown})." >&2
else
echo "No Android device/emulator is connected and ready." >&2
fi
echo "Run 'adb devices' or start an emulator, then retry." >&2
echo "Device list saved to $OUT_DIR/adb-devices.txt" >&2
exit 1
fi
# --- locate emulator binary -----------------------------------------------
# Priority: ANDROID_HOME env → PATH → common SDK install locations.
_find_sdk_root() {
for candidate in \
"$HOME/Android/Sdk" \
"$HOME/Library/Android/sdk" \
"/opt/android-sdk" \
"/usr/lib/android-sdk"; do
[ -d "$candidate" ] && echo "$candidate" && return
done
}
EMULATOR_BIN=""
if [ -n "${ANDROID_HOME:-}" ] && [ -x "$ANDROID_HOME/emulator/emulator" ]; then
EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
elif command -v emulator >/dev/null 2>&1; then
EMULATOR_BIN="$(command -v emulator)"
else
_SDK_ROOT="$(_find_sdk_root)"
if [ -n "$_SDK_ROOT" ] && [ -x "$_SDK_ROOT/emulator/emulator" ]; then
EMULATOR_BIN="$_SDK_ROOT/emulator/emulator"
fi
fi
if [ -z "$EMULATOR_BIN" ]; then
echo "No Android device found and 'emulator' binary is not available." >&2
echo " • Install the Android SDK emulator component, or" >&2
echo " • Set ANDROID_HOME to your SDK root, or" >&2
echo " • Start a device/emulator manually then retry with LAUNCH_AVD=0." >&2
exit 1
fi
echo ">>> emulator binary: $EMULATOR_BIN"
# --- select AVD -----------------------------------------------------------
if [ -z "$AVD_NAME" ]; then
AVD_NAME="$("$EMULATOR_BIN" -list-avds 2>/dev/null | head -n 1 | tr -d '\r')"
if [ -z "$AVD_NAME" ]; then
echo "No AVDs found. Create one first, for example:" >&2
echo " sdkmanager 'system-images;android-34;google_apis;x86_64'" >&2
echo " avdmanager create avd -n Pixel_7_API_34 \\" >&2
echo " -k 'system-images;android-34;google_apis;x86_64' --device 'pixel_7'" >&2
exit 1
fi
echo ">>> auto-selected AVD: $AVD_NAME"
fi
# --- launch emulator -------------------------------------------------------
EMULATOR_ARGS=( -avd "$AVD_NAME" -no-snapshot-load )
[ "$AVD_HEADLESS" = "1" ] && EMULATOR_ARGS+=( -no-window -no-audio )
# Split AVD_EXTRA_ARGS on whitespace only (disable glob expansion).
set -f
# shellcheck disable=SC2206
[ -n "$AVD_EXTRA_ARGS" ] && EMULATOR_ARGS+=( $AVD_EXTRA_ARGS )
set +f
echo ">>> launch emulator: $AVD_NAME"
"$EMULATOR_BIN" "${EMULATOR_ARGS[@]}" > "$OUT_DIR/emulator.log" 2>&1 &
_LAUNCHED_EMULATOR_PID=$!
echo "$_LAUNCHED_EMULATOR_PID" > "$OUT_DIR/emulator.pid"
echo " emulator PID: $_LAUNCHED_EMULATOR_PID"
echo " emulator log: $OUT_DIR/emulator.log"
# --- wait for adb transport -----------------------------------------------
# Poll adb get-state (≠ wait-for-device which blocks indefinitely) so we can
# honour AVD_BOOT_TIMEOUT for the whole boot sequence.
echo ">>> waiting for device to appear in adb (timeout: ${AVD_BOOT_TIMEOUT}s)"
_ELAPSED=0
while true; do
_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$_STATE" = "device" ] || [ "$_STATE" = "offline" ]; then
break
fi
if [ "$_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
echo "Device did not appear in adb within ${AVD_BOOT_TIMEOUT}s" >&2
echo "emulator log:" >&2
tail -20 "$OUT_DIR/emulator.log" >&2 || true
exit 1
fi
sleep 3
_ELAPSED=$(( _ELAPSED + 3 ))
echo " ... ${_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s"
done
# Capture emulator serial (emulator-5554 etc.) so all subsequent adb calls
# target the right device when ADB_SERIAL was not set by the caller.
if [ -z "${ADB_SERIAL:-}" ]; then
_EMU_SERIAL="$(adb devices 2>/dev/null | awk '/^emulator-/{print $1; exit}' | tr -d '\r')"
if [ -n "$_EMU_SERIAL" ]; then
ADB_SERIAL="$_EMU_SERIAL"
ADB=(adb -s "$ADB_SERIAL")
echo ">>> detected emulator serial: $ADB_SERIAL"
fi
fi
# --- wait for full Android boot -------------------------------------------
# adb get-state returning "device" means the transport is up, but the
# Android framework may still be initialising. Poll sys.boot_completed.
echo ">>> waiting for boot_completed (timeout: ${AVD_BOOT_TIMEOUT}s)"
_BOOT_ELAPSED=0
_BOOT_INTERVAL=5
while true; do
_BOOT="$("${ADB[@]}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
if [ "$_BOOT" = "1" ]; then
echo ">>> emulator boot complete"
break
fi
if [ "$_BOOT_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
echo "Emulator did not finish booting within ${AVD_BOOT_TIMEOUT}s" >&2
echo "emulator log:" >&2
tail -20 "$OUT_DIR/emulator.log" >&2 || true
exit 1
fi
sleep "$_BOOT_INTERVAL"
_BOOT_ELAPSED=$(( _BOOT_ELAPSED + _BOOT_INTERVAL ))
echo " ... ${_BOOT_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s (boot_completed='${_BOOT}')"
done
# Dismiss the lock screen so later screencap shows the app, not the keyguard.
"${ADB[@]}" shell input keyevent 82 2>/dev/null || true
# Final sanity check — device must be fully ready before we proceed.
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$DEVICE_STATE" != "device" ]; then
echo "Emulator is running but adb state is '${DEVICE_STATE:-unknown}'." >&2
exit 1
fi
fi
# ---------------------------------------------------------------------------
# Device metadata
# ---------------------------------------------------------------------------
{
echo "adb_serial=${ADB_SERIAL:-default}"
echo "package=$PACKAGE"
echo "activity=$ACTIVITY"
echo "device_state=$DEVICE_STATE"
"${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' | sed 's/^/product_model=/'
"${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' | sed 's/^/android_release=/'
"${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' | sed 's/^/android_sdk=/'
"${ADB[@]}" shell wm size 2>/dev/null | tr -d '\r' | sed 's/^/wm_size=/'
"${ADB[@]}" shell wm density 2>/dev/null | tr -d '\r' | sed 's/^/wm_density=/'
} > "$OUT_DIR/device.txt"
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-before.txt" 2>&1 || true
if [ "$BUILD_APK" = "1" ]; then
if [ -z "${ABIS:-}" ]; then
DEVICE_ABI="$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')"
case "$DEVICE_ABI" in
x86_64|arm64-v8a|armeabi-v7a)
export ABIS="$DEVICE_ABI"
;;
armeabi*)
export ABIS="armeabi-v7a"
;;
*)
echo "Could not map device ABI '$DEVICE_ABI'; using build script default ABIS." >&2
;;
esac
fi
echo ">>> build Android APK${ABIS:+ (ABIS=$ABIS)}"
scripts/build_android_apk.sh
fi
if [ "$INSTALL_APK" = "1" ]; then
[ -f "$APK_PATH" ] || {
echo "APK not found: $APK_PATH" >&2
echo "Set APK_PATH or run with BUILD_APK=1." >&2
exit 1
}
ls -lh "$APK_PATH" > "$OUT_DIR/apk.txt"
echo ">>> install $APK_PATH"
if ! "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install.txt" 2>&1; then
if [ "$RESET_ON_SIGNATURE_MISMATCH" = "1" ] && grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE" "$OUT_DIR/adb-install.txt"; then
echo ">>> signature mismatch; uninstalling $PACKAGE and retrying install"
"${ADB[@]}" uninstall "$PACKAGE" > "$OUT_DIR/adb-uninstall-before-retry.txt" 2>&1 || true
if "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install-retry.txt" 2>&1; then
cat "$OUT_DIR/adb-install-retry.txt" >> "$OUT_DIR/adb-install.txt"
else
cat "$OUT_DIR/adb-install.txt" >&2
cat "$OUT_DIR/adb-install-retry.txt" >&2
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
echo "APK install retry failed. Diagnostics saved in $OUT_DIR" >&2
exit 1
fi
else
cat "$OUT_DIR/adb-install.txt" >&2
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
echo "APK install failed. Diagnostics saved in $OUT_DIR" >&2
echo "If the package is already installed and you only need launch/log checks, retry with INSTALL_APK=0." >&2
exit 1
fi
fi
fi
if [ "$FORCE_STOP" = "1" ]; then
echo ">>> force-stop $PACKAGE"
"${ADB[@]}" shell am force-stop "$PACKAGE" || true
fi
echo ">>> clear logcat"
"${ADB[@]}" logcat -c
if [ "$LAUNCH_APP" = "1" ]; then
echo ">>> launch $PACKAGE/$ACTIVITY"
"${ADB[@]}" shell am start -n "$PACKAGE/$ACTIVITY" > "$OUT_DIR/am-start.txt"
fi
echo ">>> wait ${WAIT_SECS}s"
sleep "$WAIT_SECS"
PID="$("${ADB[@]}" shell pidof "$PACKAGE" | tr -d '\r' || true)"
if [ -z "$PID" ]; then
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt" || true
echo "app process is not running after launch: $PACKAGE" >&2
echo "logcat saved to $OUT_DIR/logcat.txt" >&2
exit 1
fi
echo "$PID" > "$OUT_DIR/pid.txt"
if [ "$CAPTURE_SCREENSHOT" = "1" ]; then
echo ">>> capture screenshot"
"${ADB[@]}" shell screencap -p "$REMOTE_SCREENSHOT"
"${ADB[@]}" pull "$REMOTE_SCREENSHOT" "$OUT_DIR/launch.png" >/dev/null
"${ADB[@]}" shell rm -f "$REMOTE_SCREENSHOT" >/dev/null 2>&1 || true
fi
echo ">>> capture logcat"
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt"
grep -iE "panic|fatal|jni|native crash|\bANR\b|exception|error|warn|keystore|safe_area" "$OUT_DIR/logcat.txt" > "$OUT_DIR/log-summary.txt" || true
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after.txt" 2>&1 || true
# Fatal patterns only. Avoid matching generic "error" because Android logs are
# noisy and many non-fatal framework lines contain that word.
if grep -iE "fatal exception|jni detected error|native crash|signal [0-9]+|ANR in|Application Not Responding|Input dispatching timed out|thread exiting with uncaught exception|panicked at" "$OUT_DIR/logcat.txt"; then
echo "Android smoke test found fatal log output" >&2
echo "Artifacts saved in $OUT_DIR" >&2
exit 1
fi
echo ">>> Android smoke test passed"
echo "Artifacts saved in $OUT_DIR"
+87 -15
View File
@@ -6,11 +6,15 @@
# ndk-build crate that we couldn't isolate; running each Android toolchain
# step explicitly gives us a debuggable pipeline.
#
# Required environment:
# ANDROID_HOME Path to Android SDK root
# ANDROID_NDK_HOME Path to the specific NDK version
# BUILD_TOOLS_VERSION e.g. "34.0.0"
# PLATFORM e.g. "android-34"
# Environment:
# ANDROID_HOME Path to Android SDK root. If unset, common SDK
# locations such as ~/Android/Sdk are tried.
# ANDROID_NDK_HOME Path to the specific NDK version. If unset, the
# newest $ANDROID_HOME/ndk/* directory is used.
# BUILD_TOOLS_VERSION e.g. "34.0.0". If unset, newest installed build-tools
# version is used.
# PLATFORM e.g. "android-34". If unset, newest installed
# $ANDROID_HOME/platforms/android-* platform is used.
#
# Optional environment:
# PROFILE "debug" (default) | "release"
@@ -19,7 +23,8 @@
# fit the runner's disk budget — a full three-ABI
# debug build can exceed 25 GB of target/ output.
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
# STRIP_NATIVE_LIBS 1 to strip .so files before packaging (default: 1)
# KEYSTORE Path to keystore for signing (default: target/android/debug.keystore)
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
# KEY_ALIAS Key alias (default: "androiddebugkey")
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
@@ -28,18 +33,63 @@
# $APK_OUT Signed, zipaligned APK
set -euo pipefail
: "${ANDROID_HOME:?ANDROID_HOME must be set}"
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
infer_latest_dir_name() {
local pattern="$1"
local latest=""
shopt -s nullglob
local dirs=( $pattern )
shopt -u nullglob
if [ ${#dirs[@]} -gt 0 ]; then
latest="$(printf '%s\n' "${dirs[@]}" | sort -V | tail -n 1)"
basename "$latest"
fi
}
if [ -z "${ANDROID_HOME:-}" ]; then
for candidate in "$HOME/Android/Sdk" "$HOME/Library/Android/sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do
if [ -d "$candidate" ]; then
ANDROID_HOME="$candidate"
export ANDROID_HOME
break
fi
done
fi
: "${ANDROID_HOME:?ANDROID_HOME must be set or discoverable under a common SDK path}"
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
NDK_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/ndk/*")"
if [ -n "$NDK_VERSION" ]; then
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
export ANDROID_NDK_HOME
fi
fi
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set or discoverable under ANDROID_HOME/ndk}"
if [ -z "${BUILD_TOOLS_VERSION:-}" ]; then
BUILD_TOOLS_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/build-tools/*")"
export BUILD_TOOLS_VERSION
fi
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set or discoverable under ANDROID_HOME/build-tools}"
if [ -z "${PLATFORM:-}" ]; then
PLATFORM="$(infer_latest_dir_name "$ANDROID_HOME/platforms/android-*")"
export PLATFORM
fi
: "${PLATFORM:?PLATFORM must be set or discoverable under ANDROID_HOME/platforms}"
PROFILE="${PROFILE:-debug}"
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
STRIP_NATIVE_LIBS="${STRIP_NATIVE_LIBS:-1}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
echo ">>> Android SDK: $ANDROID_HOME"
echo ">>> Android NDK: $ANDROID_NDK_HOME"
echo ">>> Build tools: $BUILD_TOOLS_VERSION"
echo ">>> Platform: $PLATFORM"
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
MANIFEST="solitaire_app/android/AndroidManifest.xml"
@@ -69,6 +119,24 @@ fi
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
cargo ndk "${CARGO_NDK_ARGS[@]}"
if [ "$STRIP_NATIVE_LIBS" = "1" ]; then
LLVM_STRIP=""
shopt -s nullglob
STRIP_CANDIDATES=( "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/llvm-strip )
shopt -u nullglob
if [ ${#STRIP_CANDIDATES[@]} -gt 0 ]; then
LLVM_STRIP="${STRIP_CANDIDATES[0]}"
fi
if [ -z "$LLVM_STRIP" ]; then
echo "llvm-strip not found under ANDROID_NDK_HOME; native libraries will remain unstripped" >&2
else
echo ">>> strip native libraries with $LLVM_STRIP"
find "$STAGING/lib" -name '*.so' -print0 | while IFS= read -r -d '' so; do
"$LLVM_STRIP" --strip-debug "$so"
done
fi
fi
# --- 2. compile + link resources and manifest ------------------------------
if [ -d "$RES_DIR" ]; then
echo ">>> aapt2 compile resources"
@@ -120,11 +188,15 @@ rm -f "$STAGING/app-unsigned.apk"
# --- 5. sign ---------------------------------------------------------------
if [ -z "${KEYSTORE:-}" ]; then
# Generate a deterministic debug keystore on the fly.
KEYSTORE="$STAGING/debug.keystore"
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
KEYSTORE="target/android/debug.keystore"
fi
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
if [ ! -f "$KEYSTORE" ]; then
mkdir -p "$(dirname "$KEYSTORE")"
echo ">>> generating debug keystore at $KEYSTORE"
keytool -genkeypair -v \
-keystore "$KEYSTORE" \
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Update Quaternions registry dependencies and run the full safety gate.
#
# Usage:
# scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
#
# Example:
# scripts/update_quaternions_deps.sh 0.3.1 0.4.1
#
# This script updates Cargo.lock to the requested versions (within the semver
# ranges already declared in Cargo.toml), then runs the project's required
# verification steps plus deterministic replay checks.
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "usage: $0 <klondike_version> <card_game_version>"
exit 2
fi
KLONDIKE_VERSION="$1"
CARD_GAME_VERSION="$2"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
echo ">>> Quaternions registry:"
echo " https://git.aleshym.co/api/packages/Quaternions/cargo/"
echo
echo ">>> Review upstream release notes / changelogs before proceeding:"
echo " - https://git.aleshym.co/Quaternions/card_game"
echo " - https://git.aleshym.co/Quaternions/klondike"
echo
echo ">>> Updating lockfile to klondike=$KLONDIKE_VERSION card_game=$CARD_GAME_VERSION"
cargo update -p klondike --precise "$KLONDIKE_VERSION"
cargo update -p card_game --precise "$CARD_GAME_VERSION"
echo ">>> Verifying dependency graph"
cargo tree -p solitaire_core --depth 2 | cat
echo ">>> Running workspace tests"
cargo test --workspace
echo ">>> Running workspace clippy"
cargo clippy --workspace -- -D warnings
echo ">>> Running deterministic replay / debug-api smoke checks"
cargo test -p solitaire_wasm debug_snapshot_exposes_replayable_seed_and_history -- --exact
cargo test -p solitaire_wasm debug_api_autonomous_seed_batch_smoke -- --exact
echo ">>> Quaternions dependency upgrade gate passed"
+123 -135
View File
@@ -18,26 +18,31 @@ use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
#[cfg(not(target_os = "android"))]
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
#[cfg(not(target_os = "android"))]
use bevy::winit::WinitWindows;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
SelectionPlugin, SettingsPlugin,
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
#[cfg(target_os = "android")]
use bevy::winit::{UpdateMode, WinitSettings};
use solitaire_data::{
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
settings_file_path,
};
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
/// App entry point — builds and runs the Bevy app.
fn load_settings() -> Settings {
settings_file_path()
.map(|p| load_settings_from(&p))
.unwrap_or_default()
}
/// Build the Bevy app without entering the event loop.
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
build_app_with_settings(load_settings(), sync_provider)
}
/// App entry point — configures runtime services, builds, and runs the app.
///
/// Called from both the desktop `bin` target's `main` shim and (on
/// Android) the platform's NativeActivity / GameActivity glue.
@@ -47,6 +52,12 @@ pub fn run() {
// and any debugger attached still sees the panic).
install_crash_log_hook();
// Remove any *.tmp files left behind by a crash between an atomic write
// and its rename. Safe to call unconditionally — missing data dir is a
// no-op. Must run before GamePlugin loads saved state so orphaned files
// don't accumulate across launches.
let _ = cleanup_orphaned_tmp_files();
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
@@ -54,10 +65,9 @@ pub fn run() {
// operations will fail gracefully with TokenError::KeychainUnavailable.
//
// Android: `keyring` isn't compiled in (its `rpassword` transitive
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
// ships an Android stub that returns KeychainUnavailable for every
// call — the runtime behaviour is "session login required each launch"
// until we wire Android Keystore via JNI in the Phase-Android round.
// pulls a libc symbol Android's bionic doesn't expose). The Android
// auth-token path uses Android Keystore via JNI; `android_main` passes
// the process JavaVM pointer into `solitaire_data` before `run()`.
#[cfg(not(target_os = "android"))]
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
@@ -66,13 +76,15 @@ pub fn run() {
);
}
// Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path()
.map(|p| load_settings_from(&p))
.unwrap_or_default();
let settings = load_settings();
let sync_provider = provider_for_backend(&settings.sync_backend);
build_app_with_settings(settings, sync_provider).run();
}
fn build_app_with_settings(
settings: Settings,
sync_provider: Box<dyn SyncProvider + Send + Sync>,
) -> App {
// Restore the previous window geometry if the player has one saved.
// Otherwise open at the platform default (1280×800, centred on the
// primary monitor) — `apply_smart_default_window_size` will resize
@@ -80,7 +92,7 @@ pub fn run() {
// sessions don't end up with a comparatively tiny window.
#[cfg(not(target_os = "android"))]
let had_saved_geometry = settings.window_geometry.is_some();
let (window_resolution, window_position) = match settings.window_geometry {
let (window_resolution, window_position) = match settings.window_geometry.as_ref() {
Some(geom) => (
(geom.width, geom.height).into(),
WindowPosition::At(IVec2::new(geom.x, geom.y)),
@@ -96,113 +108,90 @@ pub fn run() {
// The card-theme system's `themes://` asset source must be
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
// because that plugin freezes the asset-source list at build
// time. The matching `AssetSourcesPlugin` (added below) finishes
// the wiring after `DefaultPlugins` by populating the embedded
// default theme into Bevy's `EmbeddedAssetRegistry`.
// time. The matching `AssetSourcesPlugin` (registered by
// `CoreGamePlugin`) finishes the wiring after `DefaultPlugins`
// by populating the embedded default theme into Bevy's
// `EmbeddedAssetRegistry`.
register_theme_asset_sources(&mut app);
app
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "Ferrous Solitaire".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("ferrous-solitaire".into()),
resolution: window_resolution,
position: window_position,
// AutoNoVsync prefers Mailbox (triple-buffered) and
// falls back to Immediate, eliminating the vsync stall
// that AutoVsync produces during continuous window
// resize on X11 / Wayland. The game's frame budget is
// small enough that a few stray dropped frames from
// disabling vsync are imperceptible.
present_mode: PresentMode::AutoNoVsync,
// Android windows always fill the screen; max_width/max_height
// default to 0.0, which panics Bevy's clamp when min > max.
#[cfg(not(target_os = "android"))]
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
..default()
})
// The `assets/` directory lives at the workspace root, but
// on desktop Bevy resolves `AssetPlugin::file_path` relative
// to the binary package's `CARGO_MANIFEST_DIR`
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
// miss the workspace-root `assets/` without a `../` prefix.
//
// On Android cargo-apk packages the same directory into the
// APK at `assets/` (via `[package.metadata.android].assets`
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
// is already rooted there, so any `file_path` other than the
// default makes it walk *out* of the APK's assets root and
// all loads fail silently — which is what produced the
// solid-red card-back fallback in the v0.22.3 screenshot.
.set(bevy::asset::AssetPlugin {
app.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "Ferrous Solitaire".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("ferrous-solitaire".into()),
resolution: window_resolution,
position: window_position,
// On Android, AutoVsync caps the GPU at the display
// refresh rate (~60-90 fps). Without it the renderer
// spins as fast as the hardware allows, keeping the
// GPU fully loaded and draining the battery even when
// the game is completely idle.
//
// On desktop (X11 / Wayland) AutoNoVsync prefers
// Mailbox (triple-buffered) and falls back to
// Immediate, eliminating the vsync stall that
// AutoVsync produces during continuous window resize.
// The game's frame budget is small enough that a few
// stray dropped frames from disabling vsync are
// imperceptible on desktop.
#[cfg(target_os = "android")]
present_mode: PresentMode::AutoVsync,
#[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(),
present_mode: PresentMode::AutoNoVsync,
// Android windows always fill the screen; max_width/max_height
// default to 0.0, which panics Bevy's clamp when min > max.
#[cfg(not(target_os = "android"))]
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
)
.add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
// Cursor-icon feedback is desktop-only; Android has no pointer cursor.
// The drop-target highlight systems (update_drop_highlights,
// update_drop_target_overlays) live in CursorPlugin but ARE useful
// on Android — they've been left running because their Bevy system
// params compile and function on Android; only the CursorIcon insert
// is inert. Gate the whole plugin if the cursor APIs ever cause
// Android linker issues; for now it's harmless to leave it registered.
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default())
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(SafeAreaInsetsPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(AvatarPlugin)
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin);
..default()
})
// The `assets/` directory lives at the workspace root, but
// on desktop Bevy resolves `AssetPlugin::file_path` relative
// to the binary package's `CARGO_MANIFEST_DIR`
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
// miss the workspace-root `assets/` without a `../` prefix.
//
// On Android cargo-apk packages the same directory into the
// APK at `assets/` (via `[package.metadata.android].assets`
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
// is already rooted there, so any `file_path` other than the
// default makes it walk *out* of the APK's assets root and
// all loads fail silently — which is what produced the
// solid-red card-back fallback in the v0.22.3 screenshot.
.set(bevy::asset::AssetPlugin {
#[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(),
..default()
}),
)
.add_plugins(CoreGamePlugin::new(sync_provider));
// On Android the default WinitSettings use UpdateMode::Continuous for
// the focused window, which means Bevy renders as fast as possible even
// when the game is completely idle. Switching to reactive_low_power with
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
//
// focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when
// an event arrives (touch, resize, etc.) or an animation system writes
// RequestRedraw. The 100 ms ceiling is a fallback that ensures the game
// timer ticks at least 10×/s even with no input, while keeping the GPU
// completely idle between frames when the board is static.
// PresentMode::AutoVsync (set above) still caps the GPU at the display
// refresh rate when frames do render.
#[cfg(target_os = "android")]
app.insert_resource(WinitSettings {
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
});
// Wire the runtime window icon. Bevy 0.18 has no first-class
// `Window::icon` field; the icon is set through the underlying
@@ -229,7 +218,7 @@ pub fn run() {
app.add_systems(Update, apply_smart_default_window_size);
}
app.run();
app
}
/// One-shot Update system that runs only on launches without saved
@@ -376,6 +365,10 @@ fn set_window_icon(
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let vm_ptr = android_app.vm_as_ptr().cast();
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) {
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
}
let _ = bevy::android::ANDROID_APP.set(android_app);
run();
}
@@ -386,17 +379,12 @@ fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
/// unchanged. If the data directory is unavailable, the wrapper silently
/// falls through — the default hook handles output either way.
fn install_crash_log_hook() {
let crash_log_path = settings_file_path().and_then(|p| {
p.parent()
.map(|parent| parent.join("crash.log"))
});
let crash_log_path =
settings_file_path().and_then(|p| p.parent().map(|parent| parent.join("crash.log")));
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(path) = crash_log_path.as_ref()
&& let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(path)
&& let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path)
{
// Plain unix-seconds timestamp keeps the format trivially
// parseable and avoids pulling in chrono just for this.
+220 -50
View File
@@ -30,7 +30,9 @@ fn suit_color(suit: u8) -> [u8; 4] {
}
fn rank_str(rank: u8) -> &'static str {
["A","2","3","4","5","6","7","8","9","10","J","Q","K"][rank as usize]
[
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
][rank as usize]
}
// ---------------------------------------------------------------------------
@@ -86,13 +88,15 @@ impl Canvas {
}
fn set(&mut self, x: i32, y: i32, c: [u8; 4]) {
if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { return; }
if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 {
return;
}
let i = (y as u32 * W + x as u32) as usize * 4;
let a = c[3] as f32 / 255.0;
if a >= 0.99 {
self.data[i..i + 4].copy_from_slice(&c);
} else if a > 0.01 {
self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8;
self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8;
self.data[i + 1] = (self.data[i + 1] as f32 * (1.0 - a) + c[1] as f32 * a) as u8;
self.data[i + 2] = (self.data[i + 2] as f32 * (1.0 - a) + c[2] as f32 * a) as u8;
self.data[i + 3] = 255;
@@ -172,27 +176,36 @@ fn draw_heart(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
let oy = cy - sz * 0.04;
cv.circle(cx - sz * 0.22, oy, r, c);
cv.circle(cx + sz * 0.22, oy, r, c);
cv.triangle([
(cx - sz * 0.52, oy + r * 0.4),
(cx + sz * 0.52, oy + r * 0.4),
(cx, cy + sz * 0.52),
], c);
cv.triangle(
[
(cx - sz * 0.52, oy + r * 0.4),
(cx + sz * 0.52, oy + r * 0.4),
(cx, cy + sz * 0.52),
],
c,
);
}
fn draw_spade(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
cv.triangle([
(cx, cy - sz * 0.52),
(cx - sz * 0.52, cy + sz * 0.1),
(cx + sz * 0.52, cy + sz * 0.1),
], c);
cv.triangle(
[
(cx, cy - sz * 0.52),
(cx - sz * 0.52, cy + sz * 0.1),
(cx + sz * 0.52, cy + sz * 0.1),
],
c,
);
cv.circle(cx - sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
cv.circle(cx + sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
// stem + base
cv.triangle([
(cx, cy + sz * 0.12),
(cx - sz * 0.13, cy + sz * 0.5),
(cx + sz * 0.13, cy + sz * 0.5),
], c);
cv.triangle(
[
(cx, cy + sz * 0.12),
(cx - sz * 0.13, cy + sz * 0.5),
(cx + sz * 0.13, cy + sz * 0.5),
],
c,
);
cv.fill_rect(
(cx - sz * 0.26) as i32,
(cy + sz * 0.43) as i32,
@@ -231,7 +244,15 @@ fn draw_club(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
// Text rendering via ab_glyph
// ---------------------------------------------------------------------------
fn draw_text(cv: &mut Canvas, font: &FontRef<'_>, text: &str, px: f32, left: f32, top: f32, c: [u8; 4]) {
fn draw_text(
cv: &mut Canvas,
font: &FontRef<'_>,
text: &str,
px: f32,
left: f32,
top: f32,
c: [u8; 4],
) {
let scale = PxScale::from(px);
let baseline = top + font.as_scaled(scale).ascent();
let mut x = left;
@@ -278,12 +299,63 @@ fn pip_positions(rank: u8) -> &'static [(f32, f32)] {
1 => &[(0.5, 0.2), (0.5, 0.8)],
2 => &[(0.5, 0.12), (0.5, 0.5), (0.5, 0.88)],
3 => &[(0.25, 0.18), (0.75, 0.18), (0.25, 0.82), (0.75, 0.82)],
4 => &[(0.25, 0.18), (0.75, 0.18), (0.5, 0.5), (0.25, 0.82), (0.75, 0.82)],
5 => &[(0.25, 0.12), (0.75, 0.12), (0.25, 0.5), (0.75, 0.5), (0.25, 0.88), (0.75, 0.88)],
6 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.31), (0.25, 0.5), (0.75, 0.5), (0.25, 0.9), (0.75, 0.9)],
7 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.28), (0.25, 0.48), (0.75, 0.48), (0.5, 0.70), (0.25, 0.9), (0.75, 0.9)],
8 => &[(0.25, 0.1), (0.75, 0.1), (0.25, 0.35), (0.75, 0.35), (0.5, 0.5), (0.25, 0.65), (0.75, 0.65), (0.25, 0.9), (0.75, 0.9)],
9 => &[(0.25, 0.09), (0.75, 0.09), (0.5, 0.27), (0.25, 0.44), (0.75, 0.44), (0.25, 0.56), (0.75, 0.56), (0.5, 0.73), (0.25, 0.91), (0.75, 0.91)],
4 => &[
(0.25, 0.18),
(0.75, 0.18),
(0.5, 0.5),
(0.25, 0.82),
(0.75, 0.82),
],
5 => &[
(0.25, 0.12),
(0.75, 0.12),
(0.25, 0.5),
(0.75, 0.5),
(0.25, 0.88),
(0.75, 0.88),
],
6 => &[
(0.25, 0.1),
(0.75, 0.1),
(0.5, 0.31),
(0.25, 0.5),
(0.75, 0.5),
(0.25, 0.9),
(0.75, 0.9),
],
7 => &[
(0.25, 0.1),
(0.75, 0.1),
(0.5, 0.28),
(0.25, 0.48),
(0.75, 0.48),
(0.5, 0.70),
(0.25, 0.9),
(0.75, 0.9),
],
8 => &[
(0.25, 0.1),
(0.75, 0.1),
(0.25, 0.35),
(0.75, 0.35),
(0.5, 0.5),
(0.25, 0.65),
(0.75, 0.65),
(0.25, 0.9),
(0.75, 0.9),
],
9 => &[
(0.25, 0.09),
(0.75, 0.09),
(0.5, 0.27),
(0.25, 0.44),
(0.75, 0.44),
(0.25, 0.56),
(0.75, 0.56),
(0.5, 0.73),
(0.25, 0.91),
(0.75, 0.91),
],
_ => &[],
}
}
@@ -327,14 +399,28 @@ fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas {
let tl_x = 6.0f32;
let tl_y = 5.0f32;
draw_text(&mut cv, font, rank_s, rank_px, tl_x, tl_y, sc);
draw_suit(&mut cv, tl_x + suit_sz * 0.62, tl_y + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
draw_suit(
&mut cv,
tl_x + suit_sz * 0.62,
tl_y + rh + 2.0 + suit_sz * 0.75,
suit_sz,
suit,
sc,
);
// Bottom-right corner (right-aligned rank, suit above it)
let br_rx = W as f32 - 6.0;
let br_by = H as f32 - 5.0;
let br_ty = br_by - corner_h;
draw_text(&mut cv, font, rank_s, rank_px, br_rx - rw, br_ty, sc);
draw_suit(&mut cv, br_rx - suit_sz * 0.62, br_ty + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
draw_suit(
&mut cv,
br_rx - suit_sz * 0.62,
br_ty + rh + 2.0 + suit_sz * 0.75,
suit_sz,
suit,
sc,
);
// Center content
if rank >= 10 {
@@ -346,7 +432,14 @@ fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas {
let big_y = H as f32 * 0.28;
draw_text(&mut cv, font, rank_s, big_px, big_x, big_y, sc);
let sym_sz = 22.0f32;
draw_suit(&mut cv, W as f32 * 0.5, big_y + big_h + sym_sz * 1.0, sym_sz, suit, sc);
draw_suit(
&mut cv,
W as f32 * 0.5,
big_y + big_h + sym_sz * 1.0,
sym_sz,
suit,
sc,
);
} else {
// Pip cards
let pip_sz = if rank == 0 {
@@ -375,15 +468,17 @@ fn save_card_png(path: &Path, cv: &Canvas) {
}
fn save_png_wh(path: &Path, data: &[u8], w: u32, h: u32) {
let file = File::create(path)
.unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
let file =
File::create(path).unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
let mut bw = BufWriter::new(file);
let mut enc = png::Encoder::new(&mut bw, w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header()
let mut writer = enc
.write_header()
.unwrap_or_else(|e| panic!("png header error for {}: {e}", path.display()));
writer.write_image_data(data)
writer
.write_image_data(data)
.unwrap_or_else(|e| panic!("png data error for {}: {e}", path.display()));
}
@@ -401,8 +496,18 @@ fn make_back_0() -> Canvas {
// 2-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, LIGHT); cv.set(x, H as i32 - 1 - t, LIGHT); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, LIGHT); cv.set(W as i32 - 1 - t, y, LIGHT); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, LIGHT);
cv.set(x, H as i32 - 1 - t, LIGHT);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, LIGHT);
cv.set(W as i32 - 1 - t, y, LIGHT);
}
}
// Diamond grid: row/col spacing
let gx = 18.0f32;
@@ -455,8 +560,18 @@ fn make_back_1() -> Canvas {
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, BORDER);
cv.set(x, H as i32 - 1 - t, BORDER);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, BORDER);
cv.set(W as i32 - 1 - t, y, BORDER);
}
}
cv
}
@@ -470,8 +585,18 @@ fn make_back_2() -> Canvas {
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, BORDER);
cv.set(x, H as i32 - 1 - t, BORDER);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, BORDER);
cv.set(W as i32 - 1 - t, y, BORDER);
}
}
// Circle array (staggered rows)
let gx = 16.0f32;
@@ -513,8 +638,18 @@ fn make_back_3() -> Canvas {
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, BORDER);
cv.set(x, H as i32 - 1 - t, BORDER);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, BORDER);
cv.set(W as i32 - 1 - t, y, BORDER);
}
}
cv
}
@@ -543,8 +678,18 @@ fn make_back_4() -> Canvas {
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, BORDER);
cv.set(x, H as i32 - 1 - t, BORDER);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, BORDER);
cv.set(W as i32 - 1 - t, y, BORDER);
}
}
cv
}
@@ -574,7 +719,7 @@ fn make_bg_0() -> Canvas {
fn make_bg_1() -> Canvas {
const BASE: [u8; 4] = [0x40, 0x2D, 0x1A, 0xFF];
const PLANK_EDGE: [u8; 4] = [0x28, 0x1A, 0x0A, 0xFF]; // dark plank separator
const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak
const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Horizontal plank edges every 24 px
@@ -585,7 +730,9 @@ fn make_bg_1() -> Canvas {
// Grain lines within each plank (every 3 px between plank edges)
for y in (0..H as i32).step_by(3) {
// Skip the plank edge rows
if y % 24 < 2 { continue; }
if y % 24 < 2 {
continue;
}
cv.hline(y, 2, W as i32 - 3, GRAIN);
}
cv
@@ -608,7 +755,11 @@ fn make_bg_2() -> Canvas {
let mut cx = gx * 0.5 + offset;
while cx < W as f32 {
// alternate bright/dim to give depth
let c = if (row + (cx / gx) as u32).is_multiple_of(3) { STAR_A } else { STAR_B };
let c = if (row + (cx / gx) as u32).is_multiple_of(3) {
STAR_A
} else {
STAR_B
};
cv.circle(cx, cy, 1.0, c);
cx += gx;
}
@@ -679,12 +830,13 @@ fn main() {
let font_path = root.join("assets/fonts/main.ttf");
let font_bytes = std::fs::read(&font_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", font_path.display()));
let font = FontRef::try_from_slice(&font_bytes)
.expect("failed to parse assets/fonts/main.ttf");
let font = FontRef::try_from_slice(&font_bytes).expect("failed to parse assets/fonts/main.ttf");
// 52 card faces
let suits = ["c", "d", "h", "s"];
let ranks = ["a","2","3","4","5","6","7","8","9","10","j","q","k"];
let ranks = [
"a", "2", "3", "4", "5", "6", "7", "8", "9", "10", "j", "q", "k",
];
for suit in 0u8..4 {
for rank in 0u8..13 {
let cv = make_card_face(&font, rank, suit);
@@ -696,14 +848,32 @@ fn main() {
}
// Card backs
for (i, cv) in [make_back_0(), make_back_1(), make_back_2(), make_back_3(), make_back_4()].iter().enumerate() {
for (i, cv) in [
make_back_0(),
make_back_1(),
make_back_2(),
make_back_3(),
make_back_4(),
]
.iter()
.enumerate()
{
let path = root.join(format!("assets/cards/backs/back_{i}.png"));
save_card_png(&path, cv);
println!("wrote {}", path.display());
}
// Backgrounds
for (i, cv) in [make_bg_0(), make_bg_1(), make_bg_2(), make_bg_3(), make_bg_4()].iter().enumerate() {
for (i, cv) in [
make_bg_0(),
make_bg_1(),
make_bg_2(),
make_bg_3(),
make_bg_4(),
]
.iter()
.enumerate()
{
let path = root.join(format!("assets/backgrounds/bg_{i}.png"));
save_card_png(&path, cv);
println!("wrote {}", path.display());
@@ -20,15 +20,15 @@
//! --help Print this message
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
// Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable.
const BUDGETS: &[(&str, u64, usize)] = &[
("Easy", 1_000, 1_000),
("Medium", 5_000, 5_000),
("Hard", 25_000, 25_000),
("Expert", 100_000, 100_000),
("Easy", 1_000, 1_000),
("Medium", 5_000, 5_000),
("Hard", 25_000, 25_000),
("Expert", 100_000, 100_000),
("Grandmaster", 200_000, 200_000),
];
@@ -86,7 +86,11 @@ fn main() {
);
eprintln!(
" Tiers: {}",
BUDGETS.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")
BUDGETS
.iter()
.map(|(n, _, _)| *n)
.collect::<Vec<_>>()
.join(", ")
);
while buckets.iter().any(|b| b.len() < per_tier) {
@@ -95,8 +99,11 @@ fn main() {
if buckets[i].len() >= per_tier {
continue;
}
let cfg = SolverConfig { move_budget, state_budget };
match try_solve(seed, draw_mode.clone(), &cfg) {
let cfg = SolverConfig {
move_budget,
state_budget,
};
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable => {
buckets[i].push(seed);
eprintln!(
@@ -123,7 +130,9 @@ fn main() {
seed = seed.wrapping_add(1);
}
eprintln!("\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n");
eprintln!(
"\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n"
);
let date = current_date();
for (i, (tier_name, _, _)) in BUDGETS.iter().enumerate() {
@@ -148,7 +157,10 @@ fn main() {
fn parse_u64(s: &str) -> u64 {
let cleaned = s.replace('_', "");
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
if let Some(hex) = cleaned
.strip_prefix("0x")
.or_else(|| cleaned.strip_prefix("0X"))
{
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
eprintln!("error: could not parse '{s}' as a hex u64");
std::process::exit(1);
@@ -181,7 +193,18 @@ fn current_date() -> String {
}
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
let month_days: [u64; 12] = [
31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut m = 0usize;
for &md in &month_days {
+32 -12
View File
@@ -18,7 +18,7 @@
//! --help Print this message
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
fn main() {
let mut args = std::env::args().skip(1).peekable();
@@ -45,7 +45,14 @@ fn main() {
});
}
"--help" | "-h" => {
eprintln!("{}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/gen_seeds.rs")).lines().take(20).collect::<Vec<_>>().join("\n"));
eprintln!(
"{}",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/gen_seeds.rs"))
.lines()
.take(20)
.collect::<Vec<_>>()
.join("\n")
);
return;
}
other => {
@@ -66,16 +73,11 @@ fn main() {
let mut tried: u64 = 0;
let mut seed = start;
eprintln!(
"gen_seeds: finding {count} Winnable seeds from 0x{start:016X} (DrawOne) …"
);
eprintln!("gen_seeds: finding {count} Winnable seeds from 0x{start:016X} (DrawOne) …");
while found.len() < count {
tried += 1;
if matches!(
try_solve(seed, draw_mode.clone(), &cfg),
SolverResult::Winnable
) {
if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) {
found.push(seed);
eprintln!(
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
@@ -88,7 +90,9 @@ fn main() {
seed = seed.wrapping_add(1);
}
eprintln!("\nDone. Paste the block below into CHALLENGE_SEEDS in solitaire_data/src/challenge.rs:\n");
eprintln!(
"\nDone. Paste the block below into CHALLENGE_SEEDS in solitaire_data/src/challenge.rs:\n"
);
println!(
" // Generated by solitaire_assetgen::gen_seeds \
@@ -111,7 +115,10 @@ fn main() {
fn parse_u64(s: &str) -> u64 {
let cleaned = s.replace('_', "");
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
if let Some(hex) = cleaned
.strip_prefix("0x")
.or_else(|| cleaned.strip_prefix("0X"))
{
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
eprintln!("error: could not parse '{s}' as a hex u64");
std::process::exit(1);
@@ -144,7 +151,20 @@ fn current_date() -> String {
y += 1;
}
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
let month_days: [u64; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let month_days: [u64; 12] = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut m = 0usize;
for &md in &month_days {
if d < md {
+9 -1
View File
@@ -4,7 +4,15 @@ version.workspace = true
license.workspace = true
edition.workspace = true
[features]
default = []
test-support = []
[dev-dependencies]
proptest = "1"
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
rand = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
+92 -23
View File
@@ -8,9 +8,11 @@
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
//! unlock event for any `AchievementDef` whose record is not yet unlocked.
use serde::{Deserialize, Serialize};
/// Fields needed by achievement conditions. Constructed by the engine from
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AchievementContext {
/// Total number of games played (after this win has been recorded).
pub games_played: u32,
@@ -353,7 +355,11 @@ mod tests {
ids.sort();
let len = ids.len();
ids.dedup();
assert_eq!(ids.len(), len, "duplicate achievement ID in ALL_ACHIEVEMENTS");
assert_eq!(
ids.len(),
len,
"duplicate achievement ID in ALL_ACHIEVEMENTS"
);
}
#[test]
@@ -420,13 +426,19 @@ mod tests {
for hour in [22u32, 23, 0, 1, 2] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"night_owl"), "expected night_owl at hour {hour}");
assert!(
ids.contains(&"night_owl"),
"expected night_owl at hour {hour}"
);
}
// Daytime hours must not trigger.
for hour in [3u32, 7, 12, 20, 21] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"night_owl"), "unexpected night_owl at hour {hour}");
assert!(
!ids.contains(&"night_owl"),
"unexpected night_owl at hour {hour}"
);
}
}
@@ -438,13 +450,19 @@ mod tests {
for hour in [5u32, 6] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"early_bird"), "expected early_bird at hour {hour}");
assert!(
ids.contains(&"early_bird"),
"expected early_bird at hour {hour}"
);
}
// Outside the window must not trigger.
for hour in [0u32, 3, 4, 7, 12, 23] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"early_bird"), "unexpected early_bird at hour {hour}");
assert!(
!ids.contains(&"early_bird"),
"unexpected early_bird at hour {hour}"
);
}
}
@@ -504,7 +522,10 @@ mod tests {
#[test]
fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
assert_eq!(
achievement_by_id("first_win").map(|d| d.name),
Some("First Win")
);
assert!(achievement_by_id("nonexistent").is_none());
}
@@ -536,7 +557,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_time_seconds = 179;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
assert!(
ids.contains(&"speed_demon"),
"speed_demon should unlock at 179s"
);
}
#[test]
@@ -544,7 +568,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_time_seconds = 181;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
assert!(
!ids.contains(&"speed_demon"),
"speed_demon must not unlock at 181s"
);
}
#[test]
@@ -560,7 +587,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_time_seconds = 90;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
assert!(
!ids.contains(&"lightning"),
"lightning must not unlock at exactly 90s"
);
}
#[test]
@@ -568,7 +598,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
assert!(
ids.contains(&"no_undo"),
"no_undo should unlock when undo was not used"
);
}
#[test]
@@ -576,7 +609,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
assert!(
!ids.contains(&"no_undo"),
"no_undo must not unlock when undo was used"
);
}
#[test]
@@ -584,7 +620,10 @@ mod tests {
let mut c = ctx_defaults();
c.best_single_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
assert!(
ids.contains(&"high_scorer"),
"high_scorer should unlock at best_single_score=5000"
);
}
#[test]
@@ -592,7 +631,10 @@ mod tests {
let mut c = ctx_defaults();
c.best_single_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
assert!(
!ids.contains(&"high_scorer"),
"high_scorer must not unlock at best_single_score=4999"
);
}
#[test]
@@ -600,7 +642,10 @@ mod tests {
let mut c = ctx_defaults();
c.win_streak_current = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
assert!(
ids.contains(&"on_a_roll"),
"on_a_roll should unlock at streak=3"
);
}
#[test]
@@ -608,7 +653,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
assert!(
ids.contains(&"comeback"),
"comeback should unlock at last_win_recycle_count=3"
);
}
#[test]
@@ -629,12 +677,18 @@ mod tests {
c.win_streak_current = 9;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"unstoppable"));
assert!(ids.contains(&"on_a_roll"), "streak 9 must still satisfy on_a_roll");
assert!(
ids.contains(&"on_a_roll"),
"streak 9 must still satisfy on_a_roll"
);
c.win_streak_current = 10;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"unstoppable"));
assert!(ids.contains(&"on_a_roll"), "streak 10 must also satisfy on_a_roll");
assert!(
ids.contains(&"on_a_roll"),
"streak 10 must also satisfy on_a_roll"
);
}
#[test]
@@ -655,12 +709,18 @@ mod tests {
c.games_played = 499;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"veteran"));
assert!(ids.contains(&"century"), "499 games must also satisfy century");
assert!(
ids.contains(&"century"),
"499 games must also satisfy century"
);
c.games_played = 500;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"veteran"));
assert!(ids.contains(&"century"), "500 games must also satisfy century");
assert!(
ids.contains(&"century"),
"500 games must also satisfy century"
);
}
#[test]
@@ -725,7 +785,10 @@ mod tests {
assert!(ids.contains(&"first_win"), "first_win should unlock");
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock");
assert!(ids.contains(&"no_undo"), "no_undo should unlock");
assert!(ids.len() >= 3, "at least 3 achievements must fire simultaneously");
assert!(
ids.len() >= 3,
"at least 3 achievements must fire simultaneously"
);
}
#[test]
@@ -740,7 +803,10 @@ mod tests {
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"), "perfectionist must unlock");
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
assert!(
ids.contains(&"no_undo"),
"no_undo must also unlock when perfectionist does"
);
}
#[test]
@@ -776,6 +842,9 @@ mod tests {
c.last_win_score = 50_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"), "score far above threshold must pass");
assert!(
ids.contains(&"perfectionist"),
"score far above threshold must pass"
);
}
}
+76 -66
View File
@@ -1,64 +1,6 @@
use serde::{Deserialize, Serialize};
/// Card suit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Suit {
Clubs,
Diamonds,
Hearts,
Spades,
}
impl Suit {
/// Returns `true` for red suits (Diamonds, Hearts).
pub fn is_red(self) -> bool {
matches!(self, Suit::Diamonds | Suit::Hearts)
}
/// Returns `true` for black suits (Clubs, Spades).
pub fn is_black(self) -> bool {
!self.is_red()
}
}
/// Card rank, Ace through King.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Rank {
Ace,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
Nine,
Ten,
Jack,
Queen,
King,
}
impl Rank {
/// Numeric value: Ace = 1, King = 13.
pub fn value(self) -> u8 {
match self {
Rank::Ace => 1,
Rank::Two => 2,
Rank::Three => 3,
Rank::Four => 4,
Rank::Five => 5,
Rank::Six => 6,
Rank::Seven => 7,
Rank::Eight => 8,
Rank::Nine => 9,
Rank::Ten => 10,
Rank::Jack => 11,
Rank::Queen => 12,
Rank::King => 13,
}
}
}
pub use card_game::{Rank, Suit};
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -73,28 +15,96 @@ pub struct Card {
pub face_up: bool,
}
impl Card {
/// Creates a card with explicit face orientation.
pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self {
Self {
id,
suit,
rank,
face_up,
}
}
/// Creates a face-up card.
pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self {
Self::new(id, suit, rank, true)
}
/// Creates a face-down card.
pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self {
Self::new(id, suit, rank, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rank_values_are_sequential() {
let ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
];
for (i, r) in ranks.iter().enumerate() {
for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(r.value(), (i + 1) as u8);
}
}
#[test]
fn rank_as_u8_matches_value() {
for r in Rank::RANKS {
assert_eq!(r as u8, r.value());
}
}
#[test]
fn rank_checked_add_boundary() {
assert_eq!(Rank::King.checked_add(1), None);
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
}
#[test]
fn rank_checked_sub_boundary() {
assert_eq!(Rank::Ace.checked_sub(1), None);
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
}
#[test]
fn suit_suits_contains_all_four() {
assert_eq!(Suit::SUITS.len(), 4);
assert!(Suit::SUITS.contains(&Suit::Clubs));
assert!(Suit::SUITS.contains(&Suit::Diamonds));
assert!(Suit::SUITS.contains(&Suit::Hearts));
assert!(Suit::SUITS.contains(&Suit::Spades));
}
#[test]
fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
assert_ne!(
suit.is_red(),
suit.is_black(),
"{suit:?} must be exactly one of red/black"
);
}
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
}
#[test]
fn card_constructors_set_fields() {
let up = Card::face_up(10, Suit::Spades, Rank::Queen);
assert_eq!(up.id, 10);
assert_eq!(up.suit, Suit::Spades);
assert_eq!(up.rank, Rank::Queen);
assert!(up.face_up);
let down = Card::face_down(11, Suit::Diamonds, Rank::King);
assert_eq!(down.id, 11);
assert_eq!(down.suit, Suit::Diamonds);
assert_eq!(down.rank, Rank::King);
assert!(!down.face_up);
}
}
-163
View File
@@ -1,163 +0,0 @@
use rand::{seq::SliceRandom, SeedableRng};
use rand::rngs::StdRng;
use crate::card::{Card, Rank, Suit};
use crate::pile::{Pile, PileType};
const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const ALL_RANKS: [Rank; 13] = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
];
/// A standard 52-card deck.
pub struct Deck {
/// All 52 cards in the deck, in deal order.
pub cards: Vec<Card>,
}
impl Deck {
/// Creates an unshuffled deck with all 52 unique cards (id 051).
pub fn new() -> Self {
let mut cards = Vec::with_capacity(52);
let mut id = 0u32;
for &suit in &ALL_SUITS {
for &rank in &ALL_RANKS {
cards.push(Card { id, suit, rank, face_up: false });
id += 1;
}
}
Self { cards }
}
/// Shuffles the deck in-place using Fisher-Yates with a seeded `StdRng`.
/// The same seed always produces the same order on any platform.
pub fn shuffle(&mut self, seed: u64) {
let mut rng = StdRng::seed_from_u64(seed);
self.cards.shuffle(&mut rng);
}
}
impl Default for Deck {
fn default() -> Self {
Self::new()
}
}
/// Deals a standard Klondike layout from a pre-shuffled deck.
///
/// Returns 7 tableau piles and the remaining stock pile.
/// Column `i` contains `i + 1` cards; only the top card is face-up.
/// Stock receives the remaining 24 cards, all face-down.
pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) {
debug_assert_eq!(deck.cards.len(), 52, "deal_klondike requires a full 52-card deck");
let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i)));
// Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded.
let mut idx = 0usize;
for (col, pile) in tableau.iter_mut().enumerate() {
for row in 0..=col {
let mut card = deck.cards[idx].clone();
card.face_up = row == col;
pile.cards.push(card);
idx += 1;
}
}
let mut stock = Pile::new(PileType::Stock);
stock.cards.extend(deck.cards.into_iter().skip(idx));
(tableau, stock)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deck_new_has_52_cards() {
assert_eq!(Deck::new().cards.len(), 52);
}
#[test]
fn deck_new_has_unique_ids() {
let deck = Deck::new();
let mut ids: Vec<u32> = deck.cards.iter().map(|c| c.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 52);
}
#[test]
fn deck_new_has_all_suits_and_ranks() {
let deck = Deck::new();
for suit in ALL_SUITS {
for rank in ALL_RANKS {
assert!(
deck.cards.iter().any(|c| c.suit == suit && c.rank == rank),
"missing {rank:?} {suit:?}"
);
}
}
}
#[test]
fn same_seed_produces_same_order() {
let mut d1 = Deck::new(); d1.shuffle(42);
let mut d2 = Deck::new(); d2.shuffle(42);
assert_eq!(d1.cards, d2.cards);
}
#[test]
fn different_seeds_produce_different_orders() {
let mut d1 = Deck::new(); d1.shuffle(1);
let mut d2 = Deck::new(); d2.shuffle(2);
assert_ne!(d1.cards, d2.cards);
}
#[test]
fn deal_klondike_correct_tableau_sizes() {
let mut deck = Deck::new(); deck.shuffle(0);
let (tableau, stock) = deal_klondike(deck);
for (i, pile) in tableau.iter().enumerate() {
assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size");
}
assert_eq!(stock.cards.len(), 24);
}
#[test]
fn deal_klondike_top_cards_are_face_up() {
let mut deck = Deck::new(); deck.shuffle(0);
let (tableau, _) = deal_klondike(deck);
for pile in &tableau {
assert!(pile.cards.last().unwrap().face_up);
}
}
#[test]
fn deal_klondike_non_top_cards_are_face_down() {
let mut deck = Deck::new(); deck.shuffle(0);
let (tableau, _) = deal_klondike(deck);
for pile in &tableau {
for card in &pile.cards[..pile.cards.len().saturating_sub(1)] {
assert!(!card.face_up);
}
}
}
#[test]
fn deal_klondike_stock_is_face_down() {
let mut deck = Deck::new(); deck.shuffle(0);
let (_, stock) = deal_klondike(deck);
assert!(stock.cards.iter().all(|c| !c.face_up));
}
#[test]
fn deal_klondike_all_52_cards_present() {
let mut deck = Deck::new(); deck.shuffle(99);
let (tableau, stock) = deal_klondike(deck);
let mut ids: Vec<u32> = stock.cards.iter().map(|c| c.id).collect();
for pile in &tableau { ids.extend(pile.cards.iter().map(|c| c.id)); }
ids.sort_unstable();
assert_eq!(ids, (0u32..52).collect::<Vec<_>>());
}
}
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
//!
//! [`KlondikeAdapter`] is a pure helper namespace for:
//! - building [`KlondikeConfig`] from Ferrous settings
//! - translating between local and upstream types
//! - applying Ferrous-specific scoring policy on top of upstream defaults
//!
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
//! upstream `card_game` / `klondike` types live here so that the product modules
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
use card_game::Card as KlCard;
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
TableauStack,
};
use serde::{Deserialize, Serialize};
use crate::card;
use crate::game_state::{DrawMode, GameMode};
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
///
/// This type is intentionally zero-sized: it does not carry mutable runtime
/// state, and exists only as a namespace for configuration, conversion, and
/// scoring helpers.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KlondikeAdapter;
impl KlondikeAdapter {
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
KlondikeConfig {
draw_stock: match draw_mode {
DrawMode::DrawOne => DrawStockConfig::DrawOne,
DrawMode::DrawThree => DrawStockConfig::DrawThree,
},
move_from_foundation: if take_from_foundation {
MoveFromFoundationConfig::Allowed
} else {
MoveFromFoundationConfig::Disallowed
},
scoring: ScoringConfig::DEFAULT,
}
}
// ── Scoring helpers ───────────────────────────────────────────────────
/// Score delta for a card move.
///
/// Reads from [`ScoringConfig`] (WXP Standard values):
/// - Any pile → Foundation: +10
/// - Waste → Tableau: +5
/// - Foundation → Tableau: 15
/// - All other moves: 0
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = ScoringConfig::DEFAULT;
match (from, to) {
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
_ => 0,
}
}
/// Score delta for exposing a face-down tableau card: +5.
pub fn score_for_flip() -> i32 {
ScoringConfig::DEFAULT.flip_up_bonus
}
/// Score delta for undo: 15.
///
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
/// defaults to 0; the solver overrides it to 0 explicitly. The 15 WXP penalty
/// is applied here by `GameState` on every undo.
pub fn score_for_undo() -> i32 {
-15
}
/// Score delta for recycling waste → stock.
///
/// [`ScoringConfig::recycle`] is a flat delta (default 0 = always free).
/// WXP allows a fixed number of free recycles before charging a penalty,
/// which the upstream library cannot express with a single delta:
///
/// | Mode | Free recycles | Penalty per extra recycle |
/// |---|---|---|
/// | Draw-1 | 1 | 100 |
/// | Draw-3 | 3 | 20 |
///
/// **Design note:** recycling is *never* blocked — only penalised.
/// This is intentional: Draw-1 can be played indefinitely with the score
/// dropping toward zero after the first free recycle. A hard cap would
/// create unwinnable positions when the solver cannot find a path without
/// additional recycling. Zen mode suppresses the penalty entirely.
///
/// `recycle_count` must be the new total **after** this recycle.
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
if is_draw_three {
if recycle_count > 3 { -20 } else { 0 }
} else if recycle_count > 1 {
-100
} else {
0
}
}
/// Score delta for a card move, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_move(from, to)
}
}
/// Score delta for exposing a face-down card, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`].
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_flip()
}
}
/// Compute the new score after an undo, accounting for game mode.
///
/// In [`GameMode::Zen`] the score is always 0. Otherwise applies the
/// 15 undo penalty and clamps to 0 via [`Self::score_for_undo`].
pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
(snapshot_score + Self::score_for_undo()).max(0)
}
}
/// Score delta for recycling, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`].
pub fn score_for_recycle_with_mode(
recycle_count: u32,
is_draw_three: bool,
mode: GameMode,
) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_recycle(recycle_count, is_draw_three)
}
}
}
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
match index {
0 => Some(Tableau::Tableau1),
1 => Some(Tableau::Tableau2),
2 => Some(Tableau::Tableau3),
3 => Some(Tableau::Tableau4),
4 => Some(Tableau::Tableau5),
5 => Some(Tableau::Tableau6),
6 => Some(Tableau::Tableau7),
_ => None,
}
}
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
match slot {
0 => Some(Foundation::Foundation1),
1 => Some(Foundation::Foundation2),
2 => Some(Foundation::Foundation3),
3 => Some(Foundation::Foundation4),
_ => None,
}
}
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
match skip {
0 => Some(SkipCards::Skip0),
1 => Some(SkipCards::Skip1),
2 => Some(SkipCards::Skip2),
3 => Some(SkipCards::Skip3),
4 => Some(SkipCards::Skip4),
5 => Some(SkipCards::Skip5),
6 => Some(SkipCards::Skip6),
7 => Some(SkipCards::Skip7),
8 => Some(SkipCards::Skip8),
9 => Some(SkipCards::Skip9),
10 => Some(SkipCards::Skip10),
11 => Some(SkipCards::Skip11),
12 => Some(SkipCards::Skip12),
_ => None,
}
}
/// Convert a [`card_game::Card`] to a [`card::Card`], assigning a stable `id`
/// derived from suit and rank (051, Clubs-first ordering).
///
/// The id is consistent for the same logical card across all reconstructions.
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
let suit = kl_card.suit();
let rank = kl_card.rank();
let suit_index = match suit {
card::Suit::Clubs => 0,
card::Suit::Diamonds => 1,
card::Suit::Hearts => 2,
card::Suit::Spades => 3,
};
let id = suit_index * 13 + (rank.value() as u32 - 1);
card::Card {
id,
suit,
rank,
face_up: false,
}
}
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
//
// `klondike::KlondikeInstruction` (and its sub-types) do not derive
// `Serialize` / `Deserialize`. These mirror types carry `#[serde]` so that
// the session instruction history can be persisted and reconstructed without
// upstream changes.
//
// Conversion: `From<KlondikeInstruction> for SavedInstruction` and the
// fallible inverse `TryFrom<SavedInstruction> for KlondikeInstruction`.
// Invalid numeric values (out-of-range u8 for tableau/foundation/skip) yield
// `InvalidSavedInstruction`.
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableau(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedFoundation(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedSkipCards(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePile {
Tableau(SavedTableau),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableauStack {
pub tableau: SavedTableau,
pub skip_cards: SavedSkipCards,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePileStack {
Tableau(SavedTableauStack),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstFoundation {
pub src: SavedKlondikePile,
pub foundation: SavedFoundation,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstTableau {
pub src: SavedKlondikePileStack,
pub tableau: SavedTableau,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
///
/// Convert to/from the upstream type with:
/// ```ignore
/// let saved = SavedInstruction::from(instruction);
/// let instruction = KlondikeInstruction::try_from(saved)?;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedInstruction {
DstFoundation(SavedDstFoundation),
DstTableau(SavedDstTableau),
RotateStock,
}
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSavedInstruction {
#[error("invalid tableau index {0} (expected 06)")]
Tableau(u8),
#[error("invalid foundation index {0} (expected 03)")]
Foundation(u8),
#[error("invalid skip_cards value {0} (expected 012)")]
SkipCards(u8),
}
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
impl From<Tableau> for SavedTableau {
fn from(t: Tableau) -> Self {
Self(t as u8)
}
}
impl From<Foundation> for SavedFoundation {
fn from(f: Foundation) -> Self {
Self(f as u8)
}
}
impl From<SkipCards> for SavedSkipCards {
fn from(s: SkipCards) -> Self {
Self(s as u8)
}
}
impl From<KlondikePile> for SavedKlondikePile {
fn from(p: KlondikePile) -> Self {
match p {
KlondikePile::Tableau(t) => Self::Tableau(t.into()),
KlondikePile::Stock => Self::Stock,
KlondikePile::Foundation(f) => Self::Foundation(f.into()),
}
}
}
impl From<TableauStack> for SavedTableauStack {
fn from(ts: TableauStack) -> Self {
Self {
tableau: ts.tableau.into(),
skip_cards: ts.skip_cards.into(),
}
}
}
impl From<KlondikePileStack> for SavedKlondikePileStack {
fn from(ps: KlondikePileStack) -> Self {
match ps {
KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()),
KlondikePileStack::Stock => Self::Stock,
KlondikePileStack::Foundation(f) => Self::Foundation(f.into()),
}
}
}
impl From<DstFoundation> for SavedDstFoundation {
fn from(df: DstFoundation) -> Self {
Self {
src: df.src.into(),
foundation: df.foundation.into(),
}
}
}
impl From<DstTableau> for SavedDstTableau {
fn from(dt: DstTableau) -> Self {
Self {
src: dt.src.into(),
tableau: dt.tableau.into(),
}
}
}
impl From<KlondikeInstruction> for SavedInstruction {
fn from(i: KlondikeInstruction) -> Self {
match i {
KlondikeInstruction::RotateStock => Self::RotateStock,
KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()),
KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()),
}
}
}
// ── TryFrom impls: Saved* → KlondikeInstruction ──────────────────────────────
impl TryFrom<SavedTableau> for Tableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
}
}
impl TryFrom<SavedFoundation> for Foundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
}
}
impl TryFrom<SavedSkipCards> for SkipCards {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
}
}
impl TryFrom<SavedKlondikePile> for KlondikePile {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
Ok(match s {
SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?),
SavedKlondikePile::Stock => KlondikePile::Stock,
SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?),
})
}
}
impl TryFrom<SavedTableauStack> for TableauStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
Ok(TableauStack {
tableau: s.tableau.try_into()?,
skip_cards: s.skip_cards.try_into()?,
})
}
}
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
Ok(match s {
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
})
}
}
impl TryFrom<SavedDstFoundation> for DstFoundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
Ok(DstFoundation {
src: s.src.try_into()?,
foundation: s.foundation.try_into()?,
})
}
}
impl TryFrom<SavedDstTableau> for DstTableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
Ok(DstTableau {
src: s.src.try_into()?,
tableau: s.tableau.try_into()?,
})
}
}
impl TryFrom<SavedInstruction> for KlondikeInstruction {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
Ok(match s {
SavedInstruction::RotateStock => KlondikeInstruction::RotateStock,
SavedInstruction::DstFoundation(df) => {
KlondikeInstruction::DstFoundation(df.try_into()?)
}
SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?),
})
}
}
/// 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.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
if elapsed_seconds == 0 {
return 0;
}
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
}
+9 -3
View File
@@ -1,9 +1,15 @@
pub mod achievement;
pub mod card;
pub mod deck;
pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod pile;
pub mod rules;
pub mod scoring;
pub mod solver;
// Re-export upstream types that cross the solitaire_core API boundary so
// callers can import from one place without a direct `klondike` / `card_game` dep.
pub use card_game::Session;
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
#[cfg(test)]
mod proptest_tests;
+29 -44
View File
@@ -1,34 +1,29 @@
use serde::{Deserialize, Serialize};
use crate::card::{Card, Suit};
use klondike::KlondikePile;
/// Identifies which pile on the board a set of cards belongs to.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PileType {
/// The face-down draw pile.
Stock,
/// The face-up discard pile drawn to.
Waste,
/// One of the four foundation slots (0..=3). The claimed suit, if any,
/// is derived from the bottom card of the pile (always an Ace by
/// construction).
Foundation(u8),
/// One of the seven tableau columns (06).
Tableau(usize),
}
/// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
/// Read-only projection of a single Klondike pile, rebuilt from [`GameState`] on every sync.
///
/// `Pile` is a **data-transfer type**, not a game-state owner. Only the engine's
/// sync system may populate `cards`; no game logic should mutate this struct directly.
/// [`GameState`] is always the authoritative source of truth.
///
/// [`GameState`]: crate::game_state::GameState
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
pub pile_type: PileType,
/// Which logical Klondike pile this is.
pub pile_type: KlondikePile,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
/// Populated by the sync system; do not mutate from game-logic code.
pub cards: Vec<Card>,
}
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: PileType) -> Self {
Self { pile_type, cards: Vec::new() }
pub fn new(pile_type: KlondikePile) -> Self {
Self {
pile_type,
cards: Vec::new(),
}
}
/// Returns a reference to the top (last) card, or `None` if empty.
@@ -41,7 +36,7 @@ impl Pile {
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
KlondikePile::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
@@ -54,52 +49,42 @@ mod tests {
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(PileType::Stock);
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.cards.is_empty());
}
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(PileType::Waste);
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
pile.cards.push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Two, face_up: true });
let mut pile = Pile::new(KlondikePile::Stock);
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
assert_eq!(pile.top().unwrap().id, 1);
}
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(PileType::Waste);
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.top().is_none());
}
#[test]
fn pile_type_foundation_uses_slot_index() {
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
}
#[test]
fn pile_type_tableau_uses_index() {
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
}
#[test]
fn claimed_suit_is_none_for_empty_foundation() {
let pile = Pile::new(PileType::Foundation(0));
let pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation1));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_is_none_for_non_foundation() {
let mut pile = Pile::new(PileType::Tableau(0));
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(PileType::Foundation(2));
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
pile.cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Two, face_up: true });
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
}
+224
View File
@@ -0,0 +1,224 @@
use klondike::{Foundation, KlondikePile, Tableau};
use proptest::prelude::*;
use crate::game_state::{DrawMode, GameState};
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Collect all card IDs across every pile in a fixed traversal order:
/// stock → waste → foundations 14 → tableaux 17.
///
/// The order is deterministic for a given game state, so two calls on
/// equivalent states produce identical Vec outputs — the right fingerprint
/// for undo-reversibility checks.
fn all_card_ids(game: &GameState) -> Vec<u32> {
let foundations = [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
];
let tableaux = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
let mut ids: Vec<u32> = game.stock_cards().iter().map(|c| c.id).collect();
ids.extend(game.waste_cards().iter().map(|c| c.id));
for f in &foundations {
ids.extend(
game.pile(KlondikePile::Foundation(*f))
.iter()
.map(|c| c.id),
);
}
for t in &tableaux {
ids.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|c| c.id));
}
ids
}
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
}
/// Apply a sequence of random actions to a game, silently ignoring errors.
///
/// Each action is `(draw_flag, move_index)`:
/// - `draw_flag = true` → call `game.draw()`
/// - `draw_flag = false` → pick the `move_index % len`th legal move from
/// `possible_instructions()` and execute it.
///
/// `possible_instructions()` may return `(Stock, Stock, 1)` for the
/// RotateStock / draw action. `move_cards(Stock, Stock, 1)` is rejected by
/// the `from == to` guard, so those are dispatched to `game.draw()`.
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
for &(do_draw, idx) in actions {
if do_draw {
let _ = game.draw();
} else {
let instructions = game.possible_instructions();
if instructions.is_empty() {
continue;
}
let (from, to, count) = instructions[idx % instructions.len()];
if from == to {
let _ = game.draw();
} else {
let _ = game.move_cards(from, to, count);
}
}
}
}
/// Apply one move from `possible_instructions()` (or a draw if no move is
/// available), using `move_idx` to select among the legal options.
/// Returns `true` when a move was successfully applied.
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
if game.is_won {
return false;
}
let instructions = game.possible_instructions();
if instructions.is_empty() {
return game.draw().is_ok();
}
let (from, to, count) = instructions[move_idx % instructions.len()];
if from == to {
game.draw().is_ok()
} else {
game.move_cards(from, to, count).is_ok()
}
}
// ---------------------------------------------------------------------------
// Properties
// ---------------------------------------------------------------------------
proptest! {
/// All 52 card IDs must be present exactly once across every pile after
/// any reachable sequence of draw + move_cards actions.
///
/// Catches two bug classes at once:
/// - Card loss (fewer than 52 unique IDs after the sequence).
/// - Card duplication (52 total but deduplication reduces the set).
#[test]
fn all_52_cards_always_present(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
) {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &actions);
let mut ids = all_card_ids(&game);
prop_assert_eq!(ids.len(), 52, "card count ≠ 52 (got {})", ids.len());
ids.sort_unstable();
ids.dedup();
prop_assert_eq!(
ids.len(), 52,
"duplicate card IDs found after dedup — a card was cloned"
);
}
/// `GameState::new(seed, draw_mode)` must be deterministic: two calls
/// with the same arguments must produce identical initial pile layouts.
///
/// Pins that the deal is seeded from `seed` alone and not from any
/// implicit source like wall-clock time or global state.
#[test]
fn deal_is_deterministic(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
) {
let a = GameState::new(seed, draw_mode);
let b = GameState::new(seed, draw_mode);
prop_assert_eq!(
all_card_ids(&a),
all_card_ids(&b),
"same seed + draw_mode produced different deals",
);
}
/// After applying any single legal move and immediately undoing it, the
/// pile layout and move_count must be identical to their pre-move values.
///
/// `setup_actions` drives the game to an arbitrary mid-game position;
/// `move_idx` selects which legal move to apply and then undo.
///
/// The score is intentionally excluded: `undo()` applies a 15 penalty
/// that is by design, not a regression.
#[test]
fn undo_restores_pile_layout_and_move_count(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
move_idx in 0usize..200,
) {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &setup_actions);
// Snapshot the state before the move.
let before_ids = all_card_ids(&game);
let before_move_count = game.move_count;
// Apply one move.
if !apply_one_move(&mut game, move_idx) || game.is_won {
return Ok(()); // nothing to undo
}
// Undo and verify.
prop_assert!(
game.undo().is_ok(),
"undo must succeed immediately after a successful move",
);
prop_assert_eq!(
all_card_ids(&game),
before_ids,
"pile layout after undo differs from the pre-move snapshot",
);
prop_assert_eq!(
game.move_count,
before_move_count,
"move_count after undo must equal the pre-move value",
);
}
/// Every move returned by `possible_instructions()` must succeed when
/// applied via `move_cards()`.
///
/// `possible_instructions()` and `move_cards()` both validate moves
/// through the same upstream rule engine. This property ensures no
/// drift has opened up between what the engine reports as legal and
/// what it actually accepts.
#[test]
fn legal_moves_always_succeed(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
) {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &setup_actions);
for (from, to, count) in game.possible_instructions() {
// Clone so each move is tried from the same starting state.
let mut trial = game.clone();
let result = if from == to {
trial.draw()
} else {
trial.move_cards(from, to, count)
};
prop_assert!(
result.is_ok(),
"possible_instructions() reported ({from:?} → {to:?} ×{count}) \
as legal but the call returned Err: {result:?}",
);
}
}
}
-211
View File
@@ -1,211 +0,0 @@
use crate::card::Card;
use crate::pile::Pile;
/// Returns `true` if `card` can be placed on the foundation `pile`.
///
/// Foundation rules:
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
/// becomes the pile's claimed suit (derived from the bottom card via
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
/// - When the pile is non-empty, the next card must match the top card's
/// suit and be exactly one rank higher.
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank.value() == 1,
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
}
}
/// 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.
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank.value() == 13,
Some(top) => {
top.face_up
&& card.rank.value() + 1 == top.rank.value()
&& card.suit.is_red() != top.suit.is_red()
}
}
}
/// Returns `true` if `cards` is a legal tableau run on its own — every
/// adjacent pair descends by one rank and alternates colour. A single
/// card is trivially valid. The destination check is separate; this
/// only validates the sequence's *internal* structure, which the tableau
/// move path must enforce so a player can't smuggle an arbitrary stack
/// onto another column when the bottom card happens to land legally.
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
cards.windows(2).all(|w| {
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
use crate::pile::{Pile, PileType};
fn card(suit: Suit, rank: Rank) -> Card {
Card { id: 0, suit, rank, face_up: true }
}
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
Pile { pile_type, cards }
}
// Foundation tests
#[test]
fn foundation_ace_on_empty_is_valid() {
// Every suit's Ace must land on an empty foundation slot regardless of
// its slot index; the slot claims the suit only after the Ace lands.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
let c = card(suit, Rank::Ace);
let p = Pile::new(PileType::Foundation(0));
assert!(
can_place_on_foundation(&c, &p),
"Ace of {suit:?} must land on empty slot 0",
);
}
}
#[test]
fn foundation_non_ace_on_empty_is_invalid() {
let c = card(Suit::Hearts, Rank::Two);
let p = Pile::new(PileType::Foundation(0));
assert!(!can_place_on_foundation(&c, &p));
}
#[test]
fn foundation_two_on_ace_same_suit_is_valid() {
let c = card(Suit::Clubs, Rank::Two);
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
assert!(can_place_on_foundation(&c, &p));
}
#[test]
fn foundation_second_card_must_match_claimed_suit() {
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
// because the slot's claimed suit is Hearts after the Ace lands.
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
let c = card(Suit::Spades, Rank::Two);
assert!(!can_place_on_foundation(&c, &p));
}
#[test]
fn foundation_skipping_rank_is_invalid() {
let c = card(Suit::Diamonds, Rank::Three);
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
assert!(!can_place_on_foundation(&c, &p));
}
// Tableau tests
#[test]
fn tableau_king_on_empty_is_valid() {
let c = card(Suit::Hearts, Rank::King);
let p = Pile::new(PileType::Tableau(0));
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_non_king_on_empty_is_invalid() {
let c = card(Suit::Hearts, Rank::Queen);
let p = Pile::new(PileType::Tableau(0));
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_red_on_black_one_lower_is_valid() {
let c = card(Suit::Hearts, Rank::Nine);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_same_color_is_invalid() {
let c = card(Suit::Clubs, Rank::Nine);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_wrong_rank_difference_is_invalid() {
let c = card(Suit::Hearts, Rank::Eight);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_black_on_red_one_lower_is_valid() {
let c = card(Suit::Clubs, Rank::Six);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn foundation_king_on_queen_completes_suit() {
// The last card placed to complete a foundation is always King on Queen.
let c = card(Suit::Spades, Rank::King);
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
assert!(can_place_on_foundation(&c, &p));
}
#[test]
fn foundation_king_wrong_suit_is_invalid() {
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
let c = card(Suit::Hearts, Rank::King);
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
assert!(!can_place_on_foundation(&c, &p));
}
#[test]
fn tableau_ace_on_two_different_color_is_valid() {
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
let c = card(Suit::Hearts, Rank::Ace);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_same_rank_different_color_is_invalid() {
// Two cards of the same rank cannot be stacked regardless of colour.
let c = card(Suit::Hearts, Rank::Nine);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_face_down_destination_top_is_invalid() {
// A face-down top card must never be a valid placement target.
let c = card(Suit::Hearts, Rank::Nine);
let mut top = card(Suit::Spades, Rank::Ten);
top.face_up = false;
let p = pile_with(PileType::Tableau(0), vec![top]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_sequence_validation() {
// Single card is trivially a valid sequence.
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
// Valid descending alternating-colour run K♠ Q♥ J♣.
assert!(is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Hearts, Rank::Queen),
card(Suit::Clubs, Rank::Jack),
]));
// Same colour twice (Q♠ on K♠) — invalid.
assert!(!is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Spades, Rank::Queen),
]));
// Rank gap (K♠ → J♥) — invalid.
assert!(!is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Hearts, Rank::Jack),
]));
}
}
-95
View File
@@ -1,95 +0,0 @@
use crate::pile::PileType;
/// Score delta for moving cards from `from` to `to`.
///
/// Windows XP Standard scoring:
/// - +10 for any card reaching a foundation pile
/// - +5 for a waste → tableau move
/// - 0 for all other moves
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
match to {
PileType::Foundation(_) => 10,
PileType::Tableau(_) => {
if matches!(from, PileType::Waste) { 5 } else { 0 }
}
_ => 0,
}
}
/// Score penalty applied when the player uses undo: -15.
pub fn score_undo() -> i32 {
-15
}
/// 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.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
if elapsed_seconds == 0 {
return 0;
}
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn move_to_foundation_scores_ten() {
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
}
#[test]
fn waste_to_tableau_scores_five() {
assert_eq!(score_move(&PileType::Waste, &PileType::Tableau(3)), 5);
}
#[test]
fn tableau_to_tableau_scores_zero() {
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Tableau(1)), 0);
}
#[test]
fn undo_penalty_is_negative_fifteen() {
assert_eq!(score_undo(), -15);
}
#[test]
fn time_bonus_at_100_seconds() {
assert_eq!(compute_time_bonus(100), 7000);
}
#[test]
fn time_bonus_at_zero_is_zero() {
assert_eq!(compute_time_bonus(0), 0);
}
#[test]
fn time_bonus_at_one_second() {
assert_eq!(compute_time_bonus(1), 700_000);
}
#[test]
fn non_waste_to_tableau_scores_zero() {
// Foundation → Tableau is impossible in practice but must score 0.
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
// Tableau → Tableau (restack) scores 0.
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
}
#[test]
fn move_to_stock_or_waste_scores_zero() {
// These destinations are illegal moves in practice, but the function
// must not panic and should return 0.
assert_eq!(score_move(&PileType::Waste, &PileType::Stock), 0);
assert_eq!(score_move(&PileType::Waste, &PileType::Waste), 0);
}
#[test]
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
// Very short elapsed time would overflow without the .min() guard.
let bonus = compute_time_bonus(1);
assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
}
}
+188 -1153
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -12,10 +12,16 @@ serde_json = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
uuid = { workspace = true }
# These deps are not available / not needed on wasm32:
# dirs — platform data directories (no filesystem on browser)
# reqwest — native HTTP client (sync/analytics gated out on wasm32)
# tokio — OS-threaded async runtime (mio doesn't compile on wasm32)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
dirs = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in
@@ -24,15 +30,11 @@ uuid = { workspace = true }
# on bionic). On Android `auth_tokens` falls back to a stub
# implementation that always returns `KeychainUnavailable`; the
# real backend lands when we wire Android Keystore via JNI.
[target.'cfg(not(target_os = "android"))'.dependencies]
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
keyring-core = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
# process-wide JavaVM handle for JNI. Must be listed here so the
# symbol resolves when cross-compiling for Android targets.
bevy = { workspace = true }
[dev-dependencies]
solitaire_server = { path = "../solitaire_server" }
+6 -10
View File
@@ -10,12 +10,11 @@ use std::path::{Path, PathBuf};
pub use solitaire_sync::AchievementRecord;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const FILE_NAME: &str = "achievements.json";
/// Platform-specific default path for `achievements.json`.
pub fn achievements_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
}
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
@@ -73,14 +72,11 @@ mod tests {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let records = vec![
AchievementRecord::locked("first_win"),
{
let mut r = AchievementRecord::locked("century");
r.unlock(Utc::now());
r
},
];
let records = vec![AchievementRecord::locked("first_win"), {
let mut r = AchievementRecord::locked("century");
r.unlock(Utc::now());
r
}];
save_achievements_to(&path, &records).expect("save");
let loaded = load_achievements_from(&path);
assert_eq!(loaded.len(), 2);
+214 -87
View File
@@ -2,7 +2,10 @@
///
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
/// 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 user changes biometric/lock credentials, in which case decryption fails
@@ -11,15 +14,19 @@
///
/// Only compiled and linked on `target_os = "android"`.
use jni::{
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
JNIEnv, JavaVM,
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ffi::c_void;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
#[derive(Serialize, Deserialize)]
struct TokenBlob {
@@ -32,17 +39,37 @@ struct TokenBlob {
// JVM helper
// ---------------------------------------------------------------------------
/// Initialise Android Keystore access with the process-wide `JavaVM*`.
///
/// This is called by `solitaire_app` from Android startup code. Keeping the
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or
/// engine layer just to reach platform startup state.
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
if vm_ptr.is_null() {
return Err(TokenError::KeychainUnavailable(
"JavaVM pointer is null".into(),
));
}
if ANDROID_JVM.get().is_some() {
return Ok(());
}
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
// the process lifetime.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
let _ = ANDROID_JVM.set(vm);
Ok(())
}
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
where
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
{
let app = bevy::android::ANDROID_APP
let vm = ANDROID_JVM
.get()
.ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?;
// SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?;
let mut env = vm
.attach_current_thread_permanently()
@@ -96,8 +123,7 @@ fn load_or_create_key<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result<J
}
// No key yet — generate AES-256 with GCM block mode.
let builder_class =
env.find_class("android/security/keystore/KeyGenParameterSpec$Builder")?;
let builder_class = env.find_class("android/security/keystore/KeyGenParameterSpec$Builder")?;
let alias2 = JValueOwned::from(env.new_string(KEY_ALIAS)?);
// PURPOSE_ENCRYPT | PURPOSE_DECRYPT = 1 | 2 = 3
let purpose = JValueOwned::Int(3);
@@ -248,11 +274,7 @@ fn decrypt_gcm(
let tag_len = JValueOwned::Int(128);
let iv_arr = env.byte_array_from_slice(iv)?;
let iv_val = JValueOwned::Object(iv_arr.into());
let spec = env.new_object(
&spec_class,
"(I[B)V",
&[tag_len.borrow(), iv_val.borrow()],
)?;
let spec = env.new_object(&spec_class, "(I[B)V", &[tag_len.borrow(), iv_val.borrow()])?;
// cipher.init(Cipher.DECRYPT_MODE=2, key, spec)
let mode = JValueOwned::Int(2);
@@ -280,51 +302,122 @@ fn decrypt_gcm(
// ---------------------------------------------------------------------------
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"))
}
fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
let path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
fn read_file_bytes_from(path: &PathBuf) -> Result<Vec<u8>, TokenError> {
if !path.exists() {
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> {
let path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
let tmp = path.with_extension("tmp");
let path =
token_file_path().ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
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)
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?;
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.bin.tmp: {e}")))?;
std::fs::rename(&tmp, &path)
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
}
fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
let data = read_file_bytes().map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(username.to_string()),
other => other,
})?;
/// Decrypt raw bytes from the file and deserialise as `HashMap<String, TokenBlob>`.
///
/// 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();
if data.len() < 12 {
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
// --- 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,
})?;
if data.len() < 12 {
return Err(TokenError::Keyring(
"auth_tokens.bin corrupt (too short)".into(),
));
}
let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?;
decrypt_gcm(env, &key, &data)
})?;
// Try the current multi-user format first.
if let Ok(map) = serde_json::from_slice::<HashMap<String, TokenBlob>>(&plaintext) {
return Ok(map);
}
// Fall back: old single-blob format written by an earlier binary.
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(),
));
}
let plaintext = with_jvm(|env| {
// --- 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)?;
decrypt_gcm(env, &key, &data)
encrypt_gcm(env, &key, &plaintext)
})?;
let blob: TokenBlob = serde_json::from_slice(&plaintext)
.map_err(|e| TokenError::Keyring(format!("JSON decode: {e}")))?;
if blob.username != username {
return Err(TokenError::NotFound(username.to_string()));
}
Ok(blob)
write_file_bytes(&encrypted)
}
// ---------------------------------------------------------------------------
@@ -333,77 +426,111 @@ fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
/// 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(
username: &str,
access_token: &str,
refresh_token: &str,
) -> Result<(), TokenError> {
let blob = TokenBlob {
username: username.to_string(),
access_token: access_token.to_string(),
refresh_token: refresh_token.to_string(),
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),
};
let plaintext = serde_json::to_vec(&blob)
.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)
})?;
map.insert(
username.to_string(),
TokenBlob {
username: username.to_string(),
access_token: access_token.to_string(),
refresh_token: refresh_token.to_string(),
},
);
write_file_bytes(&encrypted)
write_map_inner(&map)
}
/// 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> {
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`.
///
/// 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> {
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.
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
if let Some(path) = token_file_path() {
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
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 path.exists() {
std::fs::remove_file(&path)
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
}
}
}
// Remove the Keystore key so a future re-login generates a fresh key.
with_jvm(|env| {
let ks_class = env.find_class("java/security/KeyStore")?;
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
let ks = env
.call_static_method(
&ks_class,
"getInstance",
"(Ljava/lang/String;)Ljava/security/KeyStore;",
&[ks_type.borrow()],
// Remove the Keystore key so a future re-login generates a fresh key.
with_jvm(|env| {
let ks_class = env.find_class("java/security/KeyStore")?;
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
let ks = env
.call_static_method(
&ks_class,
"getInstance",
"(Ljava/lang/String;)Ljava/security/KeyStore;",
&[ks_type.borrow()],
)?
.l()?;
let null = JObject::null();
env.call_method(
&ks,
"load",
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
&[JValue::Object(&null)],
)?
.l()?;
.v()?;
let null = JObject::null();
env.call_method(
&ks,
"load",
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
&[JValue::Object(&null)],
)?
.v()?;
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
env.call_method(
&ks,
"deleteEntry",
"(Ljava/lang/String;)V",
&[alias.borrow()],
)?
.v()
})
})
} else {
// Other users still exist — just rewrite the map without this user.
write_map_inner(&map)
}
}
+4 -6
View File
@@ -14,15 +14,13 @@
//! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`].
//!
//! # Android stub
//! # Android
//!
//! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android every function in this module returns
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
//! the same way they handle a Linux box without Secret Service. The
//! real Android backend will arrive in the Phase-Android round when we
//! wire Android Keystore via JNI.
//! doesn't expose). On Android this module delegates to an Android Keystore
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm`
//! from Android startup before token operations can succeed.
//!
//! # Note: no unit tests — requires live OS keychain.
+189 -185
View File
@@ -26,227 +26,227 @@ use solitaire_core::game_state::DifficultyLevel;
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
pub const EASY_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
0xD1FF_0000_0000_0001,
0xD1FF_0000_0000_0002,
0xD1FF_0000_0000_0007,
0xD1FF_0000_0000_0008,
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-06-04)
0xD1FF_0000_0000_0009,
0xD1FF_0000_0000_000E,
0xD1FF_0000_0000_0013,
0xD1FF_0000_0000_0015,
0xD1FF_0000_0000_0018,
0xD1FF_0000_0000_001D,
0xD1FF_0000_0000_0021,
0xD1FF_0000_0000_0022,
0xD1FF_0000_0000_0026,
0xD1FF_0000_0000_002C,
0xD1FF_0000_0000_002E,
0xD1FF_0000_0000_002F,
0xD1FF_0000_0000_0035,
0xD1FF_0000_0000_0036,
0xD1FF_0000_0000_003C,
0xD1FF_0000_0000_0045,
0xD1FF_0000_0000_0046,
0xD1FF_0000_0000_0048,
0xD1FF_0000_0000_0049,
0xD1FF_0000_0000_004D,
0xD1FF_0000_0000_004F,
0xD1FF_0000_0000_0050,
0xD1FF_0000_0000_0051,
0xD1FF_0000_0000_0053,
0xD1FF_0000_0000_0054,
0xD1FF_0000_0000_0057,
0xD1FF_0000_0000_0058,
0xD1FF_0000_0000_005A,
0xD1FF_0000_0000_005B,
0xD1FF_0000_0000_005C,
0xD1FF_0000_0000_005D,
0xD1FF_0000_0000_005F,
0xD1FF_0000_0000_0061,
0xD1FF_0000_0000_0062,
0xD1FF_0000_0000_0063,
0xD1FF_0000_0000_0069,
0xD1FF_0000_0000_0087,
0xD1FF_0000_0000_00EB,
0xD1FF_0000_0000_017F,
0xD1FF_0000_0000_01CE,
0xD1FF_0000_0000_020F,
0xD1FF_0000_0000_0251,
0xD1FF_0000_0000_0275,
0xD1FF_0000_0000_029C,
0xD1FF_0000_0000_02BD,
0xD1FF_0000_0000_02ED,
0xD1FF_0000_0000_038F,
0xD1FF_0000_0000_03C9,
0xD1FF_0000_0000_0415,
0xD1FF_0000_0000_045F,
0xD1FF_0000_0000_04C4,
0xD1FF_0000_0000_04CC,
0xD1FF_0000_0000_04EE,
0xD1FF_0000_0000_0631,
0xD1FF_0000_0000_0651,
0xD1FF_0000_0000_0689,
0xD1FF_0000_0000_0735,
0xD1FF_0000_0000_0748,
0xD1FF_0000_0000_0801,
0xD1FF_0000_0000_0820,
0xD1FF_0000_0000_08F9,
0xD1FF_0000_0000_091C,
0xD1FF_0000_0000_0937,
0xD1FF_0000_0000_09A6,
0xD1FF_0000_0000_09C3,
0xD1FF_0000_0000_09DD,
0xD1FF_0000_0000_0BD9,
0xD1FF_0000_0000_0BEC,
0xD1FF_0000_0000_0BF2,
0xD1FF_0000_0000_0C1B,
0xD1FF_0000_0000_0C26,
0xD1FF_0000_0000_0C36,
0xD1FF_0000_0000_0C4B,
0xD1FF_0000_0000_0C78,
0xD1FF_0000_0000_0CBC,
];
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
pub const MEDIUM_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
0xD1FF_0000_0000_0000,
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-06-04)
0xD1FF_0000_0000_0012,
0xD1FF_0000_0000_0016,
0xD1FF_0000_0000_001B,
0xD1FF_0000_0000_001C,
0xD1FF_0000_0000_0020,
0xD1FF_0000_0000_002A,
0xD1FF_0000_0000_0034,
0xD1FF_0000_0000_003A,
0xD1FF_0000_0000_0041,
0xD1FF_0000_0000_0043,
0xD1FF_0000_0000_0060,
0xD1FF_0000_0000_006A,
0xD1FF_0000_0000_006C,
0xD1FF_0000_0000_006E,
0xD1FF_0000_0000_006F,
0xD1FF_0000_0000_0071,
0xD1FF_0000_0000_0072,
0xD1FF_0000_0000_0075,
0xD1FF_0000_0000_0076,
0xD1FF_0000_0000_007B,
0xD1FF_0000_0000_007E,
0xD1FF_0000_0000_0081,
0xD1FF_0000_0000_0083,
0xD1FF_0000_0000_0084,
0xD1FF_0000_0000_0087,
0xD1FF_0000_0000_0090,
0xD1FF_0000_0000_0092,
0xD1FF_0000_0000_0093,
0xD1FF_0000_0000_0098,
0xD1FF_0000_0000_002C,
0xD1FF_0000_0000_004B,
0xD1FF_0000_0000_0052,
0xD1FF_0000_0000_0058,
0xD1FF_0000_0000_005E,
0xD1FF_0000_0000_0063,
0xD1FF_0000_0000_0099,
0xD1FF_0000_0000_009A,
0xD1FF_0000_0000_009E,
0xD1FF_0000_0000_00A5,
0xD1FF_0000_0000_00A8,
0xD1FF_0000_0000_00AA,
0xD1FF_0000_0000_00AB,
0xD1FF_0000_0000_00AE,
0xD1FF_0000_0000_00A9,
0xD1FF_0000_0000_00AF,
0xD1FF_0000_0000_00B0,
0xD1FF_0000_0000_00BB,
0xD1FF_0000_0000_00D1,
0xD1FF_0000_0000_00E3,
0xD1FF_0000_0000_0108,
0xD1FF_0000_0000_010D,
0xD1FF_0000_0000_0110,
0xD1FF_0000_0000_012F,
0xD1FF_0000_0000_0139,
0xD1FF_0000_0000_013C,
0xD1FF_0000_0000_0148,
0xD1FF_0000_0000_015E,
0xD1FF_0000_0000_016A,
0xD1FF_0000_0000_016F,
0xD1FF_0000_0000_0179,
0xD1FF_0000_0000_019E,
0xD1FF_0000_0000_01A8,
0xD1FF_0000_0000_01AB,
0xD1FF_0000_0000_01B5,
0xD1FF_0000_0000_01B8,
0xD1FF_0000_0000_01D3,
0xD1FF_0000_0000_01EE,
0xD1FF_0000_0000_01F3,
0xD1FF_0000_0000_0202,
0xD1FF_0000_0000_0203,
0xD1FF_0000_0000_021E,
0xD1FF_0000_0000_022C,
0xD1FF_0000_0000_022D,
0xD1FF_0000_0000_0233,
0xD1FF_0000_0000_0245,
0xD1FF_0000_0000_024E,
];
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
pub const HARD_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
0xD1FF_0000_0000_001F,
0xD1FF_0000_0000_0024,
0xD1FF_0000_0000_0025,
0xD1FF_0000_0000_0031,
0xD1FF_0000_0000_0032,
0xD1FF_0000_0000_003E,
0xD1FF_0000_0000_004A,
0xD1FF_0000_0000_006D,
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-06-04)
0xD1FF_0000_0000_0006,
0xD1FF_0000_0000_0008,
0xD1FF_0000_0000_000F,
0xD1FF_0000_0000_0011,
0xD1FF_0000_0000_0022,
0xD1FF_0000_0000_0023,
0xD1FF_0000_0000_002A,
0xD1FF_0000_0000_002D,
0xD1FF_0000_0000_0040,
0xD1FF_0000_0000_0042,
0xD1FF_0000_0000_0050,
0xD1FF_0000_0000_005B,
0xD1FF_0000_0000_005D,
0xD1FF_0000_0000_0067,
0xD1FF_0000_0000_0069,
0xD1FF_0000_0000_006E,
0xD1FF_0000_0000_0072,
0xD1FF_0000_0000_0079,
0xD1FF_0000_0000_007C,
0xD1FF_0000_0000_0080,
0xD1FF_0000_0000_008A,
0xD1FF_0000_0000_0097,
0xD1FF_0000_0000_0081,
0xD1FF_0000_0000_0083,
0xD1FF_0000_0000_0091,
0xD1FF_0000_0000_009B,
0xD1FF_0000_0000_00A1,
0xD1FF_0000_0000_00B1,
0xD1FF_0000_0000_00B2,
0xD1FF_0000_0000_00B3,
0xD1FF_0000_0000_00B5,
0xD1FF_0000_0000_00B7,
0xD1FF_0000_0000_00B8,
0xD1FF_0000_0000_00B9,
0xD1FF_0000_0000_00BA,
0xD1FF_0000_0000_00BB,
0xD1FF_0000_0000_00BC,
0xD1FF_0000_0000_00BD,
0xD1FF_0000_0000_00C2,
0xD1FF_0000_0000_00C3,
0xD1FF_0000_0000_00C5,
0xD1FF_0000_0000_00CC,
0xD1FF_0000_0000_00CE,
0xD1FF_0000_0000_00D1,
0xD1FF_0000_0000_00D2,
0xD1FF_0000_0000_00D6,
0xD1FF_0000_0000_00D7,
0xD1FF_0000_0000_00DC,
0xD1FF_0000_0000_00DF,
0xD1FF_0000_0000_00E0,
0xD1FF_0000_0000_00E1,
0xD1FF_0000_0000_00E4,
0xD1FF_0000_0000_00E6,
0xD1FF_0000_0000_00E7,
0xD1FF_0000_0000_00DD,
0xD1FF_0000_0000_00E8,
0xD1FF_0000_0000_00F2,
0xD1FF_0000_0000_0101,
0xD1FF_0000_0000_010F,
0xD1FF_0000_0000_0113,
0xD1FF_0000_0000_0118,
0xD1FF_0000_0000_0119,
0xD1FF_0000_0000_012D,
0xD1FF_0000_0000_0133,
0xD1FF_0000_0000_0144,
0xD1FF_0000_0000_0147,
];
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
pub const EXPERT_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
0xD1FF_0000_0000_0006,
0xD1FF_0000_0000_000B,
0xD1FF_0000_0000_0019,
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-06-04)
0xD1FF_0000_0000_0000,
0xD1FF_0000_0000_0002,
0xD1FF_0000_0000_000A,
0xD1FF_0000_0000_0013,
0xD1FF_0000_0000_0017,
0xD1FF_0000_0000_001C,
0xD1FF_0000_0000_001F,
0xD1FF_0000_0000_0021,
0xD1FF_0000_0000_0024,
0xD1FF_0000_0000_0029,
0xD1FF_0000_0000_002E,
0xD1FF_0000_0000_0035,
0xD1FF_0000_0000_0045,
0xD1FF_0000_0000_0048,
0xD1FF_0000_0000_0049,
0xD1FF_0000_0000_004F,
0xD1FF_0000_0000_0062,
0xD1FF_0000_0000_006D,
0xD1FF_0000_0000_0074,
0xD1FF_0000_0000_0076,
0xD1FF_0000_0000_0082,
0xD1FF_0000_0000_00CB,
0xD1FF_0000_0000_00D5,
0xD1FF_0000_0000_00D8,
0xD1FF_0000_0000_00E8,
0xD1FF_0000_0000_00EA,
0xD1FF_0000_0000_00EB,
0xD1FF_0000_0000_00EC,
0xD1FF_0000_0000_008F,
0xD1FF_0000_0000_0090,
0xD1FF_0000_0000_0097,
0xD1FF_0000_0000_009A,
0xD1FF_0000_0000_009F,
0xD1FF_0000_0000_00A5,
0xD1FF_0000_0000_00A8,
0xD1FF_0000_0000_00AD,
0xD1FF_0000_0000_00AE,
0xD1FF_0000_0000_00B8,
0xD1FF_0000_0000_00B9,
0xD1FF_0000_0000_00BC,
0xD1FF_0000_0000_00C5,
0xD1FF_0000_0000_00CA,
0xD1FF_0000_0000_00CE,
0xD1FF_0000_0000_00DE,
0xD1FF_0000_0000_00ED,
0xD1FF_0000_0000_00F2,
0xD1FF_0000_0000_00F3,
0xD1FF_0000_0000_00F4,
0xD1FF_0000_0000_00FE,
0xD1FF_0000_0000_00FF,
0xD1FF_0000_0000_0102,
0xD1FF_0000_0000_0103,
0xD1FF_0000_0000_0104,
0xD1FF_0000_0000_0105,
0xD1FF_0000_0000_0106,
0xD1FF_0000_0000_0109,
0xD1FF_0000_0000_010B,
0xD1FF_0000_0000_010C,
0xD1FF_0000_0000_0110,
0xD1FF_0000_0000_0113,
0xD1FF_0000_0000_0114,
0xD1FF_0000_0000_011B,
0xD1FF_0000_0000_011C,
0xD1FF_0000_0000_011E,
0xD1FF_0000_0000_0120,
0xD1FF_0000_0000_0121,
0xD1FF_0000_0000_0122,
0xD1FF_0000_0000_0123,
0xD1FF_0000_0000_0124,
0xD1FF_0000_0000_0126,
0xD1FF_0000_0000_012B,
0xD1FF_0000_0000_012C,
0xD1FF_0000_0000_012E,
0xD1FF_0000_0000_00EE,
0xD1FF_0000_0000_00EF,
];
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
pub const GRANDMASTER_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
0xD1FF_0000_0000_0027,
0xD1FF_0000_0000_00A0,
0xD1FF_0000_0000_00C4,
0xD1FF_0000_0000_00D4,
0xD1FF_0000_0000_00DE,
0xD1FF_0000_0000_00F9,
0xD1FF_0000_0000_0107,
0xD1FF_0000_0000_0108,
0xD1FF_0000_0000_0130,
0xD1FF_0000_0000_0132,
0xD1FF_0000_0000_0133,
0xD1FF_0000_0000_0134,
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-06-04)
0xD1FF_0000_0000_003C,
0xD1FF_0000_0000_0047,
0xD1FF_0000_0000_005A,
0xD1FF_0000_0000_009C,
0xD1FF_0000_0000_00D2,
0xD1FF_0000_0000_00F4,
0xD1FF_0000_0000_00F6,
0xD1FF_0000_0000_0104,
0xD1FF_0000_0000_0106,
0xD1FF_0000_0000_0111,
0xD1FF_0000_0000_0112,
0xD1FF_0000_0000_0116,
0xD1FF_0000_0000_0117,
0xD1FF_0000_0000_011A,
0xD1FF_0000_0000_0123,
0xD1FF_0000_0000_012B,
0xD1FF_0000_0000_012E,
0xD1FF_0000_0000_0135,
0xD1FF_0000_0000_0137,
0xD1FF_0000_0000_0139,
0xD1FF_0000_0000_013A,
0xD1FF_0000_0000_013D,
0xD1FF_0000_0000_013F,
0xD1FF_0000_0000_0140,
0xD1FF_0000_0000_013B,
0xD1FF_0000_0000_0141,
0xD1FF_0000_0000_0142,
0xD1FF_0000_0000_0143,
0xD1FF_0000_0000_0145,
0xD1FF_0000_0000_0146,
0xD1FF_0000_0000_014A,
0xD1FF_0000_0000_014B,
0xD1FF_0000_0000_014C,
0xD1FF_0000_0000_014D,
0xD1FF_0000_0000_014F,
0xD1FF_0000_0000_014E,
0xD1FF_0000_0000_0150,
0xD1FF_0000_0000_0151,
0xD1FF_0000_0000_0152,
0xD1FF_0000_0000_0153,
0xD1FF_0000_0000_0155,
0xD1FF_0000_0000_0157,
0xD1FF_0000_0000_0158,
0xD1FF_0000_0000_015B,
0xD1FF_0000_0000_0159,
0xD1FF_0000_0000_015A,
0xD1FF_0000_0000_015C,
0xD1FF_0000_0000_015E,
0xD1FF_0000_0000_0162,
0xD1FF_0000_0000_0164,
0xD1FF_0000_0000_015D,
0xD1FF_0000_0000_015F,
0xD1FF_0000_0000_0166,
0xD1FF_0000_0000_0173,
0xD1FF_0000_0000_0174,
0xD1FF_0000_0000_0178,
0xD1FF_0000_0000_017D,
0xD1FF_0000_0000_0182,
0xD1FF_0000_0000_0187,
];
// ---------------------------------------------------------------------------
@@ -294,7 +294,11 @@ mod tests {
sorted.sort_unstable();
let before = sorted.len();
sorted.dedup();
assert_eq!(sorted.len(), before, "duplicate seeds found across difficulty tiers");
assert_eq!(
sorted.len(),
before,
"duplicate seeds found across difficulty tiers"
);
}
#[test]
+35 -24
View File
@@ -104,67 +104,78 @@ pub use stats::{StatsExt, StatsSnapshot};
pub mod storage;
pub use storage::{
cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at,
game_state_file_path, load_game_state_from, load_stats, load_stats_from,
load_time_attack_session_from, load_time_attack_session_from_at, save_game_state_to,
save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now, TimeAttackSession,
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now,
};
pub mod achievements;
pub use achievements::{
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to,
};
pub mod progress;
pub use progress::{
daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to,
xp_for_win, PlayerProgress,
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
save_progress_to, xp_for_win,
};
pub mod weekly;
pub use weekly::{
current_iso_week_key, weekly_goal_by_id, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
WEEKLY_GOALS, WEEKLY_GOAL_XP,
WEEKLY_GOAL_XP, WEEKLY_GOALS, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
current_iso_week_key, weekly_goal_by_id,
};
pub mod challenge;
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for};
pub mod difficulty_seeds;
pub use difficulty_seeds::{seeds_for, DifficultySeeds};
pub use difficulty_seeds::{DifficultySeeds, seeds_for};
pub mod settings;
pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
load_settings_from, save_settings_to, settings_file_path,
};
#[cfg(target_os = "android")]
mod android_keystore;
#[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
};
pub mod sync_client;
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
pub use sync_client::LocalOnlyProvider;
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
};
#[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
pub use replay::{
append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
};
#[cfg(not(target_arch = "wasm32"))]
pub mod matomo_client;
#[cfg(not(target_arch = "wasm32"))]
pub use matomo_client::MatomoClient;
pub mod platform;
pub use platform::data_dir;
/// Application data subdirectory name, shared by all persistence modules.
pub(crate) const APP_DIR_NAME: &str = "ferrous_solitaire";
+6 -12
View File
@@ -47,13 +47,7 @@ impl MatomoClient {
///
/// When the buffer exceeds 100 events the oldest 50 are dropped to
/// prevent unbounded memory growth during extended offline play.
pub fn event(
&self,
category: &str,
action: &str,
name: Option<&str>,
value: Option<f64>,
) {
pub fn event(&self, category: &str, action: &str, name: Option<&str>, value: Option<f64>) {
let Ok(mut guard) = self.pending.lock() else {
return;
};
@@ -111,12 +105,12 @@ impl MatomoClient {
}
fn url_encode(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
vec![c]
s.bytes()
.flat_map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
vec![b as char]
}
c => format!("%{:02X}", c as u32).chars().collect(),
b => format!("%{b:02X}").chars().collect(),
})
.collect()
}
+13 -2
View File
@@ -55,7 +55,15 @@ pub fn data_dir() -> Option<PathBuf> {
{
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
}
#[cfg(not(target_os = "android"))]
#[cfg(target_arch = "wasm32")]
{
// No filesystem on the browser; all persistence goes through
// WasmStorage (localStorage-backed). Return None so every caller
// degrades gracefully (the same path they take on a
// misconfigured desktop environment).
None
}
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
{
dirs::data_dir()
}
@@ -87,6 +95,9 @@ mod tests {
#[test]
fn data_dir_returns_sandbox_path_on_android() {
let dir = data_dir().expect("android must report a data dir");
assert_eq!(dir, PathBuf::from("/data/data/com.ferrousapp.solitaire/files"));
assert_eq!(
dir,
PathBuf::from("/data/data/com.ferrousapp.solitaire/files")
);
}
}
+6 -4
View File
@@ -11,10 +11,9 @@ use std::path::{Path, PathBuf};
use chrono::{Datelike, NaiveDate};
pub use solitaire_sync::progress::level_for_xp;
pub use solitaire_sync::PlayerProgress;
pub use solitaire_sync::progress::level_for_xp;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const FILE_NAME: &str = "progress.json";
/// Deterministic seed derived from a date, identical for all players globally.
@@ -46,7 +45,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
/// Platform-specific default path for `progress.json`.
pub fn progress_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
}
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
@@ -148,7 +147,10 @@ mod tests {
#[test]
fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() };
let mut p = PlayerProgress {
total_xp: u64::MAX - 5,
..Default::default()
};
p.add_xp(100);
assert_eq!(p.total_xp, u64::MAX);
}
+45 -35
View File
@@ -27,9 +27,8 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
use solitaire_core::klondike_adapter::SavedKlondikePile;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -97,9 +96,9 @@ pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call.
Move {
/// Source pile.
from: PileType,
from: SavedKlondikePile,
/// Destination pile.
to: PileType,
to: SavedKlondikePile,
/// Number of cards moved.
count: usize,
},
@@ -279,14 +278,14 @@ impl ReplayHistory {
in migrate_legacy_latest_replay"
)]
pub fn latest_replay_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
}
/// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
/// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
}
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
@@ -294,11 +293,9 @@ pub fn replay_history_path() -> Option<PathBuf> {
///
/// Overwrites any existing replay — only the most recent winning replay
/// is retained on disk.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
#[deprecated(note = "single-slot replay storage replaced by the rolling history; \
use append_replay_to_history instead. Kept for the one-shot \
legacy migration."
)]
legacy migration.")]
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
@@ -318,11 +315,9 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
/// "No replay recorded yet" caption rather than a half-loaded broken
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
/// older save without further migration code.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
#[deprecated(note = "single-slot replay storage replaced by the rolling history; \
use load_replay_history_from instead. Kept for the one-shot \
legacy migration."
)]
legacy migration.")]
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
let data = fs::read(path).ok()?;
let replay: Replay = serde_json::from_slice(&data).ok()?;
@@ -384,10 +379,7 @@ pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
/// [`ReplayHistory`] is the exact value written to disk so callers can
/// update an in-memory mirror (e.g. the Stats overlay's
/// `ReplayHistoryResource`) without a follow-up `load`.
pub fn append_replay_to_history(
path: &Path,
replay: Replay,
) -> io::Result<ReplayHistory> {
pub fn append_replay_to_history(path: &Path, replay: Replay) -> io::Result<ReplayHistory> {
let mut history = load_replay_history_from(path).unwrap_or_default();
// Most recent first. Reserve the front slot; pop the oldest if we
// exceed the cap so the file never grows unbounded.
@@ -439,9 +431,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
// Migration failure is non-fatal: on the next launch we'll just
// try again. We log to stderr rather than panic so headless
// tests stay quiet.
eprintln!(
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
);
eprintln!("replay: failed to migrate legacy latest_replay.json into rolling history: {e}",);
}
}
@@ -452,6 +442,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
#[allow(deprecated)]
mod tests {
use super::*;
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
use std::env;
fn tmp_path(name: &str) -> PathBuf {
@@ -470,14 +461,14 @@ mod tests {
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(3),
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1,
},
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Tableau(3),
to: PileType::Foundation(0),
from: SavedKlondikePile::Tableau(SavedTableau(3)),
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
count: 1,
},
],
@@ -624,8 +615,8 @@ mod tests {
let mut last_returned = ReplayHistory::default();
for i in 0..10 {
last_returned = append_replay_to_history(&path, replay_with_id(i))
.expect("append must succeed");
last_returned =
append_replay_to_history(&path, replay_with_id(i)).expect("append must succeed");
}
assert_eq!(
@@ -635,7 +626,11 @@ mod tests {
);
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
// survive (newest first), ids 0 and 1 aged out.
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
let ids: Vec<i32> = last_returned
.replays
.iter()
.map(|r| r.final_score)
.collect();
assert_eq!(
ids,
vec![9, 8, 7, 6, 5, 4, 3, 2],
@@ -684,18 +679,30 @@ mod tests {
// Seed the legacy file with a real replay.
let legacy_replay = sample_replay();
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
assert!(!history.exists(), "history file must not exist pre-migration");
assert!(
!history.exists(),
"history file must not exist pre-migration"
);
migrate_legacy_latest_replay(&latest, &history);
assert!(history.exists(), "migration must create the history file");
let loaded = load_replay_history_from(&history)
.expect("post-migration history must load");
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
let loaded = load_replay_history_from(&history).expect("post-migration history must load");
assert_eq!(
loaded.replays.len(),
1,
"history must hold exactly the legacy entry"
);
assert_eq!(
loaded.replays[0], legacy_replay,
"entry must equal the legacy replay"
);
// Legacy file is intentionally retained for one release as a
// safety net — see `migrate_legacy_latest_replay` doc comment.
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
assert!(
latest.exists(),
"legacy file must NOT be deleted by migration"
);
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
@@ -721,7 +728,10 @@ mod tests {
migrate_legacy_latest_replay(&latest, &history);
let loaded = load_replay_history_from(&history).expect("load");
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
assert_eq!(
loaded, pre_existing,
"existing history must not be overwritten"
);
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
+66 -24
View File
@@ -11,7 +11,6 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
const APP_DIR_NAME: &str = "ferrous_solitaire";
const SETTINGS_FILE_NAME: &str = "settings.json";
/// Animation playback speed for card transitions.
@@ -61,7 +60,21 @@ pub enum SyncBackend {
avatar_url: Option<String>,
// JWT tokens are stored in the OS keychain — not here.
},
}
/// Touch input mode — controls what a single tap on a face-up card does.
///
/// Defaults to `OneTap` so existing behaviour is unchanged on upgrade.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TouchInputMode {
/// A single tap immediately moves the card to its best destination
/// (foundation-first, then tableau). This is the original behaviour.
#[default]
OneTap,
/// A first tap *selects* the card/stack and highlights it; a second
/// tap on a valid destination pile performs the move. Tapping the
/// selection again, or an empty / invalid target, cancels without moving.
TapToSelect,
}
/// Persisted window size (in logical pixels) and screen position
@@ -239,6 +252,12 @@ pub struct Settings {
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
#[serde(default)]
pub leaderboard_display_name: Option<String>,
/// `true` once the player has successfully opted in to the leaderboard on
/// the server. Used to decide whether a display-name change should also
/// push an update via `opt_in_leaderboard`. Older `settings.json` files
/// deserialize cleanly to `false` via `#[serde(default)]`.
#[serde(default)]
pub leaderboard_opted_in: bool,
/// When `true`, the player may drag the top card of a foundation pile back
/// onto a compatible tableau column. Enabled by default (standard Klondike
/// rules). Older `settings.json` files without this key deserialize to
@@ -260,6 +279,13 @@ pub struct Settings {
/// Defaults to `1` (the first site created in a fresh Matomo install).
#[serde(default = "default_matomo_site_id")]
pub matomo_site_id: u32,
/// Touch input mode — `OneTap` (default) auto-moves on first tap;
/// `TapToSelect` requires an explicit destination tap. Only affects
/// touch/Android; desktop mouse input is unchanged. Older
/// `settings.json` files deserialize cleanly to `OneTap` via
/// `#[serde(default)]`.
#[serde(default)]
pub touch_input_mode: TouchInputMode,
}
fn default_draw_mode() -> DrawMode {
@@ -388,10 +414,12 @@ impl Default for Settings {
replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None,
leaderboard_display_name: None,
leaderboard_opted_in: false,
take_from_foundation: true,
analytics_enabled: false,
matomo_url: None,
matomo_site_id: default_matomo_site_id(),
touch_input_mode: TouchInputMode::OneTap,
}
}
}
@@ -402,11 +430,10 @@ impl Settings {
/// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self {
// Migrate stale theme IDs: "default" was removed when the theme was
// renamed to "dark"; "classic" was briefly the default before "dark"
// was restored as the shipped default.
// Migrate stale theme IDs: "default" was the original name before it
// was renamed to "dark".
let selected_theme_id = match self.selected_theme_id.as_str() {
"default" | "classic" => "dark".to_string(),
"default" => "dark".to_string(),
_ => self.selected_theme_id,
};
Self {
@@ -442,8 +469,8 @@ impl Settings {
/// to `[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS]`. Returns the
/// new value.
pub fn adjust_tooltip_delay(&mut self, delta: f32) -> f32 {
self.tooltip_delay_secs = (self.tooltip_delay_secs + delta)
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
self.tooltip_delay_secs =
(self.tooltip_delay_secs + delta).clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
self.tooltip_delay_secs
}
@@ -480,7 +507,7 @@ impl Settings {
/// Returns the platform-specific path to `settings.json`, or `None` if
/// the platform's data directory is unavailable.
pub fn settings_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(SETTINGS_FILE_NAME))
}
/// Load settings from an explicit path. Returns `Settings::default()` if the
@@ -517,7 +544,10 @@ mod tests {
#[test]
fn adjust_sfx_volume_clamps() {
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
let mut s = Settings {
sfx_volume: 0.5,
..Default::default()
};
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -526,7 +556,10 @@ mod tests {
#[test]
fn adjust_music_volume_clamps() {
let mut s = Settings { music_volume: 0.5, ..Default::default() };
let mut s = Settings {
music_volume: 0.5,
..Default::default()
};
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -565,7 +598,10 @@ mod tests {
#[test]
fn adjust_tooltip_delay_clamps_to_range() {
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
let mut s = Settings {
tooltip_delay_secs: 0.5,
..Default::default()
};
// Step up to 0.6.
assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6);
// Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS.
@@ -578,21 +614,23 @@ mod tests {
#[test]
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
let mut s = Settings {
time_bonus_multiplier: 1.0,
..Default::default()
};
// Step up to 1.1.
assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6);
// Big positive jump clamps to TIME_BONUS_MULTIPLIER_MAX.
assert!(
(s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6
);
assert!((s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6);
// Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN.
assert!(
(s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6
);
assert!((s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6);
assert_eq!(s.time_bonus_multiplier, 0.0);
// Repeated incremental adds must not drift past the 0.1 grid.
let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() };
let mut s2 = Settings {
time_bonus_multiplier: 0.0,
..Default::default()
};
for _ in 0..10 {
s2.adjust_time_bonus_multiplier(0.1);
}
@@ -606,20 +644,24 @@ mod tests {
#[test]
fn adjust_replay_move_interval_clamps_and_rounds() {
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
let mut s = Settings {
replay_move_interval_secs: 0.45,
..Default::default()
};
// Step down to 0.40.
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
// Big positive jump clamps to MAX.
assert!(
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
);
assert!((s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6);
// Big negative jump clamps to MIN.
assert!(
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
);
// Repeated 0.05 steps must not drift past the 0.05 grid.
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
let mut s2 = Settings {
replay_move_interval_secs: 0.10,
..Default::default()
};
for _ in 0..6 {
s2.adjust_replay_move_interval(0.05);
}
+13 -3
View File
@@ -231,14 +231,24 @@ mod tests {
// Win once — current becomes 1, best must remain 5.
s.update_on_win(100, 60, &DrawMode::DrawOne);
assert_eq!(s.win_streak_current, 1);
assert_eq!(s.win_streak_best, 5, "best must not drop to match shorter streak");
assert_eq!(
s.win_streak_best, 5,
"best must not drop to match shorter streak"
);
}
#[test]
fn lifetime_score_saturates_at_u64_max() {
let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() };
let mut s = StatsSnapshot {
lifetime_score: u64::MAX - 100,
..Default::default()
};
s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
assert_eq!(
s.lifetime_score,
u64::MAX,
"lifetime_score must saturate, not overflow"
);
}
// -----------------------------------------------------------------------
+25 -43
View File
@@ -3,17 +3,16 @@
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
//! loss during a write never corrupts the saved data.
use chrono::Utc;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
use crate::stats::StatsSnapshot;
const APP_DIR_NAME: &str = "ferrous_solitaire";
const STATS_FILE_NAME: &str = "stats.json";
const GAME_STATE_FILE_NAME: &str = "game_state.json";
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
@@ -21,7 +20,7 @@ const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
/// Returns the platform-specific path to `stats.json`, or `None` if
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
pub fn stats_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(STATS_FILE_NAME))
}
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
@@ -58,9 +57,8 @@ pub fn load_stats() -> StatsSnapshot {
/// Save stats to the platform default path. Returns an error if the platform
/// data dir is unavailable or the write fails.
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
let path = stats_file_path().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable")
})?;
let path = stats_file_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
save_stats_to(&path, stats)
}
@@ -71,7 +69,7 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
/// Returns the platform-specific path to `game_state.json`, or `None` if
/// `crate::data_dir()` is unavailable.
pub fn game_state_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
}
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
@@ -90,11 +88,7 @@ pub fn load_game_state_from(path: &Path) -> Option<GameState> {
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
if gs.is_won {
None
} else {
Some(gs)
}
if gs.is_won { None } else { Some(gs) }
}
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
@@ -123,14 +117,14 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
}
}
/// Remove any leftover `*.json.tmp` files in the app data directory.
/// Remove any leftover `*.tmp` files in the app data directory.
///
/// These can be left behind if the process crashes between the write and rename
/// in an atomic save. Safe to call on startup; missing or unreadable entries
/// are silently skipped.
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
let dir = match crate::data_dir() {
Some(d) => d.join(APP_DIR_NAME),
Some(d) => d.join(crate::APP_DIR_NAME),
None => return Ok(()),
};
@@ -181,7 +175,10 @@ pub struct TimeAttackSession {
/// Returns the platform-specific path to `time_attack_session.json`, or
/// `None` if `crate::data_dir()` is unavailable.
pub fn time_attack_session_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
crate::data_dir().map(|d| {
d.join(crate::APP_DIR_NAME)
.join(TIME_ATTACK_SESSION_FILE_NAME)
})
}
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
@@ -237,9 +234,7 @@ pub fn load_time_attack_session_from_at(
/// See [`load_time_attack_session_from_at`] for the rules under which
/// the call returns `None` (missing file, corrupt JSON, expired window).
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let now = Utc::now().timestamp().max(0) as u64;
load_time_attack_session_from_at(path, now)
}
@@ -257,9 +252,7 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
/// current wall-clock time. Equivalent to constructing the struct
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let now = Utc::now().timestamp().max(0) as u64;
TimeAttackSession {
remaining_secs,
wins,
@@ -267,7 +260,7 @@ pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttac
}
}
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
/// Inner helper: delete `*.tmp` entries inside `dir`.
///
/// Per-file errors (already deleted, permission denied) are silently ignored.
fn cleanup_tmp_files_in(dir: &Path) {
@@ -277,7 +270,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".json.tmp"))
.is_some_and(|n| n.ends_with(".tmp"))
{
let _ = fs::remove_file(&path);
}
@@ -423,24 +416,10 @@ mod tests {
let mut gs = GameState::new(99, DrawMode::DrawOne);
gs.is_won = true;
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!(!path.exists(), "should not have written a file for a won game");
}
#[test]
fn load_game_state_ignores_won_games() {
use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("won_load");
let _ = fs::remove_file(&path);
// Write a won game directly (bypassing save_game_state_to's guard).
let mut gs = GameState::new(77, DrawMode::DrawOne);
gs.is_won = true;
let json = serde_json::to_string_pretty(&gs).unwrap();
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes()).unwrap();
fs::rename(&tmp, &path).unwrap();
assert!(load_game_state_from(&path).is_none());
assert!(
!path.exists(),
"should not have written a file for a won game"
);
}
#[test]
@@ -557,7 +536,10 @@ mod tests {
loaded.remaining_secs,
);
assert_eq!(loaded.wins, 3, "wins must round-trip");
assert_eq!(loaded.saved_at_unix_secs, saved_at, "timestamp must round-trip");
assert_eq!(
loaded.saved_at_unix_secs, saved_at,
"timestamp must round-trip"
);
let _ = fs::remove_file(&path);
}
+67 -33
View File
@@ -12,13 +12,17 @@
//! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait;
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
use solitaire_sync::{SyncPayload, SyncResponse};
use crate::{SyncError, SyncProvider};
#[cfg(not(target_arch = "wasm32"))]
use crate::{
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
replay::Replay,
settings::SyncBackend,
SyncError, SyncProvider,
};
// ---------------------------------------------------------------------------
@@ -54,12 +58,17 @@ impl SyncProvider for LocalOnlyProvider {
// ---------------------------------------------------------------------------
// SolitaireServerClient
// ---------------------------------------------------------------------------
// Native-only: HTTP sync client and factory function.
// On wasm32 these are gated out because reqwest uses native OS networking
// (mio + hyper) which does not compile for wasm32-unknown-unknown.
// ---------------------------------------------------------------------------
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
///
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
/// client automatically attempts a token refresh and retries the request once
/// before returning an error.
#[cfg(not(target_arch = "wasm32"))]
pub struct SolitaireServerClient {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
/// Trailing slashes are stripped on construction.
@@ -70,6 +79,7 @@ pub struct SolitaireServerClient {
client: reqwest::Client,
}
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient {
/// Construct a new client for the given server URL and username.
///
@@ -125,10 +135,7 @@ impl SolitaireServerClient {
async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> {
let status = resp.status();
if !status.is_success() {
let body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::json!({}));
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
let msg = body["error"]
.as_str()
.or_else(|| body["message"].as_str())
@@ -166,8 +173,8 @@ impl SolitaireServerClient {
/// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success.
async fn refresh_token(&self) -> Result<(), SyncError> {
let old_refresh = load_refresh_token(&self.username)
.map_err(|e| SyncError::Auth(e.to_string()))?;
let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
let resp = self
.client
@@ -186,9 +193,9 @@ impl SolitaireServerClient {
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let new_access = body["access_token"]
.as_str()
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
let new_access = body["access_token"].as_str().ok_or_else(|| {
SyncError::Serialization("missing access_token in refresh response".into())
})?;
// Server rotates refresh tokens — store the new one.
// Fall back to the old token if the field is absent (pre-rotation server).
@@ -204,6 +211,7 @@ impl SolitaireServerClient {
}
}
#[cfg(not(target_arch = "wasm32"))]
#[async_trait]
impl SyncProvider for SolitaireServerClient {
/// Fetch the latest sync payload from the server.
@@ -309,6 +317,9 @@ impl SyncProvider for SolitaireServerClient {
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
// Enforce the server's 32-char column limit at the client boundary so
// the server never receives an over-length name regardless of caller.
let display_name: String = display_name.chars().take(32).collect();
let resp = self
.client
@@ -365,13 +376,19 @@ impl SyncProvider for SolitaireServerClient {
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"opt-out failed: {}",
resp.status()
)));
}
return Ok(());
}
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"opt-out failed: {}",
resp.status()
)));
}
Ok(())
}
@@ -399,13 +416,19 @@ impl SyncProvider for SolitaireServerClient {
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("delete account failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"delete account failed: {}",
resp.status()
)));
}
return Ok(());
}
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("delete account failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"delete account failed: {}",
resp.status()
)));
}
Ok(())
}
@@ -474,30 +497,30 @@ impl SyncProvider for SolitaireServerClient {
}
}
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient {
/// Pulled out of `push_replay` so both the first attempt and the
/// post-401-retry attempt go through the same parse path.
async fn share_url_from_response(
&self,
resp: reqwest::Response,
) -> Result<String, SyncError> {
async fn share_url_from_response(&self, resp: reqwest::Response) -> Result<String, SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth(format!("server returned {status}"))
} else {
SyncError::Network(format!("server returned {status}"))
});
return Err(
if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth(format!("server returned {status}"))
} else {
SyncError::Network(format!("server returned {status}"))
},
);
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let id = body["id"].as_str().ok_or_else(|| {
SyncError::Serialization("upload response missing `id`".into())
})?;
let id = body["id"]
.as_str()
.ok_or_else(|| SyncError::Serialization("upload response missing `id`".into()))?;
Ok(format!("{}/replays/{}", self.base_url, id))
}
@@ -537,7 +560,10 @@ impl SolitaireServerClient {
/// Like [`fetch_me`] but uses an explicit token instead of reading from the
/// OS keychain. Useful immediately after login/register when the token has
/// not yet been persisted.
pub async fn fetch_me_with_token(&self, token: &str) -> Result<(String, Option<String>), SyncError> {
pub async fn fetch_me_with_token(
&self,
token: &str,
) -> Result<(String, Option<String>), SyncError> {
let url = format!("{}/api/me", self.base_url);
let resp = self
.client
@@ -549,7 +575,9 @@ impl SolitaireServerClient {
Self::extract_me_body(resp).await
}
async fn extract_me_body(resp: reqwest::Response) -> Result<(String, Option<String>), SyncError> {
async fn extract_me_body(
resp: reqwest::Response,
) -> Result<(String, Option<String>), SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(SyncError::Network(format!("GET /api/me returned {status}")));
@@ -565,9 +593,10 @@ impl SolitaireServerClient {
}
// ---------------------------------------------------------------------------
// Response extraction helpers
// Response extraction helpers (native-only, use reqwest::Response)
// ---------------------------------------------------------------------------
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a pull response body as [`SyncResponse`] and return its
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
///
@@ -591,8 +620,11 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
}
}
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<LeaderboardEntry>, SyncError> {
async fn extract_leaderboard_body(
resp: reqwest::Response,
) -> Result<Vec<LeaderboardEntry>, SyncError> {
let status = resp.status();
if status.is_success() {
resp.json()
@@ -603,6 +635,7 @@ async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<Leaderb
}
}
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a push response body as [`SyncResponse`], or map non-200
/// statuses to the appropriate [`SyncError`].
///
@@ -634,6 +667,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
/// and remains backend-agnostic.
#[cfg(not(target_arch = "wasm32"))]
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
match backend {
SyncBackend::Local => Box::new(LocalOnlyProvider),
+27 -37
View File
@@ -30,13 +30,11 @@
//! expired-on-purpose tokens for the JWT-refresh test.
use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header};
use solitaire_data::{
delete_tokens, store_tokens, SolitaireServerClient, SyncError, SyncProvider,
};
use jsonwebtoken::{EncodingKey, Header, encode};
use solitaire_data::{SolitaireServerClient, SyncError, SyncProvider, delete_tokens, store_tokens};
use solitaire_sync::{PlayerProgress, StatsSnapshot, SyncPayload};
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::SqlitePool;
use sqlx::sqlite::SqlitePoolOptions;
use std::sync::Once;
use uuid::Uuid;
@@ -58,8 +56,8 @@ static MOCK_KEYRING_INIT: Once = Once::new();
/// default. Safe to call from any test — only the first call has effect.
fn ensure_mock_keyring() {
MOCK_KEYRING_INIT.call_once(|| {
let store = keyring_core::mock::Store::new()
.expect("failed to construct mock keyring store");
let store =
keyring_core::mock::Store::new().expect("failed to construct mock keyring store");
keyring_core::set_default_store(store);
});
}
@@ -95,9 +93,7 @@ async fn spawn_test_server() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind test listener");
let addr = listener
.local_addr()
.expect("listener has no local addr");
let addr = listener.local_addr().expect("listener has no local addr");
let app = solitaire_server::build_test_router(fresh_pool().await);
@@ -119,11 +115,7 @@ async fn spawn_test_server() -> String {
/// Register a fresh user against `base_url` and return the access + refresh
/// tokens straight from the response body. Bypasses the keyring entirely so
/// the caller can store the tokens under whatever username they want.
async fn register_user_raw(
base_url: &str,
username: &str,
password: &str,
) -> (String, String) {
async fn register_user_raw(base_url: &str, username: &str, password: &str) -> (String, String) {
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/api/auth/register"))
@@ -154,19 +146,15 @@ async fn register_user_raw(
/// Decode a JWT's `sub` claim without validating expiry (so test crafted
/// tokens still parse). Returns the user UUID as a `String`.
fn decode_sub(token: &str) -> String {
use jsonwebtoken::{decode, DecodingKey, Validation};
use jsonwebtoken::{DecodingKey, Validation, decode};
#[derive(serde::Deserialize)]
struct Claims {
sub: String,
}
let mut v = Validation::default();
v.validate_exp = false;
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
&v,
)
.expect("failed to decode JWT");
let data = decode::<Claims>(token, &DecodingKey::from_secret(TEST_SECRET.as_bytes()), &v)
.expect("failed to decode JWT");
data.claims.sub
}
@@ -208,8 +196,7 @@ async fn register_login_push_pull_round_trip() {
let username = "rt_alice";
let (access, refresh) = register_user_raw(&base, username, "alicepass1!").await;
store_tokens(username, &access, &refresh)
.expect("storing tokens in mock keyring must succeed");
store_tokens(username, &access, &refresh).expect("storing tokens in mock keyring must succeed");
let user_id = decode_sub(&access);
let payload = make_payload(&user_id, 42);
@@ -257,8 +244,7 @@ async fn pull_after_concurrent_pushes_merges_correctly() {
let username = "rt_bob";
let (access, refresh) = register_user_raw(&base, username, "bobpass1!").await;
store_tokens(username, &access, &refresh)
.expect("storing tokens in mock keyring must succeed");
store_tokens(username, &access, &refresh).expect("storing tokens in mock keyring must succeed");
let user_id = decode_sub(&access);
@@ -269,11 +255,17 @@ async fn pull_after_concurrent_pushes_merges_correctly() {
// Client A: low value first.
let payload_a = make_payload(&user_id, 5);
client_a.push(&payload_a).await.expect("client A push must succeed");
client_a
.push(&payload_a)
.await
.expect("client A push must succeed");
// Client B: higher value second.
let payload_b = make_payload(&user_id, 99);
client_b.push(&payload_b).await.expect("client B push must succeed");
client_b
.push(&payload_b)
.await
.expect("client B push must succeed");
// Either client should now pull max(5, 99) = 99.
let pulled = client_a
@@ -330,8 +322,7 @@ async fn jwt_refresh_on_401_succeeds() {
let username = "rt_expiring";
// Register to get a real, valid refresh token signed with TEST_SECRET.
let (_real_access, real_refresh) =
register_user_raw(&base, username, "expirepass1!").await;
let (_real_access, real_refresh) = register_user_raw(&base, username, "expirepass1!").await;
let user_id = decode_sub(&_real_access);
// Craft an expired access token signed with TEST_SECRET so the server's
@@ -361,9 +352,10 @@ async fn jwt_refresh_on_401_succeeds() {
// Pull: server returns 401, client refreshes, retries, succeeds.
let client = SolitaireServerClient::new(&base, username);
let pulled = client.pull().await.expect(
"pull must succeed after the client transparently refreshes the access token",
);
let pulled = client
.pull()
.await
.expect("pull must succeed after the client transparently refreshes the access token");
// Default merge for a never-pushed user yields games_played = 0.
assert_eq!(
pulled.stats.games_played, 0,
@@ -387,8 +379,7 @@ async fn pull_after_account_deletion_returns_default_or_error() {
let username = "rt_deleter";
let (access, refresh) = register_user_raw(&base, username, "deletepass1!").await;
store_tokens(username, &access, &refresh)
.expect("storing tokens in mock keyring must succeed");
store_tokens(username, &access, &refresh).expect("storing tokens in mock keyring must succeed");
let user_id = decode_sub(&access);
let client = SolitaireServerClient::new(&base, username);
@@ -431,8 +422,7 @@ async fn push_retries_after_401_on_expired_access_token() {
let base = spawn_test_server().await;
let username = "rt_push_expiring";
let (_real_access, real_refresh) =
register_user_raw(&base, username, "pushexpirepass1!").await;
let (_real_access, real_refresh) = register_user_raw(&base, username, "pushexpirepass1!").await;
let user_id = decode_sub(&_real_access);
#[derive(serde::Serialize)]
+22 -11
View File
@@ -7,14 +7,11 @@ edition.workspace = true
[dependencies]
bevy = { workspace = true }
image = { workspace = true }
reqwest = { workspace = true }
kira = { workspace = true }
solitaire_core = { workspace = true }
solitaire_data = { workspace = true }
solitaire_sync = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -22,22 +19,36 @@ usvg = { workspace = true }
resvg = { workspace = true }
tiny-skia = { workspace = true }
ron = { workspace = true }
# These deps are not available / not needed on wasm32:
# reqwest — uses mio/hyper native networking (sync plugin is gated out)
# kira — uses cpal OS audio (audio plugin is gated out)
# tokio — multi-threaded runtime (TokioRuntimeResource is gated out)
# dirs — platform data directories (storage uses WasmStorage instead)
# zip — theme ZIP importer (importer is gated out on wasm32)
# arboard — clipboard (no wasm backend; stats copy-link uses localStorage)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
reqwest = { workspace = true }
kira = { workspace = true }
tokio = { workspace = true }
dirs = { workspace = true }
zip = { workspace = true }
# `arboard` provides clipboard access for the Stats overlay's
# "Copy share link" button. The crate has no Android backend
# (its `platform::Clipboard` module is unimplemented for the
# android target — `cargo apk build` fails with E0433 if this is
# left unconditional). On Android the same button surfaces an
# informational toast instead; see
# `stats_plugin::handle_copy_share_link_button`.
[target.'cfg(not(target_os = "android"))'.dependencies]
# `arboard` has no Android backend and no wasm32 backend. Gate it out for
# both; the copy-share-link button surfaces an informational toast instead.
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
arboard = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
base64 = "0.22"
getrandom = { version = "0.3", features = ["wasm_js"] }
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["Storage", "Window"] }
[dev-dependencies]
async-trait = { workspace = true }
tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] }
@@ -27,8 +27,8 @@
//! alongside the `card_plugin` constant migration.
use solitaire_engine::assets::card_face_svg::{
back_svg, face_svg, rank_filename, suit_filename, theme_rank_token, theme_suit_token,
ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET,
ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET, back_svg, face_svg, rank_filename, suit_filename,
theme_rank_token, theme_suit_token,
};
use solitaire_engine::assets::rasterize_svg;
use std::path::PathBuf;
+7 -5
View File
@@ -44,8 +44,8 @@ fn main() {
// 256×384 = 2:3 aspect at half the default svg_loader resolution.
// See migration plan § "Output format" for the rationale.
let target = UVec2::new(256, 384);
let image = rasterize_svg(svg.as_bytes(), target)
.expect("rasterising the PoC SVG should succeed");
let image =
rasterize_svg(svg.as_bytes(), target).expect("rasterising the PoC SVG should succeed");
let bytes = image
.data
@@ -61,11 +61,13 @@ fn main() {
// bytes from a Pixmap inside `svg_loader`; this round-trip is
// the cost of going through Bevy's `Image` shape.
let size = IntSize::from_wh(target.x, target.y).expect("target size is non-zero");
let pixmap = Pixmap::from_vec(bytes, size)
.expect("RGBA byte buffer should form a valid Pixmap");
let pixmap =
Pixmap::from_vec(bytes, size).expect("RGBA byte buffer should form a valid Pixmap");
let out = "/tmp/ace_spades_terminal.png";
pixmap.save_png(out).expect("writing the PNG should succeed");
pixmap
.save_png(out)
.expect("writing the PNG should succeed");
println!(
"Wrote {} ({}×{} RGBA8, {} bytes on disk)",
+1 -1
View File
@@ -18,7 +18,7 @@
//! pipeline already used by every other generated asset).
use bevy::math::UVec2;
use solitaire_engine::assets::icon_svg::{icon_svg, ICON_SIZES};
use solitaire_engine::assets::icon_svg::{ICON_SIZES, icon_svg};
use solitaire_engine::assets::rasterize_svg;
use std::path::PathBuf;
use tiny_skia::{IntSize, Pixmap};
+158 -150
View File
@@ -11,12 +11,12 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use chrono::{Local, Timelike, Utc};
use solitaire_core::achievement::{
achievement_by_id, check_achievements, AchievementContext, AchievementDef, Reward,
ALL_ACHIEVEMENTS,
ALL_ACHIEVEMENTS, AchievementContext, AchievementDef, Reward, achievement_by_id,
check_achievements,
};
use solitaire_data::{
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
AchievementRecord, save_progress_to,
AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to,
save_progress_to, save_settings_to,
};
use crate::events::{
@@ -31,8 +31,8 @@ use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
@@ -140,7 +140,10 @@ impl Plugin for AchievementPlugin {
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel)
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>,
)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
@@ -162,93 +165,97 @@ fn evaluate_on_win(
mut achievements: ResMut<AchievementsResource>,
mut progress: ResMut<ProgressResource>,
) {
let Some(ev) = wins.read().last() else {
return;
};
let ctx = AchievementContext {
games_played: stats.0.games_played,
games_won: stats.0.games_won,
win_streak_current: stats.0.win_streak_current,
best_single_score: stats.0.best_single_score,
lifetime_score: stats.0.lifetime_score,
draw_three_wins: stats.0.draw_three_wins,
daily_challenge_streak: progress.0.daily_challenge_streak,
last_win_score: ev.score,
last_win_time_seconds: ev.time_seconds,
last_win_used_undo: game.0.undo_count > 0,
wall_clock_hour: Some(Local::now().hour()),
last_win_recycle_count: game.0.recycle_count,
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
};
let hits = check_achievements(&ctx);
if hits.is_empty() {
return;
}
let now = Utc::now();
let mut achievements_changed = false;
let mut progress_changed = false;
for def in hits {
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
continue;
for ev in wins.read() {
let ctx = AchievementContext {
games_played: stats.0.games_played,
games_won: stats.0.games_won,
win_streak_current: stats.0.win_streak_current,
best_single_score: stats.0.best_single_score,
lifetime_score: stats.0.lifetime_score,
draw_three_wins: stats.0.draw_three_wins,
daily_challenge_streak: progress.0.daily_challenge_streak,
last_win_score: ev.score,
last_win_time_seconds: ev.time_seconds,
last_win_used_undo: game.0.undo_count > 0,
wall_clock_hour: Some(Local::now().hour()),
last_win_recycle_count: game.0.recycle_count,
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
};
if record.unlocked {
let hits = check_achievements(&ctx);
if hits.is_empty() {
continue;
}
record.unlock(now);
achievements_changed = true;
// Grant the reward on first unlock.
if !record.reward_granted {
if let Some(reward) = def.reward {
match reward {
Reward::CardBack(idx) => {
if !progress.0.unlocked_card_backs.contains(&idx) {
progress.0.unlocked_card_backs.push(idx);
progress_changed = true;
}
}
Reward::Background(idx) => {
if !progress.0.unlocked_backgrounds.contains(&idx) {
progress.0.unlocked_backgrounds.push(idx);
progress_changed = true;
}
}
Reward::BonusXp(amount) => {
xp_awarded.write(XpAwardedEvent { amount });
let prev_level = progress.0.add_xp(amount);
if progress.0.leveled_up_from(prev_level) {
levelups.write(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
});
}
progress_changed = true;
}
Reward::Badge => {}
}
let now = Utc::now();
let mut achievements_changed = false;
let mut progress_changed = false;
for def in hits {
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
continue;
};
if record.unlocked {
continue;
}
record.reward_granted = true;
record.unlock(now);
achievements_changed = true;
// Grant the reward on first unlock.
if !record.reward_granted {
if let Some(reward) = def.reward {
match reward {
Reward::CardBack(idx) => {
if !progress.0.unlocked_card_backs.contains(&idx) {
progress.0.unlocked_card_backs.push(idx);
progress_changed = true;
}
}
Reward::Background(idx) => {
if !progress.0.unlocked_backgrounds.contains(&idx) {
progress.0.unlocked_backgrounds.push(idx);
progress_changed = true;
}
}
Reward::BonusXp(amount) => {
xp_awarded.write(XpAwardedEvent { amount });
let prev_level = progress.0.add_xp(amount);
if progress.0.leveled_up_from(prev_level) {
levelups.write(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
});
}
progress_changed = true;
}
Reward::Badge => {}
}
}
record.reward_granted = true;
}
unlocks.write(AchievementUnlockedEvent(record.clone()));
}
unlocks.write(AchievementUnlockedEvent(record.clone()));
// Persist progress FIRST. Only if that succeeds do we mark
// `reward_granted = true` on the achievements and save them.
// This prevents the corruption where reward_granted is persisted
// but the XP was not (permanent XP loss on next launch).
if progress_changed
&& let Some(target) = &progress_path.0
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after reward: {e}");
}
if achievements_changed
&& let Some(target) = &path.0
&& let Err(e) = save_achievements_to(target, &achievements.0)
{
warn!("failed to save achievements: {e}");
}
}
if achievements_changed
&& let Some(target) = &path.0
&& let Err(e) = save_achievements_to(target, &achievements.0) {
warn!("failed to save achievements: {e}");
}
if progress_changed
&& let Some(target) = &progress_path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after reward: {e}");
}
}
/// Cinephile unlock observer.
@@ -391,6 +398,7 @@ fn toggle_achievements_screen(
achievements: Res<AchievementsResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<AchievementsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
@@ -398,7 +406,7 @@ fn toggle_achievements_screen(
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
} else if other_modal_scrims.is_empty() {
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
}
}
@@ -487,9 +495,7 @@ fn spawn_achievements_screen(
// greyed-out grid.
if !any_unlocked {
card.spawn((
Text::new(
"Complete games and try new modes to unlock achievements and rewards.",
),
Text::new("Complete games and try new modes to unlock achievements and rewards."),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
@@ -803,7 +809,10 @@ mod tests {
// trigger update_stats_on_win first (StatsUpdate runs before
// evaluate_on_win), bumping draw_three_wins to 10 — the unlock
// threshold for the draw_three_master achievement.
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 9;
app.world_mut()
.resource_mut::<StatsResource>()
.0
.draw_three_wins = 9;
// The current game must be in DrawThree mode so update_on_win
// increments draw_three_wins (and not draw_one_wins).
@@ -831,7 +840,10 @@ mod tests {
.find(|r| r.id == "draw_three_master")
.map(|r| r.unlocked)
.unwrap_or(false);
assert!(unlocked, "draw_three_master must unlock at the 10th Draw-Three win");
assert!(
unlocked,
"draw_three_master must unlock at the 10th Draw-Three win"
);
// Verify the AchievementUnlockedEvent fired for this id.
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
@@ -849,7 +861,10 @@ mod tests {
// Pre-seed eight prior Draw-Three wins. The pending GameWonEvent
// brings draw_three_wins to 9 — one short of the threshold.
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 8;
app.world_mut()
.resource_mut::<StatsResource>()
.0
.draw_three_wins = 8;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
@@ -872,7 +887,10 @@ mod tests {
.find(|r| r.id == "draw_three_master")
.map(|r| r.unlocked)
.unwrap_or(false);
assert!(!unlocked, "draw_three_master must remain locked at 9 Draw-Three wins");
assert!(
!unlocked,
"draw_three_master must remain locked at 9 Draw-Three wins"
);
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
@@ -893,10 +911,8 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
@@ -1171,9 +1187,9 @@ mod tests {
// canonical secret description in `solitaire_core` is already
// generic ("A secret achievement"); these checks guard against a
// future leak where someone replaces it with the literal predicate.
let leaked_predicate = tips.iter().any(|t| {
t.contains("90") && t.to_lowercase().contains("without undo")
});
let leaked_predicate = tips
.iter()
.any(|t| t.contains("90") && t.to_lowercase().contains("without undo"));
assert!(
!leaked_predicate,
"no tooltip may state the speed_and_skill predicate: {tips:?}"
@@ -1376,9 +1392,9 @@ mod tests {
// -----------------------------------------------------------------------
use crate::replay_playback::ReplayPlaybackState;
use solitaire_data::{Replay, ReplayMove};
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
/// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
@@ -1442,13 +1458,12 @@ mod tests {
// Frame 1: enter Playing. The observer's first sample sees
// `last_was_playing = false` and `now_playing = true`.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
assert!(
!cinephile_unlocked(&app),
@@ -1457,8 +1472,7 @@ mod tests {
// Frame 2: transition to Completed. The observer must detect
// `last_was_playing = true && now_completed = true` and unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
assert!(
@@ -1478,19 +1492,17 @@ mod tests {
fn cinephile_does_not_unlock_on_stop_button_abort() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
// Direct Playing → Inactive — the path the Stop button takes via
// `stop_replay_playback`. Must not unlock cinephile.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
app.update();
assert!(
@@ -1511,18 +1523,19 @@ mod tests {
let mut app = cinephile_app();
// First completion cycle to unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
assert!(
cinephile_unlocked(&app),
"precondition: first cycle must unlock"
);
// Drain the event queue so the next assertion doesn't double-count
// the legitimate first-time unlock event.
@@ -1531,19 +1544,16 @@ mod tests {
.clear();
// Second cycle: Inactive → Playing → Completed once more.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
assert_eq!(
@@ -1560,16 +1570,14 @@ mod tests {
fn cinephile_fires_once_across_completed_linger() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
// Stay in Completed for a few more frames as the real auto-clear
// does. Each subsequent frame the resource is still `Completed`
+37 -19
View File
@@ -9,10 +9,10 @@ use std::sync::Arc;
use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
use solitaire_core::game_state::GameMode;
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
use solitaire_data::{Settings, matomo_client::MatomoClient, settings::SyncBackend};
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
use crate::resources::GameStateResource;
use crate::resources::{GameStateResource, TokioRuntimeResource};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
// ---------------------------------------------------------------------------
@@ -50,13 +50,24 @@ impl Plugin for AnalyticsPlugin {
Update,
(
react_to_settings_change,
on_game_won,
on_forfeit,
on_new_game,
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,36 @@ fn react_to_settings_change(
fn on_game_won(
mut wins: MessageReader<GameWonEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
rt: Res<TokioRuntimeResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
let mut any = false;
for ev in wins.read() {
client.event("Game", "Won", None, Some(ev.score as f64));
fire_flush(client.clone(), &settings.0);
any = true;
}
if any {
fire_flush(client, rt.0.clone());
}
}
fn on_forfeit(
mut forfeits: MessageReader<ForfeitEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
rt: Res<TokioRuntimeResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
let mut any = false;
for _ev in forfeits.read() {
client.event("Game", "Forfeit", None, None);
fire_flush(client.clone(), &settings.0);
any = true;
}
if any {
fire_flush(client, rt.0.clone());
}
}
@@ -137,14 +156,14 @@ fn on_achievement_unlocked(
fn tick_flush_timer(
time: Res<Time>,
mut analytics: ResMut<AnalyticsResource>,
settings: Res<SettingsResource>,
rt: Res<TokioRuntimeResource>,
) {
analytics.flush_timer.tick(time.delta());
if !analytics.flush_timer.just_finished() {
return;
}
if let Some(client) = analytics.client.clone() {
fire_flush(client, &settings.0);
fire_flush(client, rt.0.clone());
}
}
@@ -161,18 +180,17 @@ fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
SyncBackend::Local => None,
};
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()
.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();
}
+1 -1
View File
@@ -6,8 +6,8 @@
pub fn set_text(text: &str) -> Result<(), String> {
use bevy::android::ANDROID_APP;
use jni::{
objects::{JObject, JValueOwned},
JavaVM,
objects::{JObject, JValueOwned},
};
let app = ANDROID_APP
+115 -47
View File
@@ -13,11 +13,12 @@
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_data::{AnimSpeed, Settings};
use crate::achievement_plugin::display_name_for;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
use crate::card_animation::{CardAnimation, MotionCurve, sample_curve};
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
@@ -32,9 +33,9 @@ use crate::progress_plugin::LevelUpEvent;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{
scaled_duration, ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS,
MOTION_CASCADE_STAGGER_SECS, MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO,
STATE_WARNING, TEXT_PRIMARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS,
MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST, scaled_duration,
};
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
@@ -53,7 +54,9 @@ pub struct EffectiveSlideDuration {
impl Default for EffectiveSlideDuration {
fn default() -> Self {
Self { slide_secs: SLIDE_SECS }
Self {
slide_secs: SLIDE_SECS,
}
}
}
@@ -72,6 +75,17 @@ const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
const CHALLENGE_TOAST_SECS: f32 = 3.0;
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.
pub const CARD_ANIM_Z_LIFT: f32 = 50.0;
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
///
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
@@ -167,6 +181,7 @@ impl Plugin for AnimationPlugin {
.add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
.add_message::<RequestRedraw>()
.init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>()
.init_resource::<ActiveToast>()
@@ -247,6 +262,11 @@ fn advance_card_anims(
anim.delay = (anim.delay - dt).max(0.0);
continue;
}
if anim.duration <= 0.0 {
transform.translation = anim.target;
commands.entity(entity).remove::<CardAnim>();
continue;
}
anim.elapsed += dt;
let t = (anim.elapsed / anim.duration).min(1.0);
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
@@ -254,7 +274,11 @@ fn advance_card_anims(
// shared `CardAnim` struct stays a simple linear-tween container — the
// upgrade is one extra `sample_curve` call per advancing animation.
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 {
transform.translation = anim.target;
commands.entity(entity).remove::<CardAnim>();
@@ -309,12 +333,12 @@ fn handle_win_cascade(
Vec3::new(-margin, 0.0, 300.0),
];
let step = settings
.as_ref()
.map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed));
let duration = settings
.as_ref()
.map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed));
let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| {
cascade_step_secs(s.0.animation_speed)
});
let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| {
cascade_duration_secs(s.0.animation_speed)
});
for (i, (entity, transform)) in cards.iter().enumerate() {
// Use the curve-aware `CardAnimation` here (not `CardAnim`) so we can
@@ -424,7 +448,11 @@ fn handle_time_attack_toast(
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
format!(
"Time Attack: {} win{}",
ev.wins,
if ev.wins == 1 { "" } else { "s" }
),
TIME_ATTACK_TOAST_SECS,
ToastVariant::Info,
);
@@ -454,8 +482,8 @@ fn handle_settings_toast(
for ev in events.read() {
let sfx = ev.0.sfx_volume;
let music = ev.0.music_volume;
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > f32::EPSILON);
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > f32::EPSILON);
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > 0.001);
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > 0.001);
*last_sfx = Some(sfx);
*last_music = Some(music);
if sfx_changed {
@@ -508,10 +536,7 @@ fn handle_auto_complete_toast(
/// This is the first half of the two-system toast queue (Task #67). The queue
/// decouples event production from rendering so multiple simultaneous events do
/// not cause overlapping toast text on screen.
fn enqueue_toasts(
mut events: MessageReader<InfoToastEvent>,
mut queue: ResMut<ToastQueue>,
) {
fn enqueue_toasts(mut events: MessageReader<InfoToastEvent>, mut queue: ResMut<ToastQueue>) {
for ev in events.read() {
queue.0.push_back(ev.0.clone());
}
@@ -552,11 +577,12 @@ fn drive_toast_display(
// If no active toast and the queue has messages, show the next one.
if active.entity.is_none()
&& let Some(message) = queue.0.pop_front() {
let entity = spawn_queued_toast(&mut commands, message);
active.entity = Some(entity);
active.timer = QUEUED_TOAST_SECS;
}
&& let Some(message) = queue.0.pop_front()
{
let entity = spawn_queued_toast(&mut commands, message);
active.entity = Some(entity);
active.timer = QUEUED_TOAST_SECS;
}
}
/// Visual variant of a toast — drives the 1px border accent per the
@@ -662,10 +688,7 @@ fn handle_move_rejected_toast(
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
/// event (not a domain-specific one) because Warning has multiple
/// candidate drivers and the call-site knows the message wording.
fn handle_warning_toast(
mut commands: Commands,
mut events: MessageReader<WarningToastEvent>,
) {
fn handle_warning_toast(mut commands: Commands, mut events: MessageReader<WarningToastEvent>) {
for ev in events.read() {
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
}
@@ -812,7 +835,11 @@ mod tests {
reduce_motion_mode: true,
..Settings::default()
};
assert_eq!(effective_slide_secs(&s), 0.0, "Fast + reduce-motion still 0.0");
assert_eq!(
effective_slide_secs(&s),
0.0,
"Fast + reduce-motion still 0.0"
);
}
#[test]
@@ -849,13 +876,24 @@ mod tests {
.world_mut()
.spawn((
Transform::from_translation(start),
CardAnim { start, target, elapsed: 0.5, duration: 1.0, delay: 0.0 },
CardAnim {
start,
target,
elapsed: 0.5,
duration: 1.0,
delay: 0.0,
},
))
.id();
app.update();
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!(
pos.x > 50.0 && pos.x < 100.0,
"with SmoothSnap, t=0.5 should be past geometric midpoint but short of target; got {}",
@@ -877,7 +915,13 @@ mod tests {
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnim { start: Vec3::ZERO, target, elapsed: 1.0, duration: 1.0, delay: 0.0 },
CardAnim {
start: Vec3::ZERO,
target,
elapsed: 1.0,
duration: 1.0,
delay: 0.0,
},
))
.id();
@@ -887,7 +931,12 @@ mod tests {
app.world().entity(entity).get::<CardAnim>().is_none(),
"CardAnim should be removed when done"
);
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!((pos.x - 10.0).abs() < 1e-3);
}
@@ -912,7 +961,12 @@ mod tests {
app.update();
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!(pos.x.abs() < 1e-3, "card must not move during delay period");
}
@@ -1001,7 +1055,8 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.world_mut().write_message(InfoToastEvent("hello".to_string()));
app.world_mut()
.write_message(InfoToastEvent("hello".to_string()));
app.update();
let count = app
@@ -1023,7 +1078,7 @@ mod tests {
// Pairs the existing audio (`card_invalid.wav`) and visual
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
// with an accessibility-focused readable text cue.
use solitaire_core::pile::PileType;
use solitaire_core::{KlondikePile, Tableau};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
@@ -1035,8 +1090,8 @@ mod tests {
.count();
app.world_mut().write_message(MoveRejectedEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.update();
@@ -1105,8 +1160,12 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let fast_settings = Settings { animation_speed: AnimSpeed::Fast, ..Default::default() };
app.world_mut().write_message(SettingsChangedEvent(fast_settings));
let fast_settings = Settings {
animation_speed: AnimSpeed::Fast,
..Default::default()
};
app.world_mut()
.write_message(SettingsChangedEvent(fast_settings));
app.update();
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
@@ -1124,8 +1183,10 @@ mod tests {
.count();
assert_eq!(before, 0, "no animations before win");
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 60 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let after = app
@@ -1142,8 +1203,10 @@ mod tests {
#[test]
fn win_cascade_uses_expressive_curve() {
let mut app = app_with_anim();
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let mut q = app.world_mut().query::<&CardAnimation>();
@@ -1159,8 +1222,10 @@ mod tests {
#[test]
fn win_cascade_applies_per_card_rotation() {
let mut app = app_with_anim();
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
// At least one card's rotation must differ from identity — the
@@ -1170,7 +1235,10 @@ mod tests {
let any_rotated = q
.iter(app.world())
.any(|(_, t)| t.rotation.z.abs() > 1e-4 || t.rotation.w < 0.999);
assert!(any_rotated, "expected at least one card to receive a Z rotation drift");
assert!(
any_rotated,
"expected at least one card to receive a Z rotation drift"
);
}
#[test]
+2 -2
View File
@@ -11,9 +11,9 @@ pub mod svg_loader;
pub mod user_dir;
pub use sources::{
AssetSourcesPlugin, CLASSIC_THEME_MANIFEST_URL, DARK_THEME_MANIFEST_URL, USER_THEMES,
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes,
populate_embedded_classic_theme, populate_embedded_dark_theme, register_theme_asset_sources,
AssetSourcesPlugin, CLASSIC_THEME_MANIFEST_URL, DARK_THEME_MANIFEST_URL, USER_THEMES,
};
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
pub use svg_loader::{SvgLoader, SvgLoaderError, SvgLoaderSettings, rasterize_svg};
pub use user_dir::{set_user_theme_dir, user_theme_dir};
+29 -20
View File
@@ -47,12 +47,16 @@
//! comments on each call out the pairing so a future reader doesn't
//! accidentally drop one half.
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
use bevy::asset::io::file::FileAssetReader;
use bevy::asset::io::AssetSourceBuilder;
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::AssetApp;
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::io::AssetSourceBuilder;
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::io::file::FileAssetReader;
use bevy::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_dir::user_theme_dir;
/// `AssetSourceId` of the user-themes asset source. Use it as
@@ -75,8 +79,7 @@ pub const DARK_THEME_MANIFEST_URL: &str =
const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/theme.ron";
/// Bytes of the bundled Dark theme manifest, embedded at compile time.
const DARK_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/dark/theme.ron");
const DARK_THEME_MANIFEST_BYTES: &[u8] = include_bytes!("../../assets/themes/dark/theme.ron");
/// Stable embedded asset URL of the bundled Classic theme manifest.
pub const CLASSIC_THEME_MANIFEST_URL: &str =
@@ -89,8 +92,7 @@ pub const CLASSIC_THEME_MANIFEST_URL: &str =
const CLASSIC_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/classic/theme.ron";
/// Bytes of the bundled Classic theme manifest, embedded at compile time.
const CLASSIC_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/classic/theme.ron");
const CLASSIC_THEME_MANIFEST_BYTES: &[u8] = include_bytes!("../../assets/themes/classic/theme.ron");
/// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG.
macro_rules! embed_dark_svg {
@@ -237,11 +239,16 @@ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
/// Returns the `&mut App` so the call can be chained from the binary
/// entry point.
pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
let root = user_theme_dir();
app.register_asset_source(
USER_THEMES,
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
);
// User themes are stored on the filesystem; wasm32 has no filesystem and
// `FileAssetReader` is not available on that target.
#[cfg(not(target_arch = "wasm32"))]
{
let root = user_theme_dir();
app.register_asset_source(
USER_THEMES,
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
);
}
app
}
@@ -377,10 +384,11 @@ mod tests {
fn populate_embedded_dark_theme_runs_without_asset_plugin() {
let mut app = App::new();
populate_embedded_dark_theme(&mut app);
assert!(app
.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some());
assert!(
app.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some()
);
}
#[test]
@@ -425,10 +433,11 @@ mod tests {
fn populate_embedded_classic_theme_runs_without_asset_plugin() {
let mut app = App::new();
populate_embedded_classic_theme(&mut app);
assert!(app
.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some());
assert!(
app.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some()
);
}
#[test]
+17 -13
View File
@@ -24,6 +24,7 @@ use std::sync::{Arc, OnceLock};
use bevy::asset::io::Reader;
use bevy::asset::{AssetLoader, LoadContext, RenderAssetUsages};
use bevy::image::Image;
use bevy::log::warn;
use bevy::math::UVec2;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
@@ -156,7 +157,7 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoader
/// share the same canonical face.
const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/main.ttf");
/// Returns a process-wide font database holding only the bundled
/// Returns a process-wide font database that tries to load the bundled
/// FiraMono-Medium face. Initialised lazily on first SVG that references
/// text, then shared (via `Arc`) across every subsequent rasterisation.
///
@@ -165,17 +166,19 @@ const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/main.ttf
/// such request directly to FiraMono so rasterisation is deterministic
/// across machines and the system font path is never consulted.
///
/// Aborts the program if the embedded bytes don't parse — bundled at
/// compile time, so a parse failure means the binary is corrupt.
/// If the embedded bytes fail to yield any faces, log a warning and
/// fall back to an empty database so startup can continue.
fn shared_fontdb() -> Arc<fontdb::Database> {
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_font_data(BUNDLED_FONT_BYTES.to_vec());
assert!(
db.faces().next().is_some(),
"bundled FiraMono failed to parse — binary is corrupt"
);
let loaded_faces = db.load_font_source(fontdb::Source::Binary(Arc::new(
BUNDLED_FONT_BYTES.to_vec(),
)));
if loaded_faces.is_empty() {
let e = "no faces loaded from bundled bytes";
warn!("Failed to load bundled FiraMono font: {e}");
}
Arc::new(db)
})
.clone()
@@ -245,8 +248,7 @@ mod tests {
#[test]
fn rasterizes_svg_with_unmatched_font_family() {
let image =
rasterize_svg(TEST_SVG_WITH_TEXT, UVec2::new(64, 96)).expect("rasterisation");
let image = rasterize_svg(TEST_SVG_WITH_TEXT, UVec2::new(64, 96)).expect("rasterisation");
assert_eq!(image.size().x, 64);
assert_eq!(image.size().y, 96);
}
@@ -259,9 +261,11 @@ mod tests {
#[test]
fn pixmap_data_is_rgba_with_target_byte_count() {
let image =
rasterize_svg(TEST_SVG, UVec2::new(32, 48)).expect("rasterisation");
let pixels = image.data.as_ref().expect("rasterised image carries pixel data");
let image = rasterize_svg(TEST_SVG, UVec2::new(32, 48)).expect("rasterisation");
let pixels = image
.data
.as_ref()
.expect("rasterised image carries pixel data");
// 32 × 48 × 4 (RGBA bytes) = 6144 bytes
assert_eq!(pixels.len(), 32 * 48 * 4);
}
+21 -8
View File
@@ -82,13 +82,23 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
/// the panic message names the supported workaround.
fn detected_platform_data_dir() -> PathBuf {
solitaire_data::data_dir().unwrap_or_else(|| {
panic!(
"user_theme_dir(): platform data directory is unavailable. \
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
the OS reported no Application Support / AppData path. \
As a workaround call solitaire_engine::assets::user_dir::\
set_user_theme_dir() before App::run()."
)
// On wasm32, data_dir() always returns None — there is no filesystem.
// User themes are not supported in the browser build; return an empty
// path so callers produce a benign empty dir rather than panicking.
#[cfg(target_arch = "wasm32")]
{
PathBuf::new()
}
#[cfg(not(target_arch = "wasm32"))]
{
panic!(
"user_theme_dir(): platform data directory is unavailable. \
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
the OS reported no Application Support / AppData path. \
As a workaround call solitaire_engine::assets::user_dir::\
set_user_theme_dir() before App::run()."
)
}
})
}
@@ -123,7 +133,10 @@ mod tests {
// user's `$HOME` on desktop, but it must at least be a
// non-empty path with a parent component.
let dir = detected_platform_data_dir();
assert!(dir.parent().is_some(), "data dir {dir:?} should be absolute");
assert!(
dir.parent().is_some(),
"data dir {dir:?} should be absolute"
);
}
// The OnceLock-based override is intentionally NOT covered here:
+33 -19
View File
@@ -22,8 +22,8 @@
use std::io::Cursor;
use bevy::prelude::*;
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
use kira::sound::Region;
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
use kira::track::{TrackBuilder, TrackHandle};
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
@@ -34,7 +34,6 @@ use crate::events::{
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use solitaire_core::pile::PileType;
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
const RECYCLE_VOLUME: f64 = 0.5;
@@ -178,8 +177,7 @@ fn build_library() -> Option<SoundLibrary> {
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
let foundation_complete =
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
let foundation_complete = decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
Some(SoundLibrary {
deal,
flip,
@@ -212,8 +210,7 @@ fn start_ambient_loop(
) -> Option<StaticSoundHandle> {
let manager = manager?;
let ambient_bytes: &'static [u8] =
include_bytes!("../../assets/audio/ambient_loop.wav");
let ambient_bytes: &'static [u8] = include_bytes!("../../assets/audio/ambient_loop.wav");
let mut data = match StaticSoundData::from_cursor(Cursor::new(ambient_bytes.to_vec())) {
Ok(d) => d,
Err(e) => {
@@ -280,13 +277,19 @@ impl AudioState {
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.sfx_track.as_mut() {
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
track.set_volume(
amplitude_to_decibels(volume.clamp(0.0, 1.0)),
Tween::default(),
);
}
}
fn set_music_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.music_track.as_mut() {
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
track.set_volume(
amplitude_to_decibels(volume.clamp(0.0, 1.0)),
Tween::default(),
);
}
}
@@ -319,7 +322,10 @@ fn apply_volume_on_change(
let sfx_muted = mute.as_ref().is_some_and(|m| m.sfx_muted);
let music_muted = mute.as_ref().is_some_and(|m| m.music_muted);
set_sfx_volume(&mut audio, if sfx_muted { 0.0 } else { ev.0.sfx_volume });
set_music_volume(&mut audio, if music_muted { 0.0 } else { ev.0.music_volume });
set_music_volume(
&mut audio,
if music_muted { 0.0 } else { ev.0.music_volume },
);
}
}
@@ -367,15 +373,11 @@ fn play_on_draw(
// When the stock pile is empty the draw action recycles the waste pile
// back to stock. Play the flip sound at half volume to give audible
// feedback that distinguishes a recycle from a normal draw.
let stock_len = game
.as_ref()
.and_then(|g| g.0.piles.get(&PileType::Stock))
.map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound
let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
if is_recycle(stock_len) {
let mut data = lib.flip.clone();
data.settings.volume =
Value::Fixed(amplitude_to_decibels(RECYCLE_VOLUME as f32));
data.settings.volume = Value::Fixed(amplitude_to_decibels(RECYCLE_VOLUME as f32));
let result = if let Some(track) = audio.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = audio.manager.as_mut() {
@@ -516,7 +518,10 @@ mod tests {
toggle_all(&mut m);
assert!(m.sfx_muted && m.music_muted, "M should mute both channels");
toggle_all(&mut m);
assert!(!m.sfx_muted && !m.music_muted, "second M should unmute both channels");
assert!(
!m.sfx_muted && !m.music_muted,
"second M should unmute both channels"
);
}
#[test]
@@ -537,14 +542,23 @@ mod tests {
assert!(m.music_muted && !m.sfx_muted);
// M should mute sfx (not-all-muted → mute-all).
toggle_all(&mut m);
assert!(m.sfx_muted && m.music_muted, "M unmutes neither — it mutes all when sfx was audible");
assert!(
m.sfx_muted && m.music_muted,
"M unmutes neither — it mutes all when sfx was audible"
);
}
#[test]
fn mute_all_when_both_already_muted_unmutes_both() {
let mut m = MuteState { sfx_muted: true, music_muted: true };
let mut m = MuteState {
sfx_muted: true,
music_muted: true,
};
toggle_all(&mut m);
assert!(!m.sfx_muted && !m.music_muted, "M should unmute both when all were muted");
assert!(
!m.sfx_muted && !m.music_muted,
"M should unmute both when all were muted"
);
}
// -----------------------------------------------------------------------
+90 -27
View File
@@ -9,21 +9,31 @@
//! returns `None` (e.g. a transient state), the plugin retries next tick.
use bevy::prelude::*;
use bevy::window::RequestRedraw;
#[cfg(not(target_arch = "wasm32"))]
use crate::audio_plugin::{AudioState, SoundLibrary};
use crate::events::{MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
/// Volume amplitude used for the auto-complete activation chime.
///
/// Plays the win fanfare at half volume so it is clearly distinguishable from
/// both normal card-place sounds and the full win fanfare that fires later.
#[cfg(not(target_arch = "wasm32"))]
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
/// Seconds between consecutive auto-complete moves.
const STEP_INTERVAL: f32 = 0.12;
/// Seconds to wait after detection before firing the first auto-complete move.
///
/// This pause gives the player a moment to register that the game is
/// transitioning into auto-complete mode before cards start moving.
const AUTO_COMPLETE_INITIAL_DELAY: f32 = 0.75;
/// Tracks whether auto-complete is active and when the next move fires.
#[derive(Resource, Default, Debug)]
pub struct AutoCompleteState {
@@ -39,6 +49,7 @@ pub struct AutoCompletePlugin;
impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>()
.add_message::<RequestRedraw>()
.add_systems(
Update,
(
@@ -71,8 +82,20 @@ fn detect_auto_complete(
}
if game.0.is_auto_completable && !state.active {
state.active = true;
state.cooldown = 0.0; // fire first move immediately
} else if !game.0.is_auto_completable {
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
} else if !game.0.is_auto_completable && state.active {
// `is_auto_completable` only becomes false after an explicit undo
// (which puts a card back on the tableau or re-fills the stock/waste)
// or a new-game reset — never as a transient gap during a normal
// auto-complete sequence. Deactivate here so `drive_auto_complete`
// does not keep retrying indefinitely after the player undoes out of
// the sequence.
//
// Note: the transient-`None` case mentioned in older versions of this
// comment referred to `next_auto_complete_move()` returning `None`, not
// to `is_auto_completable` being false. Those are independent fields;
// `drive_auto_complete` still retries on a transient `None` return from
// `next_auto_complete_move` because that check happens there, not here.
state.active = false;
}
}
@@ -83,6 +106,7 @@ fn detect_auto_complete(
/// exactly once on the `false → true` edge. The win fanfare is played at half
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
/// not overwhelm the card-place sounds that follow immediately.
#[cfg(not(target_arch = "wasm32"))]
fn on_auto_complete_start(
state: Res<AutoCompleteState>,
mut was_active: Local<bool>,
@@ -97,20 +121,32 @@ fn on_auto_complete_start(
return;
}
let (Some(audio), Some(lib)) = (audio.as_mut(), lib) else { return };
let (Some(audio), Some(lib)) = (audio.as_mut(), lib) else {
return;
};
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
}
// No audio on wasm — stub keeps the system registration unconditional.
#[cfg(target_arch = "wasm32")]
fn on_auto_complete_start(state: Res<AutoCompleteState>, mut was_active: Local<bool>) {
*was_active = state.active;
}
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut moves: MessageWriter<MoveRequestEvent>,
) {
if !state.active {
return;
}
if paused.is_some_and(|p| p.0) {
return;
}
state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 {
@@ -131,9 +167,9 @@ mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
fn headless_app() -> App {
let mut app = App::new();
@@ -146,23 +182,45 @@ mod tests {
app
}
/// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other
/// tableau piles empty, stock/waste empty, Clubs foundation empty.
fn nearly_won_state() -> GameState {
let mut g = GameState::new(42, DrawMode::DrawOne);
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();
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
let mut g = GameState::new(1, DrawMode::DrawOne);
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
g.set_test_foundation_cards(foundation, Vec::new());
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 99,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
g.set_test_tableau_cards(tableau, Vec::new());
}
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![solitaire_core::card::Card {
id: 7_001,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
);
g.is_auto_completable = true;
g
let expected = (
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Foundation(Foundation::Foundation1),
);
assert_eq!(g.next_auto_complete_move(), Some(expected));
(g, expected)
}
#[test]
@@ -174,8 +232,9 @@ mod tests {
#[test]
fn detect_activates_when_auto_completable() {
let mut app = headless_app();
// Install a nearly-won state and fire StateChangedEvent.
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
let mut g = GameState::new(42, DrawMode::DrawOne);
g.is_auto_completable = true;
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent);
app.update();
@@ -185,9 +244,14 @@ mod tests {
#[test]
fn drive_fires_move_request_when_active() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent);
app.update(); // detect runs, sets active
// Zero out the cooldown so drive fires on the next update regardless
// of the initial delay constant.
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
app.update(); // drive fires the move
let events = app.world().resource::<Messages<MoveRequestEvent>>();
@@ -195,16 +259,15 @@ mod tests {
let fired: Vec<_> = cursor.read(events).collect();
// At least one MoveRequestEvent should have been fired.
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
assert_eq!(fired[0].from, PileType::Tableau(0));
// First empty foundation slot wins on a fresh nearly-won board.
assert_eq!(fired[0].to, PileType::Foundation(0));
assert_eq!(fired[0].from, expected_from);
assert_eq!(fired[0].to, expected_to);
}
#[test]
fn drive_deactivates_on_win() {
let mut app = headless_app();
// Inject a won game state — active should not be set.
let mut gs = nearly_won_state();
let (mut gs, _) = seeded_state_with_auto_move();
gs.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().write_message(StateChangedEvent);
+25 -18
View File
@@ -19,7 +19,9 @@
use bevy::asset::RenderAssetUsages;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use crate::resources::TokioRuntimeResource;
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
/// has been fetched yet (new account, no internet, or fetch in progress).
@@ -48,34 +50,39 @@ impl Plugin for AvatarPlugin {
app.add_message::<AvatarFetchEvent>()
.init_resource::<AvatarResource>()
.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(
mut events: MessageReader<AvatarFetchEvent>,
rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingAvatarTask>,
) {
for ev in events.read() {
// Cancel any in-flight task and restart with the new URL.
let url = ev.url.clone();
let rt = rt.0.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?
.block_on(async move {
let client = reqwest::Client::new();
let bytes = client
.get(&url)
.send()
.await
.ok()?
.bytes()
.await
.ok()?;
Some(bytes.to_vec())
})
rt.block_on(async move {
let client = reqwest::Client::new();
let bytes = client.get(&url).send().await.ok()?.bytes().await.ok()?;
Some(bytes.to_vec())
})
}));
}
}
@@ -34,7 +34,7 @@ use std::f32::consts::PI;
use bevy::prelude::*;
use super::curves::{sample_curve, MotionCurve};
use super::curves::{MotionCurve, sample_curve};
use super::timing::compute_duration;
use crate::pause_plugin::PausedResource;
@@ -192,7 +192,11 @@ pub fn retarget_animation(
let carry = (t * 0.12).min(0.10);
(anim.current_xy(), transform.translation.z, carry)
}
_ => (transform.translation.truncate(), transform.translation.z, 0.0),
_ => (
transform.translation.truncate(),
transform.translation.z,
0.0,
),
};
let distance = current_xy.distance(new_end);
@@ -328,7 +332,10 @@ mod tests {
fn current_xy_at_start() {
let anim = make_anim(Vec2::ZERO, Vec2::new(100.0, 0.0), 0.0, 1.0);
let pos = anim.current_xy();
assert!(pos.x < 5.0, "at t=0 position should be near start, got {pos:?}");
assert!(
pos.x < 5.0,
"at t=0 position should be near start, got {pos:?}"
);
}
#[test]
@@ -390,7 +397,10 @@ mod tests {
fn win_scatter_targets_are_off_center() {
for t in win_scatter_targets(400.0) {
let dist = t.length();
assert!(dist > 100.0, "scatter target should be well off-center: {t:?}");
assert!(
dist > 100.0,
"scatter target should be well off-center: {t:?}"
);
}
}
}
+32 -6
View File
@@ -126,7 +126,12 @@ mod tests {
MotionCurve::Responsive,
MotionCurve::Expressive,
] {
assert_near(sample_curve(curve, 0.0), 0.0, 1e-5, &format!("{curve:?} at t=0"));
assert_near(
sample_curve(curve, 0.0),
0.0,
1e-5,
&format!("{curve:?} at t=0"),
);
}
}
@@ -137,7 +142,12 @@ mod tests {
MotionCurve::SoftBounce,
MotionCurve::Responsive,
] {
assert_near(sample_curve(curve, 1.0), 1.0, 1e-4, &format!("{curve:?} at t=1"));
assert_near(
sample_curve(curve, 1.0),
1.0,
1e-4,
&format!("{curve:?} at t=1"),
);
}
// Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
assert_near(
@@ -159,8 +169,14 @@ mod tests {
fn smooth_snap_overshoots_slightly_near_end() {
// Peak overshoot is around t = 0.875.
let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
assert!(peak > 1.0, "SmoothSnap should overshoot at t=0.875, got {peak}");
assert!(peak < 1.03, "SmoothSnap overshoot should be small (<3 %), got {peak}");
assert!(
peak > 1.0,
"SmoothSnap should overshoot at t=0.875, got {peak}"
);
assert!(
peak < 1.03,
"SmoothSnap overshoot should be small (<3 %), got {peak}"
);
}
#[test]
@@ -186,11 +202,21 @@ mod tests {
#[test]
fn sample_curve_clamps_t_below_zero() {
assert_near(sample_curve(MotionCurve::SmoothSnap, -1.0), 0.0, 1e-5, "t<0 clamped");
assert_near(
sample_curve(MotionCurve::SmoothSnap, -1.0),
0.0,
1e-5,
"t<0 clamped",
);
}
#[test]
fn sample_curve_clamps_t_above_one() {
assert_near(sample_curve(MotionCurve::Responsive, 2.0), 1.0, 1e-5, "t>1 clamped");
assert_near(
sample_curve(MotionCurve::Responsive, 2.0),
1.0,
1e-5,
"t>1 clamped",
);
}
}
@@ -190,7 +190,10 @@ mod tests {
// is_above_target(30.0) is strict: fps must be > 30, not >=.
// At exactly 30 FPS the result depends on floating-point rounding,
// so just check that it's consistent with > 60 being false.
assert!(!d.is_above_target(60.0), "30 FPS is not above 60 FPS target");
assert!(
!d.is_above_target(60.0),
"30 FPS is not above 60 FPS target"
);
}
#[test]
@@ -71,7 +71,9 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move { from: crate::events::MoveRequestEvent },
Move {
from: crate::events::MoveRequestEvent,
},
Draw,
Undo,
}
@@ -139,9 +141,7 @@ pub(crate) fn detect_hover(
let mut best: Option<(Entity, f32)> = None;
for (entity, transform) in &cards {
let pos = transform.translation.truncate();
if (cursor_world.x - pos.x).abs() < half_w
&& (cursor_world.y - pos.y).abs() < half_h
{
if (cursor_world.x - pos.x).abs() < half_w && (cursor_world.y - pos.y).abs() < half_h {
let z = transform.translation.z;
if best.is_none_or(|(_, bz)| z > bz) {
best = Some((entity, z));
@@ -187,9 +187,7 @@ pub(crate) fn apply_hover_scale(
// Update the tracked scale for external inspection.
hover_state.scale = if let Some(entity) = target_entity {
cards
.get(entity)
.map_or(hover_target, |(_, t)| t.scale.x)
cards.get(entity).map_or(hover_target, |(_, t)| t.scale.x)
} else {
1.0
};
+39 -20
View File
@@ -80,18 +80,19 @@ pub mod interaction;
pub mod timing;
pub mod tuning;
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
pub use chain::AnimationChain;
pub use curves::{sample_curve, MotionCurve};
pub use curves::{MotionCurve, sample_curve};
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
cascade_delay, compute_duration, micro_vary,
};
pub use tuning::{AnimationTuning, InputPlatform};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
@@ -125,6 +126,7 @@ impl Plugin for CardAnimationPlugin {
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<GameWonEvent>()
.add_message::<RequestRedraw>()
.init_resource::<DragState>()
.init_resource::<HoverState>()
.init_resource::<InputBuffer>()
@@ -142,6 +144,13 @@ impl Plugin for CardAnimationPlugin {
update_frame_time_diagnostics,
// Advance active 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.
advance_animation_chains,
// Interaction visuals (run after animation for final positions).
@@ -172,10 +181,7 @@ pub struct WinCascadePlugin;
impl Plugin for WinCascadePlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
trigger_expressive_win_cascade.after(GameMutation),
);
app.add_systems(Update, trigger_expressive_win_cascade.after(GameMutation));
}
}
@@ -193,9 +199,7 @@ fn trigger_expressive_win_cascade(
return;
}
let radius = layout
.as_ref()
.map_or(800.0, |l| l.0.card_size.x * 8.0);
let radius = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
let targets = win_scatter_targets(radius);
@@ -205,10 +209,16 @@ fn trigger_expressive_win_cascade(
let target = targets[index % targets.len()];
commands.entity(entity).insert(
CardAnimation::slide(start_xy, start_z, target, start_z + 60.0, MotionCurve::Expressive)
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
.with_duration(0.65)
.with_z_lift(25.0),
CardAnimation::slide(
start_xy,
start_z,
target,
start_z + 60.0,
MotionCurve::Expressive,
)
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
.with_duration(0.65)
.with_z_lift(25.0),
);
}
}
@@ -258,7 +268,8 @@ mod tests {
#[test]
fn card_animation_advances_and_removes_itself() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
let start = Vec2::new(0.0, 0.0);
let end = Vec2::new(100.0, 0.0);
@@ -299,7 +310,8 @@ mod tests {
#[test]
fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
let end = Vec2::new(200.0, 100.0);
let entity = app
@@ -346,7 +358,8 @@ mod tests {
#[test]
fn card_animation_respects_delay() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
let entity = app
.world_mut()
@@ -384,8 +397,14 @@ mod tests {
buf.push(BufferedInput::Draw);
buf.push(BufferedInput::Undo);
// FIFO: Draw comes out first.
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw));
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo));
assert!(matches!(
buf.queue.pop_front().unwrap(),
BufferedInput::Draw
));
assert!(matches!(
buf.queue.pop_front().unwrap(),
BufferedInput::Undo
));
}
#[test]
@@ -88,7 +88,10 @@ mod tests {
let mut prev = 0.0f32;
for d in [10, 50, 100, 200, 400, 600] {
let dur = compute_duration(d as f32);
assert!(dur >= prev, "duration must be monotone: d={d} dur={dur} prev={prev}");
assert!(
dur >= prev,
"duration must be monotone: d={d} dur={dur} prev={prev}"
);
prev = dur;
}
}
@@ -129,7 +132,10 @@ mod tests {
let a = micro_vary(0.2, 1);
let b = micro_vary(0.2, 2);
// Very unlikely to be equal (would require hash collision mod 65536).
assert!((a - b).abs() > 1e-9, "micro_vary should differ for different indices");
assert!(
(a - b).abs() > 1e-9,
"micro_vary should differ for different indices"
);
}
#[test]
+14 -5
View File
@@ -100,7 +100,7 @@ impl AnimationTuning {
platform: InputPlatform::Mouse,
duration_scale: 1.0,
overshoot_scale: 1.0,
drag_threshold_px: 4.0,
drag_threshold_px: 6.0,
drag_scale: 1.08,
hover_scale: 1.04,
hover_lerp_speed: 14.0,
@@ -114,7 +114,7 @@ impl AnimationTuning {
platform: InputPlatform::Touch,
duration_scale: 0.75,
overshoot_scale: 0.5,
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
drag_scale: 1.12,
hover_scale: 1.0, // no hover affordance on touch
hover_lerp_speed: 20.0,
@@ -182,15 +182,24 @@ mod tests {
assert_eq!(t.duration_scale, 1.0);
assert_eq!(t.platform, InputPlatform::Mouse);
assert!(t.hover_scale > 1.0, "desktop hover must lift the card");
assert!(t.drag_threshold_px < 10.0, "desktop threshold must be smaller than mobile");
assert!(
t.drag_threshold_px < 10.0,
"desktop threshold must be smaller than mobile"
);
}
#[test]
fn mobile_is_faster_than_desktop() {
let d = AnimationTuning::desktop();
let m = AnimationTuning::mobile();
assert!(m.duration_scale < d.duration_scale, "mobile must animate faster");
assert!(m.overshoot_scale < d.overshoot_scale, "mobile must bounce less");
assert!(
m.duration_scale < d.duration_scale,
"mobile must animate faster"
);
assert!(
m.overshoot_scale < d.overshoot_scale,
"mobile must bounce less"
);
}
#[test]
File diff suppressed because it is too large Load Diff
+20 -9
View File
@@ -58,12 +58,15 @@ fn advance_on_challenge_win(
let prev = progress.0.challenge_index;
progress.0.challenge_index = prev.saturating_add(1);
if let Some(target) = &path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after challenge advance: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after challenge advance: {e}");
}
// Human-readable level is 1-based (index 0 → "Challenge 1").
let level_number = prev.saturating_add(1);
toast.write(InfoToastEvent(format!("Challenge {level_number} complete!")));
toast.write(InfoToastEvent(format!(
"Challenge {level_number} complete!"
)));
advanced.write(ChallengeAdvancedEvent {
previous_index: prev,
new_index: progress.0.challenge_index,
@@ -90,7 +93,9 @@ fn handle_start_challenge_request(
return;
}
let Some(seed) = challenge_seed_for(progress.0.challenge_index) else {
warn!("challenge seed list is empty");
info_toast.write(InfoToastEvent(
"You've completed all challenges! More coming soon.".into(),
));
return;
};
new_game.write(NewGameRequestEvent {
@@ -184,8 +189,7 @@ mod tests {
#[test]
fn pressing_x_at_unlock_level_fires_new_game_with_challenge_seed() {
let mut app = headless_app();
app.world_mut().resource_mut::<ProgressResource>().0.level =
CHALLENGE_UNLOCK_LEVEL;
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
@@ -215,7 +219,10 @@ mod tests {
fn challenge_win_fires_complete_toast_with_level_number() {
let mut app = headless_app();
// Set challenge_index to 2 so the completed level is "Challenge 3".
app.world_mut().resource_mut::<ProgressResource>().0.challenge_index = 2;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.challenge_index = 2;
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
@@ -228,7 +235,11 @@ mod tests {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert_eq!(fired.len(), 1, "exactly one toast must fire on challenge win");
assert_eq!(
fired.len(),
1,
"exactly one toast must fire on challenge win"
);
assert!(
fired[0].0.contains("Challenge 3"),
"toast must name the 1-based level that was just completed"
+132
View File
@@ -0,0 +1,132 @@
//! Central plugin that groups all gameplay plugins.
//!
//! Register [`CoreGamePlugin`] once in the app instead of the individual
//! plugins. Plugin registration lives here rather than directly in the app
//! entry point.
use std::sync::Mutex;
use bevy::prelude::*;
use crate::platform::{
ClipboardBackendResource, StorageBackendResource, default_clipboard_backend,
default_storage_backend,
};
use crate::{
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
};
/// Groups all Ferrous Solitaire gameplay plugins.
pub struct CoreGamePlugin {
sync_provider: Mutex<Option<Box<dyn SyncProvider + Send + Sync>>>,
}
impl CoreGamePlugin {
/// Create a new [`CoreGamePlugin`] with the sync provider used by [`SyncPlugin`].
pub fn new(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> Self {
Self {
sync_provider: Mutex::new(Some(sync_provider)),
}
}
}
impl Plugin for CoreGamePlugin {
fn build(&self, app: &mut App) {
let mut sync_provider = match self.sync_provider.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
let sync_provider = sync_provider
.take()
.expect("CoreGamePlugin::build called twice");
match default_storage_backend() {
Ok(storage) => {
app.insert_resource(StorageBackendResource(storage));
}
Err(err) => {
warn!("storage: failed to initialize platform backend: {err}");
}
}
match default_clipboard_backend() {
Ok(clipboard) => {
app.insert_resource(ClipboardBackendResource(clipboard));
}
Err(err) => {
warn!("clipboard: failed to initialize platform backend: {err}");
}
}
app.add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
// Cursor-icon feedback is desktop-only; Android has no pointer cursor.
// The drop-target highlight systems (update_drop_highlights,
// update_drop_target_overlays) live in CursorPlugin but ARE useful
// on Android — they've been left running because their Bevy system
// params compile and function on Android; only the CursorIcon insert
// is inert. Gate the whole plugin if the cursor APIs ever cause
// Android linker issues; for now it's harmless to leave it registered.
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(TouchSelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default())
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(SafeAreaInsetsPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin);
// Plugins that use kira/cpal audio or multi-threaded Tokio are not
// compatible with the single-threaded wasm32 runtime. Gate them out
// so the browser build boots silently and without a sync backend.
#[cfg(not(target_arch = "wasm32"))]
app.add_plugins(AvatarPlugin)
.add_plugins(AudioPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin);
}
}
+111 -212
View File
@@ -34,14 +34,13 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource};
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{
DROP_TARGET_FILL, DROP_TARGET_OUTLINE, DROP_TARGET_OUTLINE_PX, Z_DROP_OVERLAY,
};
@@ -66,10 +65,10 @@ const MARKER_VALID: Color = Color::srgba(0.675, 0.761, 0.404, 0.55);
/// Marker component on a parent entity that owns one drop-target overlay
/// (a translucent fill plus four outline edges as children). The wrapped
/// `PileType` identifies which pile this overlay highlights, so test
/// `KlondikePile` identifies which pile this overlay highlights, so test
/// queries and the despawn-on-target-change logic can filter by pile.
#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub struct DropTargetOverlay(pub PileType);
pub struct DropTargetOverlay(pub KlondikePile);
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
pub struct CursorPlugin;
@@ -80,7 +79,7 @@ impl Plugin for CursorPlugin {
Update,
(
update_cursor_icon,
update_drop_highlights,
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
update_drop_target_overlays,
),
);
@@ -126,7 +125,9 @@ fn update_cursor_icon(
button_q: Query<&Interaction, With<Button>>,
mut commands: Commands,
) {
let Ok((win_entity, window)) = windows.single() else { return };
let Ok((win_entity, window)) = windows.single() else {
return;
};
let is_dragging = !drag.is_idle();
@@ -161,33 +162,34 @@ fn update_cursor_icon(
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
let piles = [
PileType::Waste,
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
PileType::Tableau(3),
PileType::Tableau(4),
PileType::Tableau(5),
PileType::Tableau(6),
KlondikePile::Stock,
KlondikePile::Foundation(Foundation::Foundation1),
KlondikePile::Foundation(Foundation::Foundation2),
KlondikePile::Foundation(Foundation::Foundation3),
KlondikePile::Foundation(Foundation::Foundation4),
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau2),
KlondikePile::Tableau(Tableau::Tableau3),
KlondikePile::Tableau(Tableau::Tableau4),
KlondikePile::Tableau(Tableau::Tableau5),
KlondikePile::Tableau(Tableau::Tableau6),
KlondikePile::Tableau(Tableau::Tableau7),
];
for pile in piles {
let Some(pile_cards) = game.piles.get(&pile) else {
let pile_cards = pile_cards(game, &pile);
if pile_cards.is_empty() {
continue;
};
let is_tableau = matches!(pile, PileType::Tableau(_));
}
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
let base = layout.pile_positions[&pile];
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
for (i, card) in pile_cards.iter().enumerate().rev() {
if !card.face_up {
continue;
}
// Only the topmost card is draggable on non-tableau piles.
if !is_tableau && i != pile_cards.cards.len() - 1 {
if !is_tableau && i != pile_cards.len() - 1 {
continue;
}
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
@@ -224,34 +226,14 @@ fn update_drop_highlights(
let Some(game) = game else { return };
// The first element of drag.cards is the bottom card that lands on the target.
let Some(&bottom_id) = drag.cards.first() else { return };
let bottom_card = game
.0
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == bottom_id)
.cloned();
let Some(bottom_card) = bottom_card else { return };
let drag_count = drag.cards.len();
let Some(origin) = drag.origin_pile.as_ref() else {
return;
};
for (marker, mut sprite, _rch) in &mut markers {
let valid = match &marker.0 {
PileType::Foundation(slot) => {
if drag_count != 1 {
false
} else {
let pile = game.0.piles.get(&PileType::Foundation(*slot));
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
}
}
PileType::Tableau(idx) => {
let pile = game.0.piles.get(&PileType::Tableau(*idx));
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
}
_ => false,
};
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
}
}
@@ -291,20 +273,7 @@ fn update_drop_target_overlays(
return;
};
// Resolve the bottom card of the dragged stack — same logic as
// `update_drop_highlights` so rules can't drift between the marker
// tint and the overlay.
let Some(&bottom_id) = drag.cards.first() else {
return;
};
let bottom_card = game
.0
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == bottom_id)
.cloned();
let Some(bottom_card) = bottom_card else {
let Some(origin) = drag.origin_pile.as_ref() else {
return;
};
let drag_count = drag.cards.len();
@@ -312,44 +281,24 @@ fn update_drop_target_overlays(
// Iterate the same pile list as `update_drop_highlights`. Stock and
// Waste are excluded because they are never legal drop targets.
let candidates = [
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
PileType::Tableau(3),
PileType::Tableau(4),
PileType::Tableau(5),
PileType::Tableau(6),
KlondikePile::Foundation(Foundation::Foundation1),
KlondikePile::Foundation(Foundation::Foundation2),
KlondikePile::Foundation(Foundation::Foundation3),
KlondikePile::Foundation(Foundation::Foundation4),
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau2),
KlondikePile::Tableau(Tableau::Tableau3),
KlondikePile::Tableau(Tableau::Tableau4),
KlondikePile::Tableau(Tableau::Tableau5),
KlondikePile::Tableau(Tableau::Tableau6),
KlondikePile::Tableau(Tableau::Tableau7),
];
// Compute the new set of valid piles for this frame.
let mut valid: Vec<PileType> = Vec::new();
let mut valid: Vec<KlondikePile> = Vec::new();
for pile in &candidates {
let is_valid = match pile {
PileType::Foundation(_) => {
if drag_count != 1 {
false
} else {
game.0
.piles
.get(pile)
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
}
}
PileType::Tableau(_) => game
.0
.piles
.get(pile)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
_ => false,
};
// Don't highlight the origin pile — dropping onto the source is
// a no-op.
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
valid.push(pile.clone());
if game.0.can_move_cards(origin, pile, drag_count) {
valid.push(*pile);
}
}
@@ -361,9 +310,9 @@ fn update_drop_target_overlays(
}
// Spawn overlays for piles that are now valid but don't yet have one.
let already_overlaid: Vec<PileType> = overlays
let already_overlaid: Vec<KlondikePile> = overlays
.iter()
.map(|(_, m)| m.0.clone())
.map(|(_, m)| m.0)
.filter(|p| valid.contains(p))
.collect();
@@ -382,35 +331,42 @@ fn update_drop_target_overlays(
/// for everything else it is card-sized. Replicated here rather than
/// imported because `pile_drop_rect` is private to `input_plugin` and
/// this overlay is the only other consumer.
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
let centre = layout.pile_positions[pile];
if matches!(pile, PileType::Tableau(_)) {
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
fn drop_overlay_rect(
pile: &KlondikePile,
layout: &Layout,
game: &GameState,
) -> Option<(Vec2, Vec2)> {
let centre = layout.pile_positions.get(pile).copied()?;
if matches!(pile, KlondikePile::Tableau(_)) {
let card_count = game.pile(*pile).len();
if card_count > 1 {
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
let fan = -layout.card_size.y * layout.tableau_fan_frac;
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
let top_edge = centre.y + layout.card_size.y / 2.0;
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
let span_height = top_edge - bottom_edge;
let new_centre_y = (top_edge + bottom_edge) / 2.0;
return (
return Some((
Vec2::new(centre.x, new_centre_y),
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
/// the appropriate world position for `pile`.
fn spawn_drop_target_overlay(
commands: &mut Commands,
pile: &PileType,
pile: &KlondikePile,
layout: &Layout,
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;
commands
@@ -421,7 +377,7 @@ fn spawn_drop_target_overlay(
..default()
},
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
DropTargetOverlay(pile.clone()),
DropTargetOverlay(*pile),
))
.with_children(|parent| {
// Top edge.
@@ -470,7 +426,7 @@ fn spawn_drop_target_overlay(
fn tableau_or_stack_pos(
game: &GameState,
layout: &Layout,
pile: &PileType,
pile: &KlondikePile,
index: usize,
base: Vec2,
is_tableau: bool,
@@ -478,10 +434,10 @@ fn tableau_or_stack_pos(
if is_tableau {
Vec2::new(
base.x,
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32),
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
)
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree {
let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
@@ -490,6 +446,14 @@ fn tableau_or_stack_pos(
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<solitaire_core::card::Card> {
if matches!(pile, KlondikePile::Stock) {
game.waste_cards()
} else {
game.pile(*pile)
}
}
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
let half = size / 2.0;
point.x >= center.x - half.x
@@ -529,10 +493,7 @@ mod tests {
fn marker_valid_and_default_colours_are_distinct() {
// Regression guard — ensure these constants haven't been accidentally
// set to the same value.
assert_ne!(
format!("{MARKER_VALID:?}"),
format!("{MARKER_DEFAULT:?}")
);
assert_ne!(format!("{MARKER_VALID:?}"), format!("{MARKER_DEFAULT:?}"));
}
#[test]
@@ -600,13 +561,17 @@ mod tests {
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use solitaire_core::game_state::{DrawMode, GameState};
use crate::layout::compute_layout;
use solitaire_core::game_state::{DrawMode, GameState};
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
assert!(!cursor_over_draggable(
Vec2::new(-9999.0, -9999.0),
&game,
&layout
));
}
// -----------------------------------------------------------------------
@@ -624,7 +589,12 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(GameStateResource(game))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true)))
.insert_resource(LayoutResource(compute_layout(
Vec2::new(1280.0, 800.0),
0.0,
0.0,
true,
)))
.insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays);
app
@@ -634,12 +604,8 @@ mod tests {
/// card. Used to make a specific tableau column accept a chosen
/// drag stack.
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
let pile = game
.piles
.get_mut(&PileType::Tableau(idx))
.expect("tableau pile exists");
pile.cards.clear();
pile.cards.push(card);
let tableau = GameState::tableau_from_index(idx).expect("tableau pile exists");
game.set_test_tableau_cards(tableau, vec![card]);
}
/// Inserts a single face-up dragged card into the waste pile and
@@ -649,49 +615,14 @@ mod tests {
// Place the dragged card on the waste pile (origin).
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
let waste = game
.0
.piles
.get_mut(&PileType::Waste)
.expect("waste pile exists");
waste.cards.clear();
waste.cards.push(dragged.clone());
game.0.set_test_waste_cards(vec![dragged.clone()]);
}
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![dragged.id];
drag.origin_pile = Some(PileType::Waste);
drag.origin_pile = Some(KlondikePile::Stock);
drag.committed = true;
}
#[test]
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
// (black, rank 6) — alternating colour, one rank lower → legal.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card { id: 9001, suit: Suit::Spades, rank: Rank::Six, face_up: true },
);
let dragged = Card { id: 9002, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let overlays: Vec<PileType> = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.map(|o| o.0.clone())
.collect();
assert!(
overlays.contains(&PileType::Tableau(2)),
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
);
}
#[test]
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
@@ -701,66 +632,34 @@ mod tests {
set_tableau_top(
&mut game,
2,
Card { id: 9101, suit: Suit::Clubs, rank: Rank::Six, face_up: true },
Card {
id: 9101,
suit: Suit::Clubs,
rank: Rank::Six,
face_up: true,
},
);
let dragged = Card { id: 9102, suit: Suit::Spades, rank: Rank::Five, face_up: true };
let dragged = Card {
id: 9102,
suit: Suit::Spades,
rank: Rank::Five,
face_up: true,
};
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let overlays: Vec<PileType> = app
let overlays: Vec<KlondikePile> = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.map(|o| o.0.clone())
.map(|o| o.0)
.collect();
assert!(
!overlays.contains(&PileType::Tableau(2)),
!overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)),
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
);
}
#[test]
fn drop_target_overlays_despawn_on_drag_end() {
// Set up a scenario that produces at least one valid overlay,
// confirm it spawns, then clear the drag and confirm every
// overlay is despawned.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card { id: 9201, suit: Suit::Spades, rank: Rank::Six, face_up: true },
);
let dragged = Card { id: 9202, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let count_during_drag = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.count();
assert!(
count_during_drag >= 1,
"expected ≥1 overlay during drag, got {count_during_drag}"
);
// End the drag — every overlay should despawn next frame.
app.world_mut().resource_mut::<DragState>().clear();
app.update();
let count_after_drag = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.count();
assert_eq!(
count_after_drag, 0,
"all overlays must despawn when the drag ends"
);
}
}
+107 -57
View File
@@ -13,9 +13,11 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
#[cfg(not(target_arch = "wasm32"))]
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::ChallengeGoal;
use crate::events::{
@@ -25,6 +27,7 @@ use crate::events::{
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::sync_plugin::SyncProviderResource;
/// Bonus XP awarded for completing today's daily challenge.
@@ -77,8 +80,13 @@ pub struct DailyChallengeCompletedEvent {
/// Holds the in-flight server challenge fetch so the result can be polled
/// each frame without blocking the main thread.
#[derive(Resource, Default)]
#[cfg(not(target_arch = "wasm32"))]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
#[derive(Resource, Default)]
#[cfg(target_arch = "wasm32")]
struct DailyChallengeTask;
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
/// already fired for, so the toast spawns at most once per day.
///
@@ -89,6 +97,16 @@ struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
#[derive(Resource, Default, Debug)]
struct DailyExpiryWarningShown(Option<NaiveDate>);
/// Throttle timer so `check_date_rollover` does not call `Local::now()` every frame.
#[derive(Resource)]
struct DateRolloverTimer(Timer);
impl Default for DateRolloverTimer {
fn default() -> Self {
Self(Timer::from_seconds(60.0, TimerMode::Repeating))
}
}
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
pub struct DailyChallengePlugin;
@@ -98,6 +116,7 @@ impl Plugin for DailyChallengePlugin {
app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>()
.init_resource::<DailyExpiryWarningShown>()
.init_resource::<DateRolloverTimer>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>()
@@ -105,16 +124,21 @@ impl Plugin for DailyChallengePlugin {
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning);
.add_systems(Update, check_daily_expiry_warning)
.add_systems(Update, check_date_rollover);
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
#[cfg(not(target_arch = "wasm32"))]
app.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge);
}
}
#[cfg(not(target_arch = "wasm32"))]
/// Startup system: spawns an async task to fetch the server's daily challenge.
///
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
@@ -130,6 +154,7 @@ fn fetch_server_challenge(
task_res.0 = Some(task);
}
#[cfg(not(target_arch = "wasm32"))]
/// Update system: polls the server-challenge fetch task.
///
/// On success, replaces the locally-computed seed in `DailyChallengeResource`
@@ -161,8 +186,7 @@ fn poll_server_challenge(
daily.max_time_secs = goal.max_time_secs;
info!(
"daily challenge seed updated from server: {old_seed} → {} ({})",
goal.seed,
goal.description
goal.seed, goal.description
);
}
}
@@ -184,28 +208,35 @@ fn handle_daily_completion(
}
// Enforce server-supplied goal constraints when present.
if let Some(target) = daily.target_score
&& ev.score < target {
continue; // score goal not met
}
&& ev.score < target
{
continue; // score goal not met
}
if let Some(max_secs) = daily.max_time_secs
&& ev.time_seconds > max_secs {
continue; // time limit exceeded
}
&& ev.time_seconds > max_secs
{
continue; // time limit exceeded
}
if !progress.0.record_daily_completion(daily.date) {
// Already counted today — no-op.
continue;
}
progress.0.add_xp(DAILY_BONUS_XP);
xp_awarded.write(XpAwardedEvent { amount: DAILY_BONUS_XP });
xp_awarded.write(XpAwardedEvent {
amount: DAILY_BONUS_XP,
});
if let Some(target) = &path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after daily completion: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after daily completion: {e}");
}
completed.write(DailyChallengeCompletedEvent {
date: daily.date,
streak: progress.0.daily_challenge_streak,
});
toast.write(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
toast.write(InfoToastEvent(
"Daily challenge complete! +100 XP".to_string(),
));
}
}
@@ -298,12 +329,39 @@ fn check_daily_expiry_warning(
)));
}
/// Detects when the local calendar day changes while the app is running
/// (e.g. the app stays open past midnight) and refreshes the daily
/// challenge resource for the new day.
fn check_date_rollover(
time: Res<Time>,
mut timer: ResMut<DateRolloverTimer>,
mut daily: ResMut<DailyChallengeResource>,
mut shown: ResMut<DailyExpiryWarningShown>,
) {
timer.0.tick(time.delta());
if !timer.0.just_finished() {
return;
}
let today = Local::now().date_naive();
if today != daily.date {
info!(
"daily_challenge: date rolled over from {} to {}; refreshing challenge",
daily.date, today
);
*daily = DailyChallengeResource::for_today();
// Reset the expiry-warning state so the new day's warning can fire.
shown.0 = None;
}
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
#[allow(unused_imports)]
use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App {
@@ -346,7 +404,9 @@ mod tests {
// +100 from the daily bonus
assert!(progress.total_xp >= DAILY_BONUS_XP);
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let events = app
.world()
.resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -370,7 +430,9 @@ mod tests {
let progress = &app.world().resource::<ProgressResource>().0;
assert_eq!(progress.daily_challenge_streak, 0);
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let events = app
.world()
.resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -395,7 +457,10 @@ mod tests {
app.update();
let progress = &app.world().resource::<ProgressResource>().0;
assert_eq!(progress.daily_challenge_streak, 1, "streak does not double-count");
assert_eq!(
progress.daily_challenge_streak, 1,
"streak does not double-count"
);
}
#[test]
@@ -428,7 +493,9 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let events = app
.world()
.resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
@@ -439,14 +506,21 @@ mod tests {
fn pressing_c_with_no_description_uses_fallback() {
let mut app = headless_app();
// Ensure no description is set.
assert!(app.world().resource::<DailyChallengeResource>().goal_description.is_none());
assert!(
app.world()
.resource::<DailyChallengeResource>()
.goal_description
.is_none()
);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let events = app
.world()
.resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
@@ -511,13 +585,8 @@ mod tests {
fn warning_suppressed_when_already_completed_today() {
// 23:50 UTC inside threshold, but today is already done.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 8)),
None,
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), Some(ymd(2026, 5, 8)), None, now, 30);
assert_eq!(mins, None);
}
@@ -525,26 +594,16 @@ mod tests {
fn warning_suppressed_when_yesterdays_completion_is_stale() {
// Yesterday's completion is irrelevant — we want to warn about today.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 7)),
None,
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), Some(ymd(2026, 5, 7)), None, now, 30);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_suppressed_when_already_shown_for_this_date() {
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 8)),
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), None, Some(ymd(2026, 5, 8)), now, 30);
assert_eq!(mins, None);
}
@@ -553,13 +612,8 @@ mod tests {
// Player kept the app open across a midnight rollover. Stale
// "shown" date doesn't suppress today's warning.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 7)),
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), None, Some(ymd(2026, 5, 7)), now, 30);
assert_eq!(mins, Some(10));
}
@@ -578,9 +632,7 @@ mod tests {
let today = app.world().resource::<DailyChallengeResource>().date;
// Pre-mark warning as already shown for today.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today);
app.world_mut().resource_mut::<DailyExpiryWarningShown>().0 = Some(today);
// Flush any stale events from headless_app()'s initial update (the
// double-buffer keeps them visible for one extra frame).
app.update();
@@ -596,9 +648,7 @@ mod tests {
);
// Reset shown, mark today as completed.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = None;
app.world_mut().resource_mut::<DailyExpiryWarningShown>().0 = None;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
+9 -10
View File
@@ -14,7 +14,7 @@
//! because the starting position is effectively random (player-chosen timing
//! determines which seed in the 40-entry catalog they start at).
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::Utc;
use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, GameMode};
@@ -74,10 +74,7 @@ impl Plugin for DifficultyPlugin {
app.init_resource::<DifficultyIndexResource>()
.add_message::<StartDifficultyRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_systems(
Update,
handle_difficulty_request.before(GameMutation),
);
.add_systems(Update, handle_difficulty_request.before(GameMutation));
}
}
@@ -107,10 +104,9 @@ fn handle_difficulty_request(
}
fn seed_from_system_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
// std::time::SystemTime panics on wasm32-unknown-unknown).
Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
}
// ---------------------------------------------------------------------------
@@ -210,7 +206,10 @@ mod tests {
let events = drain_new_game_events(&mut app);
assert_eq!(events.len(), 1);
assert!(events[0].seed.is_some(), "Random should always produce Some(seed)");
assert!(
events[0].seed.is_some(),
"Random should always produce Some(seed)"
);
assert_eq!(
events[0].mode,
Some(GameMode::Difficulty(DifficultyLevel::Random))
+6 -6
View File
@@ -1,9 +1,9 @@
//! Cross-system events used by the engine's plugins.
use bevy::prelude::Message;
use solitaire_core::KlondikePile;
use solitaire_core::card::Suit;
use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
@@ -11,8 +11,8 @@ use solitaire_sync::SyncResponse;
/// consumed by `GamePlugin`.
#[derive(Message, Debug, Clone)]
pub struct MoveRequestEvent {
pub from: PileType,
pub to: PileType,
pub from: KlondikePile,
pub to: KlondikePile,
pub count: usize,
}
@@ -49,8 +49,8 @@ pub struct StateChangedEvent;
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
#[derive(Message, Debug, Clone)]
pub struct MoveRejectedEvent {
pub from: PileType,
pub to: PileType,
pub from: KlondikePile,
pub to: KlondikePile,
pub count: usize,
}
@@ -302,5 +302,5 @@ pub struct HintVisualEvent {
/// The `Card::id` of the source card to be highlighted.
pub source_card_id: u32,
/// The destination pile whose `PileMarker` should be tinted gold.
pub dest_pile: solitaire_core::pile::PileType,
pub dest_pile: KlondikePile,
}
+161 -22
View File
@@ -42,7 +42,8 @@ use std::f32::consts::PI;
use std::hash::{Hash, Hasher};
use bevy::prelude::*;
use solitaire_core::pile::PileType;
use bevy::window::RequestRedraw;
use solitaire_core::{Foundation, KlondikePile};
use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim;
@@ -204,16 +205,22 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<RequestRedraw>()
.add_systems(
Update,
(
start_shake_anim.after(GameMutation),
tick_shake_anim,
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,
start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation),
tick_foundation_flourish,
),
);
}
@@ -228,14 +235,19 @@ impl Plugin for FeedbackAnimPlugin {
fn start_shake_anim(
mut events: MessageReader<MoveRejectedEvent>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
mut commands: Commands,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
for ev in events.read() {
if reduce_motion {
continue;
}
let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile.
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
let dest_cards = pile_cards(&game.0, dest_pile);
let dest_card_ids: Vec<u32> = dest_cards.iter().map(|c| c.id).collect();
if dest_card_ids.is_empty() {
continue;
@@ -305,19 +317,19 @@ fn start_settle_anim(
let mut bounce_ids: Vec<u32> = Vec::new();
for ev in moves.read() {
if let Some(pile) = game.0.piles.get(&ev.to) {
let pile = pile_cards(&game.0, &ev.to);
if !pile.is_empty() {
// The moved cards land on top — take the last `count` ids.
let n = ev.count.min(pile.cards.len());
let n = ev.count.min(pile.len());
if n > 0 {
let start = pile.cards.len() - n;
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
let start = pile.len() - n;
bounce_ids.extend(pile[start..].iter().map(|c| c.id));
}
}
}
if draws.read().next().is_some()
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
&& let Some(top) = pile.cards.last()
&& let Some(top) = game.0.waste_cards().last()
{
bounce_ids.push(top.id);
}
@@ -385,7 +397,9 @@ fn start_deal_anim(
return;
}
let Some(layout) = layout else { return };
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { return };
let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
return;
};
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
let speed = settings.as_ref().map(|s| &s.0.animation_speed);
@@ -489,20 +503,28 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
fn start_foundation_flourish(
mut events: MessageReader<FoundationCompletedEvent>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity)>,
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
mut pile_markers: Query<(
Entity,
&PileMarker,
&Sprite,
Option<&FoundationMarkerFlourish>,
)>,
mut commands: Commands,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
for ev in events.read() {
let pile_type = PileType::Foundation(ev.slot);
if reduce_motion {
continue;
}
let Some(foundation) = foundation_from_slot(ev.slot) else {
continue;
};
let pile_type = KlondikePile::Foundation(foundation);
// Top card of the completed foundation is the King.
let Some(king_id) = game
.0
.piles
.get(&pile_type)
.and_then(|p| p.cards.last())
.map(|c| c.id)
else {
let cards = game.0.pile(pile_type);
let Some(king_id) = cards.last().map(|c| c.id) else {
continue;
};
@@ -608,6 +630,26 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
)
}
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<solitaire_core::card::Card> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
fn foundation_from_slot(slot: u8) -> Option<Foundation> {
match slot {
0 => Some(Foundation::Foundation1),
1 => Some(Foundation::Foundation2),
2 => Some(Foundation::Foundation3),
3 => Some(Foundation::Foundation4),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required)
// ---------------------------------------------------------------------------
@@ -752,7 +794,8 @@ mod tests {
"flourish scale at t=0 must be 1.0"
);
assert!(
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs()
< 1e-5,
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
);
assert!(
@@ -785,7 +828,7 @@ mod tests {
#[test]
fn deal_stagger_jitter_varies_across_card_ids() {
// 52 cards should produce more than a couple distinct jitter factors;
// a constant function would return one value for all ids.
// a constant function would return one function for all ids.
use std::collections::HashSet;
let unique: HashSet<u64> = (0u32..52)
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
@@ -796,4 +839,100 @@ mod tests {
unique.len()
);
}
// -----------------------------------------------------------------------
// Reduce-motion gates — ShakeAnim, FoundationFlourish
// -----------------------------------------------------------------------
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
/// is on, even when the event targets a pile that has card entities present.
#[test]
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::Tableau;
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 = KlondikePile::Tableau(Tableau::Tableau1);
let card_id = app
.world()
.resource::<GameStateResource>()
.0
.pile(dest_pile)
.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: KlondikePile::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
// already query `Option<Res<FontResource>>` and degrade cleanly.
let Some(mut fonts) = fonts else { return };
let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec())
.expect("bundled FiraMono failed to parse — binary is corrupt");
let font = match Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec()) {
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);
commands.insert_resource(FontResource(handle));
}
File diff suppressed because it is too large Load Diff
+229 -71
View File
@@ -9,13 +9,17 @@ use bevy::prelude::*;
use crate::events::HelpRequestEvent;
use crate::font_plugin::FontResource;
#[cfg(target_os = "android")]
use crate::hud_plugin::ANDROID_HINT_LABEL;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, 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"))]
use crate::ui_theme::{BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TYPE_CAPTION, VAL_SPACE_1};
/// Marker on the help overlay root node.
#[derive(Component, Debug)]
@@ -65,6 +69,7 @@ fn toggle_help_screen(
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<HelpRequestEvent>,
screens: Query<Entity, With<HelpScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<HelpScreen>)>,
font_res: Option<Res<FontResource>>,
) {
// Either F1 or a click on the HUD "Help" button (which fires
@@ -75,7 +80,7 @@ fn toggle_help_screen(
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
} else if other_modal_scrims.is_empty() {
spawn_help_screen(&mut commands, font_res.as_deref());
}
}
@@ -140,26 +145,56 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Touch",
rows: &[
ControlRow { keys: "Tap stock", description: "Draw from stock" },
ControlRow { keys: "Drag card", description: "Move cards between piles" },
ControlRow { keys: "Tap foundation area", description: "Auto-move top card to foundation" },
ControlRow {
keys: "Tap stock",
description: "Draw from stock",
},
ControlRow {
keys: "Drag card",
description: "Move cards between piles",
},
ControlRow {
keys: "Tap foundation area",
description: "Auto-move top card to foundation",
},
],
},
ControlSection {
title: "New Game",
rows: &[
ControlRow { keys: "New+", description: "Start a new Classic game" },
ControlRow { keys: "Modes↓", description: "Pick Daily, Zen, Challenge, or Time Attack" },
ControlRow {
keys: "New+",
description: "Start a new Classic game",
},
ControlRow {
keys: "Modes↓",
description: "Pick Daily, Zen, Challenge, or Time Attack",
},
],
},
ControlSection {
title: "HUD buttons",
rows: &[
ControlRow { keys: "", description: "Undo last move" },
ControlRow { keys: "||", description: "Pause / resume" },
ControlRow { keys: "?", description: "This help screen" },
ControlRow { keys: "", description: "Show a hint" },
ControlRow { keys: "", description: "Open menu (Stats, Settings, Profile...)" },
ControlRow {
keys: "",
description: "Undo last move",
},
ControlRow {
keys: "||",
description: "Pause / resume",
},
ControlRow {
keys: "?",
description: "This help screen",
},
ControlRow {
keys: ANDROID_HINT_LABEL,
description: "Show a hint",
},
ControlRow {
keys: "",
description: "Open menu (Stats, Settings, Profile...)",
},
],
},
];
@@ -169,17 +204,35 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Gameplay",
rows: &[
ControlRow { keys: "Drag", description: "Move cards between piles" },
ControlRow { keys: "D / Space", description: "Draw from stock" },
ControlRow { keys: "U", description: "Undo last move" },
ControlRow { keys: "Click stock", description: "Draw" },
ControlRow {
keys: "Drag",
description: "Move cards between piles",
},
ControlRow {
keys: "D / Space",
description: "Draw from stock",
},
ControlRow {
keys: "U",
description: "Undo last move",
},
ControlRow {
keys: "Click stock",
description: "Draw",
},
],
},
ControlSection {
title: "Mouse",
rows: &[
ControlRow { keys: "Double-click", description: "Auto-move card to its best destination" },
ControlRow { keys: "Right-click", description: "Highlight legal destinations briefly" },
ControlRow {
keys: "Double-click",
description: "Auto-move card to its best destination",
},
ControlRow {
keys: "Right-click",
description: "Highlight legal destinations briefly",
},
ControlRow {
keys: "Hold RMB",
description: "Open radial menu — release over an icon to quick-drop",
@@ -189,48 +242,129 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Keyboard drag",
rows: &[
ControlRow { keys: "Tab", description: "Focus next draggable card" },
ControlRow { keys: "Enter", description: "Lift focused card (then arrows pick where)" },
ControlRow { keys: "Arrows / Tab", description: "Cycle legal destinations while lifted" },
ControlRow { keys: "Enter", description: "Drop the lifted cards on the focused pile" },
ControlRow { keys: "Esc", description: "Cancel lift (Esc again clears focus)" },
ControlRow { keys: "Space", description: "Auto-move focused card (foundation first)" },
ControlRow {
keys: "Tab",
description: "Focus next draggable card",
},
ControlRow {
keys: "Enter",
description: "Lift focused card (then arrows pick where)",
},
ControlRow {
keys: "Arrows / Tab",
description: "Cycle legal destinations while lifted",
},
ControlRow {
keys: "Enter",
description: "Drop the lifted cards on the focused pile",
},
ControlRow {
keys: "Esc",
description: "Cancel lift (Esc again clears focus)",
},
ControlRow {
keys: "Space",
description: "Auto-move focused card (foundation first)",
},
],
},
ControlSection {
title: "New Game",
rows: &[
ControlRow { keys: "N", description: "New Classic game (N twice if in progress)" },
ControlRow { keys: "C", description: "Start today's daily challenge" },
ControlRow { keys: "Z", description: "Start a Zen game (level 5+)" },
ControlRow { keys: "X", description: "Start the next Challenge (level 5+)" },
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
ControlRow {
keys: "N",
description: "New Classic game (N twice if in progress)",
},
ControlRow {
keys: "C",
description: "Start today's daily challenge",
},
ControlRow {
keys: "Z",
description: "Start a Zen game (level 5+)",
},
ControlRow {
keys: "X",
description: "Start the next Challenge (level 5+)",
},
ControlRow {
keys: "T",
description: "Start a Time Attack session (level 5+)",
},
],
},
ControlSection {
title: "Mode Launcher (M)",
rows: &[
ControlRow { keys: "1", description: "Launch Classic" },
ControlRow { keys: "2", description: "Launch Daily Challenge" },
ControlRow { keys: "3", description: "Launch Zen (level 5+)" },
ControlRow { keys: "4", description: "Launch Challenge (level 5+)" },
ControlRow { keys: "5", description: "Launch Time Attack (level 5+)" },
ControlRow {
keys: "1",
description: "Launch Classic",
},
ControlRow {
keys: "2",
description: "Launch Daily Challenge",
},
ControlRow {
keys: "3",
description: "Launch Zen (level 5+)",
},
ControlRow {
keys: "4",
description: "Launch Challenge (level 5+)",
},
ControlRow {
keys: "5",
description: "Launch Time Attack (level 5+)",
},
],
},
ControlSection {
title: "Overlays",
rows: &[
ControlRow { keys: "M", description: "Mode launcher (Home)" },
ControlRow { keys: "P", description: "Profile" },
ControlRow { keys: "S", description: "Stats & progression" },
ControlRow { keys: "A", description: "Achievements" },
ControlRow { keys: "L", description: "Leaderboard" },
ControlRow { keys: "O", description: "Settings" },
ControlRow { keys: "F1", description: "This help screen" },
ControlRow { keys: "F11", description: "Toggle fullscreen" },
ControlRow { keys: "Esc", description: "Pause / resume" },
ControlRow { keys: "[ / ]", description: "SFX volume down / up" },
ControlRow { keys: "Enter", description: "Play Again (on the Win Summary)" },
ControlRow {
keys: "M",
description: "Mode launcher (Home)",
},
ControlRow {
keys: "P",
description: "Profile",
},
ControlRow {
keys: "S",
description: "Stats & progression",
},
ControlRow {
keys: "A",
description: "Achievements",
},
ControlRow {
keys: "L",
description: "Leaderboard",
},
ControlRow {
keys: "O",
description: "Settings",
},
ControlRow {
keys: "F1",
description: "This help screen",
},
ControlRow {
keys: "F11",
description: "Toggle fullscreen",
},
ControlRow {
keys: "Esc",
description: "Pause / resume",
},
ControlRow {
keys: "[ / ]",
description: "SFX volume down / up",
},
ControlRow {
keys: "Enter",
description: "Play Again (on the Win Summary)",
},
],
},
];
@@ -243,7 +377,6 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default()
};
let font_row = font_section.clone();
#[cfg(not(target_os = "android"))]
let font_kbd = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
@@ -288,27 +421,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default()
})
.with_children(|line| {
// Keyboard chip — suppressed on Android (no keyboard).
#[cfg(not(target_os = "android"))]
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(row.keys),
font_kbd.clone(),
TextColor(TEXT_PRIMARY),
));
});
// Keyboard chip — suppressed on touch-first Android builds.
if SHOW_KEYBOARD_ACCELERATORS {
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(row.keys),
font_kbd.clone(),
TextColor(TEXT_PRIMARY),
));
});
}
line.spawn((
Text::new(row.description),
font_row.clone(),
@@ -346,6 +481,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
mod tests {
use super::*;
/// Regression test for M-17: Android help screen showed "→" (right-arrow)
/// for the Hint button when the actual HUD button label is "!".
/// Verifies that the HUD Buttons section contains exactly one row whose
/// `keys` matches `ANDROID_HINT_LABEL`.
#[cfg(target_os = "android")]
#[test]
fn android_hint_row_matches_hud_label() {
use crate::hud_plugin::ANDROID_HINT_LABEL;
let hud_section = CONTROL_SECTIONS
.iter()
.find(|s| s.title == "HUD buttons")
.expect("HUD buttons section must exist");
let hint_row = hud_section
.rows
.iter()
.find(|r| r.description == "Show a hint")
.expect("hint row must exist");
assert_eq!(
hint_row.keys, ANDROID_HINT_LABEL,
"help hint row must match the HUD button label"
);
}
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
+105 -79
View File
@@ -13,8 +13,8 @@
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
//! or close the overlay.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_data::save_settings_to;
@@ -28,14 +28,12 @@ use crate::events::{
};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::{
SettingsChangedEvent, SettingsResource, SettingsStoragePath,
};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
use crate::stats_plugin::StatsResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
ButtonVariant, ModalButton, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
@@ -173,22 +171,25 @@ impl HomeMode {
}
/// The keyboard accelerator that dispatches the same launch event,
/// shown in a small chip on the card.
#[cfg(not(target_os = "android"))]
fn hotkey(self) -> &'static str {
match self {
/// shown in a small chip on desktop cards.
fn hotkey(self) -> Option<&'static str> {
let key = match self {
HomeMode::Classic => "N",
HomeMode::Daily => "C",
HomeMode::Zen => "Z",
HomeMode::Challenge => "X",
HomeMode::TimeAttack => "T",
HomeMode::PlayBySeed => "6",
}
};
crate::platform::SHOW_KEYBOARD_ACCELERATORS.then_some(key)
}
/// `true` when the mode is gated behind `CHALLENGE_UNLOCK_LEVEL`.
fn requires_unlock(self) -> bool {
matches!(self, HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack)
matches!(
self,
HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack
)
}
/// `true` if the player at `level` is allowed to launch the mode.
@@ -341,7 +342,10 @@ fn spawn_home_on_launch(
}
// Pre-expand the difficulty section when the player has a saved preference.
if settings.as_ref().is_some_and(|s| s.0.last_difficulty.is_some()) {
if settings
.as_ref()
.is_some_and(|s| s.0.last_difficulty.is_some())
{
diff_expanded.0 = true;
}
@@ -373,6 +377,7 @@ fn toggle_home_screen(
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
diff_expanded: Res<DifficultyExpanded>,
) {
if !keys.just_pressed(KeyCode::KeyM) {
@@ -380,7 +385,7 @@ fn toggle_home_screen(
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
} else if other_modal_scrims.is_empty() {
spawn_home_screen(
&mut commands,
build_home_context(
@@ -427,9 +432,7 @@ fn build_home_context<'a>(
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings
.map(|s| s.0.draw_mode.clone())
.unwrap_or(DrawMode::DrawOne),
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
@@ -589,6 +592,7 @@ fn handle_home_draw_mode_buttons(
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
@@ -601,6 +605,12 @@ fn handle_home_draw_mode_buttons(
if screens.is_empty() {
return;
}
// Don't respawn while another modal sits on top — the despawn queues
// immediately but executes at end of frame, so a respawn in the same
// frame would create a second concurrent ModalScrim.
if !other_modal_scrims.is_empty() {
return;
}
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
if !want_one && !want_three {
@@ -658,6 +668,7 @@ fn handle_home_difficulty_toggle(
mut commands: Commands,
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
mut diff_expanded: ResMut<DifficultyExpanded>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
@@ -668,6 +679,9 @@ fn handle_home_difficulty_toggle(
if screens.is_empty() {
return;
}
if !other_modal_scrims.is_empty() {
return;
}
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
return;
}
@@ -1100,10 +1114,18 @@ fn spawn_draw_mode_chip<M: Component>(
/// update without Visibility component surgery.
fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_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_label = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_chip = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
let chevron = if ctx.difficulty_expanded { "" } else { "" };
let chevron = if ctx.difficulty_expanded { "v" } else { ">" };
// Header row — click to toggle expand/collapse.
parent
@@ -1171,11 +1193,7 @@ fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|c| {
c.spawn((
Text::new(level.label()),
font_chip.clone(),
TextColor(fg),
));
c.spawn((Text::new(level.label()), font_chip.clone(), TextColor(fg)));
});
}
});
@@ -1210,12 +1228,11 @@ fn score_chip_text_for(mode: HomeMode, ctx: &HomeContext<'_>) -> Option<String>
HomeMode::Zen if ctx.zen_best > 0 => {
Some(format!("Best {}", format_compact(ctx.zen_best as u64)))
}
HomeMode::Challenge if ctx.challenge_best > 0 => {
Some(format!("Best {}", format_compact(ctx.challenge_best as u64)))
}
HomeMode::Daily if ctx.daily_streak > 0 => {
Some(format!("Streak {}", ctx.daily_streak))
}
HomeMode::Challenge if ctx.challenge_best > 0 => Some(format!(
"Best {}",
format_compact(ctx.challenge_best as u64)
)),
HomeMode::Daily if ctx.daily_streak > 0 => Some(format!("Streak {}", ctx.daily_streak)),
_ => None,
}
}
@@ -1289,11 +1306,7 @@ fn attach_focusable_to_home_mode_cards(
/// feedback is supplied by `paint_modal_buttons` via the `ModalButton`
/// component, which we attach with `ButtonVariant::Secondary` so the card
/// reads as a standard interactive surface.
fn spawn_mode_card(
parent: &mut ChildSpawnerCommands,
mode: HomeMode,
ctx: &HomeContext<'_>,
) {
fn spawn_mode_card(parent: &mut ChildSpawnerCommands, mode: HomeMode, ctx: &HomeContext<'_>) {
let level = ctx.level;
let font_res = ctx.font_res;
let score_chip = score_chip_text_for(mode, ctx);
@@ -1325,10 +1338,26 @@ fn spawn_mode_card(
// Locked cards mute their text to communicate the disabled state at
// a glance; the explicit "Unlocks at level N" caption underneath
// backs that up with copy.
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
let glyph_color = if unlocked { ACCENT_PRIMARY } else { TEXT_DISABLED };
let title_color = if unlocked {
TEXT_PRIMARY
} else {
TEXT_DISABLED
};
let desc_color = if unlocked {
TEXT_SECONDARY
} else {
TEXT_DISABLED
};
let border_color = if unlocked {
BORDER_SUBTLE
} else {
BORDER_STRONG
};
let glyph_color = if unlocked {
ACCENT_PRIMARY
} else {
TEXT_DISABLED
};
parent
.spawn((
@@ -1337,6 +1366,7 @@ fn spawn_mode_card(
// bevy::ui — the click handler queries on `&Interaction`
// which Button drives.
Button,
ModalButton(ButtonVariant::Secondary),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
@@ -1378,27 +1408,28 @@ fn spawn_mode_card(
));
if unlocked {
// Hotkey chip — suppressed on Android (touch builds have no keyboard).
#[cfg(not(target_os = "android"))]
row.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(32.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(mode.hotkey().to_string()),
font_chip.clone(),
TextColor(TEXT_SECONDARY),
));
});
// Hotkey chip — suppressed on touch-first Android builds.
if let Some(hotkey) = mode.hotkey() {
row.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(32.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(hotkey),
font_chip.clone(),
TextColor(TEXT_SECONDARY),
));
});
}
} else {
// Lock icon stand-in — text glyph keeps the layout
// dependency-free (no asset loader required) and
@@ -1474,9 +1505,7 @@ fn spawn_mode_card(
// Locked footnote — explicit copy so the gate is unambiguous.
if !unlocked {
c.spawn((
Text::new(format!(
"Unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)),
Text::new(format!("Unlocks at level {CHALLENGE_UNLOCK_LEVEL}")),
TextFont {
font: font_desc.font.clone(),
font_size: TYPE_CAPTION,
@@ -1719,10 +1748,7 @@ mod tests {
fn unlocked_zen_click_fires_start_zen_event_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
@@ -1976,10 +2002,7 @@ mod tests {
let mut app = headless_app();
// Bump the player to the unlock level *before* opening the modal
// so the Mode Launcher is in its unlocked state.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
@@ -2011,10 +2034,7 @@ mod tests {
let mut app = headless_app();
// Modal is NOT open. Bump level so Zen would otherwise be allowed
// — this isolates the modal-scope guard from the unlock check.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
// Drain any pre-existing events.
app.world_mut()
@@ -2056,19 +2076,25 @@ mod tests {
zc.read(zen).next().is_none(),
"Digit keys with no modal open must not fire StartZenRequestEvent"
);
let chal = app.world().resource::<Messages<StartChallengeRequestEvent>>();
let chal = app
.world()
.resource::<Messages<StartChallengeRequestEvent>>();
let mut cc = chal.get_cursor();
assert!(
cc.read(chal).next().is_none(),
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
);
let ta = app.world().resource::<Messages<StartTimeAttackRequestEvent>>();
let ta = app
.world()
.resource::<Messages<StartTimeAttackRequestEvent>>();
let mut tc = ta.get_cursor();
assert!(
tc.read(ta).next().is_none(),
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
);
let daily = app.world().resource::<Messages<StartDailyChallengeRequestEvent>>();
let daily = app
.world()
.resource::<Messages<StartDailyChallengeRequestEvent>>();
let mut dc = daily.get_cursor();
assert!(
dc.read(daily).next().is_none(),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+211 -55
View File
@@ -7,7 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet};
use solitaire_core::pile::PileType;
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Schedule labels for layout-related systems so cross-plugin ordering is
/// explicit instead of relying on Bevy's automatic resource-conflict ordering
@@ -75,7 +75,7 @@ const VERTICAL_GAP_FRAC: f32 = 0.2;
/// column must fit at this fraction). On desktop (height-limited) windows the
/// adaptive computation returns this value exactly; on portrait phones it
/// expands to fill available vertical space.
const TABLEAU_FAN_FRAC: f32 = 0.25;
pub const TABLEAU_FAN_FRAC: f32 = 0.18;
/// Minimum fraction for face-down tableau cards. Scales proportionally with
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
@@ -84,7 +84,7 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
/// enough of each card back to read as a meaningful stack rather than a
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
/// the adaptive scaling in `compute_layout`.
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
/// after every face-down card has flipped on column 7. Layout sizing must keep
@@ -96,13 +96,33 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
/// below this band so the HUD doesn't bleed into the play surface.
///
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
/// Android: 80 px gives the same content rows comfortable clearance.
/// (Previously 128 px when action buttons lived in the top band; those are
/// now in the bottom bar so the larger reserve is no longer needed.)
/// Android: 112 px — the HUD column has 4 flex tiers with 3 inter-tier
/// gaps (4 px each) plus a SPACE_2 = 8 px top offset. With empty tiers
/// 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"))]
pub const HUD_BAND_HEIGHT: f32 = 64.0;
#[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).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -118,12 +138,12 @@ pub struct Layout {
/// Centre position of each pile, in 2D world coordinates.
///
/// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up.
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
/// Every `KlondikePile` (Stock, Waste, four Foundations, seven Tableaux) has an
/// entry. The map always contains exactly 13 entries after `compute_layout`.
pub pile_positions: HashMap<PileType, Vec2>,
pub pile_positions: HashMap<KlondikePile, Vec2>,
/// Per-step vertical offset fraction for face-up tableau cards, as a
/// fraction of `card_size.y`. On height-limited (desktop) windows this
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone)
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
/// windows it expands to fill the available vertical space so the tableau
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
/// and hit testing (`input_plugin`) both read from this field so they
@@ -163,7 +183,12 @@ pub struct Layout {
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
/// waste/stock cluster from the foundations.
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, hud_visible: bool) -> Layout {
pub fn compute_layout(
window: Vec2,
safe_area_top: f32,
safe_area_bottom: f32,
hud_visible: bool,
) -> Layout {
let window = window.max(MIN_WINDOW);
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
@@ -187,9 +212,14 @@ 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
// largest w that fits gives:
// (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 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_height = card_width * CARD_ASPECT;
@@ -211,21 +241,38 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0;
let tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
let mut pile_positions: HashMap<KlondikePile, Vec2> = HashMap::with_capacity(13);
pile_positions.insert(PileType::Stock, Vec2::new(col_x(0), top_y));
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
pile_positions.insert(KlondikePile::Stock, Vec2::new(col_x(1), top_y));
// Column 2 is skipped — visual separation between waste and foundations.
for slot in 0..4_u8 {
let foundation = match slot {
0 => Foundation::Foundation1,
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
_ => Foundation::Foundation4,
};
pile_positions.insert(
PileType::Foundation(slot),
KlondikePile::Foundation(foundation),
Vec2::new(col_x(3 + slot as usize), top_y),
);
}
for i in 0..7 {
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
let tableau = match i {
0 => Tableau::Tableau1,
1 => Tableau::Tableau2,
2 => Tableau::Tableau3,
3 => Tableau::Tableau4,
4 => Tableau::Tableau5,
5 => Tableau::Tableau6,
_ => Tableau::Tableau7,
};
pile_positions.insert(
KlondikePile::Tableau(tableau),
Vec2::new(col_x(i), tableau_y),
);
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
@@ -238,7 +285,8 @@ 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
// 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 {
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
} else {
@@ -270,21 +318,37 @@ mod tests {
use super::*;
fn assert_all_piles_present(layout: &Layout) {
assert!(layout.pile_positions.contains_key(&PileType::Stock));
assert!(layout.pile_positions.contains_key(&PileType::Waste));
for slot in 0..4_u8 {
assert!(layout.pile_positions.contains_key(&KlondikePile::Stock));
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
assert!(
layout.pile_positions.contains_key(&PileType::Foundation(slot)),
"missing foundation slot {slot}",
layout
.pile_positions
.contains_key(&KlondikePile::Foundation(foundation)),
"missing foundation slot {foundation:?}",
);
}
for i in 0..7 {
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
assert!(
layout.pile_positions.contains_key(&PileType::Tableau(i)),
"missing tableau {i}"
layout
.pile_positions
.contains_key(&KlondikePile::Tableau(tableau)),
"missing tableau {tableau:?}"
);
}
assert_eq!(layout.pile_positions.len(), 13);
assert_eq!(layout.pile_positions.len(), 12);
}
#[test]
@@ -343,9 +407,18 @@ mod tests {
#[test]
fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for i in 0..6 {
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
let tableaus = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
for i in 0..tableaus.len() - 1 {
let lhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i])].x;
let rhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i + 1])].x;
assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1);
}
}
@@ -353,8 +426,8 @@ mod tests {
#[test]
fn top_row_is_above_tableau_row() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let stock_y = layout.pile_positions[&KlondikePile::Stock].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
assert!(stock_y > tableau_y);
}
@@ -366,7 +439,7 @@ mod tests {
fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let stock_y = layout.pile_positions[&KlondikePile::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
assert!(
@@ -378,24 +451,35 @@ mod tests {
#[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_x = layout.pile_positions[&PileType::Stock].x;
let waste_x = layout.pile_positions[&PileType::Waste].x;
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
let t1_x = layout.pile_positions[&PileType::Tableau(1)].x;
assert!((stock_x - t0_x).abs() < 1e-5);
assert!((waste_x - t1_x).abs() < 1e-5);
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let t1_x = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau2)].x;
assert!((stock_x - t1_x).abs() < 1e-5);
}
#[test]
fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for slot in 0..4_u8 {
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
let target_tableaus = [
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
for (idx, foundation) in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
.iter()
.enumerate()
{
let f_x = layout.pile_positions[&KlondikePile::Foundation(*foundation)].x;
let t_x = layout.pile_positions[&KlondikePile::Tableau(target_tableaus[idx])].x;
assert!(
(f_x - t_x).abs() < 1e-5,
"foundation slot {slot} should align with tableau {}",
3 + slot as usize,
"foundation slot {idx} should align with tableau {}",
3 + idx,
);
}
}
@@ -437,7 +521,7 @@ mod tests {
// Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card.
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
@@ -456,7 +540,7 @@ mod tests {
// The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
let h_gap = layout.card_size.x / 4.0;
@@ -487,7 +571,7 @@ mod tests {
fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
let card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0;
// Bottom of the 13th (worst-case) fanned face-up card.
@@ -546,8 +630,8 @@ mod tests {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true);
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
let stock_no_inset = without.pile_positions[&KlondikePile::Stock].y;
let stock_with_inset = with_inset.pile_positions[&KlondikePile::Stock].y;
assert!(
stock_with_inset < stock_no_inset,
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
@@ -569,10 +653,10 @@ mod tests {
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true);
for pile in [
PileType::Stock,
PileType::Waste,
PileType::Tableau(0),
PileType::Tableau(6),
KlondikePile::Stock,
KlondikePile::Stock,
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau7),
] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
@@ -595,7 +679,7 @@ mod tests {
with_inset.tableau_fan_frac,
);
let card_h = with_inset.card_size.y;
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
let tableau_y = with_inset.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
let h_gap = with_inset.card_size.x / 4.0;
let margin = -window.y / 2.0 + 48.0 + h_gap;
@@ -605,13 +689,85 @@ mod tests {
);
}
/// Suspend → resume layout-consistency invariant.
///
/// If the resume handler resets `SafeAreaInsets` to zero and then the JNI
/// poller re-resolves the same values, `compute_layout` must produce an
/// identical result to the fresh-launch layout. This test also verifies
/// that a layout computed with `safe_area_top = 0` (the brief window while
/// insets haven't re-resolved after resume) differs visibly from the
/// correct layout, confirming that the bug would manifest without the fix.
#[test]
fn suspend_resume_layout_matches_fresh_launch() {
let window = Vec2::new(900.0, 2000.0);
let safe_top = 27.0_f32;
let safe_bottom = 110.0_f32;
// Fresh-launch layout — insets known from startup.
let fresh = compute_layout(window, safe_top, safe_bottom, true);
// Layout computed during the brief post-resume window before insets
// re-resolve (safe_area_top temporarily 0).
let wrong = compute_layout(window, 0.0, safe_bottom, true);
// Verify the "wrong" layout actually differs — the bug would push the
// top card row upward by exactly safe_top pixels.
let fresh_stock_y = fresh.pile_positions[&KlondikePile::Stock].y;
let wrong_stock_y = wrong.pile_positions[&KlondikePile::Stock].y;
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
// downward (y direction). So wrong_stock_y > fresh_stock_y by safe_top.
assert!(
(wrong_stock_y - fresh_stock_y - safe_top).abs() < 1e-3,
"wrong layout must displace stock upward by safe_top ({safe_top}): \
fresh={fresh_stock_y:.2} wrong={wrong_stock_y:.2} delta={:.2}",
wrong_stock_y - fresh_stock_y,
);
// After the poller re-resolves correct insets the layout must be
// identical to the fresh-launch layout.
let corrected = compute_layout(window, safe_top, safe_bottom, true);
assert_eq!(
corrected.card_size, fresh.card_size,
"card size must be preserved after resume",
);
assert!(
(corrected.pile_positions[&KlondikePile::Stock].y - fresh_stock_y).abs() < 1e-3,
"stock y must match fresh launch after resume: \
corrected={:.2} fresh={fresh_stock_y:.2}",
corrected.pile_positions[&KlondikePile::Stock].y,
);
assert!(
(corrected.pile_positions[&KlondikePile::Stock].x
- fresh.pile_positions[&KlondikePile::Stock].x)
.abs()
< 1e-3,
"stock x must be unchanged after resume",
);
// The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| {
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
};
assert!(
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
"top-of-card must match fresh launch after resume: \
corrected={:.2} fresh={:.2}",
card_top(&corrected),
card_top(&fresh),
);
}
/// safe_area_bottom must not affect horizontal positions.
#[test]
fn safe_area_bottom_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0, true);
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
for pile in [
KlondikePile::Stock,
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau7),
] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_bottom",
+426 -53
View File
@@ -9,24 +9,28 @@
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
//! the panel shows "Not available" immediately.
use bevy::input::{ButtonState, keyboard::KeyboardInput, mouse::{MouseScrollUnit, MouseWheel}};
use bevy::input::{
ButtonState,
keyboard::KeyboardInput,
mouse::{MouseScrollUnit, MouseWheel},
};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_data::{save_settings_to, settings::SyncBackend};
use solitaire_sync::LeaderboardEntry;
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent, WarningToastEvent};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, Z_PAUSE_DIALOG,
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
};
// ---------------------------------------------------------------------------
@@ -138,6 +142,8 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<OptOutTask>()
.init_resource::<DisplayNameBuffer>()
.add_message::<ToggleLeaderboardRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<InfoToastEvent>()
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
// plugin under `DefaultPlugins`; register them explicitly so all
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
@@ -159,6 +165,7 @@ impl Plugin for LeaderboardPlugin {
handle_display_name_text_input,
handle_display_name_confirm,
handle_display_name_cancel,
update_leaderboard_public_name_label,
)
.chain(),
)
@@ -184,6 +191,7 @@ fn toggle_leaderboard_screen(
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>,
settings: Option<Res<SettingsResource>>,
@@ -201,22 +209,39 @@ fn toggle_leaderboard_screen(
return;
}
// Don't stack a second modal scrim over one that is already open.
if !other_modal_scrims.is_empty() {
return;
}
// Spawn the panel immediately with whatever data we have so far.
let remote_available = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
let dn = settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref());
spawn_leaderboard_screen(
&mut commands,
&data,
remote_available,
dn,
font_res.as_deref(),
);
// Start a background fetch if not already in flight.
if task_res.0.is_none()
&& let Some(p) = provider {
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider.fetch_leaderboard().await.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
&& let Some(p) = provider
{
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.fetch_leaderboard()
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
/// Poll the background fetch task; store results when complete.
@@ -224,8 +249,12 @@ fn poll_leaderboard_fetch(
mut task_res: ResMut<LeaderboardFetchTask>,
mut result_res: ResMut<LeaderboardFetchResult>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
result_res.0 = Some(result);
}
@@ -244,7 +273,9 @@ fn update_leaderboard_panel(
font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>,
) {
let Some(result) = result_res.0.take() else { return };
let Some(result) = result_res.0.take() else {
return;
};
match result {
Ok(entries) => {
@@ -269,10 +300,18 @@ fn update_leaderboard_panel(
let remote_available = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
let dn = settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref());
for entity in &screens {
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
spawn_leaderboard_screen(
&mut commands,
&data,
remote_available,
dn,
font_res.as_deref(),
);
}
}
@@ -350,32 +389,51 @@ fn handle_opt_in_button(
None
}
})
.map(str::to_string)
.map(|n| n.chars().take(32).collect::<String>())
})
.unwrap_or_else(|| "Player".to_string());
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.opt_in_leaderboard(&display_name).await.map_err(|e| e.to_string()) });
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);
}
}
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
/// Polls the opt-in task; fires a toast and persists opted-in state on completion.
fn poll_opt_in_task(
mut task_res: ResMut<OptInTask>,
mut toast: MessageWriter<InfoToastEvent>,
mut warn_toast: MessageWriter<WarningToastEvent>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
match result {
Ok(()) => {
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
if let Some(mut s) = settings {
s.0.leaderboard_opted_in = true;
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &s.0)
{
warn!("failed to save settings after opt-in: {e}");
}
}
}
Err(e) => {
warn!("leaderboard opt-in failed: {e}");
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
warn_toast.write(WarningToastEvent("Failed to join leaderboard".to_string()));
}
}
}
@@ -395,27 +453,46 @@ fn handle_opt_out_button(
continue;
}
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.opt_out_leaderboard().await.map_err(|e| e.to_string()) });
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.opt_out_leaderboard()
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
/// Polls the opt-out task; fires a toast and clears opted-in state on completion.
fn poll_opt_out_task(
mut task_res: ResMut<OptOutTask>,
mut toast: MessageWriter<InfoToastEvent>,
mut warn_toast: MessageWriter<WarningToastEvent>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
match result {
Ok(()) => {
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
if let Some(mut s) = settings {
s.0.leaderboard_opted_in = false;
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &s.0)
{
warn!("failed to save settings after opt-out: {e}");
}
}
}
Err(e) => {
warn!("leaderboard opt-out failed: {e}");
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
warn_toast.write(WarningToastEvent("Failed to leave leaderboard".to_string()));
}
}
}
@@ -428,6 +505,12 @@ fn poll_opt_out_task(
#[derive(Component, Debug)]
pub struct LeaderboardCloseButton;
/// Marker on the "Public name: …" label inside the leaderboard panel so it
/// can be updated reactively when the player changes their display name
/// without a full panel rebuild.
#[derive(Component, Debug)]
struct LeaderboardPublicNameText;
fn spawn_leaderboard_screen(
commands: &mut Commands,
data: &LeaderboardResource,
@@ -481,6 +564,7 @@ fn spawn_leaderboard_screen(
None => "Public name: (same as username)".to_string(),
};
row.spawn((
LeaderboardPublicNameText,
Text::new(label),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
@@ -683,6 +767,7 @@ fn data_cell(
fn handle_set_display_name_button(
button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>,
existing: Query<(), With<DisplayNameModal>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<DisplayNameModal>)>,
mut commands: Commands,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
@@ -694,6 +779,9 @@ fn handle_set_display_name_button(
if !existing.is_empty() {
return; // already open
}
if !other_modal_scrims.is_empty() {
return; // Another modal is already visible.
}
buf.0 = settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.clone())
@@ -733,7 +821,9 @@ fn handle_display_name_text_input(
}
}
/// Saves the typed display name to `SettingsResource` and closes the modal.
/// Saves the typed display name to `SettingsResource`, closes the modal, and
/// pushes the new name to the server when the player is already opted in.
#[allow(clippy::too_many_arguments)]
fn handle_display_name_confirm(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
screens: Query<Entity, With<DisplayNameModal>>,
@@ -741,22 +831,58 @@ fn handle_display_name_confirm(
buf: Res<DisplayNameBuffer>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
provider: Option<Res<SyncProviderResource>>,
mut task_res: ResMut<OptInTask>,
) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
return;
}
if let Some(mut settings) = settings {
let trimmed = buf.0.trim().to_string();
let trimmed: String = buf.0.trim().chars().take(32).collect();
settings.0.leaderboard_display_name = if trimmed.is_empty() {
None
} else {
Some(trimmed)
Some(trimmed.clone())
};
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = save_settings_to(path, &settings.0)
{
warn!("failed to save settings: {e}");
}
// Push updated name to the server when already opted in and no task
// is in flight. The server's opt-in endpoint is an upsert, so calling
// it a second time only updates the display_name column.
let is_remote = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
if settings.0.leaderboard_opted_in && is_remote && task_res.0.is_none() {
let display_name = settings
.0
.leaderboard_display_name
.clone()
.unwrap_or_else(|| {
if let solitaire_data::settings::SyncBackend::SolitaireServer {
ref username,
..
} = settings.0.sync_backend
{
username.chars().take(32).collect()
} else {
"Player".to_string()
}
});
if let Some(p) = provider {
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.opt_in_leaderboard(&display_name)
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
}
for entity in &screens {
commands.entity(entity).despawn();
@@ -857,6 +983,28 @@ fn spawn_display_name_modal(
});
}
/// Keeps the "Public name: …" label in the leaderboard panel in sync with
/// `SettingsResource` after the player saves a new display name. No-op when
/// the panel is closed (`labels.is_empty()` exits immediately).
fn update_leaderboard_public_name_label(
settings: Option<Res<SettingsResource>>,
mut labels: Query<&mut Text, With<LeaderboardPublicNameText>>,
) {
if labels.is_empty() {
return;
}
let new_label = match settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref())
{
Some(n) => format!("Public name: {n}"),
None => "Public name: (same as username)".to_string(),
};
for mut text in &mut labels {
text.0 = new_label.clone();
}
}
/// Accepts printable ASCII characters (0x200x7e) for the display-name field.
fn printable_char_dn(text: &str) -> Option<char> {
let ch = text.chars().next()?;
@@ -881,14 +1029,14 @@ fn format_secs(secs: u64) -> String {
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use crate::sync_plugin::SyncPlugin;
use solitaire_data::SyncError;
use solitaire_sync::{SyncPayload, SyncResponse};
use crate::table_plugin::TablePlugin;
use chrono::Utc;
use uuid::Uuid;
use solitaire_sync::PlayerProgress;
use solitaire_data::StatsSnapshot;
use solitaire_data::SyncError;
use solitaire_sync::PlayerProgress;
use solitaire_sync::{SyncPayload, SyncResponse};
use uuid::Uuid;
struct NoOpProvider;
@@ -916,18 +1064,20 @@ mod tests {
conflicts: vec![],
})
}
fn backend_name(&self) -> &'static str { "no-op" }
fn is_authenticated(&self) -> bool { false }
fn backend_name(&self) -> &'static str {
"no-op"
}
fn is_authenticated(&self) -> bool {
false
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
Ok(vec![
LeaderboardEntry {
display_name: "Alice".to_string(),
best_score: Some(5000),
best_time_secs: Some(180),
recorded_at: Utc::now(),
},
])
Ok(vec![LeaderboardEntry {
display_name: "Alice".to_string(),
best_score: Some(5000),
best_time_secs: Some(180),
recorded_at: Utc::now(),
}])
}
}
@@ -1048,4 +1198,227 @@ mod tests {
// 65 seconds = 1:05, not 1:5
assert_eq!(format_secs(65), "1:05");
}
// -------------------------------------------------------------------------
// Bug-fix regression tests
// -------------------------------------------------------------------------
fn headless_app_with_settings() -> App {
let mut app = headless_app();
app.insert_resource(SettingsResource(
solitaire_data::settings::Settings::default(),
));
app
}
/// Bug 1: opt-in errors must fire `WarningToastEvent`, not `InfoToastEvent`.
#[test]
fn opt_in_error_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with_settings();
// Inject a pre-resolved failed task directly into OptInTask.
let failed_task = AsyncComputeTaskPool::get()
.spawn(async { Err::<(), String>("network error".to_string()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
// 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"
);
}
}

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