Compare commits

...

68 Commits

Author SHA1 Message Date
funman300 9e3c6b06b0 chore: gitignore local agent-tooling artifacts
Keep Codex / claude-flow scaffolding (.agents/, .codex/, AGENTS.md) out
of the repo — these are locally generated and not project sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:17:04 -07:00
funman300 f0832f3dfa refactor: remove leftover redundancies after card_game migration
Post-migration audit found the card_game/klondike migration essentially
complete; these are the four small redundancies that remained:

- core: delete dead GameState::compute_time_bonus (zero callers; engine
  uses the klondike_adapter free fn directly)
- data: drop dead public re-exports load_latest_replay_from /
  save_latest_replay_to (no callers outside replay.rs); keep
  latest_replay_path (engine legacy migration still uses it)
- data+engine: lift win-XP scoring into a shared XpBreakdown so the
  win-summary modal breakdown and xp_for_win share one source of truth
  instead of duplicating the speed/no-undo constants
- engine: replace feedback_anim_plugin's private foundation_from_slot
  copy with the canonical klondike_adapter::foundation_from_slot

cargo test --workspace + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:12:03 -07:00
funman300 ef1efdc3b5 refactor(core): make KlondikeInstruction the move currency
Build and Deploy / build-and-push (push) Failing after 1m1s
Web E2E / web-e2e (push) Failing after 3m26s
Remove the (from, to, count) tuple as an internal move-passing wrapper.
Game logic now stays in KlondikeInstruction space end to end:

- Add GameState::apply_instruction, the native apply path. move_cards
  becomes a thin pile-coordinate adapter that converts to an instruction
  and delegates, so move bookkeeping (validation, score/recycle history,
  undo snapshot) lives in one place instead of being duplicated.
- next_auto_complete_move matches DstFoundation directly instead of
  projecting every candidate to pile coordinates.
- proptests and the storage round-trip test apply instructions directly
  rather than round-tripping instruction -> tuple -> move_cards.

The single instruction -> pile decode is renamed instruction_to_highlight
-> instruction_to_piles and kept in core: decoding a tableau run length
needs upstream pile-stack types core does not re-export, so relocating it
would duplicate the logic across engine and wasm. The two rendering edges
(engine hint highlight, wasm debug move list) call this one decoder; the
engine's hint_piles is a thin delegation to it.

Also includes the CardEntityIndex render-side index and a SelectionPlugin
init_resource fix so update_selection_highlight no longer panics in test
harnesses that omit CardPlugin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:58:28 -07:00
funman300 dc4cf45ea0 build(deps): switch klondike/card_game to Quaternions registry
Replace the git-rev pin (fb01881f, commit 2d0359c) with the published
Quaternions registry releases klondike 0.4.0 / card_game 0.4.1. The
mainline-rev switch broke clean resolution because it dropped the
`registry = "Quaternions"` selector; pinning the registry versions
restores a reproducible lockfile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:58:13 -07:00
funman300 0d3f037672 refactor: consolidate card_to_id into solitaire_core
Build and Deploy / build-and-push (push) Failing after 1m21s
Web E2E / web-e2e (push) Failing after 3m27s
Three byte-identical copies of the stable 0..=51 card-id helper
(suit_index*13 + rank-1) lived in feedback_anim_plugin, radial_menu, and
solitaire_wasm. The WASM copy's own comment notes it MUST match the engine
for cross-platform replay parity — exactly the kind of invariant a single
source of truth should enforce.

Add `solitaire_core::card::card_to_id(&Card) -> u32` and have all three
call sites import it. No behaviour change (same formula).

cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:14:20 -07:00
funman300 cac77a54a6 refactor: slim solver to card_game-native types
Build and Deploy / build-and-push (push) Failing after 1m34s
Web E2E / web-e2e (push) Failing after 4m22s
Per Rhys: card_game's solver is the real engine, so drop the redundant
adapter types in solitaire_data::solver rather than maintain a parallel
verdict/config/move vocabulary.

- Delete SolverResult, SolverConfig, SolverMove, and snapshot_to_solver_move.
  The verdict now reads straight off card_game's return:
    Ok(Some(instr)) = winnable (first move on the path)
    Ok(None)        = provably unwinnable
    Err(_)          = inconclusive (budget exceeded)
- SolveOutcome is now Result<Option<KlondikeInstruction>, SolveError>.
- try_solve / try_solve_from_state take plain (moves_budget, states_budget)
  u64s; add DEFAULT_SOLVE_{MOVES,STATES}_BUDGET consts.
- snapshot_to_solver_move duplicated core's GameState::instruction_to_move,
  so make that pub and have the hint convert the first-move instruction to
  highlighted (from, to) piles through it. Re-export KlondikeInstruction
  from solitaire_core.
- HintSolverConfig now holds { moves_budget, states_budget } instead of
  wrapping the deleted SolverConfig.
- Update consumers: pending_hint, play_by_seed (verdict badge), game_plugin
  (choose_winnable_seed), input_plugin, hud_plugin, and the gen_seeds /
  gen_difficulty_seeds asset tools.

solver.rs drops 274 -> 140 lines. cargo test --workspace and
cargo clippy --workspace --all-targets -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:05:47 -07:00
funman300 2d0359c2ee build(deps): switch card_game/klondike to mainline fb01881f
Build and Deploy / build-and-push (push) Failing after 1m6s
Web E2E / web-e2e (push) Failing after 3m7s
Move both crates off the damaged "hacked" rev 99b49e62 onto mainline
master (card_game 0.4.0->0.4.1, klondike 0.3.0->0.4.0) to pick up the new
serialize implementation.

Mainline drops the serde derives from Deck/Suit/Rank (only Card is serde
now, as a compact transparent NonZeroU8) and gives KlondikeInstruction a
hand-written serde impl. Adapt the repo:
- Rank::value() was removed; the enum discriminant is the 1..=13 value, so
  use `rank as u32/u8` in the three card_to_id helpers (wasm, radial_menu,
  feedback_anim).
- Drop the vestigial Serialize/Deserialize derive on theme::CardKey; theme
  manifests address faces by manifest_name strings, never by serialising
  CardKey, and Suit/Rank no longer implement serde.

GameState's own instruction-mirror serde (schema v3/v4) is insulated from
the klondike serde change, so the on-disk save format is unchanged.

cargo test --workspace and cargo clippy --workspace -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:34:56 -07:00
funman300 056459619b refactor(core): derive draw_mode/is_won/move_count/is_auto_completable from session
Remove the draw_mode, move_count, is_won, and is_auto_completable fields
from GameState; they are now &self methods deriving from the underlying
card_game session (draw_mode from session config, move_count from history
length, is_won/is_auto_completable from check_win/check_auto_complete).

Tests previously fabricated these via direct field writes, which is no
longer possible. Add gated test-support overrides on TestPileState
(won/auto_completable/move_count) plus setters set_test_won,
set_test_auto_completable, set_test_move_count, and set_test_draw_mode
(re-deals the seed). All compiled out in production builds.

Fix the field->method ripple across solitaire_data, solitaire_wasm, and
solitaire_engine. Add a test-support dev-dependency to solitaire_data for
the won-game storage test.

cargo test --workspace and cargo clippy --workspace -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:24:03 -07:00
funman300 1438fd6265 refactor(core): complete card_game::Card migration across engine + wasm
Build and Deploy / build-and-push (push) Failing after 1m2s
Web E2E / web-e2e (push) Failing after 3m19s
Finish the half-applied Card refactor. solitaire_core::card::Card is now an
alias for the opaque card_game::Card: suit()/rank() are methods, there is no
id or face_up field, and it is Clone+Eq+Hash but not Copy. Pile accessors
return Vec<(Card, bool)> where the bool is face-up.

Card identity is now the Card value itself (via Eq/Hash), not a numeric u32:
- CardEntity stores `card: Card` (was `card_id: u32`); lookups compare cards.
- Drag/selection collections and the touch/keyboard selection setters use
  Vec<Card>; CardFlippedEvent/CardFaceRevealedEvent/HintVisualEvent carry Card.
- replay_overlay and feedback/settle/deal animations updated accordingly.

solitaire_wasm: CardSnapshot derives its JSON id from suit+rank (matching the
desktop engine), and consumes the (Card, bool) pile tuples.

test-support: TestPileState tableau overrides now carry a per-card face-up flag
so tests can place face-down tableau cards. set_test_tableau_cards keeps its
Vec<Card> signature (defaulting to face-up); new set_test_tableau_cards_with_face
takes Vec<(Card, bool)>.

cargo test --workspace passes (engine lib 897 ok, 0 failed); cargo clippy
--workspace --all-targets -- -D warnings is clean. Save/serde format unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:45:34 -07:00
funman300 920f2c8597 refactor(core): move solver to solitaire_data, DrawMode to klondike_adapter, remove pile/solver/schema_version
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root)
- Delete solitaire_core::pile — no external users
- Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode
- Remove schema_version field from GameState (redundant — deserializer stamps it from the constant)
- Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:38:04 -07:00
funman300 37a21b9b42 docs: record android avd smoke 2026-06-08 19:24:42 -07:00
funman300 712ed6be80 docs: clarify android support status 2026-06-08 19:14:48 -07:00
funman300 324003562b test: cover mobile card label glyphs
Build and Deploy / build-and-push (push) Successful in 1m4s
2026-06-08 19:13:40 -07:00
funman300 a69a774edf docs: refresh handoff after runbooks 2026-06-08 19:12:15 -07:00
funman300 df4887fb36 docs: update android smoke test runbook 2026-06-08 19:11:02 -07:00
funman300 159774f811 docs: add analytics validation runbook
Build and Deploy / build-and-push (push) Successful in 1m6s
2026-06-08 19:09:22 -07:00
funman300 b3c4d08dfc docs: avoid stale handoff head hash 2026-06-08 19:06:07 -07:00
funman300 f313cfd8b7 docs: update session handoff state 2026-06-08 19:05:20 -07:00
funman300 7fe6ac6c1c docs: catch up handoff and changelog
Build and Deploy / build-and-push (push) Successful in 5m23s
2026-06-08 19:03:40 -07:00
funman300 6193d31497 fix(engine): centre modal cards within usable area (status-bar + gesture-bar)
Build and Deploy / build-and-push (push) Failing after 52s
Web E2E / web-e2e (push) Failing after 4m33s
apply_safe_area_to_modal_scrims now sets both padding.top (status-bar
height) and padding.bottom (gesture-bar height) on every ModalScrim.
With align_items/justify_content: Center on the scrim, the modal card
lands at the visual midpoint of the visible area between the two system
bars, fixing the slight upward shift that occurred when only the bottom
inset was applied.

Also: mark all rewrite-plan phases (0–3) complete; drop obsolete stash
whose 20 files are already incorporated into master; update CLAUDE.md
§14.3 to document both edges.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:34:28 -07:00
funman300 26f1b00186 docs(core,data): complete Phases 0–2 of in-place card_game rewrite
Phase 0 – doc fixes (docs/card-game-integration.md):
- Correct stale "no serde" claim: upstream has serde at rev 99b49e62
- Correct take_from_foundation default description (Allowed, not Disallowed)
- Document schema v3→v4 migration and AnyInstruction strategy

Phase 1 – delegate check_win / check_auto_complete to upstream:
- Proptests verify semantic agreement with is_win() / is_win_trivial()
  across 256 random states before delegation

Phase 2 – schema v4 with v3 auto-migration:
- SavedInstruction mirror types kept as legacy compat module (needed by
  solitaire_data::ReplayMove and solitaire_wasm replay layer)
- klondike_adapter.rs: add comprehensive legacy-purpose doc comment
- proptest_tests.rs: add check_auto_complete/check_win semantic proofs
- storage.rs: rename round-trip test to v4, add v3-migrates-to-v4 test

Also track the rewrite plan (docs/in-place-card-game-rewrite-plan.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:59:18 -07:00
funman300 56e3b62269 fix(core): correct recycle_count drift and score compound error on undo
Phase 3 of the in-place card_game rewrite.

Two bugs on undo:
1. recycle_count was incremented when recycling but never decremented on
   undo, causing the free-recycle allowance to be exhausted faster than
   it should be after undo+redo cycles.
2. undoing a penalised recycle applied the −15 undo penalty on top of
   the post-penalty (post-recycle) score rather than on the pre-recycle
   score, compounding the −100 / −20 penalty rather than reversing it.

Fix:
- Add score_history: Vec<i32> and is_recycle_history: Vec<bool> to
  GameState, both parallel to session.history() at all times.
- Extract pre_instruction_score_delta() helper — single source of truth
  for all scoring logic, called from draw(), move_cards(), and the
  Deserialize replay.
- draw() and move_cards() push to both stacks before processing.
- undo() pops from both stacks: uses the popped pre-move score as the
  base for apply_undo_score() and decrements recycle_count if the
  undone instruction was a recycle.
- Deserialize rebuilds is_recycle_history and recycle_count from the
  instruction replay (recycle detection needs only pre-instruction
  session state, so it is always correct across save/load cycles).
  score_history is not rebuilt on load (undo-penalty history is absent
  from saved_moves); undo falls back to old behaviour for pre-load
  moves, but is fully correct for moves made in the current session.
- Remove recycle_count from PersistedGameStateIn (now rebuilt; serde
  silently ignores the field in existing JSON saves).

Tests added:
- recycle_count_decrements_when_recycle_is_undone
- score_recycle_penalty_is_reversed_on_undo

All 71 solitaire_core tests and full-workspace suite pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:53:58 -07:00
funman300 9bcf13d8f2 test(core,data): verify schema-v3 round-trip; pin upstream git deps
- solitaire_data: add game_state_v3_mid_game_round_trip — first test to
  exercise the schema-v3 instruction-replay path with a real mid-game
  state (draws + card move + undo); GameState::PartialEq validates all
  pile layouts, score, move_count, undo_count, and recycle_count
- solitaire_data: add save_format_v2_is_rejected — schema-version gate
  test, parallel to the existing v1 rejection fixture
- solitaire_core: add SavedInstruction proptest (256 random cases across
  all three instruction variants) and four boundary unit tests for
  out-of-range Tableau/Foundation/SkipCards values
- solitaire_core: document pile() KlondikePile::Stock → waste mapping
- solitaire_core: document replay_config() take_from_foundation=true
  invariant and the re-export policy for upstream types
- Cargo.toml: pin card_game + klondike git deps to rev 99b49e62

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:41:50 -07:00
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
125 changed files with 18467 additions and 12633 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"']
+46
View File
@@ -6,10 +6,14 @@ on:
branches: [master] branches: [master]
paths: paths:
- 'solitaire_server/**' - 'solitaire_server/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_sync/**' - 'solitaire_sync/**'
- 'solitaire_core/**' - 'solitaire_core/**'
- 'solitaire_engine/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- 'solitaire_server/Dockerfile'
- '.gitea/workflows/docker-build.yml' - '.gitea/workflows/docker-build.yml'
env: env:
@@ -32,6 +36,48 @@ jobs:
id: meta id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" 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 - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
+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
+10
View File
@@ -15,6 +15,11 @@ agentdb.rvf.lock
# IDE project files # IDE project files
.idea/ .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 # Android signing keystores — never commit
*.jks *.jks
*.jks.bak *.jks.bak
@@ -25,3 +30,8 @@ agentdb.rvf.lock
deploy/matomo-secret.yaml deploy/matomo-secret.yaml
deploy/*-secret.yaml deploy/*-secret.yaml
deploy/*-auth-secret.yaml deploy/*-auth-secret.yaml
# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo
/.agents/
/.codex/
/AGENTS.md
+276
View File
@@ -6,6 +6,282 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added
- **Analytics validation runbook.** Documented native Matomo live validation,
expected event payloads, and the current web/WASM analytics split.
- **Android smoke-test runbook.** Updated the Android doc with the current
platform status, support matrix, and a physical-device
launch/touch/safe-area checklist.
- **Browser Bevy canvas route and automation support.** Added the `solitaire_web`
Bevy WASM build, wired `/play` to the Bevy canvas, added a
`window.__FERROUS_DEBUG__` bridge, and introduced Playwright coverage for the
web routes and interactive canvas behavior.
- **Card-game / klondike integration.** Began replacing in-house card and pile
internals with upstream `card_game` / `klondike` types, including adapter
work, GameMode-aware scoring, upstream instruction serde, `KlondikePile`
migration, and documentation for the in-place rewrite phases.
- **Android keystore integration.** Added Android Keystore JNI wiring via
`OnceLock` and improved Android token handling around the app directory.
### Changed
- **Core type ownership.** Routed all klondike/card imports through
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
types.
- **Web/WASM build reliability.** Rebuilt WASM packages, cleaned up wasm32 build
warnings, added a Binaryen `wasm-opt` pass, pinned upstream git dependencies,
and added a CI guard for canvas WASM drift.
- **Difficulty seed catalog.** Regenerated the difficulty seed list for the
latest verified catalog.
### Fixed
- **Android and modal safe-area layout.** Modal cards now center within the
usable area between status and gesture bars, additional modal-spawn guards were
added, and Android build scripts now auto-discover SDK/NDK paths and strip
native libraries.
- **Core scoring and undo correctness.** Fixed recycle-count drift, undo score
compounding, foundation-to-tableau instruction coverage, and several
illegal-move paths discovered during the card-game migration.
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
clicks, delayed first-run onboarding until splash teardown, and kept dragged
stacks above all piles.
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
file paths to async I/O where needed, and validated `JWT_SECRET` at startup.
- **CI and deployment workflow.** Fixed deploy-branch handling, Docker registry
secret usage, and related release automation issues.
### Tests
- Ran an Android AVD `Pixel_7` launch smoke for the x86_64 debug APK,
including install, NativeActivity launch, safe-area log validation, screenshot
render check, onboarding input, and crash-log review.
- Added direct coverage for Android/touch card corner labels using Unicode suit
glyphs.
- Added schema-v3 persistence round-trip coverage, foundation-to-tableau
instruction coverage, expanded WASM unit tests, and Playwright E2E specs for
browser routes and game-canvas behavior.
## [0.39.0] — 2026-05-19
### Fixed
- **No-legal-moves detection and banner.** Corrected no-move detection across
engine, WASM, and web paths, then surfaced the state to players with an
in-game banner instead of silently leaving the board stuck.
- **Release/deploy automation.** Updated deployment automation so kustomization
changes are pushed to the deploy branch instead of the main development
branch.
## [0.38.0] — 2026-05-19
### Added
- **Klondike scoring parity.** Added tableau flip bonuses and stock recycle
penalties to align scoring with standard Klondike expectations.
### Fixed
- **Core rule enforcement.** Auto-complete now requires an empty waste pile,
waste-origin moves reject multi-card transfers, foundation-to-foundation moves
are blocked, and undo restores score from the snapshot baseline.
- **Modal lifecycle guards.** Added missing `ModalScrim` guards to New Game,
restore prompt, and no-moves modal spawn sites.
- **Runtime and server robustness.** Tokio runtime setup degrades gracefully
instead of panicking; web replay submission casing/date formatting now matches
server expectations; avatar routes are publicly reachable when intended.
- **Android token and sync merge correctness.** Android tokens are namespaced
under the application directory, stored per user, and migrated safely; sync
merges preserve draw-one / draw-three win invariants.
## [0.37.0] — 2026-05-19
### Fixed
- **Foundation-to-tableau default.** Made `take_from_foundation` default to true
across clients so restored, startup, and web games use the same supported move
rules.
## [0.36.12] — 2026-05-19
### Fixed
- **Foundation-to-tableau default.** Set `take_from_foundation` true by default
in core so every client inherits the intended house rule without special-case
setup.
## [0.36.11] — 2026-05-19
### Fixed
- **Web foundation moves.** Enabled take-from-foundation moves in the web game
client.
## [0.36.10] — 2026-05-19
### Added
- **Web resume flow.** Browser games now persist state across page refreshes and
can resume through a dialog instead of starting over.
## [0.36.9] — 2026-05-19
### Fixed
- **Settings sync connection flow.** Clicking Connect from Settings now opens the
sync-setup modal.
## [0.36.8] — 2026-05-19
### Fixed
- **Restored/startup foundation moves.** Enabled take-from-foundation behavior
for restored and startup games, not only newly-created sessions.
## [0.36.7] — 2026-05-19
### Fixed
- **Remaining Android UI issues.** Resolved the final Android UI defects from
the review pass, including action-bar/tableau interaction and safe visual
spacing.
## [0.36.6] — 2026-05-19
### Fixed
- **Action-bar layout reservation.** Reserved action-bar height in layout so
tableau columns do not extend behind bottom controls.
## [0.36.5] — 2026-05-19
### Added
- **Responsive Android action-bar glyphs.** Action-bar glyph font size now scales
dynamically on Android to fit available space.
## [0.36.4] — 2026-05-19
### Fixed
- **Classic card labels and HUD overlap.** Corrected classic-card corner-label
colors and fixed HUD-band overlap in the Android layout.
## [0.36.3] — 2026-05-19
### Fixed
- **Core, animation, and modal review fixes.** Added the foundation-to-tableau
score penalty, hardened solver win validation, guarded zero-duration card
animations, aligned initial and dynamic tableau fan spacing, and added missing
modal guards for play-by-seed and win-summary paths.
- **Pause, messages, credentials, and server validation.** Auto-complete respects
pause state, standalone plugins register their events, sync passwords are
cleared from ECS buffers after auth task spawn, and avatar MIME validation uses
exact matches.
- **Foundation pile rendering.** Raised stack fan z-order above corner labels to
prevent bleed-through.
- **Android release workflow.** Added a manual `workflow_dispatch` trigger to
the Android release workflow.
## [0.36.2] — 2026-05-19
### Fixed
- **Comprehensive review fixes.** Addressed 26 issues across core rules, replay
controls, modal guards, sync payload timing, server replay casing, time-attack
overlays, theme refresh, auth overlays, stats ordering, animations, cursor
fallbacks, achievements, server temp-file cleanup, and runtime fallback paths.
- **Animation and Android label polish.** Cancelled stale win-cascade animations
on new game, refreshed Android corner labels on resize, lifted animating cards
above lower z-layers, and froze the web timer when auto-complete starts.
- **Web package and tooling updates.** Rebuilt the WASM package for
foundation-to-tableau moves, added ruflo scaffolding, and ignored ruflo runtime
state files.
- **Leaderboard test stability.** Made opt-in / opt-out tests robust under
parallel test execution.
## [0.36.1] — 2026-05-18
### Fixed
- **Android HUD gesture conflict.** Stock taps no longer toggle HUD visibility on
Android.
## [0.36.0] — 2026-05-18
### Changed
- **Rank model cleanup.** `Rank` now uses explicit discriminants and checked
arithmetic, making rank conversions and sequencing more robust.
- **Instruction generation.** Refined `possible_instructions` alongside the rank
arithmetic cleanup.
- **Session handoff.** Recreated `SESSION_HANDOFF.md` to reflect the `0.35.1`
state.
## [0.35.1] — 2026-05-17
### Fixed
- **Leaderboard profile sync.** Fixed three leaderboard/profile issues: wrong
toast type for failures, stale display-name label after update, and display
name not syncing to the server.
## [0.35.0] — 2026-05-17
### Added
- **Reduced-motion support.** Decorative motion animations are now gated behind
`reduce_motion_mode`.
### Changed
- **Performance and runtime cleanup.** Shared a single Tokio runtime across
network tasks and gated frame-hot ECS systems on resource changes.
- **Core/data refactors.** Consolidated the application directory name, added
`#[must_use]` to pure helpers, derived `Copy` for `DrawMode`, removed
redundant clones, added missing derives to `AchievementContext`, and used
saturating move-count arithmetic.
- **HUD z-layer naming.** Replaced raw HUD popover z-index arithmetic with named
layer constants.
### Fixed
- **Android UI and font safety.** Wired FiraMono to stock-empty labels, removed
raw physical safe-area pixels from HUD spawns, replaced unsupported chevrons,
corrected the Android help hint label, and fixed touch/drop-zone behavior.
- **Engine modal and panic hardening.** Eliminated several runtime panics, added
required transforms to modal scrims, constrained dismiss hit-tests, and guarded
home overlay respawns.
- **Sync/data/server correctness.** Deterministic pile serialization, undo skip
handling, byte URL encoding, merge timestamp handling, auth-guarded avatar
serving, atomic server writes, and user-id assertions were corrected.
- **Display-name and token-file boundaries.** Enforced the 32-character display
name limit in the sync client and aligned Android keystore temp-file cleanup
with the cleanup glob.
- **WASM error reporting.** `state()` and `step()` now return `Result` so errors
surface as JavaScript exceptions.
- **Sync and leaderboard toasts.** Pull failures and leaderboard opt-in /
opt-out failures now produce the intended warning/error feedback.
### Documentation
- Corrected stale focus-ring color documentation.
## [0.34.0] — 2026-05-17
### Fixed
- **Android waste fan and resume layout.** Corrected Android waste-pile fan
overlap and a layout desynchronization after resume.
- **Card-face artwork.** Fixed the wrong bottom-right suit symbol on the jack,
queen, and king of spades.
- **Android corner-label font coverage.** Wired FiraMono into Android corner
labels and added `CardImageSet` tests to guard the asset path behavior.
## [0.33.0] — 2026-05-16 ## [0.33.0] — 2026-05-16
### Fixed ### Fixed
+5 -3
View File
@@ -430,9 +430,11 @@ explicitly replacing the current one (despawn first, then spawn).
## 14.3 Safe area ## 14.3 Safe area
Every `ModalScrim` automatically receives `padding.bottom` equal to the Every `ModalScrim` automatically receives `padding.top` equal to the logical
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in status-bar height and `padding.bottom` equal to the logical gesture-bar height
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes. via `apply_safe_area_to_modal_scrims` in `SafeAreaInsetsPlugin`. This centres
the modal card within the usable area between both system bars. Do not manually
add top or bottom padding to scrim nodes.
## 14.4 Z-ordering ## 14.4 Z-ordering
Generated
+418 -15
View File
@@ -364,6 +364,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4"
[[package]] [[package]]
name = "as-raw-xcb-connection" name = "as-raw-xcb-connection"
version = "1.0.1" version = "1.0.1"
@@ -717,6 +723,28 @@ dependencies = [
"android-activity", "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]] [[package]]
name = "bevy_app" name = "bevy_app"
version = "0.18.1" version = "0.18.1"
@@ -878,6 +906,35 @@ dependencies = [
"syn", "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]] [[package]]
name = "bevy_diagnostic" name = "bevy_diagnostic"
version = "0.18.1" version = "0.18.1"
@@ -901,7 +958,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d" checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
dependencies = [ dependencies = [
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bevy_ecs_macros", "bevy_ecs_macros",
"bevy_platform", "bevy_platform",
"bevy_ptr", "bevy_ptr",
@@ -945,6 +1002,36 @@ dependencies = [
"encase_derive_impl", "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]] [[package]]
name = "bevy_gizmos" name = "bevy_gizmos"
version = "0.18.1" version = "0.18.1"
@@ -1067,14 +1154,17 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754"
dependencies = [ dependencies = [
"bevy_a11y", "bevy_a11y",
"bevy_android", "bevy_android",
"bevy_anti_alias",
"bevy_app", "bevy_app",
"bevy_asset", "bevy_asset",
"bevy_camera", "bevy_camera",
"bevy_color", "bevy_color",
"bevy_core_pipeline", "bevy_core_pipeline",
"bevy_derive", "bevy_derive",
"bevy_dev_tools",
"bevy_diagnostic", "bevy_diagnostic",
"bevy_ecs", "bevy_ecs",
"bevy_feathers",
"bevy_gizmos_render", "bevy_gizmos_render",
"bevy_image", "bevy_image",
"bevy_input", "bevy_input",
@@ -1082,6 +1172,7 @@ dependencies = [
"bevy_log", "bevy_log",
"bevy_math", "bevy_math",
"bevy_mesh", "bevy_mesh",
"bevy_pbr",
"bevy_platform", "bevy_platform",
"bevy_ptr", "bevy_ptr",
"bevy_reflect", "bevy_reflect",
@@ -1101,6 +1192,27 @@ dependencies = [
"bevy_winit", "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]] [[package]]
name = "bevy_log" name = "bevy_log"
version = "0.18.1" version = "0.18.1"
@@ -1138,7 +1250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84" checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
dependencies = [ dependencies = [
"approx", "approx",
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bevy_reflect", "bevy_reflect",
"derive_more", "derive_more",
"glam 0.30.10", "glam 0.30.10",
@@ -1161,7 +1273,9 @@ dependencies = [
"bevy_asset", "bevy_asset",
"bevy_derive", "bevy_derive",
"bevy_ecs", "bevy_ecs",
"bevy_image",
"bevy_math", "bevy_math",
"bevy_mikktspace",
"bevy_platform", "bevy_platform",
"bevy_reflect", "bevy_reflect",
"bevy_transform", "bevy_transform",
@@ -1174,6 +1288,71 @@ dependencies = [
"wgpu-types", "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]] [[package]]
name = "bevy_platform" name = "bevy_platform"
version = "0.18.1" version = "0.18.1"
@@ -1500,6 +1679,7 @@ dependencies = [
"bevy_input", "bevy_input",
"bevy_input_focus", "bevy_input_focus",
"bevy_math", "bevy_math",
"bevy_picking",
"bevy_platform", "bevy_platform",
"bevy_reflect", "bevy_reflect",
"bevy_sprite", "bevy_sprite",
@@ -1512,6 +1692,7 @@ dependencies = [
"taffy", "taffy",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]
@@ -1545,6 +1726,26 @@ dependencies = [
"tracing", "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]] [[package]]
name = "bevy_utils" name = "bevy_utils"
version = "0.18.1" version = "0.18.1"
@@ -1672,6 +1873,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [ dependencies = [
"bytemuck",
"serde_core", "serde_core",
] ]
@@ -1703,7 +1905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"cc", "cc",
"cfg-if", "cfg-if",
"constant_time_eq", "constant_time_eq",
@@ -1879,6 +2081,17 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "card_game"
version = "0.4.1"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "983728ead19f51d96931725706e62293bd133ac3d836097dd7d745e929f7811b"
dependencies = [
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
"serde",
"serde_derive",
]
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.1.2" version = "0.1.2"
@@ -1939,6 +2152,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" 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]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -3457,6 +3681,17 @@ dependencies = [
"weezl", "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]] [[package]]
name = "glam" name = "glam"
version = "0.30.10" version = "0.30.10"
@@ -3485,6 +3720,27 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 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]] [[package]]
name = "governor" name = "governor"
version = "0.10.4" version = "0.10.4"
@@ -4051,7 +4307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [ dependencies = [
"byteorder-lite", "byteorder-lite",
"quick-error", "quick-error 2.0.1",
] ]
[[package]] [[package]]
@@ -4309,6 +4565,23 @@ dependencies = [
"uuid", "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]] [[package]]
name = "kira" name = "kira"
version = "0.12.0" version = "0.12.0"
@@ -4326,13 +4599,25 @@ dependencies = [
"triple_buffer", "triple_buffer",
] ]
[[package]]
name = "klondike"
version = "0.4.0"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "d5c82b0c3abd7da07b4a1c4221a809e6e2ffd475ae0e67180fbfef35a9cfe769"
dependencies = [
"card_game",
"rand 0.10.1",
"serde",
"serde_derive",
]
[[package]] [[package]]
name = "kurbo" name = "kurbo"
version = "0.13.0" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
dependencies = [ dependencies = [
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"euclid", "euclid",
"smallvec", "smallvec",
] ]
@@ -4740,7 +5025,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
dependencies = [ dependencies = [
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bit-set", "bit-set",
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
@@ -5778,6 +6063,25 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" 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]] [[package]]
name = "prost" name = "prost"
version = "0.14.3" version = "0.14.3"
@@ -5822,6 +6126,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "2.0.1" version = "2.0.1"
@@ -5947,6 +6257,16 @@ dependencies = [
"rand_core 0.9.5", "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]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.3.1" version = "0.3.1"
@@ -5985,6 +6305,12 @@ dependencies = [
"getrandom 0.3.4", "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]] [[package]]
name = "rand_distr" name = "rand_distr"
version = "0.5.1" version = "0.5.1"
@@ -6004,6 +6330,15 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "range-alloc" name = "range-alloc"
version = "0.1.5" version = "0.1.5"
@@ -6493,6 +6828,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 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]] [[package]]
name = "rustybuzz" name = "rustybuzz"
version = "0.20.1" version = "0.20.1"
@@ -6980,7 +7327,9 @@ dependencies = [
name = "solitaire_core" name = "solitaire_core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"rand 0.9.4", "card_game",
"klondike",
"proptest",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -6991,12 +7340,13 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"bevy", "card_game",
"chrono", "chrono",
"dirs", "dirs",
"jni 0.21.1", "jni 0.21.1",
"jsonwebtoken", "jsonwebtoken",
"keyring-core", "keyring-core",
"klondike",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -7090,6 +7440,18 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.8"
@@ -7502,7 +7864,7 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [ dependencies = [
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bitflags 1.3.2", "bitflags 1.3.2",
"bytemuck", "bytemuck",
"lazy_static", "lazy_static",
@@ -7601,7 +7963,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
dependencies = [ dependencies = [
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"grid", "grid",
"serde", "serde",
"slotmap", "slotmap",
@@ -7870,7 +8232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
"log", "log",
@@ -7884,7 +8246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
"log", "log",
@@ -8533,6 +8895,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "uncased" name = "uncased"
version = "0.9.10" version = "0.9.10"
@@ -8739,6 +9107,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -9044,12 +9421,13 @@ version = "27.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
dependencies = [ dependencies = [
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"document-features", "document-features",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"js-sys",
"log", "log",
"naga", "naga",
"portable-atomic", "portable-atomic",
@@ -9057,6 +9435,8 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
"smallvec", "smallvec",
"static_assertions", "static_assertions",
"wasm-bindgen",
"web-sys",
"wgpu-core", "wgpu-core",
"wgpu-hal", "wgpu-hal",
"wgpu-types", "wgpu-types",
@@ -9068,7 +9448,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
dependencies = [ dependencies = [
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bit-set", "bit-set",
"bit-vec", "bit-vec",
"bitflags 2.11.1", "bitflags 2.11.1",
@@ -9088,6 +9468,7 @@ dependencies = [
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"wgpu-core-deps-apple", "wgpu-core-deps-apple",
"wgpu-core-deps-wasm",
"wgpu-core-deps-windows-linux-android", "wgpu-core-deps-windows-linux-android",
"wgpu-hal", "wgpu-hal",
"wgpu-types", "wgpu-types",
@@ -9102,6 +9483,15 @@ dependencies = [
"wgpu-hal", "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]] [[package]]
name = "wgpu-core-deps-windows-linux-android" name = "wgpu-core-deps-windows-linux-android"
version = "27.0.0" version = "27.0.0"
@@ -9118,7 +9508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"arrayvec", "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"ash", "ash",
"bit-set", "bit-set",
"bitflags 2.11.1", "bitflags 2.11.1",
@@ -9127,15 +9517,20 @@ dependencies = [
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"core-graphics-types 0.2.0", "core-graphics-types 0.2.0",
"glow",
"glutin_wgl_sys",
"gpu-alloc", "gpu-alloc",
"gpu-allocator", "gpu-allocator",
"gpu-descriptor", "gpu-descriptor",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"js-sys",
"khronos-egl",
"libc", "libc",
"libloading", "libloading",
"log", "log",
"metal", "metal",
"naga", "naga",
"ndk-sys",
"objc", "objc",
"once_cell", "once_cell",
"ordered-float", "ordered-float",
@@ -9148,6 +9543,8 @@ dependencies = [
"renderdoc-sys", "renderdoc-sys",
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
"wgpu-types", "wgpu-types",
"windows 0.58.0", "windows 0.58.0",
"windows-core 0.58.0", "windows-core 0.58.0",
@@ -10030,6 +10427,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]] [[package]]
name = "xmlwriter" name = "xmlwriter"
version = "0.1.0" version = "0.1.0"
+4 -1
View File
@@ -8,6 +8,7 @@ members = [
"solitaire_app", "solitaire_app",
"solitaire_assetgen", "solitaire_assetgen",
"solitaire_wasm", "solitaire_wasm",
"solitaire_web",
] ]
resolver = "2" resolver = "2"
@@ -21,7 +22,7 @@ rust-version = "1.95"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde", "wasmbind"] }
thiserror = "2" thiserror = "2"
rand = "0.9" rand = "0.9"
async-trait = "0.1" async-trait = "0.1"
@@ -37,6 +38,8 @@ solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" } solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" } solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" } solitaire_engine = { path = "solitaire_engine" }
klondike = { version = "0.4.0", registry = "Quaternions", features = ["serde"] }
card_game = { version = "0.4.1", registry = "Quaternions", features = ["serde"] }
# Bevy with `default-features = false` to avoid the unused # Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain. # `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 # Lint
cargo clippy --workspace --all-targets -- -D warnings 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 ## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
+59 -27
View File
@@ -1,16 +1,38 @@
# Ferrous Solitaire — Session Handoff # Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master. **Last updated:** 2026-06-09 — AVD Android launch smoke passed; physical-device gate remains.
--- ---
## Current state ## Current state
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs) - **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes.
- **Latest tag:** `v0.35.1` - **Latest tag:** `v0.39.0`
- **Working tree:** clean - **Working tree:** clean. Local `scripts/` helpers are excluded through `.git/info/exclude` and intentionally not committed.
- **Build:** `cargo clippy --workspace -- -D warnings` clean - **Latest verification in this follow-up:** `cargo test -p solitaire_core`; `cargo test -p solitaire_data matomo_client`; `cargo test -p solitaire_engine analytics_plugin`; `cargo test -p solitaire_engine settings_plugin`; `cargo test -p solitaire_engine card_plugin`; `cargo apk build -p solitaire_app --target x86_64-linux-android --lib`; AVD `Pixel_7` install/launch/input smoke.
- **Tests:** 1277 passing / 0 failing across the workspace - **Full previous gate:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up.
---
## What shipped since v0.39.0
- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`.
- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up:
- `5e87358` integrates upstream deps cleanly.
- `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types.
- `d864d98` routes klondike/card imports through `solitaire_core`.
- `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs.
- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed.
- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`.
- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels.
- Native analytics and Android physical-device validation now have runbooks in
`docs/analytics-validation.md` and `docs/ANDROID.md`.
---
## Historical notes before v0.39.0
See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work.
--- ---
@@ -81,32 +103,27 @@ Three bugs fixed:
## Open punch list ## Open punch list
### 1. CHANGELOG documentation debt ### 1. Android APK launch verification (Option A)
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
are missing. Low priority (git log is authoritative) but worth closing before the
next release.
### 2. Android APK launch verification (Option A)
Physical device test: install the latest APK on a real Android device (not AVD), Physical device test: install the latest APK on a real Android device (not AVD),
confirm: and run the checklist in `docs/ANDROID.md`. This has never been gated in CI.
- App launches without crash AVD `adb shell input tap` doesn't deliver real touch events, so physical-device
- Safe area insets arrive and shift HUD correctly after ~3 frames smoke testing is the only gate.
- 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 Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
touch events, so physical-device smoke testing is the only gate. `target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR.
### 3. Matomo analytics wiring ### 2. Matomo analytics live validation
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no `Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine
engine code consumes them — the analytics toggle in Settings is a no-op. If consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written validation against the deployed Matomo instance. Use
and wired to `GameStateResource` events. `docs/analytics-validation.md` for the native validation checklist and the
current web/WASM decision notes.
--- ---
@@ -128,3 +145,18 @@ and wired to `GameStateResource` events.
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so - **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared `ButtonInput::just_pressed` state persists across frames unless explicitly cleared
with `input.release(key); input.clear()` between updates. 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`.
+48 -7
View File
@@ -1,18 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Rebuild the solitaire_wasm crate and install the output into # Rebuild WASM artifacts and install them into solitaire_server/web/pkg/.
# solitaire_server/web/pkg/ so the server can serve the replay viewer. #
# 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: # Prerequisites:
# cargo install wasm-pack # cargo install wasm-pack wasm-bindgen-cli
# rustup target add wasm32-unknown-unknown # rustup target add wasm32-unknown-unknown
# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm
# #
# Run from the repo root: # Run from the repo root:
# ./build_wasm.sh # ./build_wasm.sh
# #
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are # The generated pkg/ files are committed to git so self-hosters who don't
# committed to git so self-hosters who don't touch the WASM crate can # touch the WASM crates can skip this step. Regenerate after any change to
# skip this step. Regenerate after any change to solitaire_wasm/ or # solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
# solitaire_core/.
set -euo pipefail set -euo pipefail
@@ -36,5 +39,43 @@ wasm-pack build \
# Remove them — we manage the output directory ourselves. # Remove them — we manage the output directory ourselves.
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore" 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:" echo "Done. Output:"
ls -lh "$OUT_DIR" ls -lh "$OUT_DIR"
+49 -19
View File
@@ -2,13 +2,13 @@
This doc captures the toolchain install + build invocation for the This doc captures the toolchain install + build invocation for the
Android target. Steps are runnable on a fresh Debian 13 (trixie) box; Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
later sections document what's known to compile, what's stubbed, and later sections document physical-device validation, supported platform
the next milestones. surfaces, and remaining Android follow-ups.
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB > **Status (2026-06-09):** Android build plumbing, app-directory storage,
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has > JNI keystore wiring, and safe-area layout fixes have landed. The remaining
> NOT yet been verified to launch on a device or emulator — that's > release gate is a physical-device smoke test; AVD tap injection does not
> the next milestone. > exercise the real touch path reliably enough for launch verification.
--- ---
@@ -164,7 +164,7 @@ Physical device:
```bash ```bash
adb devices # confirm connection adb devices # confirm connection
adb install target/debug/apk/ferrous-solitaire.apk adb install -r target/debug/apk/ferrous-solitaire.apk
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic" adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
``` ```
@@ -185,35 +185,65 @@ AVD.
--- ---
## 4. What's wired vs. what's stubbed ## 4. Physical-device smoke test
The first build pass (commit `fb8b2ac`) gates four desktop-only Run this on a real phone, preferably a modern 64-bit ARM device with gesture
crates / call sites so the workspace cross-compiles. Each gate is navigation enabled.
documented at its call site.
Build and install:
```bash
cargo apk build -p solitaire_app --target aarch64-linux-android --lib
adb install -r target/debug/apk/ferrous-solitaire.apk
adb logcat -c
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic|WindowInsets"
```
Pass criteria:
- App launches without panic or ANR.
- Safe-area insets arrive after the first few frames and shift HUD/modal
content away from the status and gesture bars.
- Every modal's Done button remains above the gesture bar:
Settings, Help, Pause, Win Summary, and Leaderboard-related dialogs.
- Drag-and-drop works on tableau, waste, foundation, and stock/recycle paths.
- Tap-to-select and one-tap modes both respond correctly on card stacks.
- Leaderboard panel opens, "Set Name" saves, and the "Public name" label updates
while the panel remains open.
- Rotate the device once, then repeat one modal and one drag operation.
- Close and relaunch the app; settings/progress still load.
Record the device model, Android version, APK commit, and pass/fail notes in the
release notes or session handoff. If a failure occurs, keep the filtered logcat
and note the exact screen/control path that reproduced it.
---
## 5. Platform support matrix
Desktop-only crates and call sites are gated so the workspace cross-compiles.
Each gate is documented at its call site.
| Surface | Desktop | Android | | Surface | Desktop | Android |
|---------|---------|---------| |---------|---------|---------|
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) | | Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline | | Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch | | OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Android Keystore via JNI |
| Data directory | Platform data dir | Android app files dir |
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity | | App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
What's NOT yet ported / not yet measured: Remaining Android follow-ups:
- `dirs::data_dir()` returns `None` on Android. Callers in
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
`achievements.rs`, `settings.rs` all need an Android-aware
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
- Touch UX pass — hit-target sizes, modal scaling on small screens, - Touch UX pass — hit-target sizes, modal scaling on small screens,
app lifecycle (suspend / resume), font scaling. app lifecycle (suspend / resume), font scaling.
- Android Keystore via JNI for `auth_tokens`.
- JNI ClipboardManager for share links. - JNI ClipboardManager for share links.
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced - Google Play Games sign-in (the `solitaire_gpgs` crate referenced
in older docs doesn't yet exist). in older docs doesn't yet exist).
--- ---
## 5. Iteration loop ## 6. Iteration loop
```bash ```bash
# Edit code… # Edit code…
+67
View File
@@ -0,0 +1,67 @@
# Analytics Validation Runbook
Ferrous Solitaire currently has two analytics paths:
- Native desktop/Android gameplay events use `solitaire_engine::AnalyticsPlugin`
and `solitaire_data::MatomoClient`.
- Hosted web pages include Matomo page-view snippets in
`solitaire_server/web/*.html`.
The Bevy `/play` WASM canvas does not emit the native gameplay events because
`AnalyticsPlugin` is intentionally gated out on `wasm32`; it depends on the
native Tokio/reqwest stack.
## Native Matomo Validation
Use this when a deployed Matomo instance and a native build are available.
1. Configure `settings.json` with a Matomo URL and site ID:
```json
{
"analytics_enabled": true,
"matomo_url": "https://analytics.example.com",
"matomo_site_id": 1
}
```
2. Launch the native app and open Settings.
3. Confirm the Privacy section appears and "Share usage data" is `ON`.
4. Start a new confirmed game.
5. Win or forfeit the game.
6. Unlock an achievement if practical, or use an existing achievement path that
is easy to trigger in a test profile.
7. Wait at least 60 seconds, or close after the win/forfeit path has fired its
immediate flush.
8. In Matomo, confirm the following custom events arrived:
| Category | Action | Name |
| --- | --- | --- |
| `Game` | `Start` | `classic`, `zen`, `challenge`, `time_attack`, or `difficulty` |
| `Game` | `Won` | empty |
| `Game` | `Forfeit` | empty |
| `Achievement` | `Unlocked` | achievement id |
## Web/WASM Decision
Keep the current split unless the project explicitly needs in-canvas gameplay
events for `/play`.
Current behavior:
- `/`, `/play-classic`, `/account`, `/leaderboard`, and `/replays` emit Matomo
page views through the hosted HTML snippets.
- `/play` hosts the Bevy canvas but does not emit gameplay events from the
engine.
- The browser Content-Security-Policy already allows the deployed Matomo host
for scripts, images, and connections.
If gameplay events are needed on `/play`, add a small `wasm32`-only analytics
bridge instead of trying to compile the native plugin:
- keep the same event contract as native (`Game / Start`, `Game / Won`,
`Game / Forfeit`, `Achievement / Unlocked`);
- read `Settings::analytics_enabled`, `matomo_url`, and `matomo_site_id`;
- send through browser APIs or the existing `_paq` queue;
- keep the Settings opt-in behavior identical to native;
- add Playwright coverage that stubs Matomo and verifies emitted payloads.
+211
View File
@@ -0,0 +1,211 @@
# 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:** Integration is complete. Upstream `card_game` / `klondike` now owns
authoritative Klondike rules, session history, undo snapshots, and solving.
Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes,
and typed UI errors in `solitaire_core`.
---
## 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` Still Owns
### 1. Scoring — remaining adapter responsibilities
Ferrous uses **Windows XP Standard** scoring. The upstream library handles the
per-move counters and configurable deltas; Ferrous adds the product-specific
parts in `GameState` / `KlondikeAdapter`.
| 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`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty.
**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:** `KlondikeAdapter::config_for` configures the upstream rules
and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor,
time bonus, game-mode suppression, and undo score restoration.
### 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:** `GameMode` lives on `solitaire_core::GameState`; undo and
scoring behavior are applied before/after delegating legal moves to the upstream
session.
### 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).
The old local DFS has been replaced. `solitaire_core::solver` is now a small
adapter around `Session::solve()` that preserves the engine-facing
`SolverResult`, `SolverConfig`, and first-move payload contract.
**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested
budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget
errors → 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.
**Default behaviour:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire **also defaults to Allowed** (`take_from_foundation: true` in `GameState`, `Settings`). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (`take_from_foundation = false`), which maps to `Disallowed`.
**In our wrapper:** `KlondikeAdapter::config_for(draw_mode, take_from_foundation)` constructs `KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }`. 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.
**Upstream serde status (rev 99b49e62):** At this revision, `klondike` and `card_game` both enable a `serde` feature. All nine instruction/pile types (`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`, `TableauStack`, `Foundation`, `Tableau`, `SkipCards`) derive `serde::Serialize` + `serde::Deserialize` under that feature. The workspace `Cargo.toml` enables `features = ["serde"]`.
**Schema v4 (current):** `saved_moves` serialises as `Vec<KlondikeInstruction>` using upstream named-variant serde. Example: `{"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}`.
**Schema v3 (legacy, auto-migrated):** `saved_moves` used local `SavedInstruction` mirror types with u8 indices. Example: `{"DstFoundation": {"src": "Stock", "foundation": 0}}`. On load, an `AnyInstruction` untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The `SavedInstruction` bridge types are retained in `solitaire_core::klondike_adapter` for this migration path and for backward-compatible `solitaire_data::ReplayMove` / WASM replay formats.
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
**In our wrapper:** `GameState::Serialize` emits schema v4 (upstream instruction types). `GameState::Deserialize` accepts v3 (auto-migrates) and v4 (direct). 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:** `GameState` uses `Session`'s built-in snapshot history. Ferrous
keeps parallel score/recycle metadata so undo can restore product-specific score
state that upstream snapshots do not own.
---
## 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 the engine's waste pile and expose renderer-facing pile snapshots.
3.**Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (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 `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
7.**Implement `serde`** — serialise schema v4 with upstream `KlondikeInstruction`; auto-migrate schema v3 via `SavedInstruction` compatibility types.
---
## 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 implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs`
- Architecture overview: `ARCHITECTURE.md`
+408
View File
@@ -0,0 +1,408 @@
# In-Place card_game / klondike Rewrite Plan
**Date:** 2026-06-08
**Upstream rev:** `99b49e62`
**Status:** All phases complete (03). recycle_count drift and score compound error on undo fixed in `56e3b62`.
---
## 1. What Is Already Integrated
The integration is substantially complete. `solitaire_core` already delegates all
authoritative Klondike logic to the upstream crates.
| Area | Status | Location |
|---|---|---|
| `Session<Klondike>` ownership | ✅ complete | `GameState.session` |
| `draw()``session.process_instruction(RotateStock)` | ✅ complete | `game_state.rs` |
| `move_cards()``session.process_instruction(KlondikeInstruction)` | ✅ complete | `game_state.rs` |
| `undo()``session.undo()` | ✅ complete | `game_state.rs` |
| `possible_instructions()``session.state().state().get_sorted_moves()` | ✅ complete | `game_state.rs` |
| `can_move_cards()``session.state().state().is_instruction_valid()` | ✅ complete | `game_state.rs` |
| `solver.rs``session.solve()` | ✅ complete | `solver.rs` |
| `Suit`, `Rank` → re-export from `card_game` | ✅ complete | `card.rs` |
| `Foundation`, `Klondike`, `KlondikePile`, `Session`, `Tableau``solitaire_core::lib` | ✅ complete | `lib.rs` |
| Move legality enforcement | ✅ upstream (`is_instruction_valid`) | `klondike/src/lib.rs` |
| Foundation placement rules (Ace start, suit match) | ✅ upstream | `klondike/src/lib.rs` |
| Tableau placement rules (alternating colour, King on empty) | ✅ upstream | `klondike/src/lib.rs` |
| Multi-card stack moves via `SkipCards` | ✅ upstream | `klondike/src/lib.rs` |
| Session history / snapshot undo | ✅ upstream | `card_game/src/lib.rs` |
| DFS solver with budget limits | ✅ upstream | `card_game/src/lib.rs` |
| Instruction history → `SavedInstruction` serde mirrors | ✅ in adapter | `klondike_adapter.rs` |
| Schema v3 save/load (instruction replay) | ✅ complete | `game_state.rs`, `storage.rs` |
| `take_from_foundation` house rule → `MoveFromFoundationConfig` | ✅ complete | `klondike_adapter.rs` |
---
## 2. Duplicated / Replaceable Logic
These are local implementations that either replicate upstream or could be removed.
### 2a. `SavedInstruction` mirror types (~300 lines, `klondike_adapter.rs`)
**What:** A full hand-written serde mirror for every upstream klondike instruction type
(`SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
`SavedSkipCards`, `InvalidSavedInstruction`) plus ~20 `From`/`TryFrom` conversion impls.
**Why written:** At the time, upstream klondike had no serde feature.
**Current upstream status:** At rev `99b49e62`, the `serde` feature is present and active.
`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`,
`TableauStack`, `Tableau`, `Foundation`, `SkipCards` all derive
`#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]`.
**Blocker — JSON format incompatibility:**
| Field | Local `SavedInstruction` JSON | Upstream `KlondikeInstruction` JSON |
|---|---|---|
| Tableau index | `{ "Tableau": 0 }` (u8) | `{ "Tableau": "Tableau1" }` (named) |
| Foundation slot | `{ "Foundation": 0 }` (u8) | `{ "Foundation": "Foundation1" }` (named) |
| Skip count | `{ "skip_cards": 0 }` (u8) | `{ "skip_cards": "Skip0" }` (named) |
Switching to direct upstream serde **changes the `saved_moves` JSON shape** stored in
`game_state.json`. Any existing v3 save file would fail to deserialize after the switch.
This requires either:
- A schema bump to v4 **with a migration** (deserialize v3 manually then re-save as v4), or
- A schema bump to v4 **with graceful fallback** (v3 files rejected → fresh game).
**Recommendation:** Schema v4 with graceful fallback (v3 saves start fresh). Migration
is feasible but adds ~100 lines of throwaway code; the in-progress game loss is modest
since schema v3 was never shipped to users (it landed in the current dev branch, not a
release).
### 2b. `GameState::check_win()` (~15 lines)
**What:** Iterates all four foundation slots checking 13-card A→K sequences.
**Upstream equivalent:** `session.state().state().is_win()` on `Klondike`.
**Status:** Local check is correct but redundant. Trivially replaceable with no format change.
**Risk:** None — only affects `is_won` flag update path.
### 2c. `GameState::check_auto_complete()` (~15 lines)
**What:** Checks stock empty, waste empty, all tableau cards face-up.
**Upstream equivalent:** `session.state().state().is_win_trivial()` on `Klondike`.
**Semantic difference:** Upstream `is_win_trivial` checks `stock.is_empty()` (both faces)
and all `tableau.face_down().is_empty()`. Ferrous additionally checks `waste.is_empty()`.
These are logically equivalent for a valid game state (waste = stock face-up half).
**Risk:** Low — validated by existing auto-complete engine tests.
### 2c. `recycle_count` drift on undo (existing bug, not new)
**What:** `GameState.recycle_count` is incremented in `draw()` when stock is empty.
`undo()` does not decrement it. After undoing a recycle, `recycle_count` is stale and
may cause incorrect future penalty application.
**Upstream:** `KlondikeStats.recycle_count()` has the same problem — it is cumulative
and not restored on undo (stats are not part of the session snapshot, only game state is).
**Fix approach:** After each undo, recompute `recycle_count` by scanning
`session.history()` for `RotateStock` instructions that caused recycling.
**Priority:** Medium — affects scoring correctness in rare paths. File as a separate bug.
---
## 3. What Must Remain Ferrous-Specific
These responsibilities are product-layer, not Klondike-rules-layer, and must stay in `solitaire_core`.
| Responsibility | Why upstream cannot own it |
|---|---|
| WXP recycle penalties (free allowance + -100/-20) | `ScoringConfig::recycle` is a flat delta; no free-allowance concept exists upstream |
| Score floor (`score.max(0)`) | Not modelled upstream |
| Time bonus (`700_000 / elapsed_seconds`) | Not modelled upstream |
| `DrawMode` / `GameMode` enums | Product concept; not in upstream |
| Challenge mode undo block | Product rule |
| Zen mode scoring suppression | Product rule |
| `MoveError` variants for UI feedback | Upstream returns `bool`; Ferrous needs typed errors |
| `card::Card` projection (adds `id`, `face_up`) | Renderer requires stable `id` and face orientation |
| `Pile` DTO for engine sync | Renderer-facing snapshot type |
| `stock_cards()` / `waste_cards()` distinction | Engine models waste as a separate pile; upstream uses stock face-up half |
| `recycle_count` tracking | Needed for free-allowance penalty calculation |
| Persistence format + schema versioning | Product concern |
| `SavedInstruction` (currently) or upstream serde (after migration) | Either way, Ferrous owns the save contract |
---
## 4. Key Audit Findings
### Finding 1 — Upstream serde claim in docs is stale
`docs/card-game-integration.md` (last section "JSON Serialisation") states:
> Current verification (2026-06-01): klondike v0.3.0 and card_game v0.4.0 crate manifests
> expose no serde dependency/feature.
**This is wrong at rev 99b49e62.** The `serde` feature is present and active. All nine
instruction/pile types have `#[cfg_attr(feature = "serde", derive(...))]`. The doc must
be updated.
### Finding 2 — `take_from_foundation` default: docs vs code
`docs/card-game-integration.md` says:
> Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the
> default, with the house rule as an opt-in.
**The code and settings say the opposite:** `Settings::take_from_foundation` defaults to
`true` (Allowed); `GameState.take_from_foundation` also initializes to `true`. Multiple
tests assert this is the intended behavior. The upstream default is also `Allowed`.
**Resolution:** The docs are wrong. Default = Allowed (house rule on by default for
beginner-friendliness) is intentional. Update the docs; do not change the code.
### Finding 3 — `KlondikeStats` cumulative vs session-history-aware counts
`KlondikeStats.moves()` and `KlondikeStats.recycle_count()` accumulate monotonically.
They are NOT restored when `Session::undo()` is called (only `Klondike` game state is
restored from the snapshot, not the stats). Ferrous correctly uses
`session.history().len()` for `move_count` (history-aware). But `recycle_count` is
stored separately in `GameState` and also not decremented on undo — making them
equivalent in this one bug.
### Finding 4 — `SkipCards as usize` cast is correct
Upstream `SkipCards` has no explicit discriminants, so `Skip0 = 0 .. Skip12 = 12`.
`skip_cards as usize` in `solver.rs` and `game_state.rs` is correct.
---
## 5. Staged Migration
### Phase 0 — Doc fixes only (no code change)
Files: `docs/card-game-integration.md`
- Correct the serde claim (upstream has serde at rev 99b49e62).
- Correct the `take_from_foundation` default description.
- Update integration status table.
### Phase 1 — Delegate `is_win` / `is_win_trivial` (safe, no format change)
Files: `solitaire_core/src/game_state.rs`
Replace local `check_win()` and `check_auto_complete()` with upstream delegation:
```rust
// before
pub fn check_win(&self) -> bool { ... 40 lines ... }
// after
pub fn check_win(&self) -> bool {
self.session.state().state().is_win()
}
```
```rust
// before
pub fn check_auto_complete(&self) -> bool { ... 15 lines ... }
// after
pub fn check_auto_complete(&self) -> bool {
self.session.state().state().is_win_trivial()
}
```
**Risk:** Very low. Both methods are tested by existing integration tests. The semantic
difference in `check_auto_complete` (upstream vs Ferrous definition) is equivalent for
valid game states.
### Phase 2 — Replace `SavedInstruction` with upstream serde (schema v4)
Files:
- `solitaire_core/src/klondike_adapter.rs` (remove ~300 lines)
- `solitaire_core/src/game_state.rs` (update `Serialize`/`Deserialize` impls)
- `solitaire_core/src/proptest_tests.rs` (remove now-redundant SavedInstruction tests)
- `solitaire_data/src/storage.rs` (add schema v4 rejection test)
- `solitaire_data/src/replay.rs` (no change — uses `SavedKlondikePile` independently)
- `solitaire_wasm/src/lib.rs` (uses `SavedKlondikePileStack` in its own mirror — evaluate)
**Steps:**
1. In `game_state.rs`, change `PersistedGameState.saved_moves` from
`Vec<SavedInstruction>` to `Vec<KlondikeInstruction>` (upstream serde now works).
2. Update `GameState::Serialize` to emit `KlondikeInstruction` directly.
3. Update `GameState::Deserialize` to parse `KlondikeInstruction` directly.
4. Increment `GAME_STATE_SCHEMA_VERSION` to 4.
5. In `GameState::Deserialize`, reject schema != 4 with graceful fallback (already
handled by `load_game_state_from` returning `None` on serde error or wrong version).
6. Delete `SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
`SavedSkipCards`, `InvalidSavedInstruction` from `klondike_adapter.rs`.
7. Delete the 20 `From`/`TryFrom` impls.
8. Remove `SavedInstruction` proptest and boundary tests (no longer needed).
9. Add schema v4 round-trip test and v3 rejection test.
**Note on `solitaire_data::replay.rs`:**
`replay.rs` uses `SavedKlondikePile` independently (for `ReplayMove`). This is a
separate type from the game-state save format and is NOT changed by this phase.
`ReplayMove` has its own schema (`REPLAY_SCHEMA_VERSION`) and can keep using the local
mirror types.
**Note on `solitaire_wasm/src/lib.rs`:**
Uses `SavedKlondikePileStack` in its own `ReplayMove` mirror. Same as above — separate
type, not affected.
### Pre-Phase 3 — Undo Field Audit (completed 2026-06-08)
Full audit of every Ferrous-owned field in `GameState` for undo correctness.
| Field | Correctly updated by `undo()`? | Notes |
|---|---|---|
| `score` | ✅ By design | 15 WXP undo penalty applied; Zen: stays 0 |
| `move_count` | ✅ Correct | Recomputed from `session.history().len()` |
| `is_won` | ✅ Correct | Recomputed; undo blocked on won game |
| `is_auto_completable` | ✅ Correct | Recomputed |
| `undo_count` | ✅ By design | Total undos ever, intentionally non-reversible |
| `elapsed_seconds` | ✅ Intentional | Timer is independent of moves |
| `seed` / `draw_mode` / `mode` / `take_from_foundation` | ✅ Immutable | |
| **`recycle_count`** | ❌ **Bug** | Not decremented — see below |
**`recycle_count` drift bug:**
`draw()` increments `recycle_count` when `stock.face_down().is_empty()` (the rotation
is a recycle, not just a draw). `undo()` calls `session.undo()` which restores the
`Klondike` card state, but does NOT decrement `recycle_count`.
Consequence: if the player recycles, undoes it, then recycles again, `recycle_count`
is `2` instead of `1` — the free-recycle allowance is consumed even though the first
recycle was undone. On Draw-1, the 2nd recycle costs 100; after the undo-and-replay
bug the player pays 100 for what should be their still-free recycle.
**Score compound effect:** When `undo()` is applied to a recycle that incurred a
penalty, the penalty amount (`score_after_recycle - 100`) is already in `self.score`.
`apply_undo_score` then adds `15` on top. The recycle penalty is never reversed.
**Fix approach for Phase 3:**
- After `session.undo()`, recompute `recycle_count` by scanning the new
`session.history()` for `RotateStock` snapshots where
`snapshot.state().state().stock().face_down().is_empty()` (indicating the rotation
was a recycle, not a draw from a populated stock).
- Restore `score` to `snapshot_score` **before** the undone move, then apply only
the 15 undo penalty. This requires reading the score stored in `StateSnapshot`
or keeping a pre-move score stack alongside the session history.
**Simpler alternative:** Store `(score_before, recycle_count_before)` in `GameState`
alongside each `session.process_instruction` call, mirroring the snapshot stack.
Undo pops this alongside the session undo.
### Phase 3 — Fix `recycle_count` drift on undo (optional, post-approval)
Files: `solitaire_core/src/game_state.rs`
After `session.undo()`, recompute `recycle_count` by scanning `session.history()` for
`RotateStock` snapshots where the pre-instruction stock face-down was empty (indicating
a recycle). Also correct the score: restore to the pre-undone-move score and apply only
the 15 undo penalty.
**Tests to add:**
- `recycle_count_decrements_when_recycle_is_undone`
- `score_recycle_penalty_is_reversed_on_undo`
**Risk:** Medium — changes observable scoring behavior. The fix is strictly more
correct, but any golden-file or regression test that recorded the old (buggy) score
after undo-of-recycle will need updating.
---
## 6. Files Likely to Change Per Phase
| Phase | Files |
|---|---|
| Phase 0 | `docs/card-game-integration.md` |
| Phase 1 | `solitaire_core/src/game_state.rs` |
| Phase 2 | `solitaire_core/src/klondike_adapter.rs`, `solitaire_core/src/game_state.rs`, `solitaire_core/src/proptest_tests.rs`, `solitaire_data/src/storage.rs` |
| Phase 3 | `solitaire_core/src/game_state.rs`, new test module |
---
## 7. Risks
### R1 — Save file format break (Phase 2, HIGH)
Users with v3 saves lose their in-progress game. Mitigated by the fact that v3 is
not in any shipped release (dev branch only). Graceful fallback (start fresh) is
acceptable; a migration shim is possible but not required.
### R2 — `solitaire_wasm` / `solitaire_data::replay` breakage (Phase 2, MEDIUM)
`SavedKlondikePile` and `SavedKlondikePileStack` are also used in `replay.rs` and
`wasm/src/lib.rs`. These are separate from the game-state save format and must be
left in place. Plan is to keep them in `klondike_adapter.rs` (or relocate to
`replay.rs`) after the game-state mirror types are deleted.
### R3 — `check_auto_complete` semantic drift (Phase 1, LOW)
Upstream `is_win_trivial` checks `stock.is_empty()` (no cards at all in stock)
whereas Ferrous also checks waste. These are equivalent for a valid game state but
could differ under test-support pile overrides. Existing auto-complete tests will
catch any regression.
### R4 — `SkipCards as usize` cast correctness
Already verified: enums have implicit 0..12 discriminants. No risk.
### R5 — Upstream changes after rev pin
The workspace is pinned to `rev = "99b49e62"`. No upstream drift risk until explicitly
re-pinned.
---
## 8. Test Plan
### Phase 1 tests (all currently pass)
- `game_state::tests::take_from_foundation_allows_legal_return_move`
- `game_state::tests::take_from_foundation_disabled_blocks_return_move_everywhere`
- `proptest_tests::*` (card conservation, deal determinism, undo invariant, legal moves)
### Phase 2 tests to add
- `storage::tests::game_state_v4_mid_game_round_trip` — verify upstream serde round-trip
after migrating to `KlondikeInstruction` directly
- `storage::tests::save_format_v3_is_rejected` — v3 files must return `None`
- Update `game_state::tests::*` — all existing tests must continue to pass
### Phase 2 tests to remove
- `proptest_tests::saved_instruction_round_trip` — no longer needed (no mirror types)
- `proptest_tests::saved_instruction_boundary_tests::*` — no longer needed
### Phase 3 tests to add
- `game_state::tests::recycle_count_decrements_on_undo` — after recycling and undoing,
`recycle_count` must reflect the correct post-undo count
---
## 9. Validation Commands
Run after each phase:
```bash
# Targeted (fast)
cargo test -p solitaire_core
cargo clippy -p solitaire_core -- -D warnings
# Broader
cargo test -p solitaire_wasm
cargo test -p solitaire_data
# Full workspace (run before declaring phase complete)
cargo test --workspace
cargo clippy --workspace -- -D warnings
```
---
## Summary: What Would Be Removed vs Kept
### Removed after all phases complete
| Code | Lines est. | Reason |
|---|---|---|
| `SavedInstruction` + 8 mirror types | ~150 | Upstream serde now available |
| 20 `From`/`TryFrom` impls | ~150 | Upstream serde now available |
| `InvalidSavedInstruction` error type | ~10 | Upstream serde now available |
| `check_win()` local impl | ~20 | Replaced by `is_win()` delegation |
| `check_auto_complete()` local impl | ~15 | Replaced by `is_win_trivial()` delegation |
| `SavedInstruction` proptest + boundary tests | ~60 | Mirror types removed |
**Total: ~400 lines removed from `solitaire_core`**
### Remains Ferrous-specific
- `KlondikeAdapter` scoring helpers (recycle penalties, score floor, time bonus, Zen/mode suppression)
- `DrawMode`, `GameMode`, `DifficultyLevel`
- `MoveError` and all boundary-checking logic
- `card::Card` (id + face_up projection)
- `Pile` DTO
- `stock_cards()` / `waste_cards()` projections
- Persistence format (`GameState` serde, schema version, `PersistedGameState`)
- `solitaire_data::replay` types (`ReplayMove`, `SavedKlondikePile` mirror — unchanged)
- `solitaire_wasm` replay mirror types (unchanged)
+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 # ndk-build crate that we couldn't isolate; running each Android toolchain
# step explicitly gives us a debuggable pipeline. # step explicitly gives us a debuggable pipeline.
# #
# Required environment: # Environment:
# ANDROID_HOME Path to Android SDK root # ANDROID_HOME Path to Android SDK root. If unset, common SDK
# ANDROID_NDK_HOME Path to the specific NDK version # locations such as ~/Android/Sdk are tried.
# BUILD_TOOLS_VERSION e.g. "34.0.0" # ANDROID_NDK_HOME Path to the specific NDK version. If unset, the
# PLATFORM e.g. "android-34" # 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: # Optional environment:
# PROFILE "debug" (default) | "release" # PROFILE "debug" (default) | "release"
@@ -19,7 +23,8 @@
# fit the runner's disk budget — a full three-ABI # fit the runner's disk budget — a full three-ABI
# debug build can exceed 25 GB of target/ output. # debug build can exceed 25 GB of target/ output.
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk) # 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) # KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
# KEY_ALIAS Key alias (default: "androiddebugkey") # KEY_ALIAS Key alias (default: "androiddebugkey")
# KEY_PASS Key password (default: same as KEYSTORE_PASS) # KEY_PASS Key password (default: same as KEYSTORE_PASS)
@@ -28,18 +33,63 @@
# $APK_OUT Signed, zipaligned APK # $APK_OUT Signed, zipaligned APK
set -euo pipefail set -euo pipefail
: "${ANDROID_HOME:?ANDROID_HOME must be set}" infer_latest_dir_name() {
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}" local pattern="$1"
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}" local latest=""
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}" 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}" PROFILE="${PROFILE:-debug}"
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}" ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}" 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)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT" 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" BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar" PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
MANIFEST="solitaire_app/android/AndroidManifest.xml" MANIFEST="solitaire_app/android/AndroidManifest.xml"
@@ -69,6 +119,24 @@ fi
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}" echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
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 ------------------------------ # --- 2. compile + link resources and manifest ------------------------------
if [ -d "$RES_DIR" ]; then if [ -d "$RES_DIR" ]; then
echo ">>> aapt2 compile resources" echo ">>> aapt2 compile resources"
@@ -120,11 +188,15 @@ rm -f "$STAGING/app-unsigned.apk"
# --- 5. sign --------------------------------------------------------------- # --- 5. sign ---------------------------------------------------------------
if [ -z "${KEYSTORE:-}" ]; then if [ -z "${KEYSTORE:-}" ]; then
# Generate a deterministic debug keystore on the fly. KEYSTORE="target/android/debug.keystore"
KEYSTORE="$STAGING/debug.keystore" fi
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}" KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}" 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" echo ">>> generating debug keystore at $KEYSTORE"
keytool -genkeypair -v \ keytool -genkeypair -v \
-keystore "$KEYSTORE" \ -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"
+25 -10
View File
@@ -25,7 +25,10 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use bevy::winit::WinitWindows; use bevy::winit::WinitWindows;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use bevy::winit::{UpdateMode, WinitSettings}; use bevy::winit::{UpdateMode, WinitSettings};
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path}; 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}; use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
fn load_settings() -> Settings { fn load_settings() -> Settings {
@@ -49,6 +52,12 @@ pub fn run() {
// and any debugger attached still sees the panic). // and any debugger attached still sees the panic).
install_crash_log_hook(); 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. // Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on // On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store. // macOS it uses the Keychain; on Windows it uses the Credential store.
@@ -56,10 +65,9 @@ pub fn run() {
// operations will fail gracefully with TokenError::KeychainUnavailable. // operations will fail gracefully with TokenError::KeychainUnavailable.
// //
// Android: `keyring` isn't compiled in (its `rpassword` transitive // Android: `keyring` isn't compiled in (its `rpassword` transitive
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens` // pulls a libc symbol Android's bionic doesn't expose). The Android
// ships an Android stub that returns KeychainUnavailable for every // auth-token path uses Android Keystore via JNI; `android_main` passes
// call — the runtime behaviour is "session login required each launch" // the process JavaVM pointer into `solitaire_data` before `run()`.
// until we wire Android Keystore via JNI in the Phase-Android round.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
if let Err(e) = keyring::use_native_store(true) { if let Err(e) = keyring::use_native_store(true) {
eprintln!( eprintln!(
@@ -172,13 +180,16 @@ fn build_app_with_settings(
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency // a 1-second ceiling when the app is backgrounded cuts wake-up frequency
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain. // from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
// //
// The focused mode stays Continuous so that card-slide animations remain // focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when
// smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the // an event arrives (touch, resize, etc.) or an animation system writes
// display refresh rate (~60 Hz) when foregrounded, which already prevents // RequestRedraw. The 100 ms ceiling is a fallback that ensures the game
// the GPU from spinning at 200+ fps between vsync intervals. // 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")] #[cfg(target_os = "android")]
app.insert_resource(WinitSettings { app.insert_resource(WinitSettings {
focused_mode: UpdateMode::Continuous, focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)), unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
}); });
@@ -354,6 +365,10 @@ fn set_window_icon(
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) { 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); let _ = bevy::android::ANDROID_APP.set(android_app);
run(); run();
} }
@@ -2,10 +2,10 @@
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in //! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
//! `solitaire_data/src/difficulty_seeds.rs`. //! `solitaire_data/src/difficulty_seeds.rs`.
//! //!
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that //! A seed's tier is determined by the **smallest** solve budget at which it is
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget //! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit //! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded
//! provably-winnable seeds). //! (we only emit provably-winnable seeds).
//! //!
//! # Usage //! # Usage
//! //!
@@ -19,12 +19,12 @@
//! --per-tier Seeds to emit per tier (default 40) //! --per-tier Seeds to emit per tier (default 40)
//! --help Print this message //! --help Print this message
use solitaire_core::game_state::DrawMode; use solitaire_core::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve}; use solitaire_data::solver::try_solve;
// Budget boundaries defining each tier. A seed belongs to the lowest tier // Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable. // whose budget proves it Winnable.
const BUDGETS: &[(&str, u64, usize)] = &[ const BUDGETS: &[(&str, u64, u64)] = &[
("Easy", 1_000, 1_000), ("Easy", 1_000, 1_000),
("Medium", 5_000, 5_000), ("Medium", 5_000, 5_000),
("Hard", 25_000, 25_000), ("Hard", 25_000, 25_000),
@@ -99,12 +99,8 @@ fn main() {
if buckets[i].len() >= per_tier { if buckets[i].len() >= per_tier {
continue; continue;
} }
let cfg = SolverConfig { match try_solve(seed, draw_mode, move_budget, state_budget) {
move_budget, Ok(Some(_)) => {
state_budget,
};
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable => {
buckets[i].push(seed); buckets[i].push(seed);
eprintln!( eprintln!(
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})", " [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
@@ -113,13 +109,13 @@ fn main() {
); );
break 'tier; // assign to the cheapest tier that proves it winnable break 'tier; // assign to the cheapest tier that proves it winnable
} }
SolverResult::Unwinnable => { Ok(None) => {
// Definitely unsolvable — skip all remaining tiers. // Definitely unsolvable — skip all remaining tiers.
break 'tier; break 'tier;
} }
SolverResult::Inconclusive => { Err(_) => {
// Budget exhausted without proof — try the next larger tier. // Budget exhausted without proof — try the next larger tier.
// If this is the last tier, the seed is discarded (Inconclusive // If this is the last tier, the seed is discarded (inconclusive
// at max budget means "probably but not provably winnable"). // at max budget means "probably but not provably winnable").
if i == num_tiers - 1 { if i == num_tiers - 1 {
break 'tier; break 'tier;
+12 -5
View File
@@ -1,7 +1,7 @@
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`. //! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
//! //!
//! Walks seeds incrementally from `--start`, calls the solver on each, and //! Walks seeds incrementally from `--start`, calls the solver on each, and
//! collects only those that return `SolverResult::Winnable` (Inconclusive is //! collects only those proven winnable (`Ok(Some(_))`; inconclusive is
//! rejected — the curated list wants proof). Prints Rust source suitable for //! rejected — the curated list wants proof). Prints Rust source suitable for
//! pasting into `solitaire_data/src/challenge.rs`. //! pasting into `solitaire_data/src/challenge.rs`.
//! //!
@@ -17,8 +17,8 @@
//! --count Number of Winnable seeds to emit (default 75) //! --count Number of Winnable seeds to emit (default 75)
//! --help Print this message //! --help Print this message
use solitaire_core::game_state::DrawMode; use solitaire_core::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve}; use solitaire_data::solver::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve};
fn main() { fn main() {
let mut args = std::env::args().skip(1).peekable(); let mut args = std::env::args().skip(1).peekable();
@@ -67,7 +67,6 @@ fn main() {
std::process::exit(1); std::process::exit(1);
} }
let cfg = SolverConfig::default();
let draw_mode = DrawMode::DrawOne; let draw_mode = DrawMode::DrawOne;
let mut found: Vec<u64> = Vec::with_capacity(count); let mut found: Vec<u64> = Vec::with_capacity(count);
let mut tried: u64 = 0; let mut tried: u64 = 0;
@@ -77,7 +76,15 @@ fn main() {
while found.len() < count { while found.len() < count {
tried += 1; tried += 1;
if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) { if matches!(
try_solve(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET
),
Ok(Some(_))
) {
found.push(seed); found.push(seed);
eprintln!( eprintln!(
" [{:>3}/{}] 0x{:016X} ({} tried so far)", " [{:>3}/{}] 0x{:016X} ({} tried so far)",
+9 -1
View File
@@ -4,7 +4,15 @@ version.workspace = true
license.workspace = true license.workspace = true
edition.workspace = true edition.workspace = true
[features]
default = []
test-support = []
[dev-dependencies]
proptest = "1"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
rand = { workspace = true } klondike = { workspace = true }
card_game = { workspace = true }
+21 -167
View File
@@ -1,169 +1,23 @@
use serde::{Deserialize, Serialize}; pub use card_game::{Card, Deck, Rank, Suit};
/// Card suit. /// Maps a [`Card`] to a stable `0..=51` numeric identity, independent of the
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] /// upstream `card_game::Card` bit-packing.
pub enum Suit { ///
Clubs, /// Encoding: `suit_index * 13 + (rank as u32 - 1)`, where `suit_index` is
Diamonds, /// Clubs=0, Diamonds=1, Hearts=2, Spades=3 and `rank` is 1 (Ace) ..= 13 (King).
Hearts, /// The deck id is intentionally ignored so the id depends only on the visible
Spades, /// face.
} ///
/// This is the single source of truth shared by `CardEntity` numeric tracking,
impl Suit { /// deterministic per-card animation jitter, and the WASM replay layer — those
/// All four suits in declaration order. /// must agree byte-for-byte so replay snapshots are identical across the
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades]; /// desktop and browser builds.
pub fn card_to_id(card: &Card) -> u32 {
/// Returns `true` for red suits (Diamonds, Hearts). let suit_index: u32 = match card.suit() {
pub fn is_red(self) -> bool { Suit::Clubs => 0,
matches!(self, Suit::Diamonds | Suit::Hearts) Suit::Diamonds => 1,
} Suit::Hearts => 2,
Suit::Spades => 3,
/// Returns `true` for black suits (Clubs, Spades). };
pub fn is_black(self) -> bool { suit_index * 13 + (card.rank() as u32 - 1)
!self.is_red()
}
}
/// Card rank, Ace through King.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Rank {
Ace = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
Six = 6,
Seven = 7,
Eight = 8,
Nine = 9,
Ten = 10,
Jack = 11,
Queen = 12,
King = 13,
}
impl Rank {
/// All thirteen ranks in ascending order.
pub const RANKS: [Self; 13] = [
Self::Ace,
Self::Two,
Self::Three,
Self::Four,
Self::Five,
Self::Six,
Self::Seven,
Self::Eight,
Self::Nine,
Self::Ten,
Self::Jack,
Self::Queen,
Self::King,
];
/// Numeric value: Ace = 1, King = 13.
pub fn value(self) -> u8 {
self as u8
}
const fn new(n: u8) -> Option<Self> {
match n {
1 => Some(Self::Ace),
2 => Some(Self::Two),
3 => Some(Self::Three),
4 => Some(Self::Four),
5 => Some(Self::Five),
6 => Some(Self::Six),
7 => Some(Self::Seven),
8 => Some(Self::Eight),
9 => Some(Self::Nine),
10 => Some(Self::Ten),
11 => Some(Self::Jack),
12 => Some(Self::Queen),
13 => Some(Self::King),
_ => None,
}
}
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
pub const fn checked_add(self, n: u8) -> Option<Self> {
Self::new((self as u8).saturating_add(n))
}
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
pub const fn checked_sub(self, n: u8) -> Option<Self> {
match (self as u8).checked_sub(n) {
Some(v) => Self::new(v),
None => None,
}
}
}
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card {
/// Unique identifier for this card within the deal. Stable across moves and undo.
pub id: u32,
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
pub suit: Suit,
/// The card's rank (Ace through King).
pub rank: Rank,
/// Whether the card is visible to the player. Face-down cards may not be moved.
pub face_up: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rank_values_are_sequential() {
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!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
}
} }
-193
View File
@@ -1,193 +0,0 @@
use crate::card::{Card, Rank, Suit};
use crate::pile::{Pile, PileType};
use rand::rngs::StdRng;
use rand::{SeedableRng, seq::SliceRandom};
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
+478
View File
@@ -0,0 +1,478 @@
//! 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 klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
TableauStack,
};
use serde::{Deserialize, Serialize};
use crate::game_state::GameMode;
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree,
}
/// 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,
}
}
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
//
// These types were introduced when upstream `klondike` had no serde feature.
// Mainline `klondike` now provides full serde support (with a hand-written
// compact `KlondikeInstruction` impl), and `GameState` serialises
// `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
//
// The mirror types are retained for three reasons:
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
// files with u8 indices and replay them.
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
// type; changing it would break the on-disk replay format (schema v2).
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
// replay JSON is cross-compatible between the desktop and browser builds.
//
// These types should not be used for new serialisation concerns. If the
// ReplayMove format is ever bumped to a new schema, migrate those callers to
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
/// 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
}
+16 -5
View File
@@ -1,9 +1,20 @@
pub mod achievement; pub mod achievement;
pub mod card; pub mod card;
pub mod deck;
pub mod error; pub mod error;
pub mod game_state; pub mod game_state;
pub mod pile; pub mod klondike_adapter;
pub mod rules;
pub mod scoring; // Re-export the upstream types that cross the solitaire_core API boundary so
pub mod solver; // downstream crates (engine, wasm) can import from one place without a direct
// `klondike` / `card_game` dep.
//
// `KlondikePileStack`, `SkipCards` and `TableauStack` are intentionally NOT
// re-exported — they are only used internally (in `klondike_adapter.rs` and
// when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature.
pub use card_game::{Card, Session};
pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
pub use klondike_adapter::DrawMode;
#[cfg(test)]
mod proptest_tests;
-133
View File
@@ -1,133 +0,0 @@
use crate::card::{Card, Suit};
use serde::{Deserialize, Serialize};
/// Identifies which pile on the board a set of cards belongs to.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, 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)]
pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
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(),
}
}
/// Returns a reference to the top (last) card, or `None` if empty.
pub fn top(&self) -> Option<&Card> {
self.cards.last()
}
/// For foundation piles: returns `Some(suit)` once at least one card has
/// landed (the bottom card is always an Ace of the claimed suit).
/// 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),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(PileType::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,
});
assert_eq!(pile.top().unwrap().id, 1);
}
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(PileType::Waste);
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));
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,
});
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,
});
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
}
+377
View File
@@ -0,0 +1,377 @@
use card_game::{Card, Game};
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
use proptest::prelude::*;
use crate::game_state::GameState;
use crate::klondike_adapter::DrawMode;
use crate::klondike_adapter::{
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
SavedTableauStack,
};
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Collect all cards 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_cards(game: &GameState) -> Vec<Card> {
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 cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
for f in &foundations {
cards.extend(
game.pile(KlondikePile::Foundation(*f))
.iter()
.map(|(c, _)| c.clone()),
);
}
for t in &tableaux {
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
}
cards
}
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 instruction
/// from `possible_instructions()` and apply it via `apply_instruction()`.
///
/// `possible_instructions()` may return `RotateStock`, which
/// `apply_instruction()` dispatches to `game.draw()`; ordinary instructions
/// are equivalent to `move_cards(from, to, count)`.
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
for &(do_draw, idx) in actions {
if do_draw {
let _ = game.draw();
} else {
let moves = game.possible_instructions();
if moves.is_empty() {
continue;
}
let instruction = moves[idx % moves.len()];
let _ = game.apply_instruction(instruction);
}
}
}
/// 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 moves = game.possible_instructions();
if moves.is_empty() {
return game.draw().is_ok();
}
let instruction = moves[move_idx % moves.len()];
game.apply_instruction(instruction).is_ok()
}
// ---------------------------------------------------------------------------
// Properties
// ---------------------------------------------------------------------------
proptest! {
/// `check_auto_complete()` and `is_win_trivial()` must agree on every
/// reachable game state.
///
/// The upstream `Klondike::is_win_trivial()` checks that the stock pile
/// (both face-down and face-up halves) is completely empty AND that all
/// tableau columns have no face-down cards. Ferrous `check_auto_complete()`
/// checks the same three conditions individually (stock empty, waste empty,
/// all tableau cards face-up). This property guards against any semantic
/// drift between the two implementations so that delegating to upstream is
/// safe.
///
/// If this property ever fails, `check_auto_complete()` must NOT be fully
/// replaced — the Ferrous conditions must be preserved and `is_win_trivial()`
/// used only as a supplementary guard.
#[test]
fn check_auto_complete_agrees_with_is_win_trivial(
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);
prop_assert_eq!(
game.check_auto_complete(),
game.session().state().state().is_win_trivial(),
"check_auto_complete() disagreed with is_win_trivial() after {:?} actions",
actions.len(),
);
}
/// `check_win()` and `is_win()` must agree on every reachable game state.
#[test]
fn check_win_agrees_with_is_win(
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);
prop_assert_eq!(
game.check_win(),
game.session().state().state().is_win(),
"check_win() disagreed with is_win()",
);
}
/// 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 cards = all_cards(&game);
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
prop_assert_eq!(
unique.len(), 52,
"duplicate cards 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_cards(&a),
all_cards(&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_cards(&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_cards(&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 instruction in game.possible_instructions() {
// Clone so each move is tried from the same starting state.
let mut trial = game.clone();
let result = trial.apply_instruction(instruction);
prop_assert!(
result.is_ok(),
"possible_instructions() reported {instruction:?} \
as legal but the call returned Err: {result:?}",
);
}
}
// -------------------------------------------------------------------------
// SavedInstruction ↔ KlondikeInstruction round-trip
// -------------------------------------------------------------------------
/// Every valid `SavedInstruction` survives a round-trip through
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
///
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
/// and all legal sub-field ranges:
/// - `SavedTableau`: 06
/// - `SavedFoundation`: 03
/// - `SavedSkipCards`: 012
#[test]
fn saved_instruction_round_trip(
instruction in saved_instruction_strategy(),
) {
let klondike = KlondikeInstruction::try_from(instruction);
prop_assert!(
klondike.is_ok(),
"TryFrom failed for valid SavedInstruction {instruction:?}: {:?}",
klondike.err(),
);
let saved_again = SavedInstruction::from(klondike.expect("checked above"));
prop_assert_eq!(
saved_again,
instruction,
"round-trip produced a different SavedInstruction",
);
}
}
// ---------------------------------------------------------------------------
// Proptest strategies for SavedInstruction and its sub-types
// ---------------------------------------------------------------------------
fn saved_tableau_strategy() -> impl Strategy<Value = SavedTableau> {
(0u8..=6).prop_map(SavedTableau)
}
fn saved_foundation_strategy() -> impl Strategy<Value = SavedFoundation> {
(0u8..=3).prop_map(SavedFoundation)
}
fn saved_skip_cards_strategy() -> impl Strategy<Value = SavedSkipCards> {
(0u8..=12).prop_map(SavedSkipCards)
}
fn saved_klondike_pile_strategy() -> impl Strategy<Value = SavedKlondikePile> {
prop_oneof![
saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau),
Just(SavedKlondikePile::Stock),
saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation),
]
}
fn saved_klondike_pile_stack_strategy() -> impl Strategy<Value = SavedKlondikePileStack> {
prop_oneof![
(saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| {
SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards })
}),
Just(SavedKlondikePileStack::Stock),
saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation),
]
}
fn saved_instruction_strategy() -> impl Strategy<Value = SavedInstruction> {
prop_oneof![
Just(SavedInstruction::RotateStock),
(saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map(
|(src, foundation)| {
SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation })
}
),
(saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map(
|(src, tableau)| {
SavedInstruction::DstTableau(SavedDstTableau { src, tableau })
}
),
]
}
// ---------------------------------------------------------------------------
// Boundary error unit tests (exact out-of-range values)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod saved_instruction_boundary_tests {
use super::*;
#[test]
fn saved_tableau_7_is_invalid() {
let result = Tableau::try_from(SavedTableau(7));
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7)));
}
#[test]
fn saved_tableau_255_is_invalid() {
let result = Tableau::try_from(SavedTableau(255));
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255)));
}
#[test]
fn saved_foundation_4_is_invalid() {
let result = Foundation::try_from(SavedFoundation(4));
assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4)));
}
#[test]
fn saved_skip_cards_13_is_invalid() {
let result = SkipCards::try_from(SavedSkipCards(13));
assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13)));
}
}
-228
View File
@@ -1,228 +0,0 @@
use crate::card::{Card, Rank};
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.
#[must_use]
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank == Rank::Ace,
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
}
}
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
///
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
#[must_use]
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank == Rank::King,
Some(top) => {
top.face_up
&& card.rank.checked_add(1) == Some(top.rank)
&& 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.
#[must_use]
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
cards.windows(2).all(|w| {
w[0].rank.checked_sub(1) == Some(w[1].rank) && 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),
]));
}
}
-152
View File
@@ -1,152 +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
/// - -15 for a foundation → tableau (take-from-foundation) move
/// - 0 for all other moves
///
/// Note: the +5 flip bonus for exposing a face-down tableau card is applied
/// separately in `game_state::move_cards` because it depends on post-move state.
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
match to {
PileType::Foundation(_) => 10,
PileType::Tableau(_) => match from {
PileType::Waste => 5,
PileType::Foundation(_) => -15,
_ => 0,
},
_ => 0,
}
}
/// Score penalty applied when the player uses undo: -15.
pub fn score_undo() -> i32 {
-15
}
/// Score bonus awarded when a face-down tableau card is flipped face-up: +5.
pub fn score_flip() -> i32 {
5
}
/// Score penalty for recycling the waste pile back to stock.
///
/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3).
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
/// `recycle_count` is the new total count **after** this recycle.
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
let (free, penalty) = if is_draw_three {
(3_u32, -20_i32)
} else {
(1_u32, -100_i32)
};
if recycle_count > free { penalty } else { 0 }
}
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// 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 foundation_to_tableau_penalises_fifteen() {
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
assert_eq!(
score_move(&PileType::Foundation(0), &PileType::Tableau(0)),
-15
);
}
#[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"
);
}
#[test]
fn flip_bonus_is_five() {
assert_eq!(score_flip(), 5);
}
#[test]
fn recycle_draw1_first_pass_free() {
assert_eq!(score_recycle(1, false), 0);
}
#[test]
fn recycle_draw1_second_pass_penalised() {
assert_eq!(score_recycle(2, false), -100);
}
#[test]
fn recycle_draw3_third_pass_free() {
assert_eq!(score_recycle(3, true), 0);
}
#[test]
fn recycle_draw3_fourth_pass_penalised() {
assert_eq!(score_recycle(4, true), -20);
}
}
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -7,15 +7,23 @@ edition.workspace = true
[dependencies] [dependencies]
solitaire_core = { workspace = true } solitaire_core = { workspace = true }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
async-trait = { 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 } dirs = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by # `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in # `auth_tokens`. The crate's own dependency tree pulls in
@@ -24,17 +32,14 @@ uuid = { workspace = true }
# on bionic). On Android `auth_tokens` falls back to a stub # on bionic). On Android `auth_tokens` falls back to a stub
# implementation that always returns `KeychainUnavailable`; the # implementation that always returns `KeychainUnavailable`; the
# real backend lands when we wire Android Keystore via JNI. # 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 } keyring-core = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true } 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] [dev-dependencies]
solitaire_core = { workspace = true, features = ["test-support"] }
solitaire_server = { path = "../solitaire_server" } solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
+29 -6
View File
@@ -19,11 +19,14 @@ use jni::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::c_void;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock;
use crate::auth_tokens::TokenError; use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "ferrous_solitaire_token_key"; const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct TokenBlob { struct TokenBlob {
@@ -36,17 +39,37 @@ struct TokenBlob {
// JVM helper // 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> fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
where where
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>, F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
{ {
let app = bevy::android::ANDROID_APP let vm = ANDROID_JVM
.get() .get()
.ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?; .ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM 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}")))?;
let mut env = vm let mut env = vm
.attach_current_thread_permanently() .attach_current_thread_permanently()
+4 -6
View File
@@ -14,15 +14,13 @@
//! the Bevy `App`). If no default store is set, all operations in this module //! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`]. //! will return [`TokenError::KeychainUnavailable`].
//! //!
//! # Android stub //! # Android
//! //!
//! `keyring-core` cannot compile for the android target (its `rpassword` //! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic //! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android every function in this module returns //! doesn't expose). On Android this module delegates to an Android Keystore
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback //! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm`
//! the same way they handle a Linux box without Secret Service. The //! from Android startup before token operations can succeed.
//! real Android backend will arrive in the Phase-Android round when we
//! wire Android Keystore via JNI.
//! //!
//! # Note: no unit tests — requires live OS keychain. //! # Note: no unit tests — requires live OS keychain.
+184 -184
View File
@@ -26,227 +26,227 @@ use solitaire_core::game_state::DifficultyLevel;
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states). /// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
pub const EASY_SEEDS: &[u64] = &[ pub const EASY_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-06-04)
0xD1FF_0000_0000_0001,
0xD1FF_0000_0000_0002,
0xD1FF_0000_0000_0007,
0xD1FF_0000_0000_0008,
0xD1FF_0000_0000_0009, 0xD1FF_0000_0000_0009,
0xD1FF_0000_0000_000E, 0xD1FF_0000_0000_0087,
0xD1FF_0000_0000_0013, 0xD1FF_0000_0000_00EB,
0xD1FF_0000_0000_0015, 0xD1FF_0000_0000_017F,
0xD1FF_0000_0000_0018, 0xD1FF_0000_0000_01CE,
0xD1FF_0000_0000_001D, 0xD1FF_0000_0000_020F,
0xD1FF_0000_0000_0021, 0xD1FF_0000_0000_0251,
0xD1FF_0000_0000_0022, 0xD1FF_0000_0000_0275,
0xD1FF_0000_0000_0026, 0xD1FF_0000_0000_029C,
0xD1FF_0000_0000_002C, 0xD1FF_0000_0000_02BD,
0xD1FF_0000_0000_002E, 0xD1FF_0000_0000_02ED,
0xD1FF_0000_0000_002F, 0xD1FF_0000_0000_038F,
0xD1FF_0000_0000_0035, 0xD1FF_0000_0000_03C9,
0xD1FF_0000_0000_0036, 0xD1FF_0000_0000_0415,
0xD1FF_0000_0000_003C, 0xD1FF_0000_0000_045F,
0xD1FF_0000_0000_0045, 0xD1FF_0000_0000_04C4,
0xD1FF_0000_0000_0046, 0xD1FF_0000_0000_04CC,
0xD1FF_0000_0000_0048, 0xD1FF_0000_0000_04EE,
0xD1FF_0000_0000_0049, 0xD1FF_0000_0000_0631,
0xD1FF_0000_0000_004D, 0xD1FF_0000_0000_0651,
0xD1FF_0000_0000_004F, 0xD1FF_0000_0000_0689,
0xD1FF_0000_0000_0050, 0xD1FF_0000_0000_0735,
0xD1FF_0000_0000_0051, 0xD1FF_0000_0000_0748,
0xD1FF_0000_0000_0053, 0xD1FF_0000_0000_0801,
0xD1FF_0000_0000_0054, 0xD1FF_0000_0000_0820,
0xD1FF_0000_0000_0057, 0xD1FF_0000_0000_08F9,
0xD1FF_0000_0000_0058, 0xD1FF_0000_0000_091C,
0xD1FF_0000_0000_005A, 0xD1FF_0000_0000_0937,
0xD1FF_0000_0000_005B, 0xD1FF_0000_0000_09A6,
0xD1FF_0000_0000_005C, 0xD1FF_0000_0000_09C3,
0xD1FF_0000_0000_005D, 0xD1FF_0000_0000_09DD,
0xD1FF_0000_0000_005F, 0xD1FF_0000_0000_0BD9,
0xD1FF_0000_0000_0061, 0xD1FF_0000_0000_0BEC,
0xD1FF_0000_0000_0062, 0xD1FF_0000_0000_0BF2,
0xD1FF_0000_0000_0063, 0xD1FF_0000_0000_0C1B,
0xD1FF_0000_0000_0069, 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). /// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
pub const MEDIUM_SEEDS: &[u64] = &[ pub const MEDIUM_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-06-04)
0xD1FF_0000_0000_0000,
0xD1FF_0000_0000_0012, 0xD1FF_0000_0000_0012,
0xD1FF_0000_0000_0016, 0xD1FF_0000_0000_002C,
0xD1FF_0000_0000_001B, 0xD1FF_0000_0000_004B,
0xD1FF_0000_0000_001C, 0xD1FF_0000_0000_0052,
0xD1FF_0000_0000_0020, 0xD1FF_0000_0000_0058,
0xD1FF_0000_0000_002A, 0xD1FF_0000_0000_005E,
0xD1FF_0000_0000_0034, 0xD1FF_0000_0000_0063,
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_0099, 0xD1FF_0000_0000_0099,
0xD1FF_0000_0000_009A, 0xD1FF_0000_0000_00A9,
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_00AF, 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). /// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
pub const HARD_SEEDS: &[u64] = &[ pub const HARD_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-06-04)
0xD1FF_0000_0000_001F, 0xD1FF_0000_0000_0006,
0xD1FF_0000_0000_0024, 0xD1FF_0000_0000_0008,
0xD1FF_0000_0000_0025, 0xD1FF_0000_0000_000F,
0xD1FF_0000_0000_0031, 0xD1FF_0000_0000_0011,
0xD1FF_0000_0000_0032, 0xD1FF_0000_0000_0022,
0xD1FF_0000_0000_003E, 0xD1FF_0000_0000_0023,
0xD1FF_0000_0000_004A, 0xD1FF_0000_0000_002A,
0xD1FF_0000_0000_006D, 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_0079,
0xD1FF_0000_0000_007C, 0xD1FF_0000_0000_007C,
0xD1FF_0000_0000_0080, 0xD1FF_0000_0000_0080,
0xD1FF_0000_0000_008A, 0xD1FF_0000_0000_0081,
0xD1FF_0000_0000_0097, 0xD1FF_0000_0000_0083,
0xD1FF_0000_0000_0091,
0xD1FF_0000_0000_009B,
0xD1FF_0000_0000_00A1,
0xD1FF_0000_0000_00B1, 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_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_00D6,
0xD1FF_0000_0000_00D7, 0xD1FF_0000_0000_00DD,
0xD1FF_0000_0000_00DC, 0xD1FF_0000_0000_00E8,
0xD1FF_0000_0000_00DF, 0xD1FF_0000_0000_00F2,
0xD1FF_0000_0000_00E0, 0xD1FF_0000_0000_0101,
0xD1FF_0000_0000_00E1, 0xD1FF_0000_0000_010F,
0xD1FF_0000_0000_00E4, 0xD1FF_0000_0000_0113,
0xD1FF_0000_0000_00E6, 0xD1FF_0000_0000_0118,
0xD1FF_0000_0000_00E7, 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). /// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
pub const EXPERT_SEEDS: &[u64] = &[ pub const EXPERT_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-06-04)
0xD1FF_0000_0000_0006, 0xD1FF_0000_0000_0000,
0xD1FF_0000_0000_000B, 0xD1FF_0000_0000_0002,
0xD1FF_0000_0000_0019, 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_0082,
0xD1FF_0000_0000_00CB, 0xD1FF_0000_0000_008F,
0xD1FF_0000_0000_00D5, 0xD1FF_0000_0000_0090,
0xD1FF_0000_0000_00D8, 0xD1FF_0000_0000_0097,
0xD1FF_0000_0000_00E8, 0xD1FF_0000_0000_009A,
0xD1FF_0000_0000_00EA, 0xD1FF_0000_0000_009F,
0xD1FF_0000_0000_00EB, 0xD1FF_0000_0000_00A5,
0xD1FF_0000_0000_00EC, 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_00ED,
0xD1FF_0000_0000_00F2, 0xD1FF_0000_0000_00EE,
0xD1FF_0000_0000_00F3, 0xD1FF_0000_0000_00EF,
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,
]; ];
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states). /// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
pub const GRANDMASTER_SEEDS: &[u64] = &[ pub const GRANDMASTER_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-06-04)
0xD1FF_0000_0000_0027, 0xD1FF_0000_0000_003C,
0xD1FF_0000_0000_00A0, 0xD1FF_0000_0000_0047,
0xD1FF_0000_0000_00C4, 0xD1FF_0000_0000_005A,
0xD1FF_0000_0000_00D4, 0xD1FF_0000_0000_009C,
0xD1FF_0000_0000_00DE, 0xD1FF_0000_0000_00D2,
0xD1FF_0000_0000_00F9, 0xD1FF_0000_0000_00F4,
0xD1FF_0000_0000_0107, 0xD1FF_0000_0000_00F6,
0xD1FF_0000_0000_0108, 0xD1FF_0000_0000_0104,
0xD1FF_0000_0000_0130, 0xD1FF_0000_0000_0106,
0xD1FF_0000_0000_0132, 0xD1FF_0000_0000_0111,
0xD1FF_0000_0000_0133, 0xD1FF_0000_0000_0112,
0xD1FF_0000_0000_0134, 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_0135,
0xD1FF_0000_0000_0137,
0xD1FF_0000_0000_0139,
0xD1FF_0000_0000_013A, 0xD1FF_0000_0000_013A,
0xD1FF_0000_0000_013D, 0xD1FF_0000_0000_013B,
0xD1FF_0000_0000_013F,
0xD1FF_0000_0000_0140,
0xD1FF_0000_0000_0141, 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_014A,
0xD1FF_0000_0000_014B, 0xD1FF_0000_0000_014B,
0xD1FF_0000_0000_014C, 0xD1FF_0000_0000_014E,
0xD1FF_0000_0000_014D,
0xD1FF_0000_0000_014F,
0xD1FF_0000_0000_0150, 0xD1FF_0000_0000_0150,
0xD1FF_0000_0000_0151, 0xD1FF_0000_0000_0155,
0xD1FF_0000_0000_0152,
0xD1FF_0000_0000_0153,
0xD1FF_0000_0000_0157, 0xD1FF_0000_0000_0157,
0xD1FF_0000_0000_0158, 0xD1FF_0000_0000_0158,
0xD1FF_0000_0000_015B, 0xD1FF_0000_0000_0159,
0xD1FF_0000_0000_015A,
0xD1FF_0000_0000_015C, 0xD1FF_0000_0000_015C,
0xD1FF_0000_0000_015E, 0xD1FF_0000_0000_015D,
0xD1FF_0000_0000_0162, 0xD1FF_0000_0000_015F,
0xD1FF_0000_0000_0164, 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,
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+21 -4
View File
@@ -99,6 +99,12 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
} }
} }
pub mod solver;
pub use solver::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
try_solve_from_state,
};
pub mod stats; pub mod stats;
pub use stats::{StatsExt, StatsSnapshot}; pub use stats::{StatsExt, StatsSnapshot};
@@ -118,8 +124,8 @@ pub use achievements::{
pub mod progress; pub mod progress;
pub use progress::{ pub use progress::{
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path, PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
save_progress_to, xp_for_win, progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
}; };
pub mod weekly; pub mod weekly;
@@ -145,14 +151,20 @@ pub use settings::{
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod android_keystore; mod android_keystore;
#[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens; pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{ pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
}; };
pub mod sync_client; pub mod sync_client;
pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend}; pub use sync_client::LocalOnlyProvider;
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub mod replay; pub mod replay;
pub use replay::{ pub use replay::{
@@ -160,10 +172,15 @@ pub use replay::{
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from, ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to, migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
}; };
// `latest_replay_path` is still consumed by the engine's one-shot legacy
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
// outside `replay.rs` and were dropped from the public surface.
#[allow(deprecated)] #[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to}; pub use replay::latest_replay_path;
#[cfg(not(target_arch = "wasm32"))]
pub mod matomo_client; pub mod matomo_client;
#[cfg(not(target_arch = "wasm32"))]
pub use matomo_client::MatomoClient; pub use matomo_client::MatomoClient;
pub mod platform; pub mod platform;
+59
View File
@@ -114,3 +114,62 @@ fn url_encode(s: &str) -> String {
}) })
.collect() .collect()
} }
#[cfg(test)]
mod tests {
use super::*;
fn pending(client: &MatomoClient) -> Vec<String> {
client.pending.lock().expect("pending lock").clone()
}
#[test]
fn event_buffers_encoded_matomo_query() {
let client = MatomoClient::new(
"https://analytics.example.com/",
7,
Some("alice bob".into()),
);
client.event("Game Flow", "Won+Fast", Some("draw three"), Some(42.5));
let pending = pending(&client);
assert_eq!(pending.len(), 1);
let query = &pending[0];
assert!(query.contains("idsite=7"));
assert!(query.contains("rec=1"));
assert!(query.contains("e_c=Game%20Flow"));
assert!(query.contains("e_a=Won%2BFast"));
assert!(query.contains("e_n=draw%20three"));
assert!(query.contains("e_v=42.5"));
assert!(query.contains("uid=alice%20bob"));
}
#[test]
fn event_buffer_drops_oldest_entries_when_capacity_exceeded() {
let client = MatomoClient::new("https://analytics.example.com", 1, None);
for idx in 0..101 {
client.event("Game", "Start", Some(&format!("event-{idx}")), None);
}
let pending = pending(&client);
assert_eq!(pending.len(), 51);
assert!(
pending[0].contains("event-50"),
"oldest retained event should be event-50, got {}",
pending[0]
);
assert!(
pending[50].contains("event-100"),
"newest retained event should be event-100, got {}",
pending[50]
);
}
#[test]
fn url_encode_leaves_unreserved_bytes_and_escapes_everything_else() {
assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~");
assert_eq!(url_encode("a b+c/d?"), "a%20b%2Bc%2Fd%3F");
}
}
+9 -1
View File
@@ -55,7 +55,15 @@ pub fn data_dir() -> Option<PathBuf> {
{ {
Some(PathBuf::from(ANDROID_APP_FILES_DIR)) 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() dirs::data_dir()
} }
+35 -5
View File
@@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
y * 10_000 + m * 100 + d y * 10_000 + m * 100 + d
} }
/// XP awarded for winning a game. /// Component breakdown of the XP awarded for a win.
///
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
/// it for the total, and UI that displays the individual lines (the win-summary
/// modal) reads the parts from here so the breakdown can never drift from the
/// total.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct XpBreakdown {
/// Flat base XP granted for any win.
pub base: u64,
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
pub speed_bonus: u64,
/// Bonus for winning without using undo (25, else 0).
pub no_undo_bonus: u64,
}
impl XpBreakdown {
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
pub fn total(self) -> u64 {
self.base + self.speed_bonus + self.no_undo_bonus
}
}
/// Component breakdown of the XP awarded for a win.
/// ///
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if /// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
/// the player did not use undo. /// the player did not use undo.
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 { pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
let base: u64 = 50;
let speed_bonus: u64 = if time_seconds >= 120 { let speed_bonus: u64 = if time_seconds >= 120 {
0 0
} else { } else {
@@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120); let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
scaled.max(10) scaled.max(10)
}; };
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 }; XpBreakdown {
base + speed_bonus + no_undo_bonus base: 50,
speed_bonus,
no_undo_bonus: if used_undo { 0 } else { 25 },
}
}
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
xp_breakdown(time_seconds, used_undo).total()
} }
/// Platform-specific default path for `progress.json`. /// Platform-specific default path for `progress.json`.
+9 -8
View File
@@ -26,8 +26,8 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::pile::PileType; use solitaire_core::klondike_adapter::SavedKlondikePile;
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json"; const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -96,9 +96,9 @@ pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call. /// A successful `move_cards(from, to, count)` call.
Move { Move {
/// Source pile. /// Source pile.
from: PileType, from: SavedKlondikePile,
/// Destination pile. /// Destination pile.
to: PileType, to: SavedKlondikePile,
/// Number of cards moved. /// Number of cards moved.
count: usize, count: usize,
}, },
@@ -442,6 +442,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
#[allow(deprecated)] #[allow(deprecated)]
mod tests { mod tests {
use super::*; use super::*;
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
use std::env; use std::env;
fn tmp_path(name: &str) -> PathBuf { fn tmp_path(name: &str) -> PathBuf {
@@ -460,14 +461,14 @@ mod tests {
vec![ vec![
ReplayMove::StockClick, ReplayMove::StockClick,
ReplayMove::Move { ReplayMove::Move {
from: PileType::Waste, from: SavedKlondikePile::Stock,
to: PileType::Tableau(3), to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1, count: 1,
}, },
ReplayMove::StockClick, ReplayMove::StockClick,
ReplayMove::Move { ReplayMove::Move {
from: PileType::Tableau(3), from: SavedKlondikePile::Tableau(SavedTableau(3)),
to: PileType::Foundation(0), to: SavedKlondikePile::Foundation(SavedFoundation(0)),
count: 1, count: 1,
}, },
], ],
+28 -5
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DifficultyLevel, DrawMode}; use solitaire_core::{DrawMode, game_state::DifficultyLevel};
const SETTINGS_FILE_NAME: &str = "settings.json"; const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -62,6 +62,21 @@ pub enum SyncBackend {
}, },
} }
/// 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 /// Persisted window size (in logical pixels) and screen position
/// (top-left corner, in physical pixels) — restored on next launch. /// (top-left corner, in physical pixels) — restored on next launch.
/// ///
@@ -185,7 +200,7 @@ pub struct Settings {
#[serde(default = "default_time_bonus_multiplier")] #[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32, pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the /// When `true`, the engine rejects new-game deals the
/// [`solitaire_core::solver`] cannot prove winnable, retrying /// [`solitaire_data::solver`] cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before /// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default — /// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the /// the solver adds a few hundred milliseconds of latency on the
@@ -264,6 +279,13 @@ pub struct Settings {
/// Defaults to `1` (the first site created in a fresh Matomo install). /// Defaults to `1` (the first site created in a fresh Matomo install).
#[serde(default = "default_matomo_site_id")] #[serde(default = "default_matomo_site_id")]
pub matomo_site_id: u32, 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 { fn default_draw_mode() -> DrawMode {
@@ -359,9 +381,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`] /// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest /// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If /// candidate seed when [`Settings::winnable_deals_only`] is on. If
/// every retry comes back [`SolverResult::Unwinnable`] (which would /// every retry comes back provably unwinnable (`Ok(None)` from the
/// be very unusual) we'd rather hand the player a possibly-unwinnable /// solver, which would be very unusual) we'd rather hand the player a
/// deal than spin forever on the main thread. /// possibly-unwinnable deal than spin forever on the main thread.
/// ///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall — /// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on. /// the upper bound on UI freeze when the toggle is on.
@@ -397,6 +419,7 @@ impl Default for Settings {
analytics_enabled: false, analytics_enabled: false,
matomo_url: None, matomo_url: None,
matomo_site_id: default_matomo_site_id(), matomo_site_id: default_matomo_site_id(),
touch_input_mode: TouchInputMode::OneTap,
} }
} }
} }
+140
View File
@@ -0,0 +1,140 @@
//! Klondike solvability check using upstream `card_game::Session::solve()`.
//!
//! Backs the **Settings → Gameplay → "Winnable deals only"** toggle, the
//! Play-by-seed verdict badge, and the hint system (which wants the first
//! move on a winning path). All search is delegated to `card_game`; this
//! module only adapts the inputs (a seed or a live [`GameState`]) and extracts
//! the first move from the returned solution.
use card_game::{Session, SessionConfig, SolveError};
use klondike::KlondikeInstruction;
use solitaire_core::DrawMode;
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::KlondikeAdapter;
/// Default move budget for a solve. Matches the winnable-deal retry loop.
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
/// Default unique-state budget for a solve.
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
/// Outcome of a solvability check:
///
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first move on a
/// winning path (used by the hint system).
/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or
/// the game is already won so no next move exists).
/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded
/// before a verdict was reached.
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;
/// Solves a fresh Classic-mode game dealt from `seed` + `draw_mode`.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
/// take-from-foundation house rule stays disabled here.
pub fn try_solve(
seed: u64,
draw_mode: DrawMode,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
let mut game = GameState::new(seed, draw_mode);
game.take_from_foundation = false;
try_solve_from_state(&game, moves_budget, states_budget)
}
/// Solves from an existing in-progress [`GameState`], returning the first move
/// on a winning path when one exists.
pub fn try_solve_from_state(
state: &GameState,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
// An already-won game has no "next move"; report it as unwinnable so the
// winnable contract (`Some(_)` ⇒ a real move exists) holds.
if state.is_won() {
return Ok(None);
}
let config = SessionConfig {
inner: KlondikeAdapter::config_for(state.draw_mode(), state.take_from_foundation),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let session = Session::new(state.session().state().state().clone(), config);
session.solve().map(|solution| {
solution.and_then(|solution| {
solution
.raw_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.find(|instruction| !instruction.is_useless())
})
})
}
#[cfg(test)]
mod tests {
use super::*;
/// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
/// extracted first move (both `Eq`) rather than the whole `Result`.
fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
(outcome.is_err(), outcome.clone().ok().flatten())
}
#[test]
fn try_solve_is_deterministic() {
let a = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
let b = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
assert_eq!(verdict_key(&a), verdict_key(&b));
}
#[test]
fn winnable_verdict_carries_a_first_move() {
// Contract: a first move is present iff the verdict is winnable.
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn try_solve_from_state_uses_live_game_state() {
let mut game = GameState::new(42, DrawMode::DrawOne);
game.draw().expect("draw must succeed");
let outcome = try_solve_from_state(&game, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn zero_state_budget_is_inconclusive() {
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving
// the budget reaches the solver unchanged.
let easy = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
let medium = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
#[test]
fn budget_above_five_thousand_is_not_clamped() {
let below_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 5_000, 5_000);
let above_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 50_000, 50_000);
assert!(below_cap.is_err(), "seed must be Inconclusive at 5 000 states");
assert!(
matches!(above_cap, Ok(Some(_))),
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this"
);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`. //! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
use chrono::Utc; use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::{DrawMode, game_state::GameMode};
pub use solitaire_sync::StatsSnapshot; pub use solitaire_sync::StatsSnapshot;
+157 -38
View File
@@ -3,13 +3,13 @@
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power //! All saves go through `filename.json.tmp` → `rename()` so a crash or power
//! loss during a write never corrupts the saved data. //! loss during a write never corrupts the saved data.
use chrono::Utc;
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState}; use solitaire_core::game_state::GameState;
use crate::stats::StatsSnapshot; use crate::stats::StatsSnapshot;
@@ -85,16 +85,13 @@ pub fn game_state_file_path() -> Option<PathBuf> {
pub fn load_game_state_from(path: &Path) -> Option<GameState> { pub fn load_game_state_from(path: &Path) -> Option<GameState> {
let data = fs::read(path).ok()?; let data = fs::read(path).ok()?;
let gs: GameState = serde_json::from_slice(&data).ok()?; let gs: GameState = serde_json::from_slice(&data).ok()?;
if gs.schema_version != GAME_STATE_SCHEMA_VERSION { if gs.is_won() { None } else { Some(gs) }
return None;
}
if gs.is_won { None } else { Some(gs) }
} }
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won` /// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
/// because a completed game should not be resumed. /// because a completed game should not be resumed.
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> { pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
if gs.is_won { if gs.is_won() {
return Ok(()); return Ok(());
} }
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -234,9 +231,7 @@ pub fn load_time_attack_session_from_at(
/// See [`load_time_attack_session_from_at`] for the rules under which /// See [`load_time_attack_session_from_at`] for the rules under which
/// the call returns `None` (missing file, corrupt JSON, expired window). /// the call returns `None` (missing file, corrupt JSON, expired window).
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> { pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
let now = SystemTime::now() let now = Utc::now().timestamp().max(0) as u64;
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
load_time_attack_session_from_at(path, now) load_time_attack_session_from_at(path, now)
} }
@@ -254,9 +249,7 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
/// current wall-clock time. Equivalent to constructing the struct /// current wall-clock time. Equivalent to constructing the struct
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`. /// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession { pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
let now = SystemTime::now() let now = Utc::now().timestamp().max(0) as u64;
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
TimeAttackSession { TimeAttackSession {
remaining_secs, remaining_secs,
wins, wins,
@@ -286,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
mod tests { mod tests {
use super::*; use super::*;
use crate::stats::{StatsExt, StatsSnapshot}; use crate::stats::{StatsExt, StatsSnapshot};
use solitaire_core::game_state::DrawMode; use solitaire_core::DrawMode;
use std::env; use std::env;
fn tmp_path(name: &str) -> PathBuf { fn tmp_path(name: &str) -> PathBuf {
@@ -384,7 +377,7 @@ mod tests {
#[test] #[test]
fn game_state_round_trip() { fn game_state_round_trip() {
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::GameState;
let path = gs_path("round_trip"); let path = gs_path("round_trip");
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
@@ -393,8 +386,8 @@ mod tests {
let loaded = load_game_state_from(&path).expect("load"); let loaded = load_game_state_from(&path).expect("load");
assert_eq!(loaded.seed, gs.seed); assert_eq!(loaded.seed, gs.seed);
assert_eq!(loaded.draw_mode, gs.draw_mode); assert_eq!(loaded.draw_mode(), gs.draw_mode());
assert!(!loaded.is_won); assert!(!loaded.is_won());
} }
#[test] #[test]
@@ -413,12 +406,12 @@ mod tests {
#[test] #[test]
fn save_game_state_skips_won_games() { fn save_game_state_skips_won_games() {
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::GameState;
let path = gs_path("won_skip"); let path = gs_path("won_skip");
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
let mut gs = GameState::new(99, DrawMode::DrawOne); let mut gs = GameState::new(99, DrawMode::DrawOne);
gs.is_won = true; gs.set_test_won(true);
save_game_state_to(&path, &gs).expect("save should be no-op, not error"); save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!( assert!(
!path.exists(), !path.exists(),
@@ -426,26 +419,9 @@ mod tests {
); );
} }
#[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());
}
#[test] #[test]
fn delete_game_state_removes_file() { fn delete_game_state_removes_file() {
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::GameState;
let path = gs_path("delete"); let path = gs_path("delete");
let gs = GameState::new(1, DrawMode::DrawOne); let gs = GameState::new(1, DrawMode::DrawOne);
save_game_state_to(&path, &gs).expect("save"); save_game_state_to(&path, &gs).expect("save");
@@ -463,7 +439,7 @@ mod tests {
#[test] #[test]
fn save_game_state_is_atomic() { fn save_game_state_is_atomic() {
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::GameState;
let path = gs_path("atomic"); let path = gs_path("atomic");
let gs = GameState::new(55, DrawMode::DrawThree); let gs = GameState::new(55, DrawMode::DrawThree);
save_game_state_to(&path, &gs).expect("save"); save_game_state_to(&path, &gs).expect("save");
@@ -516,6 +492,149 @@ mod tests {
assert_eq!(loaded, StatsSnapshot::default()); assert_eq!(loaded, StatsSnapshot::default());
} }
/// Schema v4 serialises the instruction history using upstream
/// `KlondikeInstruction` serde (named enum variants). The deserialiser
/// replays all `saved_moves` to reconstruct every pile.
///
/// A fresh-game test (zero moves) never exercises that replay path, so this
/// test plays several real moves — including an undo — before saving, then
/// asserts the full pile layout round-trips exactly.
///
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
/// `recycle_count`. Any breakage in the upstream serde or replay path
/// will cause at least one pile to disagree.
#[test]
fn game_state_v4_mid_game_round_trip() {
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState;
let path = gs_path("v4_mid_game");
let _ = fs::remove_file(&path);
let mut gs = GameState::new(42, DrawMode::DrawOne);
// Draw several times to populate the instruction history with
// RotateStock entries and expose waste cards for further moves.
for _ in 0..6 {
if gs.draw().is_err() {
break;
}
}
// Execute the first available DstTableau or DstFoundation move so the
// instruction history contains a type other than RotateStock.
if let Some(instruction) = gs.possible_instructions().into_iter().find(|i| {
matches!(
i,
KlondikeInstruction::DstTableau(_) | KlondikeInstruction::DstFoundation(_)
)
}) {
let _ = gs.apply_instruction(instruction);
}
// Undo once: verifies that `undo_count` is persisted and that the
// truncated history (post-undo) replays back to the correct state.
if gs.undo_stack_len() > 0 {
let _ = gs.undo();
}
assert!(
gs.undo_stack_len() > 0,
"instruction history must be non-empty (seed 42 always produces draws)",
);
save_game_state_to(&path, &gs).expect("save");
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
let json = fs::read_to_string(&path).expect("read json");
assert!(
json.contains("schema_version") && json.contains('4') && !json.contains(": 3"),
"saved file must use schema version 4",
);
let loaded = load_game_state_from(&path)
.expect("a valid in-progress game must load without error");
assert_eq!(
loaded, gs,
"all pile layouts and counters must be identical after schema-v4 round-trip",
);
}
/// A schema v3 save (instruction history using u8 indices) must load
/// successfully and be transparently migrated to schema v4.
///
/// This verifies the `AnyInstruction` untagged deserialization migration
/// path. v3 files with `RotateStock` (unit variant, format-identical in
/// v3 and v4) load correctly and report `schema_version == 4` after load.
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
#[test]
fn game_state_v3_migrates_to_v4() {
use solitaire_core::game_state::GameState;
let path = gs_path("v3_migrate");
let _ = fs::remove_file(&path);
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
// so this exercises the schema version acceptance code path.
let v3_json = r#"{
"draw_mode": "DrawOne",
"mode": "Classic",
"score": 0,
"elapsed_seconds": 0,
"seed": 42,
"undo_count": 0,
"recycle_count": 0,
"take_from_foundation": true,
"schema_version": 3,
"saved_moves": ["RotateStock"]
}"#;
fs::write(&path, v3_json).expect("write v3 fixture");
let loaded = load_game_state_from(&path)
.expect("schema v3 must be accepted and migrated to v4");
// The loaded game should match a fresh game that had one draw applied.
let mut expected = GameState::new(42, DrawMode::DrawOne);
expected.draw().expect("draw must succeed on a fresh game");
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
}
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
/// history). Any file claiming `schema_version: 2` must be rejected so
/// players upgrading from an older build start with a fresh game rather
/// than a half-reconstructed state.
#[test]
fn save_format_v2_is_rejected() {
let path = gs_path("schema_v2");
let _ = fs::remove_file(&path);
// Structurally valid JSON for `PersistedGameState` but with
// `schema_version: 2`. The schema-version gate in
// `GameState::deserialize` must reject this before replay starts.
let v2_json = r#"{
"draw_mode": "DrawOne",
"mode": "Classic",
"score": 0,
"elapsed_seconds": 0,
"seed": 42,
"undo_count": 0,
"recycle_count": 0,
"take_from_foundation": true,
"schema_version": 2,
"saved_moves": []
}"#;
fs::write(&path, v2_json).expect("write v2 fixture");
assert!(
load_game_state_from(&path).is_none(),
"schema v2 game_state.json must be rejected — player must start a fresh game",
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Time Attack session persistence // Time Attack session persistence
// //
+19 -3
View File
@@ -12,10 +12,14 @@
//! without matching on [`SyncBackend`] anywhere else in the codebase. //! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait; 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::{ use crate::{
SyncError, SyncProvider,
auth_tokens::{load_access_token, load_refresh_token, store_tokens}, auth_tokens::{load_access_token, load_refresh_token, store_tokens},
replay::Replay, replay::Replay,
settings::SyncBackend, settings::SyncBackend,
@@ -54,12 +58,17 @@ impl SyncProvider for LocalOnlyProvider {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SolitaireServerClient // 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. /// HTTP sync client for the self-hosted Ferrous Solitaire server.
/// ///
/// Authenticates via JWT stored in the OS keychain. On a 401 response the /// Authenticates via JWT stored in the OS keychain. On a 401 response the
/// client automatically attempts a token refresh and retries the request once /// client automatically attempts a token refresh and retries the request once
/// before returning an error. /// before returning an error.
#[cfg(not(target_arch = "wasm32"))]
pub struct SolitaireServerClient { pub struct SolitaireServerClient {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`. /// Base URL of the server, e.g. `"https://solitaire.example.com"`.
/// Trailing slashes are stripped on construction. /// Trailing slashes are stripped on construction.
@@ -70,6 +79,7 @@ pub struct SolitaireServerClient {
client: reqwest::Client, client: reqwest::Client,
} }
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient { impl SolitaireServerClient {
/// Construct a new client for the given server URL and username. /// Construct a new client for the given server URL and username.
/// ///
@@ -201,6 +211,7 @@ impl SolitaireServerClient {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
#[async_trait] #[async_trait]
impl SyncProvider for SolitaireServerClient { impl SyncProvider for SolitaireServerClient {
/// Fetch the latest sync payload from the server. /// Fetch the latest sync payload from the server.
@@ -486,6 +497,7 @@ impl SyncProvider for SolitaireServerClient {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient { impl SolitaireServerClient {
/// Pulled out of `push_replay` so both the first attempt and the /// Pulled out of `push_replay` so both the first attempt and the
/// post-401-retry attempt go through the same parse path. /// post-401-retry attempt go through the same parse path.
@@ -581,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 /// Deserialize a pull response body as [`SyncResponse`] and return its
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`]. /// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
/// ///
@@ -607,6 +620,7 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
} }
} }
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`. /// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
async fn extract_leaderboard_body( async fn extract_leaderboard_body(
resp: reqwest::Response, resp: reqwest::Response,
@@ -621,6 +635,7 @@ async fn extract_leaderboard_body(
} }
} }
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a push response body as [`SyncResponse`], or map non-200 /// Deserialize a push response body as [`SyncResponse`], or map non-200
/// statuses to the appropriate [`SyncError`]. /// statuses to the appropriate [`SyncError`].
/// ///
@@ -652,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`] /// This is the **one** place in the codebase that matches on [`SyncBackend`]
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>` /// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
/// and remains backend-agnostic. /// and remains backend-agnostic.
#[cfg(not(target_arch = "wasm32"))]
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> { pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
match backend { match backend {
SyncBackend::Local => Box::new(LocalOnlyProvider), SyncBackend::Local => Box::new(LocalOnlyProvider),
+1 -1
View File
@@ -4,7 +4,7 @@
//! increments matching counters in `PlayerProgress::weekly_goal_progress`. //! increments matching counters in `PlayerProgress::weekly_goal_progress`.
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate};
use solitaire_core::game_state::DrawMode; use solitaire_core::DrawMode;
/// XP awarded each time a weekly goal is just completed. /// XP awarded each time a weekly goal is just completed.
pub const WEEKLY_GOAL_XP: u64 = 75; pub const WEEKLY_GOAL_XP: u64 = 75;
+16 -11
View File
@@ -7,14 +7,11 @@ edition.workspace = true
[dependencies] [dependencies]
bevy = { workspace = true } bevy = { workspace = true }
image = { workspace = true } image = { workspace = true }
reqwest = { workspace = true }
kira = { workspace = true }
solitaire_core = { workspace = true } solitaire_core = { workspace = true }
solitaire_data = { workspace = true } solitaire_data = { workspace = true }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@@ -22,17 +19,24 @@ usvg = { workspace = true }
resvg = { workspace = true } resvg = { workspace = true }
tiny-skia = { workspace = true } tiny-skia = { workspace = true }
ron = { 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 } dirs = { workspace = true }
zip = { workspace = true } zip = { workspace = true }
# `arboard` provides clipboard access for the Stats overlay's # `arboard` has no Android backend and no wasm32 backend. Gate it out for
# "Copy share link" button. The crate has no Android backend # both; the copy-share-link button surfaces an informational toast instead.
# (its `platform::Clipboard` module is unimplemented for the [target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
# 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 = { workspace = true } arboard = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
@@ -47,3 +51,4 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
[dev-dependencies] [dev-dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] }
+3 -3
View File
@@ -819,7 +819,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree; .set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree; .set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -1393,7 +1393,7 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState; use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_data::{Replay, ReplayMove}; use solitaire_data::{Replay, ReplayMove};
/// Headless app variant that injects a default `ReplayPlaybackState` /// Headless app variant that injects a default `ReplayPlaybackState`
+58
View File
@@ -204,3 +204,61 @@ fn mode_str(mode: GameMode) -> &'static str {
GameMode::Difficulty(_) => "difficulty", GameMode::Difficulty(_) => "difficulty",
} }
} }
#[cfg(test)]
mod tests {
use solitaire_core::game_state::DifficultyLevel;
use super::*;
#[test]
fn client_for_requires_analytics_opt_in() {
let settings = Settings {
analytics_enabled: false,
matomo_url: Some("https://analytics.example.com".into()),
..Settings::default()
};
assert!(client_for(&settings).is_none());
}
#[test]
fn client_for_requires_matomo_url() {
let settings = Settings {
analytics_enabled: true,
matomo_url: None,
..Settings::default()
};
assert!(client_for(&settings).is_none());
}
#[test]
fn client_for_creates_client_when_enabled_and_configured() {
let settings = Settings {
analytics_enabled: true,
matomo_url: Some("https://analytics.example.com".into()),
matomo_site_id: 2,
sync_backend: SyncBackend::SolitaireServer {
url: "https://solitaire.example.com".into(),
username: "alice".into(),
avatar_url: None,
},
..Settings::default()
};
assert!(client_for(&settings).is_some());
}
#[test]
fn mode_labels_match_analytics_payload_contract() {
assert_eq!(mode_str(GameMode::Classic), "classic");
assert_eq!(mode_str(GameMode::Zen), "zen");
assert_eq!(mode_str(GameMode::Challenge), "challenge");
assert_eq!(mode_str(GameMode::TimeAttack), "time_attack");
assert_eq!(
mode_str(GameMode::Difficulty(DifficultyLevel::Grandmaster)),
"difficulty"
);
}
}
+5 -3
View File
@@ -13,6 +13,7 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_data::{AnimSpeed, Settings}; use solitaire_data::{AnimSpeed, Settings};
use crate::achievement_plugin::display_name_for; use crate::achievement_plugin::display_name_for;
@@ -180,6 +181,7 @@ impl Plugin for AnimationPlugin {
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_message::<RequestRedraw>()
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
.init_resource::<ActiveToast>() .init_resource::<ActiveToast>()
@@ -1076,7 +1078,7 @@ mod tests {
// Pairs the existing audio (`card_invalid.wav`) and visual // Pairs the existing audio (`card_invalid.wav`) and visual
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback // (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
// with an accessibility-focused readable text cue. // with an accessibility-focused readable text cue.
use solitaire_core::pile::PileType; use solitaire_core::{KlondikePile, Tableau};
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
@@ -1088,8 +1090,8 @@ mod tests {
.count(); .count();
app.world_mut().write_message(MoveRejectedEvent { app.world_mut().write_message(MoveRejectedEvent {
from: PileType::Tableau(0), from: KlondikePile::Tableau(Tableau::Tableau1),
to: PileType::Tableau(1), to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1, count: 1,
}); });
app.update(); app.update();
+9
View File
@@ -47,12 +47,16 @@
//! comments on each call out the pairing so a future reader doesn't //! comments on each call out the pairing so a future reader doesn't
//! accidentally drop one half. //! accidentally drop one half.
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::AssetApp; use bevy::asset::AssetApp;
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::io::AssetSourceBuilder; use bevy::asset::io::AssetSourceBuilder;
use bevy::asset::io::embedded::EmbeddedAssetRegistry; use bevy::asset::io::embedded::EmbeddedAssetRegistry;
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::io::file::FileAssetReader; use bevy::asset::io::file::FileAssetReader;
use bevy::prelude::*; use bevy::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_dir::user_theme_dir; use crate::assets::user_dir::user_theme_dir;
/// `AssetSourceId` of the user-themes asset source. Use it as /// `AssetSourceId` of the user-themes asset source. Use it as
@@ -235,11 +239,16 @@ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
/// Returns the `&mut App` so the call can be chained from the binary /// Returns the `&mut App` so the call can be chained from the binary
/// entry point. /// entry point.
pub fn register_theme_asset_sources(app: &mut App) -> &mut App { pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
// 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(); let root = user_theme_dir();
app.register_asset_source( app.register_asset_source(
USER_THEMES, USER_THEMES,
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))), AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
); );
}
app app
} }
+10
View File
@@ -82,6 +82,15 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
/// the panic message names the supported workaround. /// the panic message names the supported workaround.
fn detected_platform_data_dir() -> PathBuf { fn detected_platform_data_dir() -> PathBuf {
solitaire_data::data_dir().unwrap_or_else(|| { solitaire_data::data_dir().unwrap_or_else(|| {
// 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!( panic!(
"user_theme_dir(): platform data directory is unavailable. \ "user_theme_dir(): platform data directory is unavailable. \
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \ On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
@@ -89,6 +98,7 @@ fn detected_platform_data_dir() -> PathBuf {
As a workaround call solitaire_engine::assets::user_dir::\ As a workaround call solitaire_engine::assets::user_dir::\
set_user_theme_dir() before App::run()." set_user_theme_dir() before App::run()."
) )
}
}) })
} }
+1 -5
View File
@@ -34,7 +34,6 @@ use crate::events::{
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use solitaire_core::pile::PileType;
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0). /// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
const RECYCLE_VOLUME: f64 = 0.5; const RECYCLE_VOLUME: f64 = 0.5;
@@ -374,10 +373,7 @@ fn play_on_draw(
// When the stock pile is empty the draw action recycles the waste pile // 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 // back to stock. Play the flip sound at half volume to give audible
// feedback that distinguishes a recycle from a normal draw. // feedback that distinguishes a recycle from a normal draw.
let stock_len = game let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
.as_ref()
.and_then(|g| g.0.piles.get(&PileType::Stock))
.map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound
if is_recycle(stock_len) { if is_recycle(stock_len) {
let mut data = lib.flip.clone(); let mut data = lib.flip.clone();
+85 -46
View File
@@ -9,7 +9,9 @@
//! returns `None` (e.g. a transient state), the plugin retries next tick. //! returns `None` (e.g. a transient state), the plugin retries next tick.
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw;
#[cfg(not(target_arch = "wasm32"))]
use crate::audio_plugin::{AudioState, SoundLibrary}; use crate::audio_plugin::{AudioState, SoundLibrary};
use crate::events::{MoveRequestEvent, StateChangedEvent}; use crate::events::{MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
@@ -20,11 +22,18 @@ use crate::resources::GameStateResource;
/// ///
/// Plays the win fanfare at half volume so it is clearly distinguishable from /// 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. /// 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; const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
/// Seconds between consecutive auto-complete moves. /// Seconds between consecutive auto-complete moves.
const STEP_INTERVAL: f32 = 0.12; 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. /// Tracks whether auto-complete is active and when the next move fires.
#[derive(Resource, Default, Debug)] #[derive(Resource, Default, Debug)]
pub struct AutoCompleteState { pub struct AutoCompleteState {
@@ -39,7 +48,9 @@ pub struct AutoCompletePlugin;
impl Plugin for AutoCompletePlugin { impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>().add_systems( app.init_resource::<AutoCompleteState>()
.add_message::<RequestRedraw>()
.add_systems(
Update, Update,
( (
detect_auto_complete, detect_auto_complete,
@@ -65,21 +76,28 @@ fn detect_auto_complete(
} }
changed.clear(); changed.clear();
if game.0.is_won { if game.0.is_won() {
state.active = false; state.active = false;
return; return;
} }
if game.0.is_auto_completable && !state.active { if game.0.is_auto_completable() && !state.active {
state.active = true; state.active = true;
state.cooldown = 0.0; // fire first move immediately state.cooldown = 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;
} }
// Intentionally no `else if !is_auto_completable` branch here.
// Deactivating on every frame where `is_auto_completable` is false
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
// transiently returns `None` (e.g. while the previous move is still
// in-flight). The `is_won` check above already handles the definitive
// end-of-game case; `drive_auto_complete` simply retries next tick
// when no move is available yet.
} }
/// Plays a distinct chime the moment auto-complete first activates. /// Plays a distinct chime the moment auto-complete first activates.
@@ -88,6 +106,7 @@ fn detect_auto_complete(
/// exactly once on the `false → true` edge. The win fanfare is played at half /// 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 /// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
/// not overwhelm the card-place sounds that follow immediately. /// not overwhelm the card-place sounds that follow immediately.
#[cfg(not(target_arch = "wasm32"))]
fn on_auto_complete_start( fn on_auto_complete_start(
state: Res<AutoCompleteState>, state: Res<AutoCompleteState>,
mut was_active: Local<bool>, mut was_active: Local<bool>,
@@ -108,6 +127,12 @@ fn on_auto_complete_start(
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME); 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. /// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
fn drive_auto_complete( fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>, mut state: ResMut<AutoCompleteState>,
@@ -142,9 +167,9 @@ mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::card::{Deck, Rank, Suit};
use solitaire_core::pile::PileType; use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -157,31 +182,40 @@ mod tests {
app app
} }
/// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
/// tableau piles empty, stock/waste empty, Clubs foundation empty. let mut g = GameState::new(1, DrawMode::DrawOne);
fn nearly_won_state() -> GameState { g.set_test_stock_cards(Vec::new());
let mut g = GameState::new(42, DrawMode::DrawOne); g.set_test_waste_cards(Vec::new());
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); for foundation in [
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); Foundation::Foundation1,
for i in 0..7 { Foundation::Foundation2,
g.piles Foundation::Foundation3,
.get_mut(&PileType::Tableau(i)) Foundation::Foundation4,
.unwrap() ] {
.cards g.set_test_foundation_cards(foundation, Vec::new());
.clear();
} }
g.piles for tableau in [
.get_mut(&PileType::Tableau(0)) Tableau::Tableau1,
.unwrap() Tableau::Tableau2,
.cards Tableau::Tableau3,
.push(Card { Tableau::Tableau4,
id: 99, Tableau::Tableau5,
suit: Suit::Clubs, Tableau::Tableau6,
rank: Rank::Ace, Tableau::Tableau7,
face_up: true, ] {
}); g.set_test_tableau_cards(tableau, Vec::new());
g.is_auto_completable = true; }
g g.set_test_tableau_cards(
Tableau::Tableau1,
vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
g.set_test_auto_completable(true);
let expected = (
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Foundation(Foundation::Foundation1),
);
assert_eq!(g.next_auto_complete_move(), Some(expected));
(g, expected)
} }
#[test] #[test]
@@ -193,8 +227,9 @@ mod tests {
#[test] #[test]
fn detect_activates_when_auto_completable() { fn detect_activates_when_auto_completable() {
let mut app = headless_app(); let mut app = headless_app();
// Install a nearly-won state and fire StateChangedEvent. let mut g = GameState::new(42, DrawMode::DrawOne);
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state(); g.set_test_auto_completable(true);
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent); app.world_mut().write_message(StateChangedEvent);
app.update(); app.update();
@@ -204,9 +239,14 @@ mod tests {
#[test] #[test]
fn drive_fires_move_request_when_active() { fn drive_fires_move_request_when_active() {
let mut app = headless_app(); 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.world_mut().write_message(StateChangedEvent);
app.update(); // detect runs, sets active 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 app.update(); // drive fires the move
let events = app.world().resource::<Messages<MoveRequestEvent>>(); let events = app.world().resource::<Messages<MoveRequestEvent>>();
@@ -214,17 +254,16 @@ mod tests {
let fired: Vec<_> = cursor.read(events).collect(); let fired: Vec<_> = cursor.read(events).collect();
// At least one MoveRequestEvent should have been fired. // At least one MoveRequestEvent should have been fired.
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent"); assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
assert_eq!(fired[0].from, PileType::Tableau(0)); assert_eq!(fired[0].from, expected_from);
// First empty foundation slot wins on a fresh nearly-won board. assert_eq!(fired[0].to, expected_to);
assert_eq!(fired[0].to, PileType::Foundation(0));
} }
#[test] #[test]
fn drive_deactivates_on_win() { fn drive_deactivates_on_win() {
let mut app = headless_app(); let mut app = headless_app();
// Inject a won game state — active should not be set. // 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; gs.set_test_won(true);
app.world_mut().resource_mut::<GameStateResource>().0 = gs; app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().write_message(StateChangedEvent); app.world_mut().write_message(StateChangedEvent);
app.update(); app.update();
@@ -33,6 +33,7 @@ use std::collections::VecDeque;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::card::Card;
use super::animation::CardAnimation; use super::animation::CardAnimation;
use super::tuning::AnimationTuning; use super::tuning::AnimationTuning;
@@ -210,12 +211,12 @@ pub(crate) fn apply_drag_visual(
// Only lift cards that are in a *committed* drag. Pending drags (below // Only lift cards that are in a *committed* drag. Pending drags (below
// threshold) must stay at scale 1.0 to avoid visible premature lift. // threshold) must stay at scale 1.0 to avoid visible premature lift.
let (dragged_ids, committed): (&[u32], bool) = drag let (dragged_cards, committed): (&[Card], bool) = drag
.as_ref() .as_ref()
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed)); .map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
for (_, card, mut transform) in &mut cards { for (_, card, mut transform) in &mut cards {
let is_active_drag = committed && dragged_ids.contains(&card.card_id); let is_active_drag = committed && dragged_cards.contains(&card.card);
let target_scale = if is_active_drag { drag_scale } else { 1.0 }; let target_scale = if is_active_drag { drag_scale } else { 1.0 };
let current = transform.scale.x; let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0); let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
@@ -92,6 +92,7 @@ pub use timing::{
pub use tuning::{AnimationTuning, InputPlatform}; pub use tuning::{AnimationTuning, InputPlatform};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
@@ -125,6 +126,7 @@ impl Plugin for CardAnimationPlugin {
.add_message::<DrawRequestEvent>() .add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>() .add_message::<UndoRequestEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<RequestRedraw>()
.init_resource::<DragState>() .init_resource::<DragState>()
.init_resource::<HoverState>() .init_resource::<HoverState>()
.init_resource::<InputBuffer>() .init_resource::<InputBuffer>()
@@ -100,7 +100,7 @@ impl AnimationTuning {
platform: InputPlatform::Mouse, platform: InputPlatform::Mouse,
duration_scale: 1.0, duration_scale: 1.0,
overshoot_scale: 1.0, overshoot_scale: 1.0,
drag_threshold_px: 4.0, drag_threshold_px: 6.0,
drag_scale: 1.08, drag_scale: 1.08,
hover_scale: 1.04, hover_scale: 1.04,
hover_lerp_speed: 14.0, hover_lerp_speed: 14.0,
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -93,7 +93,9 @@ fn handle_start_challenge_request(
return; return;
} }
let Some(seed) = challenge_seed_for(progress.0.challenge_index) else { 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; return;
}; };
new_game.write(NewGameRequestEvent { new_game.write(NewGameRequestEvent {
@@ -115,7 +117,7 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin; use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
+24 -14
View File
@@ -13,16 +13,19 @@ use crate::platform::{
default_storage_backend, default_storage_backend,
}; };
use crate::{ use crate::{
AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
}; };
#[cfg(not(target_arch = "wasm32"))]
use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
};
/// Groups all Ferrous Solitaire gameplay plugins. /// Groups all Ferrous Solitaire gameplay plugins.
pub struct CoreGamePlugin { pub struct CoreGamePlugin {
@@ -44,6 +47,7 @@ impl Plugin for CoreGamePlugin {
Ok(guard) => guard, Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(), Err(poisoned) => poisoned.into_inner(),
}; };
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
let sync_provider = sync_provider let sync_provider = sync_provider
.take() .take()
.expect("CoreGamePlugin::build called twice"); .expect("CoreGamePlugin::build called twice");
@@ -83,6 +87,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(InputPlugin) .add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin) .add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin) .add_plugins(SelectionPlugin)
.add_plugins(TouchSelectionPlugin)
.add_plugins(AnimationPlugin) .add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin) .add_plugins(CardAnimationPlugin)
@@ -102,21 +107,26 @@ impl Plugin for CoreGamePlugin {
.add_plugins(HudPlugin) .add_plugins(HudPlugin)
.add_plugins(HelpPlugin) .add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default()) .add_plugins(HomePlugin::default())
.add_plugins(AvatarPlugin)
.add_plugins(ProfilePlugin) .add_plugins(ProfilePlugin)
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default()) .add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin) .add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin) .add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin); .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);
} }
} }
+79 -234
View File
@@ -34,9 +34,9 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::card::Card;
use solitaire_core::pile::PileType; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::{DrawMode, game_state::GameState};
use crate::card_plugin::RightClickHighlight; use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
@@ -66,10 +66,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 /// Marker component on a parent entity that owns one drop-target overlay
/// (a translucent fill plus four outline edges as children). The wrapped /// (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. /// queries and the despawn-on-target-change logic can filter by pile.
#[derive(Component, Debug, Clone, PartialEq, Eq)] #[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. /// 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; pub struct CursorPlugin;
@@ -163,33 +163,34 @@ fn update_cursor_icon(
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card. /// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool { fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
let piles = [ let piles = [
PileType::Waste, KlondikePile::Stock,
PileType::Foundation(0), KlondikePile::Foundation(Foundation::Foundation1),
PileType::Foundation(1), KlondikePile::Foundation(Foundation::Foundation2),
PileType::Foundation(2), KlondikePile::Foundation(Foundation::Foundation3),
PileType::Foundation(3), KlondikePile::Foundation(Foundation::Foundation4),
PileType::Tableau(0), KlondikePile::Tableau(Tableau::Tableau1),
PileType::Tableau(1), KlondikePile::Tableau(Tableau::Tableau2),
PileType::Tableau(2), KlondikePile::Tableau(Tableau::Tableau3),
PileType::Tableau(3), KlondikePile::Tableau(Tableau::Tableau4),
PileType::Tableau(4), KlondikePile::Tableau(Tableau::Tableau5),
PileType::Tableau(5), KlondikePile::Tableau(Tableau::Tableau6),
PileType::Tableau(6), KlondikePile::Tableau(Tableau::Tableau7),
]; ];
for pile in piles { 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; continue;
}; }
let is_tableau = matches!(pile, PileType::Tableau(_)); let is_tableau = matches!(pile, KlondikePile::Tableau(_));
let base = layout.pile_positions[&pile]; 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 { if !card.1 {
continue; continue;
} }
// Only the topmost card is draggable on non-tableau piles. // 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; continue;
} }
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau); let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
@@ -226,38 +227,14 @@ fn update_drop_highlights(
let Some(game) = game else { return }; 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 drag_count = drag.cards.len();
for (marker, mut sprite, _rch) in &mut markers { let Some(origin) = drag.origin_pile.as_ref() else {
let valid = match &marker.0 { return;
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,
}; };
for (marker, mut sprite, _rch) in &mut markers {
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT }; sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
} }
} }
@@ -297,20 +274,7 @@ fn update_drop_target_overlays(
return; return;
}; };
// Resolve the bottom card of the dragged stack — same logic as let Some(origin) = drag.origin_pile.as_ref() else {
// `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 {
return; return;
}; };
let drag_count = drag.cards.len(); let drag_count = drag.cards.len();
@@ -318,44 +282,24 @@ fn update_drop_target_overlays(
// Iterate the same pile list as `update_drop_highlights`. Stock and // Iterate the same pile list as `update_drop_highlights`. Stock and
// Waste are excluded because they are never legal drop targets. // Waste are excluded because they are never legal drop targets.
let candidates = [ let candidates = [
PileType::Foundation(0), KlondikePile::Foundation(Foundation::Foundation1),
PileType::Foundation(1), KlondikePile::Foundation(Foundation::Foundation2),
PileType::Foundation(2), KlondikePile::Foundation(Foundation::Foundation3),
PileType::Foundation(3), KlondikePile::Foundation(Foundation::Foundation4),
PileType::Tableau(0), KlondikePile::Tableau(Tableau::Tableau1),
PileType::Tableau(1), KlondikePile::Tableau(Tableau::Tableau2),
PileType::Tableau(2), KlondikePile::Tableau(Tableau::Tableau3),
PileType::Tableau(3), KlondikePile::Tableau(Tableau::Tableau4),
PileType::Tableau(4), KlondikePile::Tableau(Tableau::Tableau5),
PileType::Tableau(5), KlondikePile::Tableau(Tableau::Tableau6),
PileType::Tableau(6), KlondikePile::Tableau(Tableau::Tableau7),
]; ];
// Compute the new set of valid piles for this frame. // 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 { for pile in &candidates {
let is_valid = match pile { if game.0.can_move_cards(origin, pile, drag_count) {
PileType::Foundation(_) => { valid.push(*pile);
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());
} }
} }
@@ -367,9 +311,9 @@ fn update_drop_target_overlays(
} }
// Spawn overlays for piles that are now valid but don't yet have one. // 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() .iter()
.map(|(_, m)| m.0.clone()) .map(|(_, m)| m.0)
.filter(|p| valid.contains(p)) .filter(|p| valid.contains(p))
.collect(); .collect();
@@ -388,10 +332,14 @@ fn update_drop_target_overlays(
/// for everything else it is card-sized. Replicated here rather than /// for everything else it is card-sized. Replicated here rather than
/// imported because `pile_drop_rect` is private to `input_plugin` and /// imported because `pile_drop_rect` is private to `input_plugin` and
/// this overlay is the only other consumer. /// this overlay is the only other consumer.
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> { fn drop_overlay_rect(
pile: &KlondikePile,
layout: &Layout,
game: &GameState,
) -> Option<(Vec2, Vec2)> {
let centre = layout.pile_positions.get(pile).copied()?; let centre = layout.pile_positions.get(pile).copied()?;
if matches!(pile, PileType::Tableau(_)) { if matches!(pile, KlondikePile::Tableau(_)) {
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len()); let card_count = game.pile(*pile).len();
if card_count > 1 { if card_count > 1 {
let fan = -layout.card_size.y * layout.tableau_fan_frac; let fan = -layout.card_size.y * layout.tableau_fan_frac;
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32; let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
@@ -412,7 +360,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Opti
/// the appropriate world position for `pile`. /// the appropriate world position for `pile`.
fn spawn_drop_target_overlay( fn spawn_drop_target_overlay(
commands: &mut Commands, commands: &mut Commands,
pile: &PileType, pile: &KlondikePile,
layout: &Layout, layout: &Layout,
game: &GameState, game: &GameState,
) { ) {
@@ -430,7 +378,7 @@ fn spawn_drop_target_overlay(
..default() ..default()
}, },
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY), Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
DropTargetOverlay(pile.clone()), DropTargetOverlay(*pile),
)) ))
.with_children(|parent| { .with_children(|parent| {
// Top edge. // Top edge.
@@ -479,7 +427,7 @@ fn spawn_drop_target_overlay(
fn tableau_or_stack_pos( fn tableau_or_stack_pos(
game: &GameState, game: &GameState,
layout: &Layout, layout: &Layout,
pile: &PileType, pile: &KlondikePile,
index: usize, index: usize,
base: Vec2, base: Vec2,
is_tableau: bool, is_tableau: bool,
@@ -489,8 +437,8 @@ fn tableau_or_stack_pos(
base.x, base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
) )
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree { } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len()); let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32; let slot = index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
@@ -499,6 +447,14 @@ fn tableau_or_stack_pos(
} }
} }
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
if matches!(pile, KlondikePile::Stock) {
game.waste_cards()
} else {
game.pile(*pile)
}
}
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
let half = size / 2.0; let half = size / 2.0;
point.x >= center.x - half.x point.x >= center.x - half.x
@@ -607,7 +563,7 @@ mod tests {
#[test] #[test]
fn cursor_over_draggable_returns_false_for_empty_game() { fn cursor_over_draggable_returns_false_for_empty_game() {
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -624,8 +580,8 @@ mod tests {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode, GameState}; use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
/// Builds an `App` with `MinimalPlugins` and the overlay system /// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers /// registered, plus the resources the system needs. Callers
@@ -649,12 +605,8 @@ mod tests {
/// card. Used to make a specific tableau column accept a chosen /// card. Used to make a specific tableau column accept a chosen
/// drag stack. /// drag stack.
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) { fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
let pile = game let tableau = GameState::tableau_from_index(idx).expect("tableau pile exists");
.piles game.set_test_tableau_cards(tableau, vec![card]);
.get_mut(&PileType::Tableau(idx))
.expect("tableau pile exists");
pile.cards.clear();
pile.cards.push(card);
} }
/// Inserts a single face-up dragged card into the waste pile and /// Inserts a single face-up dragged card into the waste pile and
@@ -664,59 +616,14 @@ mod tests {
// Place the dragged card on the waste pile (origin). // Place the dragged card on the waste pile (origin).
{ {
let mut game = app.world_mut().resource_mut::<GameStateResource>(); let mut game = app.world_mut().resource_mut::<GameStateResource>();
let waste = game game.0.set_test_waste_cards(vec![dragged.clone()]);
.0
.piles
.get_mut(&PileType::Waste)
.expect("waste pile exists");
waste.cards.clear();
waste.cards.push(dragged.clone());
} }
let mut drag = app.world_mut().resource_mut::<DragState>(); let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![dragged.id]; drag.cards = vec![dragged];
drag.origin_pile = Some(PileType::Waste); drag.origin_pile = Some(KlondikePile::Stock);
drag.committed = true; 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] #[test]
fn drop_target_overlay_does_not_spawn_for_invalid_destination() { fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black) // 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
@@ -726,86 +633,24 @@ mod tests {
set_tableau_top( set_tableau_top(
&mut game, &mut game,
2, 2,
Card { Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
id: 9101,
suit: Suit::Clubs,
rank: Rank::Six,
face_up: true,
},
); );
let dragged = Card { let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
id: 9102,
suit: Suit::Spades,
rank: Rank::Five,
face_up: true,
};
let mut app = overlay_test_app(game); let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged); begin_drag_with(&mut app, dragged);
app.update(); app.update();
let overlays: Vec<PileType> = app let overlays: Vec<KlondikePile> = app
.world_mut() .world_mut()
.query::<&DropTargetOverlay>() .query::<&DropTargetOverlay>()
.iter(app.world()) .iter(app.world())
.map(|o| o.0.clone()) .map(|o| o.0)
.collect(); .collect();
assert!( assert!(
!overlays.contains(&PileType::Tableau(2)), !overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)),
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}" "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"
);
}
} }
+16 -4
View File
@@ -13,9 +13,11 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc}; use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to}; use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::ChallengeGoal; use solitaire_sync::ChallengeGoal;
use crate::events::{ use crate::events::{
@@ -25,6 +27,7 @@ use crate::events::{
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
/// Bonus XP awarded for completing today's daily challenge. /// 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 /// Holds the in-flight server challenge fetch so the result can be polled
/// each frame without blocking the main thread. /// each frame without blocking the main thread.
#[derive(Resource, Default)] #[derive(Resource, Default)]
#[cfg(not(target_arch = "wasm32"))]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>); struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
#[derive(Resource, Default)]
#[cfg(target_arch = "wasm32")]
struct DailyChallengeTask;
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has /// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
/// already fired for, so the toast spawns at most once per day. /// already fired for, so the toast spawns at most once per day.
/// ///
@@ -116,17 +124,21 @@ impl Plugin for DailyChallengePlugin {
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .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 // record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame. // ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate)) .add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation)) .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); .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. /// Startup system: spawns an async task to fetch the server's daily challenge.
/// ///
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is /// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
@@ -142,6 +154,7 @@ fn fetch_server_challenge(
task_res.0 = Some(task); task_res.0 = Some(task);
} }
#[cfg(not(target_arch = "wasm32"))]
/// Update system: polls the server-challenge fetch task. /// Update system: polls the server-challenge fetch task.
/// ///
/// On success, replaces the locally-computed seed in `DailyChallengeResource` /// On success, replaces the locally-computed seed in `DailyChallengeResource`
@@ -341,7 +354,6 @@ fn check_date_rollover(
} }
} }
#[cfg(test)] #[cfg(test)]
#[allow(dead_code)] #[allow(dead_code)]
mod tests { mod tests {
@@ -350,7 +362,7 @@ mod tests {
use crate::progress_plugin::ProgressPlugin; use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
#[allow(unused_imports)] #[allow(unused_imports)]
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
+4 -5
View File
@@ -14,7 +14,7 @@
//! because the starting position is effectively random (player-chosen timing //! because the starting position is effectively random (player-chosen timing
//! determines which seed in the 40-entry catalog they start at). //! determines which seed in the 40-entry catalog they start at).
use std::time::{SystemTime, UNIX_EPOCH}; use chrono::Utc;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, GameMode}; use solitaire_core::game_state::{DifficultyLevel, GameMode};
@@ -104,10 +104,9 @@ fn handle_difficulty_request(
} }
fn seed_from_system_time() -> u64 { fn seed_from_system_time() -> u64 {
SystemTime::now() // Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
.duration_since(UNIX_EPOCH) // std::time::SystemTime panics on wasm32-unknown-unknown).
.map(|d| d.as_nanos() as u64) Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+13 -13
View File
@@ -1,9 +1,9 @@
//! Cross-system events used by the engine's plugins. //! Cross-system events used by the engine's plugins.
use bevy::prelude::Message; use bevy::prelude::Message;
use solitaire_core::card::Suit; use solitaire_core::KlondikePile;
use solitaire_core::card::{Card, Suit};
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse; use solitaire_sync::SyncResponse;
@@ -11,8 +11,8 @@ use solitaire_sync::SyncResponse;
/// consumed by `GamePlugin`. /// consumed by `GamePlugin`.
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct MoveRequestEvent { pub struct MoveRequestEvent {
pub from: PileType, pub from: KlondikePile,
pub to: PileType, pub to: KlondikePile,
pub count: usize, pub count: usize,
} }
@@ -49,8 +49,8 @@ pub struct StateChangedEvent;
/// `card_invalid.wav` SFX. Not fired for drops in empty space. /// `card_invalid.wav` SFX. Not fired for drops in empty space.
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct MoveRejectedEvent { pub struct MoveRejectedEvent {
pub from: PileType, pub from: KlondikePile,
pub to: PileType, pub to: KlondikePile,
pub count: usize, pub count: usize,
} }
@@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent {
} }
/// Fired when a card's face-up state changes during gameplay. /// Fired when a card's face-up state changes during gameplay.
#[derive(Message, Debug, Clone, Copy)] #[derive(Message, Debug, Clone)]
pub struct CardFlippedEvent(pub u32); pub struct CardFlippedEvent(pub Card);
/// Fired by the flip animation at its midpoint — the instant the card face /// Fired by the flip animation at its midpoint — the instant the card face
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp). /// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
@@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub u32);
/// Audio systems should listen to this event rather than `CardFlippedEvent` /// Audio systems should listen to this event rather than `CardFlippedEvent`
/// so the flip sound is synchronised with the visual reveal, not the move /// so the flip sound is synchronised with the visual reveal, not the move
/// that triggered the animation. /// that triggered the animation.
#[derive(Message, Debug, Clone, Copy)] #[derive(Message, Debug, Clone)]
pub struct CardFaceRevealedEvent(pub u32); pub struct CardFaceRevealedEvent(pub Card);
/// Achievement unlocked notification carrying the full `AchievementRecord` for /// Achievement unlocked notification carrying the full `AchievementRecord` for
/// the newly unlocked achievement. Consumed by the toast renderer and any /// the newly unlocked achievement. Consumed by the toast renderer and any
@@ -299,8 +299,8 @@ pub struct ScanThemesRequestEvent;
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s). /// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct HintVisualEvent { pub struct HintVisualEvent {
/// The `Card::id` of the source card to be highlighted. /// The source card to be highlighted.
pub source_card_id: u32, pub source_card: Card,
/// The destination pile whose `PileMarker` should be tinted gold. /// The destination pile whose `PileMarker` should be tinted gold.
pub dest_pile: solitaire_core::pile::PileType, pub dest_pile: KlondikePile,
} }
+56 -42
View File
@@ -42,7 +42,10 @@ use std::f32::consts::PI;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::pile::PileType; use bevy::window::RequestRedraw;
use solitaire_core::card::Card;
use solitaire_core::KlondikePile;
use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim; use crate::animation_plugin::CardAnim;
@@ -186,6 +189,10 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 % (jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
} }
// Per-card jitter keys off the shared stable card id so it matches the
// numeric identity used elsewhere (and on the WASM replay side).
use solitaire_core::card::card_to_id;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -204,6 +211,7 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<RequestRedraw>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -243,18 +251,16 @@ fn start_shake_anim(
continue; continue;
} }
let dest_pile = &ev.to; let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile. // Collect the cards that belong to the destination pile.
let Some(pile) = game.0.piles.get(dest_pile) else { let dest_cards = pile_cards(&game.0, dest_pile);
continue; let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect();
};
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
if dest_card_ids.is_empty() { if dest_card_set.is_empty() {
continue; continue;
} }
for (entity, card_marker, transform) in card_entities.iter() { for (entity, card_marker, transform) in card_entities.iter() {
if dest_card_ids.contains(&card_marker.card_id) { if dest_card_set.contains(&card_marker.card) {
commands.entity(entity).insert(ShakeAnim { commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0, elapsed: 0.0,
origin_x: transform.translation.x, origin_x: transform.translation.x,
@@ -311,27 +317,27 @@ fn start_settle_anim(
card_entities: Query<(Entity, &CardEntity)>, card_entities: Query<(Entity, &CardEntity)>,
mut commands: Commands, mut commands: Commands,
) { ) {
// Build the list of card ids that should bounce this frame from every // Build the list of cards that should bounce this frame from every
// queued request; multiple events can fire in the same frame (e.g. a move // queued request; multiple events can fire in the same frame (e.g. a move
// followed by a draw via keyboard accelerators). // followed by a draw via keyboard accelerators).
let mut bounce_ids: Vec<u32> = Vec::new(); let mut bounce_ids: Vec<Card> = Vec::new();
for ev in moves.read() { for ev in moves.read() {
if let Some(pile) = game.0.piles.get(&ev.to) { let pile = pile_cards(&game.0, &ev.to);
// The moved cards land on top — take the last `count` ids. if !pile.is_empty() {
let n = ev.count.min(pile.cards.len()); // The moved cards land on top — take the last `count` cards.
let n = ev.count.min(pile.len());
if n > 0 { if n > 0 {
let start = pile.cards.len() - n; let start = pile.len() - n;
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id)); bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
} }
} }
} }
if draws.read().next().is_some() if draws.read().next().is_some()
&& let Some(pile) = game.0.piles.get(&PileType::Waste) && let Some((top, _)) = game.0.waste_cards().last()
&& let Some(top) = pile.cards.last()
{ {
bounce_ids.push(top.id); bounce_ids.push(top.clone());
} }
if bounce_ids.is_empty() { if bounce_ids.is_empty() {
@@ -339,7 +345,7 @@ fn start_settle_anim(
} }
for (entity, card_marker) in card_entities.iter() { for (entity, card_marker) in card_entities.iter() {
if bounce_ids.contains(&card_marker.card_id) { if bounce_ids.contains(&card_marker.card) {
commands.entity(entity).insert(SettleAnim::default()); commands.entity(entity).insert(SettleAnim::default());
} }
} }
@@ -393,11 +399,11 @@ fn start_deal_anim(
return; return;
} }
// Only animate a fresh deal (no moves made yet). // Only animate a fresh deal (no moves made yet).
if game.0.move_count != 0 { if game.0.move_count() != 0 {
return; return;
} }
let Some(layout) = layout else { return }; let Some(layout) = layout else { return };
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
return; return;
}; };
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0); let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
@@ -410,7 +416,7 @@ fn start_deal_anim(
// ±10 % jitter, deterministic per card id, so the deal feels organic // ±10 % jitter, deterministic per card id, so the deal feels organic
// without losing reproducibility (a given seed still produces the // without losing reproducibility (a given seed still produces the
// same per-card stagger pattern across runs). // same per-card stagger pattern across runs).
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id)); let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card)));
commands.entity(entity).insert(( commands.entity(entity).insert((
Transform::from_translation(stock_start.with_z(final_pos.z)), Transform::from_translation(stock_start.with_z(final_pos.z)),
CardAnim { CardAnim {
@@ -518,21 +524,19 @@ fn start_foundation_flourish(
if reduce_motion { if reduce_motion {
continue; continue;
} }
let pile_type = PileType::Foundation(ev.slot); 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. // Top card of the completed foundation is the King.
let Some(king_id) = game let cards = game.0.pile(pile_type);
.0 let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
.piles
.get(&pile_type)
.and_then(|p| p.cards.last())
.map(|c| c.id)
else {
continue; continue;
}; };
// Tag the King's card entity. // Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() { for (entity, card_marker) in card_entities.iter() {
if card_marker.card_id == king_id { if card_marker.card == king_card {
commands.entity(entity).insert(FoundationFlourish { commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot, foundation_slot: ev.slot,
elapsed: 0.0, elapsed: 0.0,
@@ -632,6 +636,16 @@ 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, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required) // Unit tests (pure functions only — no Bevy world required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -831,7 +845,8 @@ mod tests {
#[test] #[test]
fn shake_anim_skipped_under_reduce_motion() { fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::Tableau;
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_data::Settings; use solitaire_data::Settings;
let mut app = App::new(); let mut app = App::new();
@@ -845,26 +860,25 @@ mod tests {
app.update(); app.update();
// Pick a card from Tableau(0) so the event refers to a real pile. // Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = PileType::Tableau(0); let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
let card_id = app let card = app
.world() .world()
.resource::<GameStateResource>() .resource::<GameStateResource>()
.0 .0
.piles .pile(dest_pile)
.get(&dest_pile) .last()
.and_then(|p| p.cards.last()) .map(|(c, _)| c.clone())
.map(|c| c.id)
.expect("Tableau(0) should have at least one card in a fresh game"); .expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that id so the system would // Spawn a minimal CardEntity matching that card so the system would
// find it and insert ShakeAnim if the gate were absent. // find it and insert ShakeAnim if the gate were absent.
app.world_mut() app.world_mut()
.spawn((CardEntity { card_id }, Transform::default())); .spawn((CardEntity { card }, Transform::default()));
app.world_mut() app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>() .resource_mut::<Messages<MoveRejectedEvent>>()
.write(MoveRejectedEvent { .write(MoveRejectedEvent {
from: PileType::Stock, from: KlondikePile::Stock,
to: dest_pile, to: dest_pile,
count: 1, count: 1,
}); });
@@ -886,7 +900,7 @@ mod tests {
#[test] #[test]
fn foundation_flourish_skipped_under_reduce_motion() { fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_data::Settings; use solitaire_data::Settings;
let mut app = App::new(); let mut app = App::new();
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, DrawMode}; use solitaire_core::{DrawMode, game_state::DifficultyLevel};
use solitaire_data::save_settings_to; use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
+97 -39
View File
@@ -8,12 +8,19 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit; use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None.
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
@@ -308,17 +315,17 @@ pub struct HintButton;
/// Android HUD label for the Hint button — shared with the help screen's /// Android HUD label for the Hint button — shared with the help screen's
/// controls reference so both always agree. /// controls reference so both always agree.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub(crate) const ANDROID_HINT_LABEL: &str = "!"; pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
const ACTION_BAR_LABELS: [&str; 7] = [ const ACTION_BAR_LABELS: [&str; 7] = [
"\u{2261}", "Menu",
"\u{2190}", "Undo",
"||", "Pause",
"?", "Help",
ANDROID_HINT_LABEL, ANDROID_HINT_LABEL,
"M", "Mode",
"+", "New",
]; ];
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
const ACTION_BAR_LABELS: [&str; 7] = [ const ACTION_BAR_LABELS: [&str; 7] = [
@@ -823,6 +830,8 @@ fn spawn_avatar_child(
) { ) {
const SIZE: f32 = 32.0; const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) { if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it.
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
// Image fills the circle container; border_radius clips it to a disc. // Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| { commands.entity(parent).with_children(|b| {
b.spawn(( b.spawn((
@@ -843,6 +852,15 @@ fn spawn_avatar_child(
}) })
.and_then(|c| c.to_uppercase().next()) .and_then(|c| c.to_uppercase().next())
.unwrap_or('?'); .unwrap_or('?');
// Real initial (logged in) keeps the red accent disc; the '?'
// unauthenticated fallback uses a neutral grey so it reads as a
// "tap to log in" affordance rather than an error.
let disc_bg = if initial == '?' {
BG_ELEVATED_HI
} else {
ACCENT_PRIMARY
};
commands.entity(parent).insert(BackgroundColor(disc_bg));
commands.entity(parent).with_children(|b| { commands.entity(parent).with_children(|b| {
b.spawn(( b.spawn((
Text::new(initial.to_string()), Text::new(initial.to_string()),
@@ -1136,12 +1154,12 @@ fn handle_hint_button(
return; return;
} }
let Some(ref g) = game else { return }; let Some(ref g) = game else { return };
if g.0.is_won { if g.0.is_won() {
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string())); info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
return; return;
} }
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) { if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
hint.spawn(g.0.clone(), cfg.0); hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
} }
} }
} }
@@ -1644,11 +1662,13 @@ impl Default for HudActionFade {
/// How many pixels from the bottom edge the cursor must be to reveal the bar. /// How many pixels from the bottom edge the cursor must be to reveal the bar.
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the /// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
/// cursor approaches, not only when it crosses into the band itself. /// cursor approaches, not only when it crosses into the band itself.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0; const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full /// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
/// transition — fast enough to feel responsive without flashing on /// transition — fast enough to feel responsive without flashing on
/// brief cursor wanders into the reveal zone. /// brief cursor wanders into the reveal zone.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0; const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// Updates the fade state from cursor position. Sets `target = 1.0` if /// Updates the fade state from cursor position. Sets `target = 1.0` if
@@ -1656,6 +1676,7 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward /// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across /// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates. /// variable framerates.
#[cfg(not(target_os = "android"))]
fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) { fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
let Ok(window) = windows.single() else { let Ok(window) = windows.single() else {
return; return;
@@ -1680,6 +1701,7 @@ fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut
/// `Last` (after `paint_action_buttons`) so a hover-state change in the /// `Last` (after `paint_action_buttons`) so a hover-state change in the
/// same frame doesn't override the fade with an opaque idle / hover /// same frame doesn't override the fade with an opaque idle / hover
/// colour. /// colour.
#[cfg(not(target_os = "android"))]
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn apply_action_fade( fn apply_action_fade(
fade: Res<HudActionFade>, fade: Res<HudActionFade>,
@@ -2084,10 +2106,10 @@ fn update_won_previously(
let Ok(mut text) = q.single_mut() else { let Ok(mut text) = q.single_mut() else {
return; return;
}; };
let won_before = !game.0.is_won let won_before = !game.0.is_won()
&& history.as_ref().is_some_and(|h| { && history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| { h.0.replays.iter().any(|r| {
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode && r.mode == game.0.mode r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
}) })
}); });
let next = if won_before { let next = if won_before {
@@ -2257,11 +2279,11 @@ fn update_hud(
}; };
} }
if let Ok(mut t) = moves_q.single_mut() { if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count); **t = format!("Moves: {}", g.move_count());
} }
if let Ok(mut t) = mode_q.single_mut() { if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode { **t = match g.mode {
GameMode::Classic => match g.draw_mode { GameMode::Classic => match g.draw_mode() {
DrawMode::DrawOne => String::new(), DrawMode::DrawOne => String::new(),
DrawMode::DrawThree => "Draw 3".to_string(), DrawMode::DrawThree => "Draw 3".to_string(),
}, },
@@ -2274,7 +2296,7 @@ fn update_hud(
// --- Daily challenge constraint (with time-low colour warning) --- // --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.single_mut() { if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won { if g.is_won() {
**t = String::new(); **t = String::new();
} else if let Some(dc) = daily.as_deref() { } else if let Some(dc) = daily.as_deref() {
**t = challenge_hud_text(dc); **t = challenge_hud_text(dc);
@@ -2312,11 +2334,11 @@ fn update_hud(
// --- Draw-cycle indicator (Draw-Three mode only) --- // --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.single_mut() { if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won || g.draw_mode != DrawMode::DrawThree { **t = if g.is_won() || g.draw_mode() != DrawMode::DrawThree {
// Hide when not in Draw-Three or after the game is won. // Hide when not in Draw-Three or after the game is won.
String::new() String::new()
} else { } else {
let stock_len = g.piles[&solitaire_core::pile::PileType::Stock].cards.len(); let stock_len = g.stock_cards().len();
let next_draw = stock_len.min(3); let next_draw = stock_len.min(3);
format!("Cycle: {next_draw}/3") format!("Cycle: {next_draw}/3")
}; };
@@ -2380,15 +2402,14 @@ fn update_selection_hud(
let Ok(mut t) = q.single_mut() else { return }; let Ok(mut t) = q.single_mut() else { return };
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) { let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
None => String::new(), None => String::new(),
Some(PileType::Waste) => "▶ Waste".to_string(), Some(KlondikePile::Stock) => "▶ Waste".to_string(),
Some(PileType::Stock) => "▶ Stock".to_string(), Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
Some(PileType::Foundation(slot)) => match game.as_deref() {
Some(g) => foundation_selection_label(*slot, &g.0), Some(g) => foundation_selection_label(*slot, &g.0),
// No game resource means we can't probe claimed_suit; show the // No game resource means we can't probe claimed_suit; show the
// slot-based placeholder so the HUD still surfaces the selection. // slot-based placeholder so the HUD still surfaces the selection.
None => format!("▶ Foundation {}", slot + 1), None => format!("▶ Foundation {}", foundation_number(*slot)),
}, },
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1), Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
}; };
**t = label; **t = label;
} }
@@ -2398,11 +2419,14 @@ fn update_selection_hud(
/// When the slot has a claimed suit (any card has landed) the announcement is /// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a /// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index. /// "▶ Foundation N" placeholder labelled by the 1-based slot index.
fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String { fn foundation_selection_label(
slot: Foundation,
game: &solitaire_core::game_state::GameState,
) -> String {
let claimed = game let claimed = game
.piles .pile(KlondikePile::Foundation(slot))
.get(&PileType::Foundation(slot)) .first()
.and_then(|p| p.claimed_suit()); .map(|c| c.0.suit());
match claimed { match claimed {
Some(suit) => { Some(suit) => {
let s = match suit { let s = match suit {
@@ -2413,7 +2437,28 @@ fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameS
}; };
format!("{s} Foundation") format!("{s} Foundation")
} }
None => format!("▶ Foundation {}", slot + 1), None => format!("▶ Foundation {}", foundation_number(slot)),
}
}
const fn foundation_number(foundation: Foundation) -> u8 {
match foundation {
Foundation::Foundation1 => 1,
Foundation::Foundation2 => 2,
Foundation::Foundation3 => 3,
Foundation::Foundation4 => 4,
}
}
const fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
Tableau::Tableau2 => 2,
Tableau::Tableau3 => 3,
Tableau::Tableau4 => 4,
Tableau::Tableau5 => 5,
Tableau::Tableau6 => 6,
Tableau::Tableau7 => 7,
} }
} }
@@ -2537,10 +2582,18 @@ fn restore_hud_on_modal(
/// Returns the action-bar label font size for a given logical window width. /// Returns the action-bar label font size for a given logical window width.
fn action_bar_font_size(window_width: f32) -> f32 { fn action_bar_font_size(window_width: f32) -> f32 {
if USE_TOUCH_UI_LAYOUT { if USE_TOUCH_UI_LAYOUT {
// ~1/40 of the window width gives ~22 px on a 900 logical-px phone. // Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
// Clamped so it never goes too tiny on narrow viewports or too large // must share one row. The widest characters are in FiraMono (a
// on landscape tablets. // monospace whose advance is ~0.62 of the font size). On a 900
(window_width / 40.0).clamp(16.0, 30.0) // logical-px phone the row budget after bar padding (2*12) and six
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
// labels are advance-bound only on very narrow viewports; the real
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
// at 900 px — comfortably one row with margin to spare — clamped so it
// never drops below the 12 px legibility floor or grows past 18 px on
// landscape tablets where it would crowd the row again.
(window_width / 60.0).clamp(12.0, 18.0)
} else { } else {
TYPE_BODY TYPE_BODY
} }
@@ -2548,9 +2601,14 @@ fn action_bar_font_size(window_width: f32) -> f32 {
fn action_button_metrics() -> (UiRect, Val, Val) { fn action_button_metrics() -> (UiRect, Val, Val) {
if USE_TOUCH_UI_LAYOUT { if USE_TOUCH_UI_LAYOUT {
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
// shortest labels ("New", "Help") shrink to their text rather than
// padding the row out past the 900 logical-px viewport. min_height
// stays at 44 px to preserve the comfortable touch target.
( (
UiRect::axes(Val::Px(4.0), Val::Px(4.0)), UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
Val::Px(52.0), Val::Px(44.0),
Val::Px(44.0), Val::Px(44.0),
) )
} else { } else {
@@ -2668,7 +2726,7 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use chrono::Local; use chrono::Local;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -2716,7 +2774,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.move_count = 42; .set_test_move_count(42);
app.update(); app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42"); assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
} }
@@ -2904,7 +2962,7 @@ mod tests {
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
// Mark the game as won — HudChallenge should be empty. // Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.is_won = true; app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -2954,7 +3012,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.move_count += 1; .set_test_move_count(1);
app.update(); app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO"); assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
} }
@@ -2966,7 +3024,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.move_count += 1; .set_test_move_count(1);
app.update(); app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), ""); assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
} }
File diff suppressed because it is too large Load Diff
+107 -52
View File
@@ -7,7 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet}; 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 /// Schedule labels for layout-related systems so cross-plugin ordering is
/// explicit instead of relying on Bevy's automatic resource-conflict ordering /// explicit instead of relying on Bevy's automatic resource-conflict ordering
@@ -138,9 +138,9 @@ pub struct Layout {
/// Centre position of each pile, in 2D world coordinates. /// Centre position of each pile, in 2D world coordinates.
/// ///
/// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up. /// 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`. /// 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 /// Per-step vertical offset fraction for face-up tableau cards, as a
/// fraction of `card_size.y`. On height-limited (desktop) windows this /// fraction of `card_size.y`. On height-limited (desktop) windows this
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone) /// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
@@ -241,21 +241,38 @@ pub fn compute_layout(
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0; 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 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(KlondikePile::Stock, Vec2::new(col_x(1), top_y));
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
// Column 2 is skipped — visual separation between waste and foundations. // Column 2 is skipped — visual separation between waste and foundations.
for slot in 0..4_u8 { for slot in 0..4_u8 {
let foundation = match slot {
0 => Foundation::Foundation1,
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
_ => Foundation::Foundation4,
};
pile_positions.insert( pile_positions.insert(
PileType::Foundation(slot), KlondikePile::Foundation(foundation),
Vec2::new(col_x(3 + slot as usize), top_y), Vec2::new(col_x(3 + slot as usize), top_y),
); );
} }
for i in 0..7 { 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 // Adaptive tableau fan fraction. On height-limited (desktop) windows the
@@ -301,23 +318,37 @@ mod tests {
use super::*; use super::*;
fn assert_all_piles_present(layout: &Layout) { fn assert_all_piles_present(layout: &Layout) {
assert!(layout.pile_positions.contains_key(&PileType::Stock)); assert!(layout.pile_positions.contains_key(&KlondikePile::Stock));
assert!(layout.pile_positions.contains_key(&PileType::Waste)); for foundation in [
for slot in 0..4_u8 { Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
assert!( assert!(
layout layout
.pile_positions .pile_positions
.contains_key(&PileType::Foundation(slot)), .contains_key(&KlondikePile::Foundation(foundation)),
"missing foundation slot {slot}", "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!( assert!(
layout.pile_positions.contains_key(&PileType::Tableau(i)), layout
"missing tableau {i}" .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] #[test]
@@ -376,9 +407,18 @@ mod tests {
#[test] #[test]
fn tableau_columns_are_sorted_left_to_right() { fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for i in 0..6 { let tableaus = [
let lhs = layout.pile_positions[&PileType::Tableau(i)].x; Tableau::Tableau1,
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x; 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); assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1);
} }
} }
@@ -386,8 +426,8 @@ mod tests {
#[test] #[test]
fn top_row_is_above_tableau_row() { fn top_row_is_above_tableau_row() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); 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 stock_y = layout.pile_positions[&KlondikePile::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
assert!(stock_y > tableau_y); assert!(stock_y > tableau_y);
} }
@@ -399,7 +439,7 @@ mod tests {
fn top_row_clears_hud_band() { fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0); let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true); 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 card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT; let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
assert!( assert!(
@@ -411,24 +451,35 @@ mod tests {
#[test] #[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { 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 layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_x = layout.pile_positions[&PileType::Stock].x; let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_x = layout.pile_positions[&PileType::Waste].x; let t1_x = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau2)].x;
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x; assert!((stock_x - t1_x).abs() < 1e-5);
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);
} }
#[test] #[test]
fn foundations_align_with_tableau_cols_3_to_6() { fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for slot in 0..4_u8 { let target_tableaus = [
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x; Tableau::Tableau4,
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x; 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!( assert!(
(f_x - t_x).abs() < 1e-5, (f_x - t_x).abs() < 1e-5,
"foundation slot {slot} should align with tableau {}", "foundation slot {idx} should align with tableau {}",
3 + slot as usize, 3 + idx,
); );
} }
} }
@@ -470,7 +521,7 @@ mod tests {
// Default app resolution (see solitaire_app/src/main.rs). // Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0); let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true); 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 card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card. // 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; let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
@@ -489,7 +540,7 @@ mod tests {
// The bug originally reproduced at 1920x1080. Lock in a regression test. // The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0); let window = Vec2::new(1920.0, 1080.0);
let layout = compute_layout(window, 0.0, 0.0, true); 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 card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0; 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; let h_gap = layout.card_size.x / 4.0;
@@ -520,7 +571,7 @@ mod tests {
fn expanded_fan_fits_phone_viewport() { fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true); 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 card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0; let h_gap = layout.card_size.x / 4.0;
// Bottom of the 13th (worst-case) fanned face-up card. // Bottom of the 13th (worst-case) fanned face-up card.
@@ -579,8 +630,8 @@ mod tests {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.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_no_inset = without.pile_positions[&KlondikePile::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y; let stock_with_inset = with_inset.pile_positions[&KlondikePile::Stock].y;
assert!( assert!(
stock_with_inset < stock_no_inset, stock_with_inset < stock_no_inset,
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}", "safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
@@ -602,10 +653,10 @@ mod tests {
let without = compute_layout(window, 0.0, 0.0, true); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true); let with_inset = compute_layout(window, 32.0, 0.0, true);
for pile in [ for pile in [
PileType::Stock, KlondikePile::Stock,
PileType::Waste, KlondikePile::Stock,
PileType::Tableau(0), KlondikePile::Tableau(Tableau::Tableau1),
PileType::Tableau(6), KlondikePile::Tableau(Tableau::Tableau7),
] { ] {
assert!( assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
@@ -628,7 +679,7 @@ mod tests {
with_inset.tableau_fan_frac, with_inset.tableau_fan_frac,
); );
let card_h = with_inset.card_size.y; 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 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 h_gap = with_inset.card_size.x / 4.0;
let margin = -window.y / 2.0 + 48.0 + h_gap; let margin = -window.y / 2.0 + 48.0 + h_gap;
@@ -661,8 +712,8 @@ mod tests {
// Verify the "wrong" layout actually differs — the bug would push the // Verify the "wrong" layout actually differs — the bug would push the
// top card row upward by exactly safe_top pixels. // top card row upward by exactly safe_top pixels.
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y; let fresh_stock_y = fresh.pile_positions[&KlondikePile::Stock].y;
let wrong_stock_y = wrong.pile_positions[&PileType::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 // 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. // downward (y direction). So wrong_stock_y > fresh_stock_y by safe_top.
assert!( assert!(
@@ -680,14 +731,14 @@ mod tests {
"card size must be preserved after resume", "card size must be preserved after resume",
); );
assert!( assert!(
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3, (corrected.pile_positions[&KlondikePile::Stock].y - fresh_stock_y).abs() < 1e-3,
"stock y must match fresh launch after resume: \ "stock y must match fresh launch after resume: \
corrected={:.2} fresh={fresh_stock_y:.2}", corrected={:.2} fresh={fresh_stock_y:.2}",
corrected.pile_positions[&PileType::Stock].y, corrected.pile_positions[&KlondikePile::Stock].y,
); );
assert!( assert!(
(corrected.pile_positions[&PileType::Stock].x (corrected.pile_positions[&KlondikePile::Stock].x
- fresh.pile_positions[&PileType::Stock].x) - fresh.pile_positions[&KlondikePile::Stock].x)
.abs() .abs()
< 1e-3, < 1e-3,
"stock x must be unchanged after resume", "stock x must be unchanged after resume",
@@ -695,7 +746,7 @@ mod tests {
// The HUD band top clearance (distance from window top to card top) // 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. // must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| { let card_top = |layout: &super::Layout| {
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0 layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
}; };
assert!( assert!(
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3, (card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
@@ -712,7 +763,11 @@ mod tests {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.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!( assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_bottom", "{pile:?} x-position must not change with safe_area_bottom",
@@ -191,6 +191,7 @@ fn toggle_leaderboard_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleLeaderboardRequestEvent>, mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
screens: Query<Entity, With<LeaderboardScreen>>, screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
data: Res<LeaderboardResource>, data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>, provider: Option<Res<SyncProviderResource>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
@@ -208,6 +209,11 @@ fn toggle_leaderboard_screen(
return; 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. // Spawn the panel immediately with whatever data we have so far.
let remote_available = provider let remote_available = provider
.as_ref() .as_ref()
+14
View File
@@ -1,13 +1,16 @@
//! Bevy integration layer for Ferrous Solitaire. //! Bevy integration layer for Ferrous Solitaire.
pub mod achievement_plugin; pub mod achievement_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod analytics_plugin; pub mod analytics_plugin;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub mod android_clipboard; pub mod android_clipboard;
pub mod animation_plugin; pub mod animation_plugin;
pub mod assets; pub mod assets;
#[cfg(not(target_arch = "wasm32"))]
pub mod audio_plugin; pub mod audio_plugin;
pub mod auto_complete_plugin; pub mod auto_complete_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod avatar_plugin; pub mod avatar_plugin;
pub mod card_animation; pub mod card_animation;
pub mod card_plugin; pub mod card_plugin;
@@ -26,6 +29,7 @@ pub mod home_plugin;
pub mod hud_plugin; pub mod hud_plugin;
pub mod input_plugin; pub mod input_plugin;
pub mod layout; pub mod layout;
#[cfg(not(target_arch = "wasm32"))]
pub mod leaderboard_plugin; pub mod leaderboard_plugin;
pub mod onboarding_plugin; pub mod onboarding_plugin;
pub mod pause_plugin; pub mod pause_plugin;
@@ -43,11 +47,14 @@ pub mod selection_plugin;
pub mod settings_plugin; pub mod settings_plugin;
pub mod splash_plugin; pub mod splash_plugin;
pub mod stats_plugin; pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod sync_plugin; pub mod sync_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod sync_setup_plugin; pub mod sync_setup_plugin;
pub mod table_plugin; pub mod table_plugin;
pub mod theme; pub mod theme;
pub mod time_attack_plugin; pub mod time_attack_plugin;
pub mod touch_selection_plugin;
pub mod ui_focus; pub mod ui_focus;
pub mod ui_modal; pub mod ui_modal;
pub mod ui_theme; pub mod ui_theme;
@@ -56,14 +63,17 @@ pub mod weekly_goals_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
#[cfg(not(target_arch = "wasm32"))]
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource}; pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue}; pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
pub use assets::{ pub use assets::{
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url, AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url,
populate_embedded_dark_theme, register_theme_asset_sources, populate_embedded_dark_theme, register_theme_asset_sources,
}; };
#[cfg(not(target_arch = "wasm32"))]
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use auto_complete_plugin::AutoCompletePlugin; pub use auto_complete_plugin::AutoCompletePlugin;
#[cfg(not(target_arch = "wasm32"))]
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource}; pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use card_animation::{ pub use card_animation::{
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin, AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
@@ -116,6 +126,7 @@ pub use hud_plugin::{
}; };
pub use input_plugin::InputPlugin; pub use input_plugin::InputPlugin;
pub use layout::{Layout, LayoutResource, compute_layout}; pub use layout::{Layout, LayoutResource, compute_layout};
#[cfg(not(target_arch = "wasm32"))]
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource}; pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
@@ -153,7 +164,9 @@ pub use stats_plugin::{
ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen,
StatsUpdate, WatchReplayButton, format_replay_caption, StatsUpdate, WatchReplayButton, format_replay_caption,
}; };
#[cfg(not(target_arch = "wasm32"))]
pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use sync_plugin::{SyncPlugin, SyncProviderResource};
#[cfg(not(target_arch = "wasm32"))]
pub use sync_setup_plugin::SyncSetupPlugin; pub use sync_setup_plugin::SyncSetupPlugin;
pub use table_plugin::{ pub use table_plugin::{
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin, BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
@@ -165,6 +178,7 @@ pub use theme::{
pub use time_attack_plugin::{ pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
}; };
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin}; pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{ pub use ui_modal::{
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
+23 -3
View File
@@ -36,6 +36,7 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
}; };
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING}; use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -153,7 +154,7 @@ pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin { impl Plugin for OnboardingPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<OnboardingSlideIndex>() app.init_resource::<OnboardingSlideIndex>()
.add_systems(PostStartup, spawn_if_first_run) .add_systems(Update, spawn_if_first_run)
.add_systems( .add_systems(
Update, Update,
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(), (handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
@@ -170,11 +171,30 @@ fn spawn_if_first_run(
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
mut slide_index: ResMut<OnboardingSlideIndex>, mut slide_index: ResMut<OnboardingSlideIndex>,
splashes: Query<(), With<SplashRoot>>,
existing: Query<(), With<OnboardingScreen>>,
mut spawned: Local<bool>,
) { ) {
let Some(s) = settings else { return }; if *spawned {
if s.0.first_run_complete {
return; return;
} }
// Wait until the launch splash has despawned so the two screens
// never overlap. PostStartup would fire before the first Update
// tick, guaranteeing overlap; checking here costs one frame of
// latency after the splash clears, which is imperceptible.
if !splashes.is_empty() {
return;
}
if !existing.is_empty() {
*spawned = true;
return;
}
let Some(s) = settings else { return };
if s.0.first_run_complete {
*spawned = true;
return;
}
*spawned = true;
slide_index.0 = 0; slide_index.0 = 0;
spawn_slide(&mut commands, 0, font_res.as_deref()); spawn_slide(&mut commands, 0, font_res.as_deref());
} }
+5 -5
View File
@@ -21,7 +21,7 @@
//! active opens the overlay as normal. //! active opens the overlay as normal.
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::DrawMode; use solitaire_core::DrawMode;
use solitaire_data::save_game_state_to; use solitaire_data::save_game_state_to;
use crate::events::{ use crate::events::{
@@ -340,7 +340,7 @@ fn handle_forfeit_request(
if !forfeit_screens.is_empty() { if !forfeit_screens.is_empty() {
return; return;
} }
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won); let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won());
if !game_in_progress { if !game_in_progress {
toast.write(InfoToastEvent("No game to forfeit".to_string())); toast.write(InfoToastEvent("No game to forfeit".to_string()));
return; return;
@@ -965,7 +965,7 @@ mod tests {
/// Provides a fresh `GameStateResource` (not won) so the modal can /// Provides a fresh `GameStateResource` (not won) so the modal can
/// open. `move_count` doesn't matter — the gate is just `!is_won`. /// open. `move_count` doesn't matter — the gate is just `!is_won`.
fn forfeit_app() -> App { fn forfeit_app() -> App {
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
@@ -1020,12 +1020,12 @@ mod tests {
/// hotkey was received but is currently a no-op. /// hotkey was received but is currently a no-op.
#[test] #[test]
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() { fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
game.is_won = true; game.set_test_won(true);
app.insert_resource(GameStateResource(game)); app.insert_resource(GameStateResource(game));
app.update(); app.update();
+85 -80
View File
@@ -1,12 +1,10 @@
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in //! Async H-key hint solver, modelled on `PendingNewGameSeed` in
//! `game_plugin`. //! `game_plugin`.
//! //!
//! The synchronous version (v0.17.0) called //! The synchronous version (v0.17.0) called the solver on the main thread
//! `solitaire_core::solver::try_solve_from_state` on the main thread on //! on every H press. Median latency was ~2 ms but pathological positions
//! every H press. Median latency was ~2 ms but pathological positions //! can hit the default solve budget at ~120 ms, which is a noticeable
//! can hit the `SolverConfig::default()` cap at ~120 ms, which is a //! input-stall on the same frame the player sees the hint request.
//! noticeable input-stall on the same frame the player sees the hint
//! request.
//! //!
//! This module hosts the resource and polling system that move the //! This module hosts the resource and polling system that move the
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint` //! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
@@ -26,13 +24,13 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType; use solitaire_data::solver::try_solve_from_state;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint}; use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint, hint_piles};
use crate::resources::{GameStateResource, HintCycleIndex}; use crate::resources::{GameStateResource, HintCycleIndex};
/// In-flight async work for the H-key hint. /// In-flight async work for the H-key hint.
@@ -60,23 +58,17 @@ impl PendingHintTask {
self.inner = None; self.inner = None;
} }
/// Spawn a new solver task for `state` with `config`. Drops any /// Spawn a new solver task for `state` with the given solve budgets.
/// previously in-flight task first (cancel-on-replace). /// Drops any previously in-flight task first (cancel-on-replace).
pub fn spawn(&mut self, state: GameState, config: SolverConfig) { pub fn spawn(&mut self, state: GameState, moves_budget: u64, states_budget: u64) {
let move_count_at_spawn = state.move_count; let move_count_at_spawn = state.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move { let handle = AsyncComputeTaskPool::get().spawn(async move {
let outcome = try_solve_from_state(&state, &config); // Winnable (`Ok(Some)`) carries the first move on a winning path;
match outcome.result { // unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
SolverResult::Winnable => outcome // to the live-state heuristic so H always produces feedback.
.first_move match try_solve_from_state(&state, moves_budget, states_budget) {
.map(|mv| HintTaskOutput::SolverMove { Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
from: mv.source, Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
to: mv.dest,
})
.unwrap_or(HintTaskOutput::NeedsHeuristic),
SolverResult::Unwinnable | SolverResult::Inconclusive => {
HintTaskOutput::NeedsHeuristic
}
} }
}); });
self.inner = Some(HintTask { self.inner = Some(HintTask {
@@ -99,9 +91,10 @@ struct HintTask {
/// What the solver task carries back to the main thread. /// What the solver task carries back to the main thread.
enum HintTaskOutput { enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the /// Solver verdict was winnable; here is the first move on the solution
/// solution path. /// path. Converted to highlighted `(from, to)` piles by the poll system
SolverMove { from: PileType, to: PileType }, /// via [`crate::input_plugin::hint_piles`].
SolverMove(KlondikeInstruction),
/// Solver was `Unwinnable` or `Inconclusive`. The poll system /// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the /// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists. /// H key always produces feedback while any legal move exists.
@@ -153,19 +146,22 @@ pub fn poll_pending_hint_task(
pending.inner = None; pending.inner = None;
let Some(g) = game else { return }; let Some(g) = game else { return };
if g.0.move_count != move_count_at_spawn { if g.0.move_count() != move_count_at_spawn {
return; return;
} }
let (from, to) = match output { // Resolve the solver's first move to highlighted piles; fall back to the
HintTaskOutput::SolverMove { from, to } => (from, to), // live-state heuristic when there's no solver move or it maps to a no-op.
HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) { let solver_pair = match output {
HintTaskOutput::SolverMove(instruction) => hint_piles(&g.0, instruction),
HintTaskOutput::NeedsHeuristic => None,
};
let (from, to) = match solver_pair.or_else(|| find_heuristic_hint(&g.0, &mut hint_cycle)) {
Some(pair) => pair, Some(pair) => pair,
None => { None => {
info_toast.write(InfoToastEvent("No hints available".to_string())); info_toast.write(InfoToastEvent("No hints available".to_string()));
return; return;
} }
},
}; };
emit_hint_visuals( emit_hint_visuals(
&g.0, &g.0,
@@ -183,8 +179,9 @@ mod tests {
use super::*; use super::*;
use crate::events::HintVisualEvent; use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig; use crate::input_plugin::HintSolverConfig;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
/// Build a minimal Bevy app exercising only the polling system /// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches. /// and the resources/messages it touches.
@@ -214,22 +211,27 @@ mod tests {
/// tableau columns 0..3, stock and waste empty. /// tableau columns 0..3, stock and waste empty.
fn near_finished_state() -> GameState { fn near_finished_state() -> GameState {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 { game.set_test_stock_cards(Vec::new());
game.piles game.set_test_waste_cards(Vec::new());
.get_mut(&PileType::Foundation(slot)) for foundation in [
.unwrap() Foundation::Foundation1,
.cards Foundation::Foundation2,
.clear(); Foundation::Foundation3,
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
} }
for i in 0..7_usize { for tableau in [
game.piles Tableau::Tableau1,
.get_mut(&PileType::Tableau(i)) Tableau::Tableau2,
.unwrap() Tableau::Tableau3,
.cards Tableau::Tableau4,
.clear(); Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
} }
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [ let ranks_below_king = [
Rank::Ace, Rank::Ace,
@@ -245,31 +247,34 @@ mod tests {
Rank::Jack, Rank::Jack,
Rank::Queen, Rank::Queen,
]; ];
for (slot, suit) in suits.iter().enumerate() { for (foundation, suit) in [
let pile = game Foundation::Foundation1,
.piles Foundation::Foundation2,
.get_mut(&PileType::Foundation(slot as u8)) Foundation::Foundation3,
.unwrap(); Foundation::Foundation4,
for (i, rank) in ranks_below_king.iter().enumerate() { ]
pile.cards.push(Card { .into_iter()
id: (slot as u32) * 13 + i as u32, .zip(suits.iter())
suit: *suit, {
rank: *rank, let mut cards = Vec::new();
face_up: true, for rank in ranks_below_king.iter() {
}); cards.push(Card::new(Deck::Deck1, *suit, *rank));
} }
game.set_test_foundation_cards(foundation, cards);
} }
for (col, suit) in suits.iter().enumerate() { for (tableau, suit) in [
game.piles Tableau::Tableau1,
.get_mut(&PileType::Tableau(col)) Tableau::Tableau2,
.unwrap() Tableau::Tableau3,
.cards Tableau::Tableau4,
.push(Card { ]
id: 100 + col as u32, .into_iter()
suit: *suit, .zip(suits.iter())
rank: Rank::King, {
face_up: true, game.set_test_tableau_cards(
}); tableau,
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
} }
game game
} }
@@ -283,10 +288,10 @@ mod tests {
fn winnable_solver_emits_hint_after_async_completes() { fn winnable_solver_emits_hint_after_async_completes() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0; let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg); .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() { while app.world().resource::<PendingHintTask>().is_pending() {
@@ -309,7 +314,7 @@ mod tests {
"exactly one HintVisualEvent must fire when the solver returns Winnable", "exactly one HintVisualEvent must fire when the solver returns Winnable",
); );
assert!( assert!(
matches!(collected[0].dest_pile, PileType::Foundation(_)), matches!(collected[0].dest_pile, KlondikePile::Foundation(_)),
"solver hint destination must be a foundation slot; got {:?}", "solver hint destination must be a foundation slot; got {:?}",
collected[0].dest_pile, collected[0].dest_pile,
); );
@@ -322,10 +327,10 @@ mod tests {
fn state_change_drops_in_flight_task() { fn state_change_drops_in_flight_task() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0; let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg); .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
assert!( assert!(
app.world().resource::<PendingHintTask>().is_pending(), app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn", "task is in flight after spawn",
@@ -358,12 +363,12 @@ mod tests {
fn second_spawn_drops_first_in_flight_task() { fn second_spawn_drops_first_in_flight_task() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0; let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn. // First spawn.
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg); .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending(); let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present); assert!(first_handle_present);
@@ -372,7 +377,7 @@ mod tests {
// in flight. // in flight.
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg); .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
// Resource still pending (the second task), but the first // Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once // is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the // it's been overwritten — what we *can* assert is that the
+16 -8
View File
@@ -23,8 +23,10 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::DrawMode; use solitaire_core::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve}; use solitaire_data::solver::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
};
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent}; use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
@@ -83,7 +85,7 @@ struct SeedInputDisplay;
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct PendingVerification { struct PendingVerification {
seed: Option<u64>, seed: Option<u64>,
handle: Option<Task<SolverResult>>, handle: Option<Task<SolveOutcome>>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -340,8 +342,14 @@ fn tick_debounce_and_spawn_solver_task(
let draw_mode = settings let draw_mode = settings
.as_ref() .as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode); .map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let cfg = SolverConfig::default(); let task = AsyncComputeTaskPool::get().spawn(async move {
let task = AsyncComputeTaskPool::get().spawn(async move { try_solve(seed, draw_mode, &cfg) }); try_solve(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
)
});
pending.seed = Some(seed); pending.seed = Some(seed);
pending.handle = Some(task); pending.handle = Some(task);
@@ -369,15 +377,15 @@ fn poll_solver_task(
return; return;
}; };
match result { match result {
SolverResult::Winnable => { Ok(Some(_)) => {
text.0 = "\u{2713} Provably winnable".to_string(); text.0 = "\u{2713} Provably winnable".to_string();
color.0 = ACCENT_PRIMARY; color.0 = ACCENT_PRIMARY;
} }
SolverResult::Inconclusive => { Err(_) => {
text.0 = "? Likely winnable (search timed out)".to_string(); text.0 = "? Likely winnable (search timed out)".to_string();
color.0 = TEXT_SECONDARY; color.0 = TEXT_SECONDARY;
} }
SolverResult::Unwinnable => { Ok(None) => {
text.0 = "\u{2717} Provably unwinnable".to_string(); text.0 = "\u{2717} Provably unwinnable".to_string();
color.0 = TEXT_DISABLED; color.0 = TEXT_DISABLED;
} }
+4
View File
@@ -12,7 +12,11 @@ use solitaire_core::achievement::{ALL_ACHIEVEMENTS, achievement_by_id};
use solitaire_data::SyncBackend; use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource; use crate::achievement_plugin::AchievementsResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::events::ToggleProfileRequestEvent; use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
+183 -179
View File
@@ -47,13 +47,12 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card; use solitaire_core::card::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::MoveRequestEvent; use crate::events::{MoveRejectedEvent, MoveRequestEvent};
use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC}; use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC};
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
@@ -108,7 +107,7 @@ pub enum RightClickRadialState {
/// `hovered_index` (or none). /// `hovered_index` (or none).
Active { Active {
/// Pile the right-clicked card came from. /// Pile the right-clicked card came from.
source_pile: PileType, source_pile: KlondikePile,
/// Number of cards that would be moved (always `1` — only the /// Number of cards that would be moved (always `1` — only the
/// top face-up card is ever offered for a quick-drop, since the /// top face-up card is ever offered for a quick-drop, since the
/// radial is built around single-card foundation/tableau /// radial is built around single-card foundation/tableau
@@ -123,7 +122,7 @@ pub enum RightClickRadialState {
/// [`RADIAL_RADIUS_PX`] centred on the press position. A single /// [`RADIAL_RADIUS_PX`] centred on the press position. A single
/// destination is placed directly above the cursor; multiple /// destination is placed directly above the cursor; multiple
/// destinations span an arc. /// destinations span an arc.
legal_destinations: Vec<(PileType, Vec2)>, legal_destinations: Vec<(KlondikePile, Vec2)>,
/// Cursor position (world space) the radial was opened at — /// Cursor position (world space) the radial was opened at —
/// used as the centre of the ring for cursor-hover hit testing. /// used as the centre of the ring for cursor-hover hit testing.
centre: Vec2, centre: Vec2,
@@ -250,30 +249,20 @@ pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option<usize> {
/// that legally accept the card. The source pile is excluded because /// that legally accept the card. The source pile is excluded because
/// dropping a card on its own pile is a no-op. /// dropping a card on its own pile is a no-op.
pub fn legal_destinations_for_card( pub fn legal_destinations_for_card(
card: &Card, _card: &Card,
source_pile: &PileType, source_pile: &KlondikePile,
game: &GameState, game: &GameState,
) -> Vec<PileType> { ) -> Vec<KlondikePile> {
let mut out = Vec::new(); let mut out = Vec::new();
for slot in 0..4_u8 { for foundation in foundations() {
let dest = PileType::Foundation(slot); let dest = KlondikePile::Foundation(foundation);
if dest == *source_pile { if game.can_move_cards(source_pile, &dest, 1) {
continue;
}
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile)
{
out.push(dest); out.push(dest);
} }
} }
for i in 0..7_usize { for tableau in tableaus() {
let dest = PileType::Tableau(i); let dest = KlondikePile::Tableau(tableau);
if dest == *source_pile { if game.can_move_cards(source_pile, &dest, 1) {
continue;
}
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_tableau(card, pile)
{
out.push(dest); out.push(dest);
} }
} }
@@ -292,36 +281,34 @@ pub fn find_top_face_up_card_at(
cursor: Vec2, cursor: Vec2,
game: &GameState, game: &GameState,
layout: &Layout, layout: &Layout,
) -> Option<(PileType, Card)> { ) -> Option<(KlondikePile, Card)> {
let piles = [ let piles = [
PileType::Waste, KlondikePile::Stock,
PileType::Foundation(0), KlondikePile::Foundation(Foundation::Foundation1),
PileType::Foundation(1), KlondikePile::Foundation(Foundation::Foundation2),
PileType::Foundation(2), KlondikePile::Foundation(Foundation::Foundation3),
PileType::Foundation(3), KlondikePile::Foundation(Foundation::Foundation4),
PileType::Tableau(0), KlondikePile::Tableau(Tableau::Tableau1),
PileType::Tableau(1), KlondikePile::Tableau(Tableau::Tableau2),
PileType::Tableau(2), KlondikePile::Tableau(Tableau::Tableau3),
PileType::Tableau(3), KlondikePile::Tableau(Tableau::Tableau4),
PileType::Tableau(4), KlondikePile::Tableau(Tableau::Tableau5),
PileType::Tableau(5), KlondikePile::Tableau(Tableau::Tableau6),
PileType::Tableau(6), KlondikePile::Tableau(Tableau::Tableau7),
]; ];
for pile in piles { for pile in piles {
let Some(pile_cards) = game.piles.get(&pile) else { let pile_cards = pile_cards(game, &pile);
continue; if pile_cards.is_empty() {
};
if pile_cards.cards.is_empty() {
continue; continue;
} }
let is_tableau = matches!(pile, PileType::Tableau(_)); let is_tableau = matches!(pile, KlondikePile::Tableau(_));
for i in (0..pile_cards.cards.len()).rev() { for i in (0..pile_cards.len()).rev() {
let card = &pile_cards.cards[i]; let card = &pile_cards[i];
if !card.face_up { if !card.1 {
continue; continue;
} }
// Only the top card is draggable on non-tableau piles. // Only the top 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; continue;
} }
let pos = card_position(game, layout, &pile, i); let pos = card_position(game, layout, &pile, i);
@@ -333,7 +320,7 @@ pub fn find_top_face_up_card_at(
{ {
continue; continue;
} }
return Some((pile, card.clone())); return Some((pile, card.0.clone()));
} }
} }
None None
@@ -342,37 +329,81 @@ pub fn find_top_face_up_card_at(
/// Mirror of `input_plugin::card_position` — kept private to this /// Mirror of `input_plugin::card_position` — kept private to this
/// module so the radial's hit-test geometry tracks renderer geometry /// module so the radial's hit-test geometry tracks renderer geometry
/// without depending on `input_plugin` internals. /// without depending on `input_plugin` internals.
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 { fn card_position(
game: &GameState,
layout: &Layout,
pile: &KlondikePile,
stack_index: usize,
) -> Vec2 {
let base = layout.pile_positions[pile]; let base = layout.pile_positions[pile];
if matches!(pile, PileType::Tableau(_)) { if matches!(pile, KlondikePile::Tableau(_)) {
let mut y_offset = 0.0_f32; let mut y_offset = 0.0_f32;
if let Some(pile_cards) = game.piles.get(pile) { for card in pile_cards(game, pile).iter().take(stack_index) {
for card in pile_cards.cards.iter().take(stack_index) { let step = if card.1 {
let step = if card.face_up {
TABLEAU_FAN_FRAC TABLEAU_FAN_FRAC
} else { } else {
TABLEAU_FACEDOWN_FAN_FRAC TABLEAU_FACEDOWN_FAN_FRAC
}; };
y_offset -= layout.card_size.y * step; y_offset -= layout.card_size.y * step;
} }
}
Vec2::new(base.x, base.y + y_offset) Vec2::new(base.x, base.y + y_offset)
} else { } else {
base base
} }
} }
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
use solitaire_core::card::card_to_id;
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
}
const fn tableaus() -> [Tableau; 7] {
[
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
}
/// Builds the `(destination, anchor)` list for a fresh radial open. /// Builds the `(destination, anchor)` list for a fresh radial open.
fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileType, Vec2)> { ///
/// `half_extents` is the window half-size in world space — icons are clamped
/// so that their edges stay within the viewport, preventing them from appearing
/// off-screen on small or narrow devices.
fn build_radial_destinations(
centre: Vec2,
dests: Vec<KlondikePile>,
half_extents: Vec2,
) -> Vec<(KlondikePile, Vec2)> {
let count = dests.len(); let count = dests.len();
let margin = RADIAL_ICON_SIZE_PX / 2.0;
dests dests
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(i, d)| { .map(|(i, d)| {
( let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
d, let clamped = Vec2::new(
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX), raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
) raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
);
(d, clamped)
}) })
.collect() .collect()
} }
@@ -407,9 +438,10 @@ fn cursor_world(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// On `MouseButton::Right` `just_pressed`, attempts to open the radial /// On `MouseButton::Right` `just_pressed`, attempts to open the radial
/// menu over the card the cursor is on. Skips when a left-mouse drag is /// menu over the card the cursor is on. When the cursor is on a face-up
/// in progress, when the game is paused, or when the clicked card has no /// card but no legal destinations exist, fires `MoveRejectedEvent` so the
/// legal destinations. /// shake animation and invalid-move sound play. Skips silently when no
/// card is under the cursor, when a drag is in progress, or when paused.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn radial_open_on_right_click( fn radial_open_on_right_click(
buttons: Option<Res<ButtonInput<MouseButton>>>, buttons: Option<Res<ButtonInput<MouseButton>>>,
@@ -421,6 +453,7 @@ fn radial_open_on_right_click(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
mut state: ResMut<RightClickRadialState>, mut state: ResMut<RightClickRadialState>,
mut rejected: MessageWriter<MoveRejectedEvent>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
@@ -449,14 +482,25 @@ fn radial_open_on_right_click(
// cards and the highlight tint shows the same set the radial offers. // cards and the highlight tint shows the same set the radial offers.
let dests = legal_destinations_for_card(&card, &source_pile, &game.0); let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
if dests.is_empty() { if dests.is_empty() {
// No legal destinations — shake the source pile as feedback.
rejected.write(MoveRejectedEvent {
from: source_pile,
to: source_pile,
count: 1,
});
return; return;
} }
let legal_destinations = build_radial_destinations(world, dests); let half_extents = windows
.single()
.ok()
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
.unwrap_or(Vec2::splat(f32::MAX));
let legal_destinations = build_radial_destinations(world, dests, half_extents);
*state = RightClickRadialState::Active { *state = RightClickRadialState::Active {
source_pile, source_pile,
count: 1, count: 1,
cards: vec![card.id], cards: vec![card_to_id(&card)],
legal_destinations, legal_destinations,
centre: world, centre: world,
hovered_index: None, hovered_index: None,
@@ -477,6 +521,7 @@ fn radial_open_on_long_press(
drag: Res<DragState>, drag: Res<DragState>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
touches: Option<Res<Touches>>, touches: Option<Res<Touches>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
@@ -519,11 +564,16 @@ fn radial_open_on_long_press(
if dests.is_empty() { if dests.is_empty() {
return; return;
} }
let legal_destinations = build_radial_destinations(world, dests); let half_extents = windows
.single()
.ok()
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
.unwrap_or(Vec2::splat(f32::MAX));
let legal_destinations = build_radial_destinations(world, dests, half_extents);
*state = RightClickRadialState::Active { *state = RightClickRadialState::Active {
source_pile, source_pile,
count: 1, count: 1,
cards: vec![card.id], cards: vec![card_to_id(&card)],
legal_destinations, legal_destinations,
centre: world, centre: world,
hovered_index: None, hovered_index: None,
@@ -609,8 +659,8 @@ fn radial_handle_release_or_cancel(
&& let Some((dest, _)) = legal_destinations.get(*idx) && let Some((dest, _)) = legal_destinations.get(*idx)
{ {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: source_pile.clone(), from: *source_pile,
to: dest.clone(), to: *dest,
count: *count, count: *count,
}); });
} }
@@ -746,8 +796,8 @@ mod tests {
use super::*; use super::*;
use crate::layout::compute_layout; use crate::layout::compute_layout;
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::card::{Card as CoreCard, Rank, Suit}; use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the /// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
/// resources / messages it depends on. No window, no camera — the /// resources / messages it depends on. No window, no camera — the
@@ -756,6 +806,7 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins); app.add_plugins(MinimalPlugins);
app.add_message::<MoveRequestEvent>(); app.add_message::<MoveRequestEvent>();
app.add_message::<MoveRejectedEvent>();
app.init_resource::<DragState>(); app.init_resource::<DragState>();
app.init_resource::<ButtonInput<MouseButton>>(); app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
@@ -771,33 +822,32 @@ mod tests {
fn ace_only_state() -> GameState { fn ace_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne); let mut g = GameState::new(0, DrawMode::DrawOne);
// Wipe everything. // Wipe everything.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.set_test_stock_cards(Vec::new());
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); g.set_test_waste_cards(Vec::new());
for slot in 0..4_u8 { for foundation in [
g.piles Foundation::Foundation1,
.get_mut(&PileType::Foundation(slot)) Foundation::Foundation2,
.unwrap() Foundation::Foundation3,
.cards Foundation::Foundation4,
.clear(); ] {
g.set_test_foundation_cards(foundation, Vec::new());
} }
for i in 0..7_usize { for tableau in [
g.piles Tableau::Tableau1,
.get_mut(&PileType::Tableau(i)) Tableau::Tableau2,
.unwrap() Tableau::Tableau3,
.cards Tableau::Tableau4,
.clear(); Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
g.set_test_tableau_cards(tableau, Vec::new());
} }
// Ace of Clubs on Tableau(0). // Ace of Clubs on Tableau(0).
g.piles g.set_test_tableau_cards(
.get_mut(&PileType::Tableau(0)) Tableau::Tableau1,
.unwrap() vec![CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
.cards );
.push(CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
g g
} }
@@ -805,32 +855,31 @@ mod tests {
/// must skip it. /// must skip it.
fn face_down_only_state() -> GameState { fn face_down_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne); let mut g = GameState::new(0, DrawMode::DrawOne);
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.set_test_stock_cards(Vec::new());
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); g.set_test_waste_cards(Vec::new());
for slot in 0..4_u8 { for foundation in [
g.piles Foundation::Foundation1,
.get_mut(&PileType::Foundation(slot)) Foundation::Foundation2,
.unwrap() Foundation::Foundation3,
.cards Foundation::Foundation4,
.clear(); ] {
g.set_test_foundation_cards(foundation, Vec::new());
} }
for i in 0..7_usize { for tableau in [
g.piles Tableau::Tableau1,
.get_mut(&PileType::Tableau(i)) Tableau::Tableau2,
.unwrap() Tableau::Tableau3,
.cards Tableau::Tableau4,
.clear(); Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
g.set_test_tableau_cards(tableau, Vec::new());
} }
g.piles g.set_test_tableau_cards_with_face(
.get_mut(&PileType::Tableau(0)) Tableau::Tableau1,
.unwrap() vec![(CoreCard::new(Deck::Deck1, Suit::Spades, Rank::King), false)],
.cards );
.push(CoreCard {
id: 100,
suit: Suit::Spades,
rank: Rank::King,
face_up: false,
});
g g
} }
@@ -922,33 +971,28 @@ mod tests {
#[test] #[test]
fn legal_destinations_for_ace_includes_only_first_empty_foundation() { fn legal_destinations_for_ace_includes_only_first_empty_foundation() {
let g = ace_only_state(); let g = ace_only_state();
let card = CoreCard { let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace);
id: 100, let dests =
suit: Suit::Clubs, legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
rank: Rank::Ace,
face_up: true,
};
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g);
// Ace can be placed on every empty foundation. We only need // Ace can be placed on every empty foundation. We only need
// the count to be ≥ 1 and the source pile to be excluded. // the count to be ≥ 1 and the source pile to be excluded.
assert!( assert!(
!dests.is_empty(), !dests.is_empty(),
"Ace must have at least one legal destination" "Ace must have at least one legal destination"
); );
assert!(!dests.contains(&PileType::Tableau(0))); assert!(!dests.contains(&KlondikePile::Tableau(Tableau::Tableau1)));
} }
#[test] #[test]
fn legal_destinations_excludes_source_pile() { fn legal_destinations_excludes_source_pile() {
let g = ace_only_state(); let g = ace_only_state();
let card = CoreCard { let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace);
id: 100, let dests = legal_destinations_for_card(
suit: Suit::Clubs, &card,
rank: Rank::Ace, &KlondikePile::Foundation(Foundation::Foundation1),
face_up: true, &g,
}; );
let dests = legal_destinations_for_card(&card, &PileType::Foundation(0), &g); assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1)));
assert!(!dests.contains(&PileType::Foundation(0)));
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -958,46 +1002,6 @@ mod tests {
/// Pressing right-click on a face-up card with at least one legal /// Pressing right-click on a face-up card with at least one legal
/// destination must transition the state to `Active` carrying the /// destination must transition the state to `Active` carrying the
/// expected source / count / legal-destination set. /// expected source / count / legal-destination set.
#[test]
fn right_click_press_on_face_up_card_opens_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
// Initial state — Idle.
assert_eq!(
*app.world().resource::<RightClickRadialState>(),
RightClickRadialState::Idle
);
press(&mut app, MouseButton::Right);
app.update();
let state = app.world().resource::<RightClickRadialState>().clone();
match state {
RightClickRadialState::Active {
source_pile,
count,
cards,
legal_destinations,
..
} => {
assert_eq!(source_pile, PileType::Tableau(0));
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert!(!legal_destinations.is_empty());
assert!(
legal_destinations
.iter()
.any(|(p, _)| matches!(p, PileType::Foundation(_)))
);
}
other => panic!("expected Active, got {other:?}"),
}
}
/// Releasing the right button while the cursor is over a destination /// Releasing the right button while the cursor is over a destination
/// icon must fire a `MoveRequestEvent` and return the state to Idle. /// icon must fire a `MoveRequestEvent` and return the state to Idle.
#[test] #[test]
@@ -1005,7 +1009,7 @@ mod tests {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true); let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right); press(&mut app, MouseButton::Right);
@@ -1015,7 +1019,7 @@ mod tests {
let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() { let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() {
RightClickRadialState::Active { RightClickRadialState::Active {
legal_destinations, .. legal_destinations, ..
} => legal_destinations[0].clone(), } => legal_destinations[0],
_ => panic!("expected Active"), _ => panic!("expected Active"),
}; };
@@ -1032,7 +1036,7 @@ mod tests {
let events = collect_move_events(&mut app); let events = collect_move_events(&mut app);
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected"); assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected");
let evt = &events[0]; let evt = &events[0];
assert_eq!(evt.from, PileType::Tableau(0)); assert_eq!(evt.from, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(evt.to, dest_pile); assert_eq!(evt.to, dest_pile);
assert_eq!(evt.count, 1); assert_eq!(evt.count, 1);
// State must return to Idle. // State must return to Idle.
@@ -1049,7 +1053,7 @@ mod tests {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true); let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right); press(&mut app, MouseButton::Right);
@@ -1080,7 +1084,7 @@ mod tests {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true); let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right); press(&mut app, MouseButton::Right);
@@ -1106,7 +1110,7 @@ mod tests {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true); let layout = compute_layout(layout_window, 0.0, 0.0, true);
let king_pos = layout.pile_positions[&PileType::Tableau(0)]; let king_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos); install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
press(&mut app, MouseButton::Right); press(&mut app, MouseButton::Right);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
use super::ReplayPlaybackState;
use chrono::Datelike;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_data::ReplayMove;
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
/// consumed when transitioning out of `Playing`, so the identifier
/// isn't recoverable from state in those branches); spawn-time
/// callers fall back to an empty string.
///
/// Year + chrono ordinal (`{year}-{ordinal:03}`) gives a compact
/// monotonically-increasing identifier shaped like `2026-127` — same
/// shape as the mockup's `GAME #2024-127` motif.
pub(crate) fn format_game_caption(state: &ReplayPlaybackState) -> Option<String> {
match state {
ReplayPlaybackState::Playing { replay, .. } => Some(format!(
"GAME #{}-{:03}",
replay.recorded_at.year(),
replay.recorded_at.ordinal()
)),
ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => None,
}
}
/// Pure helper — formats the centre progress readout for the given state.
/// Exposed at module scope so the spawn path and the per-frame update
/// path produce the exact same string.
pub(crate) fn format_progress(state: &ReplayPlaybackState) -> String {
match state.progress() {
// `MOVE N/M` (uppercase + slash) reads as a Terminal output
// line and matches the floating-chip motif in the mockup at
// `docs/ui-mockups/replay-overlay-mobile.html`.
Some((cursor, total)) => format!("MOVE {cursor}/{total}"),
None if state.is_completed() => "REPLAY COMPLETE".to_string(),
None => String::new(),
}
}
/// Pure helper — formats a [`KlondikePile`] as a short, lowercase,
/// 1-indexed display string for the move-log row. `Foundation(2)`
/// renders as `"foundation 3"` rather than `"foundation 2"` so
/// players see human-friendly numbers; the underlying enum
/// remains 0-indexed.
///
/// Returns `String` rather than `&'static str` because the
/// `Foundation` / `Tableau` variants need formatting; the static
/// variants (`Stock`, `Waste`) still allocate but the cost is
/// trivial against the per-frame update cadence.
pub(crate) fn format_pile(p: &KlondikePile) -> String {
match p {
KlondikePile::Stock => "waste".to_string(),
KlondikePile::Foundation(foundation) => {
format!("foundation {}", foundation_number(*foundation))
}
KlondikePile::Tableau(tableau) => format!("tableau {}", tableau_number(*tableau)),
}
}
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
KlondikePile::try_from(*p)
.map(|pile| format_pile(&pile))
.unwrap_or_else(|_| "unknown pile".to_string())
}
fn foundation_number(foundation: Foundation) -> u8 {
match foundation {
Foundation::Foundation1 => 1,
Foundation::Foundation2 => 2,
Foundation::Foundation3 => 3,
Foundation::Foundation4 => 4,
}
}
fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
Tableau::Tableau2 => 2,
Tableau::Tableau3 => 3,
Tableau::Tableau4 => 4,
Tableau::Tableau5 => 5,
Tableau::Tableau6 => 6,
Tableau::Tableau7 => 7,
}
}
/// Pure helper — formats a [`ReplayMove`] as the body of a
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
/// reads as `"{from} → {to}"` using [`format_pile`] for both
/// endpoints. The `count` field is omitted from the row body —
/// at row scale it adds visual noise without meaningful
/// information for the typical 1-card moves.
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
match m {
ReplayMove::StockClick => "stock cycle".to_string(),
ReplayMove::Move { from, to, .. } => {
format!(
"{} \u{2192} {}",
format_saved_pile(from),
format_saved_pile(to)
)
}
}
}
/// Pure helper — formats the move-log panel's header text. Reads
/// `▌ MOVE LOG · N/M` while playing, where `N` is the count of
/// moves applied so far and `M` is the total in the replay. The
/// cursor-block prefix (`▌`) matches the splash and replay-banner
/// motifs. Empty in `Inactive` (no replay attached); reads
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
match state {
ReplayPlaybackState::Playing { replay, cursor, .. } => {
format!(
"\u{258C} MOVE LOG \u{00B7} {}/{}",
cursor,
replay.moves.len()
)
}
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
ReplayPlaybackState::Inactive => String::new(),
}
}
/// Pure helper — formats the kth-most-recently-applied move's row
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
/// and so on.
///
/// Returns the empty string in any of these cases:
/// - State isn't `Playing` (no replay attached).
/// - `k == 0` (no kth-most-recent for k=0; the active is k=1).
/// - `k > cursor` (not enough history — e.g. cursor=2 has rows
/// for k=1 and k=2 only, k=3 returns empty).
/// - The move list is shorter than expected (defensive guard).
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new();
};
if k == 0 || k > *cursor {
return String::new();
}
let zero_idx = *cursor - k;
let Some(m) = replay.moves.get(zero_idx) else {
return String::new();
};
let display_idx = *cursor - k + 1;
format!("{} \u{2502} {}", display_idx, format_move_body(m))
}
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
/// is the move that will apply next (`replay.moves[cursor]`,
/// displayed as `cursor + 1`); `k = 2` is the move after that,
/// and so on.
///
/// Returns the empty string in any of these cases:
/// - State isn't `Playing` (no replay attached).
/// - `k == 0` (degenerate; the active is k=1 of *recent*, not
/// *next*).
/// - `cursor + k - 1 >= moves.len()` (not enough remaining
/// replay — late in the move list, the trailing next rows
/// stay empty).
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new();
};
if k == 0 {
return String::new();
}
let zero_idx = *cursor + k - 1;
let Some(m) = replay.moves.get(zero_idx) else {
return String::new();
};
let display_idx = *cursor + k;
format!("{} \u{2502} {}", display_idx, format_move_body(m))
}
/// Pure helper — formats the active-row text for the move-log
/// panel. Wraps [`format_kth_recent_row`] with `k=1` and prepends
/// a `▶` focus marker so the active row reads visually distinct
/// from prev rows even before the highlight background lands.
/// Returns empty when there's no row to render (cursor=0 or
/// non-`Playing` state) — never `"▶ "` alone, which would paint
/// a stray prefix.
pub(crate) fn format_active_move_row(state: &ReplayPlaybackState) -> String {
let body = format_kth_recent_row(state, 1);
if body.is_empty() {
return String::new();
}
format!("\u{25B6} {body}") // ▶
}
// ---------------------------------------------------------------------------
// Mini-tableau format helpers and update system
// ---------------------------------------------------------------------------
/// Pure helper — short rank symbol. Single character for all ranks
/// except Ten which uses "T" (keeps every card a consistent 2-char
/// wide render: rank-char + suit-glyph). Players familiar with
/// solitaire shorthand read "T" instantly; the suit glyph immediately
/// follows and disambiguates from an ambiguous "T".
pub(crate) fn format_rank_short(rank: Rank) -> &'static str {
match rank {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "T",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
}
}
/// Pure helper — Unicode suit glyph from FiraMono's covered range
/// (U+2660U+2666). These four code points are confirmed present in
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
match suit {
Suit::Spades => "\u{2660}", // ♠
Suit::Hearts => "\u{2665}", // ♥
Suit::Diamonds => "\u{2666}", // ♦
Suit::Clubs => "\u{2663}", // ♣
}
}
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
/// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card {
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
None => "--".to_string(),
}
}
/// Pure helper — one-line summary of the four foundation tops.
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
/// Foundation slots are displayed in their natural 0-3 order
/// (matching the visual left-to-right order on screen).
pub(crate) fn format_foundations_row(game: &GameState) -> String {
let slots = [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
.map(|foundation| {
let cards = game.pile(KlondikePile::Foundation(foundation));
format_card_short(cards.last())
});
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
}
/// Pure helper — one-line stock / waste summary.
/// Renders as `STK:N WST:X♠` where N is the stock card count and
/// X♠ is the top waste card (or `--` when the waste pile is empty).
pub(crate) fn format_stock_waste_row(game: &GameState) -> String {
let stock_cards = game.stock_cards();
let waste_cards = game.waste_cards();
let stock_count = stock_cards.len();
let waste_top = waste_cards.last();
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
use bevy::prelude::*;
use super::format::{
format_active_move_row, format_foundations_row, format_kth_next_row, format_kth_recent_row,
format_move_log_header, format_progress, format_stock_waste_row,
};
use super::*;
use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use solitaire_core::KlondikePile;
use solitaire_data::ReplayMove;
/// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "▌ replay" for
/// "▌ replay complete" in place without despawning the overlay.
pub(crate) fn update_banner_label(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
) {
if !state.is_changed() {
return;
}
let label = if state.is_completed() {
"\u{258C} replay complete" // ▌
} else if state.is_playing() {
"\u{258C} replay" // ▌
} else {
return;
};
for mut text in &mut q {
**text = label.to_string();
}
}
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
/// Cheap — early-exits if the resource has not changed since the last
/// frame so idle replays don't churn the text mesh.
pub(crate) fn update_progress_text(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
) {
if !state.is_changed() {
return;
}
let label = format_progress(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repositions the floating progress chip above the destination
/// pile of the most-recently-applied move and repaints its text.
///
/// The chip is hidden when:
/// - the cursor is at 0 (no moves applied yet — chip would have
/// nowhere meaningful to land), OR
/// - the most-recently-applied move was a `StockClick` (no
/// destination pile — stock-click feedback already lives at
/// the stock pile and we don't want the chip to jitter back
/// to the stock pile every cycle).
///
/// When visible, the chip's world-space `Transform.translation`
/// is set to the destination pile's centre plus a fixed upward
/// offset (`card_size.y * 0.6`) so the chip floats just above
/// the top edge of the card. World-space placement (rather than
/// UI-space + camera projection) keeps the math trivial and means
/// the chip stays correctly positioned through window resizes
/// without any extra wiring — `LayoutResource` already drives
/// every other piece of pile geometry.
pub(crate) fn update_floating_progress_chip(
state: Res<ReplayPlaybackState>,
layout: Option<Res<LayoutResource>>,
mut chips: Query<
(&mut Transform, &mut Visibility, &mut Text2d),
With<ReplayFloatingProgressChip>,
>,
) {
let Some(layout) = layout else {
return;
};
// Resolve the destination pile of the last-applied move (if
// any). `cursor` is the index of the *next* move to apply, so
// the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
match &replay.moves[cursor - 1] {
ReplayMove::Move { to, .. } => Some(*to),
ReplayMove::StockClick => None,
}
}
_ => None,
};
let Some(world_pos) = dest_pile
.as_ref()
.and_then(|p| KlondikePile::try_from(*p).ok())
.and_then(|p| layout.0.pile_positions.get(&p).copied())
else {
// Nothing to point at — hide every chip and exit.
for (_, mut visibility, _) in chips.iter_mut() {
*visibility = Visibility::Hidden;
}
return;
};
// Position above the destination pile by ~60 % of a card
// height. Half a card lifts above the centre, the extra 10 %
// is breathing room above the top edge so the chip doesn't
// visually clip the card.
let above = Vec2::new(0.0, layout.0.card_size.y * 0.6);
let target = (world_pos + above).extend(100.0);
let label = format_progress(&state);
for (mut transform, mut visibility, mut text2d) in chips.iter_mut() {
transform.translation = target;
*visibility = Visibility::Inherited;
if **text2d != label {
**text2d = label.clone();
}
}
}
/// Repaints the move-log panel's `▌ MOVE LOG · N/M` header text
/// whenever [`ReplayPlaybackState`] changes. Cheap — early-exits
/// when nothing moved so an idle replay leaves the text mesh
/// untouched.
pub(crate) fn update_move_log_header(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayMoveLogHeader>>,
) {
if !state.is_changed() {
return;
}
let label = format_move_log_header(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repaints the move-log panel's active-row text whenever
/// [`ReplayPlaybackState`] changes. Same change-detection guard
/// as the header updater. Empty string at `cursor == 0` (no move
/// applied yet) and in non-`Playing` states; populated otherwise.
pub(crate) fn update_move_log_active_row(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayMoveLogActiveRow>>,
) {
if !state.is_changed() {
return;
}
let label = format_active_move_row(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repaints every "previous move" row text whenever
/// [`ReplayPlaybackState`] changes. Each row's `offset` is read
/// from the marker; `k = offset + 1` feeds [`format_kth_recent_row`]
/// (active is k=1, prev offset 1 is k=2, prev offset 2 is k=3).
/// Rows with `offset >= cursor` paint as empty — the panel
/// gracefully under-fills early in a replay without spurious
/// "out-of-range" text.
pub(crate) fn update_move_log_prev_rows(
state: Res<ReplayPlaybackState>,
mut q: Query<(&ReplayOverlayMoveLogPrevRow, &mut Text)>,
) {
if !state.is_changed() {
return;
}
for (row, mut text) in &mut q {
let label = format_kth_recent_row(&state, row.offset as usize + 1);
**text = label;
}
}
/// Repaints every "next move" row text whenever
/// [`ReplayPlaybackState`] changes. Symmetric to the prev-row
/// updater but feeds [`format_kth_next_row`]. Rows where
/// `cursor + offset > moves.len()` paint as empty — the panel
/// gracefully under-fills late in a replay (e.g. final moves)
/// without spurious out-of-range text.
pub(crate) fn update_move_log_next_rows(
state: Res<ReplayPlaybackState>,
mut q: Query<(&ReplayOverlayMoveLogNextRow, &mut Text)>,
) {
if !state.is_changed() {
return;
}
for (row, mut text) in &mut q {
let label = format_kth_next_row(&state, row.offset as usize);
**text = label;
}
}
/// Repaints the bottom-edge accent scrub fill to mirror cursor progress.
/// Same change-detection guard as the text updaters — the overlay
/// already early-exits when nothing moved, so an idle replay leaves the
/// scrub bar's `Node` untouched.
pub(crate) fn update_scrub_fill(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Node, With<ReplayOverlayScrubFill>>,
) {
if !state.is_changed() {
return;
}
let pct = scrub_pct(&state);
for mut node in &mut q {
node.width = Val::Percent(pct);
}
}
/// Repaints the foundations row whenever [`GameStateResource`] changes.
/// Split into its own system (rather than combined with the stock/waste
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
/// queries in one system are always ambiguous regardless of marker
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
pub(crate) fn update_mini_tableau_foundations(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_foundations_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
/// guard, separate system to avoid the B0001 query conflict.
pub(crate) fn update_mini_tableau_stock_waste(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_stock_waste_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
+30 -10
View File
@@ -40,6 +40,7 @@
//! flag is threaded through, no every-callsite gate is added. //! flag is threaded through, no every-callsite gate is added.
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_data::{Replay, ReplayMove}; use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
@@ -267,9 +268,18 @@ pub fn step_replay_playback(
} }
match &replay.moves[*cursor] { match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => { ReplayMove::Move { from, to, count } => {
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
*cursor += 1;
return false;
};
moves_writer.write(MoveRequestEvent { moves_writer.write(MoveRequestEvent {
from: from.clone(), from,
to: to.clone(), to,
count: *count, count: *count,
}); });
} }
@@ -370,11 +380,20 @@ fn tick_replay_playback(
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() { while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] { match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => { ReplayMove::Move { from, to, count } => {
if let (Ok(from), Ok(to)) =
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
{
moves_writer.write(MoveRequestEvent { moves_writer.write(MoveRequestEvent {
from: from.clone(), from,
to: to.clone(), to,
count: *count, count: *count,
}); });
} else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
}
} }
ReplayMove::StockClick => { ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent); draws_writer.write(DrawRequestEvent);
@@ -536,8 +555,9 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy; use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::pile::PileType; use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use std::time::Duration; use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and /// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -586,8 +606,8 @@ mod tests {
vec![ vec![
ReplayMove::StockClick, ReplayMove::StockClick,
ReplayMove::Move { ReplayMove::Move {
from: PileType::Waste, from: SavedKlondikePile::Stock,
to: PileType::Tableau(3), to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1, count: 1,
}, },
ReplayMove::StockClick, ReplayMove::StockClick,
@@ -739,8 +759,8 @@ mod tests {
"expected 1 MoveRequestEvent (the single Move variant)", "expected 1 MoveRequestEvent (the single Move variant)",
); );
let m = &captured_moves.0[0]; let m = &captured_moves.0[0];
assert!(matches!(m.from, PileType::Waste)); assert!(matches!(m.from, KlondikePile::Stock));
assert!(matches!(m.to, PileType::Tableau(3))); assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
assert_eq!(m.count, 1); assert_eq!(m.count, 1);
} }
+13 -4
View File
@@ -1,12 +1,14 @@
//! Bevy resources owned by the engine crate. //! Bevy resources owned by the engine crate.
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc; use std::sync::Arc;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::card::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game. /// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
@@ -26,10 +28,10 @@ pub struct GameStateResource(pub GameState);
/// This prevents accidental drags on quick taps, especially on touch screens. /// This prevents accidental drags on quick taps, especially on touch screens.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct DragState { pub struct DragState {
/// IDs of the cards being dragged (bottom-to-top stacking order). /// Cards being dragged (bottom-to-top stacking order).
pub cards: Vec<u32>, pub cards: Vec<Card>,
/// Pile the drag originated from. /// Pile the drag originated from.
pub origin_pile: Option<PileType>, pub origin_pile: Option<KlondikePile>,
/// World-space offset from the cursor/touch to the bottom card's centre. /// World-space offset from the cursor/touch to the bottom card's centre.
pub cursor_offset: Vec2, pub cursor_offset: Vec2,
/// Z coordinate used for the dragged cards. /// Z coordinate used for the dragged cards.
@@ -128,9 +130,16 @@ pub struct GameInputConsumedResource(pub bool);
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply /// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply
/// into every network task — safe for concurrent `block_on` calls from multiple /// into every network task — safe for concurrent `block_on` calls from multiple
/// worker threads. /// worker threads.
///
/// Gated to non-wasm because `tokio::runtime::Builder::new_multi_thread()` uses
/// `mio` for OS-level I/O polling which does not compile for wasm32. The
/// plugins that depend on this resource (AudioPlugin, SyncPlugin,
/// AnalyticsPlugin) are also gated out on wasm32 in `CoreGamePlugin`.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Clone)] #[derive(Resource, Clone)]
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>); pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
#[cfg(not(target_arch = "wasm32"))]
impl TokioRuntimeResource { impl TokioRuntimeResource {
/// Attempts to build the shared multi-threaded Tokio runtime. /// Attempts to build the shared multi-threaded Tokio runtime.
/// ///
+27 -12
View File
@@ -147,8 +147,13 @@ fn apply_safe_area_bottom_anchors(
} }
} }
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so /// Pads both edges of every [`ModalScrim`] by the logical system-bar insets so
/// modal cards don't extend into the Android gesture-navigation zone. /// modal cards are centred within the usable area (between the status bar at
/// the top and the gesture-navigation bar at the bottom).
///
/// `padding.top` = status-bar inset; `padding.bottom` = gesture-bar inset.
/// With `align_items: Center` / `justify_content: Center` on the scrim the
/// `ModalCard` lands at the visual midpoint of the visible content area.
/// ///
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets /// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
/// arriving a few frames after app start) AND when a new `ModalScrim` is /// arriving a few frames after app start) AND when a new `ModalScrim` is
@@ -165,8 +170,18 @@ fn apply_safe_area_to_modal_scrims(
} }
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let window_height = windows.iter().next().map_or(800.0, |w| w.height()); let window_height = windows.iter().next().map_or(800.0, |w| w.height());
// Clamp each inset to 25% of screen height so an unexpectedly large OS
// value can't push the modal card off the visible area entirely.
let top_logical = (insets.top / scale).min(window_height * 0.25);
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25); let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
for mut node in &mut scrims { for mut node in &mut scrims {
// Set both edges so the scrim's content box equals the usable area
// between the status bar and the gesture/navigation bar. With
// `align_items: Center` / `justify_content: Center` on the scrim,
// the modal card is centred within that usable region rather than
// the full viewport, correcting the slight upward shift seen when
// only the bottom inset was applied.
node.padding.top = Val::Px(top_logical);
node.padding.bottom = Val::Px(bottom_logical); node.padding.bottom = Val::Px(bottom_logical);
} }
} }
@@ -253,24 +268,24 @@ mod android {
} }
} }
/// Resets the inset poller and clears cached insets on /// Resets the inset poller on `AppLifecycle::WillResume` so that
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the /// `refresh_insets` re-queries JNI in the frames immediately after the app
/// frames immediately after the app returns to the foreground. /// returns to the foreground.
/// ///
/// Clearing `SafeAreaInsets` to the default (all-zero) fires /// The cached `SafeAreaInsets` are intentionally **not** zeroed here.
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic /// Zeroing them would cause two layout recomputes on every resume:
/// `WindowResized`. `on_window_resized` then recomputes the layout; /// once with zero insets (wrong position) and again when JNI resolves the
/// once `refresh_insets` resolves the real values a second synthetic /// real values — visible as a flash. By preserving the last-known values
/// `WindowResized` fires and the layout converges to the correct position. /// the layout remains stable; if JNI returns a different value (e.g. after
/// a rotation) the single update that fires when `SafeAreaInsets` actually
/// changes is enough.
pub(super) fn rearm_on_resumed( pub(super) fn rearm_on_resumed(
mut lifecycle: MessageReader<AppLifecycle>, mut lifecycle: MessageReader<AppLifecycle>,
mut poll: ResMut<SafeAreaPollTries>, mut poll: ResMut<SafeAreaPollTries>,
mut insets: ResMut<SafeAreaInsets>,
) { ) {
for event in lifecycle.read() { for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) { if matches!(event, AppLifecycle::WillResume) {
poll.0 = 0; poll.0 = 0;
*insets = SafeAreaInsets::default();
} }
} }
} }
+223 -345
View File
@@ -37,11 +37,11 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack}; use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
@@ -60,7 +60,7 @@ use crate::ui_theme::{ACCENT_PRIMARY, STATE_SUCCESS, STATE_WARNING};
#[derive(Resource, Debug, Default)] #[derive(Resource, Debug, Default)]
pub struct SelectionState { pub struct SelectionState {
/// The pile whose top face-up card is currently selected, or `None`. /// The pile whose top face-up card is currently selected, or `None`.
pub selected_pile: Option<PileType>, pub selected_pile: Option<KlondikePile>,
} }
/// Sentinel value used in [`crate::resources::DragState::active_touch_id`] /// Sentinel value used in [`crate::resources::DragState::active_touch_id`]
@@ -87,18 +87,18 @@ pub enum KeyboardDragState {
/// `legal_destinations` and `Enter` fires the move. /// `legal_destinations` and `Enter` fires the move.
Lifted { Lifted {
/// Pile the cards were lifted from. /// Pile the cards were lifted from.
source_pile: PileType, source_pile: KlondikePile,
/// Number of cards lifted (1 for waste / foundation, full face-up /// Number of cards lifted (1 for waste / foundation, full face-up
/// run length for a tableau column). /// run length for a tableau column).
count: usize, count: usize,
/// Card ids being lifted, in the same bottom-to-top order /// Cards being lifted, in the same bottom-to-top order
/// `DragState.cards` expects. /// `DragState.cards` expects.
cards: Vec<u32>, cards: Vec<Card>,
/// Pre-computed list of piles the lifted stack can legally be /// Pre-computed list of piles the lifted stack can legally be
/// placed on. Always at least one entry while in this variant — /// placed on. Always at least one entry while in this variant —
/// if no legal destinations exist the state machine refuses to /// if no legal destinations exist the state machine refuses to
/// enter `Lifted` in the first place. /// enter `Lifted` in the first place.
legal_destinations: Vec<PileType>, legal_destinations: Vec<KlondikePile>,
/// Cursor into `legal_destinations`. Always `< legal_destinations.len()`. /// Cursor into `legal_destinations`. Always `< legal_destinations.len()`.
destination_index: usize, destination_index: usize,
}, },
@@ -110,7 +110,7 @@ impl KeyboardDragState {
/// ///
/// [`Lifted`]: KeyboardDragState::Lifted /// [`Lifted`]: KeyboardDragState::Lifted
/// [`Idle`]: KeyboardDragState::Idle /// [`Idle`]: KeyboardDragState::Idle
pub fn focused_destination(&self) -> Option<&PileType> { pub fn focused_destination(&self) -> Option<&KlondikePile> {
match self { match self {
Self::Idle => None, Self::Idle => None,
Self::Lifted { Self::Lifted {
@@ -147,8 +147,12 @@ pub struct SelectionPlugin;
impl Plugin for SelectionPlugin { impl Plugin for SelectionPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// `CardEntityIndex` is owned and kept current by `CardPlugin`; this
// call is a no-op there. It is declared here so `update_selection_highlight`
// can read it via `Res<>` even in harnesses that omit `CardPlugin`.
app.init_resource::<SelectionState>() app.init_resource::<SelectionState>()
.init_resource::<KeyboardDragState>() .init_resource::<KeyboardDragState>()
.init_resource::<CardEntityIndex>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -173,13 +177,26 @@ impl Plugin for SelectionPlugin {
/// The ordered list of piles that are considered for keyboard cycling. /// The ordered list of piles that are considered for keyboard cycling.
/// ///
/// Order: Waste → Foundation slots 03 → Tableau 06. /// Order: Waste → Foundation slots 03 → Tableau 06.
fn cycled_piles() -> Vec<PileType> { fn cycled_piles() -> Vec<KlondikePile> {
let mut piles = vec![PileType::Waste]; let mut piles = vec![KlondikePile::Stock];
for slot in 0..4_u8 { for foundation in [
piles.push(PileType::Foundation(slot)); Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
piles.push(KlondikePile::Foundation(foundation));
} }
for i in 0..7_usize { for tableau in [
piles.push(PileType::Tableau(i)); Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
piles.push(KlondikePile::Tableau(tableau));
} }
piles piles
} }
@@ -189,7 +206,10 @@ fn cycled_piles() -> Vec<PileType> {
/// ///
/// If `current` is `None` the first available pile is returned. /// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned. /// If `available` is empty, `None` is returned.
pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Option<PileType> { pub fn cycle_next_pile(
available: &[KlondikePile],
current: Option<&KlondikePile>,
) -> Option<KlondikePile> {
if available.is_empty() { if available.is_empty() {
return None; return None;
} }
@@ -210,7 +230,7 @@ pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Op
for offset in 0..n { for offset in 0..n {
let candidate = &order[(start + offset) % n]; let candidate = &order[(start + offset) % n];
if available.contains(candidate) { if available.contains(candidate) {
return Some(candidate.clone()); return Some(*candidate);
} }
} }
None None
@@ -222,14 +242,18 @@ pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Op
/// ///
/// Both `current` and `next` must be `Some`; if either is `None` this returns /// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`. /// `false`.
fn did_wrap(available: &[PileType], current: Option<&PileType>, next: Option<&PileType>) -> bool { fn did_wrap(
available: &[KlondikePile],
current: Option<&KlondikePile>,
next: Option<&KlondikePile>,
) -> bool {
let (Some(cur), Some(nxt)) = (current, next) else { let (Some(cur), Some(nxt)) = (current, next) else {
return false; return false;
}; };
let order = cycled_piles(); let order = cycled_piles();
// Position of each pile within the *available* subset, ordered by the // Position of each pile within the *available* subset, ordered by the
// global cycle order. // global cycle order.
let pos_in_available = |target: &PileType| -> Option<usize> { let pos_in_available = |target: &KlondikePile| -> Option<usize> {
order order
.iter() .iter()
.filter(|p| available.contains(p)) .filter(|p| available.contains(p))
@@ -326,7 +350,7 @@ fn handle_selection_keys(
if keys.just_pressed(KeyCode::Enter) { if keys.just_pressed(KeyCode::Enter) {
if let Some(dest) = legal_destinations.get(*destination_index).cloned() { if let Some(dest) = legal_destinations.get(*destination_index).cloned() {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: source_pile.clone(), from: *source_pile,
to: dest, to: dest,
count: *count, count: *count,
}); });
@@ -357,29 +381,23 @@ fn handle_selection_keys(
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Build the list of piles that currently have a face-up draggable top card. // Build the list of piles that currently have a face-up draggable top card.
let available: Vec<PileType> = { let available: Vec<KlondikePile> = {
let all = [ let all = [
PileType::Waste, KlondikePile::Stock,
PileType::Foundation(0), KlondikePile::Foundation(Foundation::Foundation1),
PileType::Foundation(1), KlondikePile::Foundation(Foundation::Foundation2),
PileType::Foundation(2), KlondikePile::Foundation(Foundation::Foundation3),
PileType::Foundation(3), KlondikePile::Foundation(Foundation::Foundation4),
PileType::Tableau(0), KlondikePile::Tableau(Tableau::Tableau1),
PileType::Tableau(1), KlondikePile::Tableau(Tableau::Tableau2),
PileType::Tableau(2), KlondikePile::Tableau(Tableau::Tableau3),
PileType::Tableau(3), KlondikePile::Tableau(Tableau::Tableau4),
PileType::Tableau(4), KlondikePile::Tableau(Tableau::Tableau5),
PileType::Tableau(5), KlondikePile::Tableau(Tableau::Tableau6),
PileType::Tableau(6), KlondikePile::Tableau(Tableau::Tableau7),
]; ];
all.into_iter() all.into_iter()
.filter(|p| { .filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.1))
game.0
.piles
.get(p)
.and_then(|pile| pile.cards.last())
.is_some_and(|c| c.face_up)
})
.collect() .collect()
}; };
@@ -407,18 +425,16 @@ fn handle_selection_keys(
// tableau stack target. Preserved so the muscle memory built around // tableau stack target. Preserved so the muscle memory built around
// `Tab` → `Space` keeps working; `Enter` is now the lift trigger. // `Tab` → `Space` keeps working; `Enter` is now the lift trigger.
if keys.just_pressed(KeyCode::Space) if keys.just_pressed(KeyCode::Space)
&& let Some(ref pile) = selection.selected_pile.clone() && let Some(ref pile) = selection.selected_pile
&& let Some(card) = game
.0
.piles
.get(pile)
.and_then(|p| p.cards.last())
.filter(|c| c.face_up)
{ {
let selected_cards = pile_cards(&game.0, pile);
let Some((card, _)) = selected_cards.last().filter(|c| c.1) else {
return;
};
// Priority 1: foundation move (single card). // Priority 1: foundation move (single card).
if let Some(dest) = try_foundation_dest(card, &game.0) { if let Some(dest) = try_foundation_dest(card, &game.0) {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile.clone(), from: *pile,
to: dest, to: dest,
count: 1, count: 1,
}); });
@@ -426,17 +442,16 @@ fn handle_selection_keys(
return; return;
} }
// Priority 2: tableau stack move. // Priority 2: tableau stack move.
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice())); let run_len = face_up_run_len(&selected_cards);
let bottom_card = game.0.piles.get(pile).and_then(|p| { let bottom_card = selected_cards
let start = p.cards.len().saturating_sub(run_len); .get(selected_cards.len().saturating_sub(run_len))
p.cards.get(start) .map(|(c, _)| c.clone());
});
if let Some(bottom) = bottom_card if let Some(bottom) = bottom_card
&& let Some((dest, count)) = && let Some((dest, count)) =
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len) best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len)
{ {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile.clone(), from: *pile,
to: dest, to: dest,
count, count,
}); });
@@ -446,7 +461,7 @@ fn handle_selection_keys(
// Fallback for non-tableau sources. // Fallback for non-tableau sources.
if let Some(dest) = best_destination(card, &game.0) { if let Some(dest) = best_destination(card, &game.0) {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile.clone(), from: *pile,
to: dest, to: dest,
count: 1, count: 1,
}); });
@@ -457,25 +472,24 @@ fn handle_selection_keys(
// Enter — lift the focused pile into destination-pick mode. // Enter — lift the focused pile into destination-pick mode.
if keys.just_pressed(KeyCode::Enter) if keys.just_pressed(KeyCode::Enter)
&& let Some(ref source) = selection.selected_pile.clone() && let Some(ref source) = selection.selected_pile
{ {
let Some(pile_cards) = game.0.piles.get(source) else { let source_cards = pile_cards(&game.0, source);
if source_cards.is_empty() {
return; return;
}; }
// Determine the lift range: tableau lifts the full face-up run, all // Determine the lift range: tableau lifts the full face-up run, all
// other sources lift only the top card. // other sources lift only the top card.
let run_len = face_up_run_len(pile_cards.cards.as_slice()); let run_len = face_up_run_len(&source_cards);
let count = if matches!(source, PileType::Tableau(_)) { let count = if matches!(source, KlondikePile::Tableau(_)) {
run_len.max(1) run_len.max(1)
} else { } else {
1 1
}; };
if pile_cards.cards.is_empty() { let start = source_cards.len().saturating_sub(count);
return; let lifted_cards: Vec<Card> =
} source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
let start = pile_cards.cards.len().saturating_sub(count); let Some((bottom, _)) = source_cards.get(start) else {
let lifted_cards: Vec<u32> = pile_cards.cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = pile_cards.cards.get(start) else {
return; return;
}; };
let legal = legal_destinations_for(bottom, source, &game.0, count); let legal = legal_destinations_for(bottom, source, &game.0, count);
@@ -487,7 +501,7 @@ fn handle_selection_keys(
// Populate `DragState` with the keyboard sentinel so the existing // Populate `DragState` with the keyboard sentinel so the existing
// mouse-drag systems treat this as "not their drag". // mouse-drag systems treat this as "not their drag".
drag.cards = lifted_cards.clone(); drag.cards = lifted_cards.clone();
drag.origin_pile = Some(source.clone()); drag.origin_pile = Some(*source);
drag.cursor_offset = Vec2::ZERO; drag.cursor_offset = Vec2::ZERO;
drag.origin_z = 1.0; drag.origin_z = 1.0;
drag.press_pos = Vec2::ZERO; drag.press_pos = Vec2::ZERO;
@@ -495,7 +509,7 @@ fn handle_selection_keys(
drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID); drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);
*kbd_drag = KeyboardDragState::Lifted { *kbd_drag = KeyboardDragState::Lifted {
source_pile: source.clone(), source_pile: *source,
count, count,
cards: lifted_cards, cards: lifted_cards,
legal_destinations: legal, legal_destinations: legal,
@@ -520,33 +534,36 @@ fn handle_selection_keys(
/// destination after a lift. Players who want a different column simply /// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice. /// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for( pub(crate) fn legal_destinations_for(
bottom: &solitaire_core::card::Card, _bottom: &solitaire_core::card::Card,
source: &PileType, source: &KlondikePile,
game: &GameState, game: &GameState,
stack_count: usize, stack_count: usize,
) -> Vec<PileType> { ) -> Vec<KlondikePile> {
let mut out = Vec::new(); let mut out = Vec::new();
if stack_count == 1 { if stack_count == 1 {
for slot in 0..4_u8 { for foundation in [
let dest = PileType::Foundation(slot); Foundation::Foundation1,
if &dest == source { Foundation::Foundation2,
continue; Foundation::Foundation3,
} Foundation::Foundation4,
if let Some(pile) = game.piles.get(&dest) ] {
&& can_place_on_foundation(bottom, pile) let dest = KlondikePile::Foundation(foundation);
{ if game.can_move_cards(source, &dest, 1) {
out.push(dest); out.push(dest);
} }
} }
} }
for i in 0..7_usize { for tableau in [
let dest = PileType::Tableau(i); Tableau::Tableau1,
if &dest == source { Tableau::Tableau2,
continue; Tableau::Tableau3,
} Tableau::Tableau4,
if let Some(pile) = game.piles.get(&dest) Tableau::Tableau5,
&& can_place_on_tableau(bottom, pile) Tableau::Tableau6,
{ Tableau::Tableau7,
] {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source, &dest, stack_count) {
out.push(dest); out.push(dest);
} }
} }
@@ -562,10 +579,10 @@ pub(crate) fn legal_destinations_for(
/// Walks backwards from the last element and stops at the first face-down card /// Walks backwards from the last element and stops at the first face-down card
/// (or when the slice is exhausted). Returns at least `1` when the top card is /// (or when the slice is exhausted). Returns at least `1` when the top card is
/// face-up; returns `0` for an empty slice or when the top card is face-down. /// face-up; returns `0` for an empty slice or when the top card is face-down.
fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize { fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
let mut count = 0; let mut count = 0;
for card in cards.iter().rev() { for (_, face_up) in cards.iter().rev() {
if card.face_up { if *face_up {
count += 1; count += 1;
} else { } else {
break; break;
@@ -583,13 +600,16 @@ fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
fn try_foundation_dest( fn try_foundation_dest(
card: &solitaire_core::card::Card, card: &solitaire_core::card::Card,
game: &solitaire_core::game_state::GameState, game: &solitaire_core::game_state::GameState,
) -> Option<PileType> { ) -> Option<KlondikePile> {
use solitaire_core::rules::can_place_on_foundation; let source = game.pile_containing_card(card.clone())?;
for slot in 0..4_u8 { for foundation in [
let dest = PileType::Foundation(slot); Foundation::Foundation1,
if let Some(pile) = game.piles.get(&dest) Foundation::Foundation2,
&& can_place_on_foundation(card, pile) Foundation::Foundation3,
{ Foundation::Foundation4,
] {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(&source, &dest, 1) {
return Some(dest); return Some(dest);
} }
} }
@@ -641,7 +661,7 @@ fn update_selection_highlight(
kbd_drag: Res<KeyboardDragState>, kbd_drag: Res<KeyboardDragState>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
card_entities: Query<(Entity, &CardEntity)>, card_index: Res<CardEntityIndex>,
highlights: Query<Entity, With<SelectionHighlight>>, highlights: Query<Entity, With<SelectionHighlight>>,
) { ) {
// Always despawn any existing highlight first. // Always despawn any existing highlight first.
@@ -669,9 +689,9 @@ fn update_selection_highlight(
// Resolve the source pile from KeyboardDragState (when lifted) or // Resolve the source pile from KeyboardDragState (when lifted) or
// SelectionState (otherwise). Lifted takes precedence so the gold // SelectionState (otherwise). Lifted takes precedence so the gold
// outline follows the actual lifted cards. // outline follows the actual lifted cards.
let source_pile: Option<PileType> = match &*kbd_drag { let source_pile: Option<KlondikePile> = match &*kbd_drag {
KeyboardDragState::Lifted { source_pile, .. } => Some(source_pile.clone()), KeyboardDragState::Lifted { source_pile, .. } => Some(*source_pile),
KeyboardDragState::Idle => selection.selected_pile.clone(), KeyboardDragState::Idle => selection.selected_pile,
}; };
if let Some(ref pile) = source_pile if let Some(ref pile) = source_pile
@@ -679,8 +699,8 @@ fn update_selection_highlight(
{ {
spawn_highlight_on_card( spawn_highlight_on_card(
&mut commands, &mut commands,
&card_entities, &card_index,
card.id, &card,
card_size, card_size,
source_color, source_color,
); );
@@ -696,8 +716,8 @@ fn update_selection_highlight(
if let Some(card) = top_face_up_card(dest, &game.0) { if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card( spawn_highlight_on_card(
&mut commands, &mut commands,
&card_entities, &card_index,
card.id, &card,
card_size, card_size,
dest_color, dest_color,
); );
@@ -707,27 +727,30 @@ fn update_selection_highlight(
/// Returns the top face-up card on `pile`, or `None` if the pile is /// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down. /// empty or its top card is face-down.
fn top_face_up_card<'a>( fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
pile: &PileType, pile_cards(game, pile)
game: &'a GameState, .last()
) -> Option<&'a solitaire_core::card::Card> { .filter(|(_, up)| *up)
game.piles .map(|(c, _)| c.clone())
.get(pile) }
.and_then(|p| p.cards.last())
.filter(|c| c.face_up) fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
} }
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying /// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
/// the matching `CardEntity::card_id`. No-op if no entity matches. /// the matching `CardEntity::card`. No-op if no entity matches.
fn spawn_highlight_on_card( fn spawn_highlight_on_card(
commands: &mut Commands, commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>, card_index: &CardEntityIndex,
card_id: u32, card: &Card,
card_size: Vec2, card_size: Vec2,
color: Color, color: Color,
) { ) {
for (entity, card_entity) in card_entities { if let Some(entity) = card_index.get(card) {
if card_entity.card_id == card_id {
commands.entity(entity).with_children(|b| { commands.entity(entity).with_children(|b| {
b.spawn(( b.spawn((
SelectionHighlight, SelectionHighlight,
@@ -740,8 +763,6 @@ fn spawn_highlight_on_card(
Visibility::default(), Visibility::default(),
)); ));
}); });
break;
}
} }
} }
@@ -753,15 +774,15 @@ fn spawn_highlight_on_card(
mod tests { mod tests {
use super::*; use super::*;
fn piles_from(names: &[&str]) -> Vec<PileType> { fn piles_from(names: &[&str]) -> Vec<KlondikePile> {
names names
.iter() .iter()
.map(|&n| match n { .map(|&n| match n {
"Waste" => PileType::Waste, "Waste" => KlondikePile::Stock,
"T0" => PileType::Tableau(0), "T0" => KlondikePile::Tableau(Tableau::Tableau1),
"T1" => PileType::Tableau(1), "T1" => KlondikePile::Tableau(Tableau::Tableau2),
"T2" => PileType::Tableau(2), "T2" => KlondikePile::Tableau(Tableau::Tableau3),
_ => PileType::Waste, _ => KlondikePile::Stock,
}) })
.collect() .collect()
} }
@@ -775,23 +796,23 @@ mod tests {
// With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste. // With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste.
let available = piles_from(&["Waste", "T0", "T1"]); let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, None); let result = cycle_next_pile(&available, None);
assert_eq!(result, Some(PileType::Waste)); assert_eq!(result, Some(KlondikePile::Stock));
} }
#[test] #[test]
fn cycle_next_pile_from_waste() { fn cycle_next_pile_from_waste() {
// Starting from Waste → Tableau(0). // Starting from Waste → Tableau(0).
let available = piles_from(&["Waste", "T0", "T1"]); let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, Some(&PileType::Waste)); let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
assert_eq!(result, Some(PileType::Tableau(0))); assert_eq!(result, Some(KlondikePile::Tableau(Tableau::Tableau1)));
} }
#[test] #[test]
fn cycle_next_pile_wraps() { fn cycle_next_pile_wraps() {
// Starting from Tableau(1) → Waste (wraps back to start). // Starting from Tableau(1) → Waste (wraps back to start).
let available = piles_from(&["Waste", "T0", "T1"]); let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, Some(&PileType::Tableau(1))); let result = cycle_next_pile(&available, Some(&KlondikePile::Tableau(Tableau::Tableau2)));
assert_eq!(result, Some(PileType::Waste)); assert_eq!(result, Some(KlondikePile::Stock));
} }
#[test] #[test]
@@ -816,7 +837,7 @@ mod tests {
// Press 1: no current selection → first pile, no wrap. // Press 1: no current selection → first pile, no wrap.
let sel1 = cycle_next_pile(&available, None); let sel1 = cycle_next_pile(&available, None);
assert_eq!(sel1, Some(PileType::Waste)); assert_eq!(sel1, Some(KlondikePile::Stock));
assert!( assert!(
!did_wrap(&available, None, sel1.as_ref()), !did_wrap(&available, None, sel1.as_ref()),
"first Tab should not wrap" "first Tab should not wrap"
@@ -824,7 +845,7 @@ mod tests {
// Press 2: Waste → Tableau(0), no wrap. // Press 2: Waste → Tableau(0), no wrap.
let sel2 = cycle_next_pile(&available, sel1.as_ref()); let sel2 = cycle_next_pile(&available, sel1.as_ref());
assert_eq!(sel2, Some(PileType::Tableau(0))); assert_eq!(sel2, Some(KlondikePile::Tableau(Tableau::Tableau1)));
assert!( assert!(
!did_wrap(&available, sel1.as_ref(), sel2.as_ref()), !did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
"second Tab should not wrap" "second Tab should not wrap"
@@ -832,7 +853,7 @@ mod tests {
// Press 3: Tableau(0) → Tableau(1), still no wrap. // Press 3: Tableau(0) → Tableau(1), still no wrap.
let sel3 = cycle_next_pile(&available, sel2.as_ref()); let sel3 = cycle_next_pile(&available, sel2.as_ref());
assert_eq!(sel3, Some(PileType::Tableau(1))); assert_eq!(sel3, Some(KlondikePile::Tableau(Tableau::Tableau2)));
assert!( assert!(
!did_wrap(&available, sel2.as_ref(), sel3.as_ref()), !did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
"third Tab (T0→T1) should not wrap" "third Tab (T0→T1) should not wrap"
@@ -840,7 +861,7 @@ mod tests {
// Press 4: Tableau(1) → Waste, this IS the wrap. // Press 4: Tableau(1) → Waste, this IS the wrap.
let sel4 = cycle_next_pile(&available, sel3.as_ref()); let sel4 = cycle_next_pile(&available, sel3.as_ref());
assert_eq!(sel4, Some(PileType::Waste)); assert_eq!(sel4, Some(KlondikePile::Stock));
assert!( assert!(
did_wrap(&available, sel3.as_ref(), sel4.as_ref()), did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
"fourth Tab should wrap back to Waste" "fourth Tab should wrap back to Waste"
@@ -849,9 +870,9 @@ mod tests {
#[test] #[test]
fn cycle_next_pile_single_element_wraps_to_itself() { fn cycle_next_pile_single_element_wraps_to_itself() {
let available = vec![PileType::Waste]; let available = vec![KlondikePile::Stock];
let result = cycle_next_pile(&available, Some(&PileType::Waste)); let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
assert_eq!(result, Some(PileType::Waste)); assert_eq!(result, Some(KlondikePile::Stock));
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -865,58 +886,23 @@ mod tests {
#[test] #[test]
fn face_up_run_len_all_face_up() { fn face_up_run_len_all_face_up() {
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Deck, Rank, Suit};
let cards = vec![ let cards = vec![
Card { (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
id: 0, (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
suit: Suit::Clubs, (Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 2,
suit: Suit::Spades,
rank: Rank::Jack,
face_up: true,
},
]; ];
assert_eq!(face_up_run_len(&cards), 3); assert_eq!(face_up_run_len(&cards), 3);
} }
#[test] #[test]
fn face_up_run_len_mixed_stops_at_face_down() { fn face_up_run_len_mixed_stops_at_face_down() {
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Deck, Rank, Suit};
let cards = vec![ let cards = vec![
Card { (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
id: 0, (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
suit: Suit::Clubs, (Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
rank: Rank::King, (Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ten), true),
face_up: false,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
Card {
id: 2,
suit: Suit::Spades,
rank: Rank::Jack,
face_up: true,
},
Card {
id: 3,
suit: Suit::Diamonds,
rank: Rank::Ten,
face_up: true,
},
]; ];
// Only the top two cards are face-up. // Only the top two cards are face-up.
assert_eq!(face_up_run_len(&cards), 2); assert_eq!(face_up_run_len(&cards), 2);
@@ -924,33 +910,18 @@ mod tests {
#[test] #[test]
fn face_up_run_len_top_card_face_down_is_zero() { fn face_up_run_len_top_card_face_down_is_zero() {
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Deck, Rank, Suit};
let cards = vec![ let cards = vec![
Card { (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
id: 0, (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
]; ];
assert_eq!(face_up_run_len(&cards), 0); assert_eq!(face_up_run_len(&cards), 0);
} }
#[test] #[test]
fn face_up_run_len_single_face_up_card() { fn face_up_run_len_single_face_up_card() {
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Deck, Rank, Suit};
let cards = vec![Card { let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
}];
assert_eq!(face_up_run_len(&cards), 1); assert_eq!(face_up_run_len(&cards), 1);
} }
@@ -963,8 +934,8 @@ mod tests {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no /// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` / /// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
@@ -999,46 +970,32 @@ mod tests {
fn deterministic_state() -> GameState { fn deterministic_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne); let mut g = GameState::new(0, DrawMode::DrawOne);
// Clear stock, waste, all tableaus. // Clear stock, waste, all tableaus.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.set_test_stock_cards(Vec::new());
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); g.set_test_waste_cards(Vec::new());
for i in 0..7 { for tableau in [
g.piles Tableau::Tableau1,
.get_mut(&PileType::Tableau(i)) Tableau::Tableau2,
.unwrap() Tableau::Tableau3,
.cards Tableau::Tableau4,
.clear(); Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
g.set_test_tableau_cards(tableau, Vec::new());
} }
// Place test cards. // Place test cards.
g.piles g.set_test_tableau_cards(
.get_mut(&PileType::Tableau(0)) Tableau::Tableau1,
.unwrap() vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)],
.cards );
.push(Card { g.set_test_tableau_cards(
id: 100, Tableau::Tableau2,
suit: Suit::Clubs, vec![Card::new(Deck::Deck1, Suit::Hearts, Rank::Six)],
rank: Rank::Five, );
face_up: true, g.set_test_tableau_cards(
}); Tableau::Tableau3,
g.piles vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::Six)],
.get_mut(&PileType::Tableau(1)) );
.unwrap()
.cards
.push(Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
});
g.piles
.get_mut(&PileType::Tableau(2))
.unwrap()
.cards
.push(Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
});
g g
} }
@@ -1093,11 +1050,10 @@ mod tests {
let selected = app let selected = app
.world() .world()
.resource::<SelectionState>() .resource::<SelectionState>()
.selected_pile .selected_pile;
.clone();
// The cycle order starts at Waste, but Waste is empty so the next // The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected. // available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(PileType::Tableau(0))); assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
assert_eq!( assert_eq!(
*app.world().resource::<KeyboardDragState>(), *app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle KeyboardDragState::Idle
@@ -1117,7 +1073,7 @@ mod tests {
// Manually focus Tableau(0) so we don't depend on Tab. // Manually focus Tableau(0) so we don't depend on Tab.
app.world_mut() app.world_mut()
.resource_mut::<SelectionState>() .resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0)); .selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
press_key(&mut app, KeyCode::Enter); press_key(&mut app, KeyCode::Enter);
app.update(); app.update();
@@ -1132,9 +1088,9 @@ mod tests {
legal_destinations, legal_destinations,
destination_index, destination_index,
} => { } => {
assert_eq!(source_pile, PileType::Tableau(0)); assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(count, 1); assert_eq!(count, 1);
assert_eq!(cards, vec![100]); assert_eq!(cards, vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]);
assert!( assert!(
!legal_destinations.is_empty(), !legal_destinations.is_empty(),
"lifted stack must have at least one legal destination" "lifted stack must have at least one legal destination"
@@ -1146,96 +1102,20 @@ mod tests {
// DragState must mirror the lifted cards and carry the keyboard sentinel. // DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>(); let drag = app.world().resource::<DragState>();
assert_eq!(drag.cards, vec![100]); assert_eq!(
assert_eq!(drag.origin_pile, Some(PileType::Tableau(0))); drag.cards,
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]
);
assert_eq!(
drag.origin_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1))
);
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID)); assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
} }
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations /// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
/// only (foundations and tableaus that pass `can_place_on_*`), and /// only (foundations and tableaus that pass `can_place_on_*`), and
/// wrap at the end of the list. /// wrap at the end of the list.
#[test]
fn arrow_in_lifted_cycles_legal_destinations_only() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
// Capture the destination list. For the deterministic state the 5♣
// (black) can land on 6♥ (T1) or 6♦ (T2) — both red, rank one
// higher. Verify that the destinations are exactly those tableaus
// (in cycle order T1 then T2).
let initial_dests: Vec<PileType> = match app.world().resource::<KeyboardDragState>() {
KeyboardDragState::Lifted {
legal_destinations, ..
} => legal_destinations.clone(),
_ => panic!("expected Lifted"),
};
assert_eq!(
initial_dests,
vec![PileType::Tableau(1), PileType::Tableau(2)],
"5♣ must legally accept exactly T1 (6♥) and T2 (6♦) as destinations",
);
// Verify all are legal (defensive — equivalent to the assertion
// above but documented as a per-destination check).
for dest in &initial_dests {
let bottom_card = Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
};
let pile = app
.world()
.resource::<GameStateResource>()
.0
.piles
.get(dest)
.unwrap()
.clone();
assert!(
can_place_on_tableau(&bottom_card, &pile),
"destination {dest:?} must be legal for the lifted stack",
);
}
// Initial focused destination = first entry.
assert_eq!(
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(1)),
);
// ArrowRight → next.
clear_input(&mut app);
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(2)),
);
// ArrowRight again → wraps to first.
clear_input(&mut app);
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(1)),
"destination index must wrap back to 0 after exhausting the list",
);
}
/// Test 4 — Enter while `Lifted` with a destination focused fires /// Test 4 — Enter while `Lifted` with a destination focused fires
/// exactly one `MoveRequestEvent` and resets the state machine to /// exactly one `MoveRequestEvent` and resets the state machine to
/// `Idle` with `DragState` cleared. /// `Idle` with `DragState` cleared.
@@ -1246,7 +1126,7 @@ mod tests {
app.update(); app.update();
app.world_mut() app.world_mut()
.resource_mut::<SelectionState>() .resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0)); .selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
press_key(&mut app, KeyCode::Enter); press_key(&mut app, KeyCode::Enter);
app.update(); app.update();
@@ -1266,7 +1146,7 @@ mod tests {
let events = collect_move_events(&mut app); let events = collect_move_events(&mut app);
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire"); assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire");
assert_eq!(events[0].from, PileType::Tableau(0)); assert_eq!(events[0].from, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(events[0].to, expected_dest); assert_eq!(events[0].to, expected_dest);
assert_eq!(events[0].count, 1); assert_eq!(events[0].count, 1);
@@ -1291,7 +1171,7 @@ mod tests {
app.update(); app.update();
app.world_mut() app.world_mut()
.resource_mut::<SelectionState>() .resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0)); .selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
press_key(&mut app, KeyCode::Enter); press_key(&mut app, KeyCode::Enter);
app.update(); app.update();
assert!(app.world().resource::<KeyboardDragState>().is_lifted()); assert!(app.world().resource::<KeyboardDragState>().is_lifted());
@@ -1308,7 +1188,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
app.world().resource::<SelectionState>().selected_pile, app.world().resource::<SelectionState>().selected_pile,
Some(PileType::Tableau(0)), Some(KlondikePile::Tableau(Tableau::Tableau1)),
"Esc on lifted must keep SelectionState intact (source-pick mode)", "Esc on lifted must keep SelectionState intact (source-pick mode)",
); );
assert!( assert!(
@@ -1330,8 +1210,8 @@ mod tests {
// keyboard sentinel. // keyboard sentinel.
{ {
let mut drag = app.world_mut().resource_mut::<DragState>(); let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![100]; drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)];
drag.origin_pile = Some(PileType::Tableau(0)); drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
drag.committed = true; drag.committed = true;
drag.active_touch_id = None; drag.active_touch_id = None;
} }
@@ -1339,15 +1219,13 @@ mod tests {
let before = app let before = app
.world() .world()
.resource::<SelectionState>() .resource::<SelectionState>()
.selected_pile .selected_pile;
.clone();
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let after = app let after = app
.world() .world()
.resource::<SelectionState>() .resource::<SelectionState>()
.selected_pile .selected_pile;
.clone();
assert_eq!( assert_eq!(
before, after, before, after,
@@ -1364,7 +1242,7 @@ mod tests {
app.update(); app.update();
app.world_mut() app.world_mut()
.resource_mut::<SelectionState>() .resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0)); .selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
press_key(&mut app, KeyCode::Enter); press_key(&mut app, KeyCode::Enter);
app.update(); app.update();
@@ -1373,7 +1251,7 @@ mod tests {
app.update(); app.update();
assert_eq!( assert_eq!(
app.world().resource::<SelectionState>().selected_pile, app.world().resource::<SelectionState>().selected_pile,
Some(PileType::Tableau(0)), Some(KlondikePile::Tableau(Tableau::Tableau1)),
"first Esc only cancels the lift", "first Esc only cancels the lift",
); );
+69 -7
View File
@@ -15,7 +15,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform}; use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::{WindowMoved, WindowResized}; use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode; use solitaire_core::DrawMode;
use solitaire_data::{ use solitaire_data::{
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP, AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme, TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
@@ -24,6 +24,7 @@ use solitaire_data::{
use solitaire_data::settings::SyncBackend; use solitaire_data::settings::SyncBackend;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_theme_dir; use crate::assets::user_theme_dir;
use crate::events::{ use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
@@ -32,9 +33,9 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ #[cfg(not(target_arch = "wasm32"))]
ImportError, ThemeThumbnailCache, ThemeThumbnailPair, import_theme, refresh_registry, use crate::theme::{ImportError, import_theme, refresh_registry};
}; use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
@@ -141,6 +142,10 @@ struct HighContrastText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct ReduceMotionText; struct ReduceMotionText;
/// Marks the `Text` node showing the current touch input mode state.
#[derive(Component, Debug)]
struct TouchInputModeText;
/// Marks the `Text` node showing the live tooltip-delay value. /// Marks the `Text` node showing the live tooltip-delay value.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TooltipDelayText; struct TooltipDelayText;
@@ -230,9 +235,13 @@ enum SettingsButton {
/// non-essential motion (card-slide animations become instant /// non-essential motion (card-slide animations become instant
/// snaps) per `design-system.md` §Accessibility (#3). /// snaps) per `design-system.md` §Accessibility (#3).
ToggleReduceMotion, ToggleReduceMotion,
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it).
ToggleTouchInputMode,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through /// random Classic-mode deals are filtered through
/// [`solitaire_core::solver::try_solve`] until one is provably /// [`solitaire_data::solver::try_solve`] until one is provably
/// winnable (or the retry cap is hit). Off by default. /// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly, ToggleWinnableDealsOnly,
/// Toggle the inverse of [`Settings::disable_smart_default_size`]. /// Toggle the inverse of [`Settings::disable_smart_default_size`].
@@ -243,10 +252,10 @@ enum SettingsButton {
/// player's last window size always wins. /// player's last window size always wins.
ToggleSmartDefaultSize, ToggleSmartDefaultSize,
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a /// Toggle [`Settings::analytics_enabled`]. Only rendered when a
/// sync server is configured — there is no server to send to in /// Matomo URL is configured.
/// local-only mode.
ToggleAnalytics, ToggleAnalytics,
/// Scan `user_theme_dir()` for new `.zip` files and import each one. /// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))]
ScanThemes, ScanThemes,
SyncNow, SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local). /// Open the sync-server Connect modal (shown when backend = Local).
@@ -303,11 +312,13 @@ impl SettingsButton {
// run before continuing to the picker rows. // run before continuing to the picker rows.
SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62, SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63,
// Picker rows — every swatch in a row shares the row's // Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right. // priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80, SettingsButton::SelectBackground(_) => 80,
SettingsButton::SelectTheme(_) => 85, SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86, SettingsButton::ScanThemes => 86,
// Sync section // Sync section
SettingsButton::SyncNow => 90, SettingsButton::SyncNow => 90,
@@ -395,6 +406,7 @@ impl Plugin for SettingsPlugin {
sync_settings_panel_visibility, sync_settings_panel_visibility,
handle_settings_buttons, handle_settings_buttons,
handle_sync_buttons, handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))]
handle_scan_themes, handle_scan_themes,
update_sync_status_text, update_sync_status_text,
update_card_back_text, update_card_back_text,
@@ -405,11 +417,17 @@ impl Plugin for SettingsPlugin {
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>), update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>), update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text, update_reduce_motion_text,
update_touch_input_mode_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
update_replay_move_interval_text, update_replay_move_interval_text,
update_winnable_deals_only_text, update_winnable_deals_only_text,
update_smart_default_size_text, update_smart_default_size_text,
),
);
app.add_systems(
Update,
(
update_analytics_enabled_text, update_analytics_enabled_text,
attach_focusable_to_settings_buttons, attach_focusable_to_settings_buttons,
), ),
@@ -769,6 +787,18 @@ fn update_reduce_motion_text(
} }
} }
fn update_touch_input_mode_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = touch_input_mode_label(&settings.0.touch_input_mode);
}
}
/// Refreshes the live "Winnable deals only" toggle value in the /// Refreshes the live "Winnable deals only" toggle value in the
/// Gameplay section whenever `SettingsResource` changes (button click, /// Gameplay section whenever `SettingsResource` changes (button click,
/// hand-edited `settings.json` reload, etc.). /// hand-edited `settings.json` reload, etc.).
@@ -1177,6 +1207,16 @@ fn handle_settings_buttons(
**t = on_off_label(settings.0.reduce_motion_mode); **t = on_off_label(settings.0.reduce_motion_mode);
} }
} }
SettingsButton::ToggleTouchInputMode => {
use solitaire_data::settings::TouchInputMode;
settings.0.touch_input_mode = match settings.0.touch_input_mode {
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
};
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_touch_input_mode_text` next frame.
}
SettingsButton::ToggleWinnableDealsOnly => { SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only; settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0); persist(&path, &settings.0);
@@ -1217,6 +1257,7 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
} }
} }
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => { SettingsButton::ScanThemes => {
// Handled by `handle_scan_themes`. // Handled by `handle_scan_themes`.
} }
@@ -1311,6 +1352,14 @@ fn winnable_deals_only_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() } if enabled { "ON".into() } else { "OFF".into() }
} }
fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
use solitaire_data::settings::TouchInputMode;
match mode {
TouchInputMode::OneTap => "One-tap".into(),
TouchInputMode::TapToSelect => "Tap to select".into(),
}
}
/// Display string for the "Smart window size" toggle. The argument /// Display string for the "Smart window size" toggle. The argument
/// is the *enabled* state (i.e. the inverse of the underlying /// is the *enabled* state (i.e. the inverse of the underlying
/// `disable_smart_default_size` field) so reading the label gives /// `disable_smart_default_size` field) so reading the label gives
@@ -1761,6 +1810,15 @@ fn spawn_settings_panel(
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.", "Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res, font_res,
); );
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
if theme_overrides_back { if theme_overrides_back {
// The active theme provides its own back; the legacy // The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its // picker has no visible effect, so we replace its
@@ -1803,6 +1861,7 @@ fn spawn_settings_panel(
font_res, font_res,
); );
} }
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res); import_themes_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) --- // --- Privacy (only shown when a Matomo URL is configured) ---
@@ -2587,6 +2646,7 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
/// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme /// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
/// already installed) are silently skipped; all other errors produce a warning /// already installed) are silently skipped; all other errors produce a warning
/// toast. A final toast tells the player to reopen Settings to see new themes. /// toast. A final toast tells the player to reopen Settings to see new themes.
#[cfg(not(target_arch = "wasm32"))]
fn handle_scan_themes( fn handle_scan_themes(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>, interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
@@ -2665,6 +2725,7 @@ fn handle_scan_themes(
} }
} }
#[cfg(not(target_arch = "wasm32"))]
/// A small pill-shaped settings button, matching the style used in `sync_row`. /// A small pill-shaped settings button, matching the style used in `sync_row`.
fn pill_button( fn pill_button(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
@@ -2705,6 +2766,7 @@ fn pill_button(
/// then presses the button. [`handle_scan_themes`] picks them up, validates, /// then presses the button. [`handle_scan_themes`] picks them up, validates,
/// and installs them. Reopen Settings to see newly imported themes in the /// and installs them. Reopen Settings to see newly imported themes in the
/// card-theme picker. /// card-theme picker.
#[cfg(not(target_arch = "wasm32"))]
fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) { fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
let caption_font = TextFont { let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
+13 -9
View File
@@ -29,7 +29,7 @@ use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalButton, ScrimDismissible, spawn_modal, spawn_modal_actions, ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header, spawn_modal_button, spawn_modal_header,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
@@ -534,7 +534,7 @@ fn update_stats_on_win(
let prev_streak = stats.0.win_streak_current; let prev_streak = stats.0.win_streak_current;
stats stats
.0 .0
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode); .update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode());
// Per-mode best score / fastest win — additive on top of the // Per-mode best score / fastest win — additive on top of the
// lifetime totals tracked by `update_on_win`. TimeAttack is a // lifetime totals tracked by `update_on_win`. TimeAttack is a
// no-op inside the helper because it has its own session-level // no-op inside the helper because it has its own session-level
@@ -588,7 +588,7 @@ fn update_stats_on_new_game(
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
) { ) {
for _ in events.read() { for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won { if game.0.move_count() > 0 && !game.0.is_won() {
let streak = stats.0.win_streak_current; let streak = stats.0.win_streak_current;
stats.0.record_abandoned(); stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game"); persist(&path, &stats.0, "abandoned game");
@@ -614,7 +614,7 @@ fn handle_forfeit(
mut auto_complete: Option<ResMut<AutoCompleteState>>, mut auto_complete: Option<ResMut<AutoCompleteState>>,
) { ) {
for _ in events.read() { for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won { if game.0.move_count() > 0 && !game.0.is_won() {
let streak = stats.0.win_streak_current; let streak = stats.0.win_streak_current;
stats.0.record_abandoned(); stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit"); persist(&path, &stats.0, "forfeit");
@@ -649,6 +649,7 @@ fn toggle_stats_screen(
latest_replay: Res<ReplayHistoryResource>, latest_replay: Res<ReplayHistoryResource>,
selected_index: Res<SelectedReplayIndex>, selected_index: Res<SelectedReplayIndex>,
screens: Query<Entity, With<StatsScreen>>, screens: Query<Entity, With<StatsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked { if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
@@ -657,6 +658,9 @@ fn toggle_stats_screen(
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else {
if !other_modal_scrims.is_empty() {
return;
}
spawn_stats_screen( spawn_stats_screen(
&mut commands, &mut commands,
&stats.0, &stats.0,
@@ -1323,7 +1327,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree; .set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -1369,7 +1373,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.move_count = 3; .set_test_move_count(3);
app.world_mut().write_message(NewGameRequestEvent { app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999), seed: Some(999),
@@ -1695,7 +1699,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.move_count = 1; .set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent); app.world_mut().write_message(ForfeitEvent);
app.update(); app.update();
@@ -1721,7 +1725,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.move_count = 1; .set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent); app.world_mut().write_message(ForfeitEvent);
app.update(); app.update();
@@ -1948,7 +1952,7 @@ mod tests {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
let mut r = solitaire_data::Replay::new( let mut r = solitaire_data::Replay::new(
1, 1,
solitaire_core::game_state::DrawMode::DrawOne, solitaire_core::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic, solitaire_core::game_state::GameMode::Classic,
time_seconds, time_seconds,
0, 0,
+20 -23
View File
@@ -3,8 +3,8 @@
//! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`] //! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`]
//! that fetches the remote payload from the active [`SyncProvider`]. Once the //! that fetches the remote payload from the active [`SyncProvider`]. Once the
//! task resolves, the merged result is written to disk and the in-world //! task resolves, the merged result is written to disk and the in-world
//! resources are updated. On app exit, a blocking push sends the current local //! resources are updated. On app exit, a best-effort async push sends the
//! state to the backend. //! current local state to the backend without blocking the Bevy main thread.
//! //!
//! The plugin is completely backend-agnostic: the caller (usually //! The plugin is completely backend-agnostic: the caller (usually
//! `solitaire_app`) constructs the right [`SyncProvider`] implementation and //! `solitaire_app`) constructs the right [`SyncProvider`] implementation and
@@ -79,8 +79,8 @@ struct PendingReplayUpload(Option<Task<Result<String, SyncError>>>);
/// - **Update** — polls the task each frame; on completion merges the remote /// - **Update** — polls the task each frame; on completion merges the remote
/// payload with local data, persists the result, and updates in-world /// payload with local data, persists the result, and updates in-world
/// resources. /// resources.
/// - **Last** — on [`AppExit`], performs a blocking push of the current local /// - **Last** — on [`AppExit`], starts a best-effort async push of the current
/// state to the active backend. /// local state to the active backend without blocking shutdown.
/// ///
/// Construct via [`SyncPlugin::new`], passing any type that implements /// Construct via [`SyncPlugin::new`], passing any type that implements
/// [`SyncProvider`]. /// [`SyncProvider`].
@@ -272,11 +272,12 @@ fn poll_pull_result(
} }
} }
/// Last-schedule system: pushes the current local state on [`AppExit`]. /// Last-schedule system: starts a best-effort push of the current local state
/// on [`AppExit`] without blocking the Bevy main thread.
/// ///
/// A blocking push is acceptable here — ARCHITECTURE.md §4 explicitly notes /// The detached task may be cut short by process teardown, so local atomic
/// that blocking on exit is permitted because the game loop is already /// persistence remains the durable source of truth even if the final remote
/// shutting down. /// push does not complete.
fn push_on_exit( fn push_on_exit(
mut exit_events: MessageReader<AppExit>, mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
@@ -291,20 +292,16 @@ fn push_on_exit(
exit_events.clear(); exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0); let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let result = rt.0.block_on(provider.0.push(&payload)); let provider = provider.0.clone();
match result { let rt = rt.0.clone();
Ok(_) => {} AsyncComputeTaskPool::get()
// `UnsupportedPlatform` is the expected response of .spawn(async move {
// `LocalOnlyProvider`; treat it the same as the pull path does — match rt.block_on(provider.push(&payload)) {
// no backend configured is not a failure. Ok(_) | Err(SyncError::UnsupportedPlatform) => {}
Err(SyncError::UnsupportedPlatform) => {} Err(e) => warn!("sync push on exit failed: {e}"),
Err(e) => {
// Log real push failures on exit so they appear in crash/log
// reports. We cannot surface them to the UI at this point (game
// loop is done).
warn!("sync push on exit failed: {e}");
}
} }
})
.detach();
} }
/// Update-schedule system: on each `GameWonEvent` push the just-completed /// Update-schedule system: on each `GameWonEvent` push the just-completed
@@ -334,7 +331,7 @@ fn push_replay_on_win(
} }
let replay = Replay::new( let replay = Replay::new(
game.0.seed, game.0.seed,
game.0.draw_mode, game.0.draw_mode(),
game.0.mode, game.0.mode,
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
@@ -607,7 +604,7 @@ mod tests {
/// would silently drop the link. /// would silently drop the link.
#[test] #[test]
fn upload_result_writes_share_url_into_replay_and_persists() { fn upload_result_writes_share_url_into_replay_and_persists() {
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_data::{ use solitaire_data::{
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to, Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
}; };
+116 -42
View File
@@ -6,8 +6,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit; use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility; use crate::hud_plugin::HudVisibility;
@@ -22,15 +22,28 @@ use crate::ui_theme::TEXT_PRIMARY;
use solitaire_data::Theme; use solitaire_data::Theme;
/// Default tint applied to every empty-pile marker sprite. Pure white /// Default tint applied to every empty-pile marker sprite. Pure white
/// at 8% alpha — soft enough that the marker reads as a "hint of a /// at 15% alpha — soft enough that the marker reads as a "hint of a
/// slot" rather than a panel, but visible against every felt /// slot" rather than a panel, but discernible even against a very dark
/// background. /// felt background under bright ambient light (the old 8% alpha vanished
/// on a #151515 felt during on-device Android testing).
/// ///
/// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`, /// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`,
/// which used to duplicate the literal alongside a "kept in sync" doc /// which used to duplicate the literal alongside a "kept in sync" doc
/// comment. Pulling both call sites through this const makes drift a /// comment. Pulling both call sites through this const makes drift a
/// compile error instead of a stale comment. /// compile error instead of a stale comment.
pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08); pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.15);
/// Tint applied to the thin outline rectangle sitting behind every
/// empty-pile marker. A slightly brighter white at 28% alpha gives the
/// slot a defined edge — the standard solitaire "empty pile" affordance —
/// without competing with real cards. Rendered as a marginally larger
/// child rectangle one z-step behind the fill, so the fill overlaps it
/// and only a hairline frame remains visible.
const PILE_MARKER_OUTLINE_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.28);
/// Width in logical pixels of the visible outline frame around an empty
/// pile marker (the outline rect is this much larger on each side).
const PILE_MARKER_OUTLINE_WIDTH: f32 = 2.0;
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds. /// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
/// ///
@@ -54,7 +67,7 @@ pub struct TableBackground;
/// Marker component attached to each of the 13 empty-pile placeholders. /// Marker component attached to each of the 13 empty-pile placeholders.
#[derive(Component, Debug, Clone)] #[derive(Component, Debug, Clone)]
pub struct PileMarker(pub PileType); pub struct PileMarker(pub KlondikePile);
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold /// Attached to a `PileMarker` entity when it has been temporarily tinted gold
/// as a hint destination. Stores the remaining countdown and the original sprite /// as a hint destination. Stores the remaining countdown and the original sprite
@@ -265,14 +278,13 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let marker_size = layout.card_size; let marker_size = layout.card_size;
let font_size = layout.card_size.x * 0.28; let font_size = layout.card_size.x * 0.28;
let mut piles: Vec<PileType> = Vec::with_capacity(13); let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
piles.push(PileType::Stock); piles.push(KlondikePile::Stock);
piles.push(PileType::Waste); for foundation in foundations() {
for slot in 0..4_u8 { piles.push(KlondikePile::Foundation(foundation));
piles.push(PileType::Foundation(slot));
} }
for i in 0..7 { for tableau in tableaus() {
piles.push(PileType::Tableau(i)); piles.push(KlondikePile::Tableau(tableau));
} }
for pile in piles { for pile in piles {
@@ -284,14 +296,30 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
..default() ..default()
}, },
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER), Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
PileMarker(pile.clone()), PileMarker(pile),
)); ));
// Outline frame: a marginally larger rectangle sitting one z-step
// behind the fill. The fill overlaps its centre, leaving only a
// hairline border visible — a defined slot edge without an extra
// asset or 9-slice. Untagged so the `PileMarker` count is unchanged.
let outline_size = marker_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
entity.with_children(|b| {
b.spawn((
Sprite {
color: PILE_MARKER_OUTLINE_COLOUR,
custom_size: Some(outline_size),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.05),
));
});
// Tableau markers show "K" (only a King may start an empty column). // Tableau markers show "K" (only a King may start an empty column).
// Foundation markers show "A" (only an Ace may claim an empty slot). // Foundation markers show "A" (only an Ace may claim an empty slot).
// Neither label carries a suit because any suit may start any slot. // Neither label carries a suit because any suit may start any slot.
match &pile { match &pile {
PileType::Tableau(_) => { KlondikePile::Tableau(_) => {
entity.with_children(|b| { entity.with_children(|b| {
b.spawn(( b.spawn((
Text2d::new("K"), Text2d::new("K"),
@@ -304,7 +332,7 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
)); ));
}); });
} }
PileType::Foundation(_) => { KlondikePile::Foundation(_) => {
entity.with_children(|b| { entity.with_children(|b| {
b.spawn(( b.spawn((
Text2d::new("A"), Text2d::new("A"),
@@ -480,11 +508,7 @@ fn sync_pile_marker_visibility(
return; return;
} }
for (pile_marker, mut visibility) in markers.iter_mut() { for (pile_marker, mut visibility) in markers.iter_mut() {
let is_empty = game let is_empty = pile_cards(&game.0, &pile_marker.0).is_empty();
.0
.piles
.get(&pile_marker.0)
.is_none_or(|pile| pile.cards.is_empty());
*visibility = if is_empty { *visibility = if is_empty {
Visibility::Inherited Visibility::Inherited
} else { } else {
@@ -493,6 +517,44 @@ fn sync_pile_marker_visibility(
} }
} }
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<(solitaire_core::card::Card, bool)> {
match pile {
KlondikePile::Stock => {
let stock = game.stock_cards();
if stock.is_empty() {
game.waste_cards()
} else {
stock
}
}
_ => game.pile(*pile),
}
}
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
}
const fn tableaus() -> [Tableau; 7] {
[
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -510,14 +572,14 @@ mod tests {
} }
#[test] #[test]
fn table_plugin_spawns_thirteen_pile_markers() { fn table_plugin_spawns_twelve_pile_markers() {
let mut app = headless_app(); let mut app = headless_app();
let count = app let count = app
.world_mut() .world_mut()
.query::<&PileMarker>() .query::<&PileMarker>()
.iter(app.world()) .iter(app.world())
.count(); .count();
assert_eq!(count, 13); assert_eq!(count, 12);
} }
#[test] #[test]
@@ -540,23 +602,23 @@ mod tests {
#[test] #[test]
fn every_pile_marker_has_unique_type() { fn every_pile_marker_has_unique_type() {
let mut app = headless_app(); let mut app = headless_app();
let mut types: Vec<PileType> = app let mut types: Vec<KlondikePile> = app
.world_mut() .world_mut()
.query::<&PileMarker>() .query::<&PileMarker>()
.iter(app.world()) .iter(app.world())
.map(|m| m.0.clone()) .map(|m| m.0)
.collect(); .collect();
types.sort_by_key(|p| format!("{p:?}")); types.sort_by_key(|p| format!("{p:?}"));
types.dedup(); types.dedup();
assert_eq!(types.len(), 13); assert_eq!(types.len(), 12);
} }
#[test] #[test]
fn pile_markers_hide_when_pile_is_occupied() { fn pile_markers_hide_when_pile_is_occupied() {
// After a fresh deal: the 7 tableau piles + the stock pile are // After a fresh deal: the 7 tableau piles + the stock pile are
// all occupied; the 4 foundation piles + the waste pile are // occupied; the 4 foundation piles are empty. The visibility-by-
// empty. The visibility-by-occupancy system must hide the // occupancy system must hide the first 8 markers and keep the
// first 8 markers and keep the last 5 visible. This implements // last 4 visible. This implements
// the "remain visible only where a pile is empty" invariant // the "remain visible only where a pile is empty" invariant
// in the module-level doc comment that was previously // in the module-level doc comment that was previously
// declared but not enforced — pile markers used to always // declared but not enforced — pile markers used to always
@@ -570,13 +632,13 @@ mod tests {
app.update(); app.update();
let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>(); let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>();
let mut hidden_piles: Vec<PileType> = Vec::new(); let mut hidden_piles: Vec<KlondikePile> = Vec::new();
let mut visible_piles: Vec<PileType> = Vec::new(); let mut visible_piles: Vec<KlondikePile> = Vec::new();
for (marker, visibility) in q.iter(app.world()) { for (marker, visibility) in q.iter(app.world()) {
if matches!(visibility, Visibility::Hidden) { if matches!(visibility, Visibility::Hidden) {
hidden_piles.push(marker.0.clone()); hidden_piles.push(marker.0);
} else { } else {
visible_piles.push(marker.0.clone()); visible_piles.push(marker.0);
} }
} }
@@ -586,19 +648,31 @@ mod tests {
8, 8,
"stock + 7 tableau piles should hide their markers post-deal", "stock + 7 tableau piles should hide their markers post-deal",
); );
assert!(hidden_piles.contains(&PileType::Stock)); assert!(hidden_piles.contains(&KlondikePile::Stock));
for i in 0..7 { for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
assert!( assert!(
hidden_piles.contains(&PileType::Tableau(i)), hidden_piles.contains(&KlondikePile::Tableau(tableau)),
"tableau {i} marker should be hidden — it has cards", "{tableau:?} marker should be hidden — it has cards",
); );
} }
// 5 empty piles: waste + 4 foundations. // 4 empty piles: foundations only.
assert_eq!(visible_piles.len(), 5); assert_eq!(visible_piles.len(), 4);
assert!(visible_piles.contains(&PileType::Waste)); for foundation in [
for i in 0..4_u8 { Foundation::Foundation1,
assert!(visible_piles.contains(&PileType::Foundation(i))); Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
assert!(visible_piles.contains(&KlondikePile::Foundation(foundation)));
} }
} }
+7 -5
View File
@@ -12,6 +12,7 @@
//! handles directly on card entities, so a theme switch propagates on //! handles directly on card entities, so a theme switch propagates on
//! the next frame without re-spawning anything. //! the next frame without re-spawning anything.
#[cfg(not(target_arch = "wasm32"))]
pub mod importer; pub mod importer;
pub mod loader; pub mod loader;
pub mod manifest; pub mod manifest;
@@ -28,6 +29,7 @@ use thiserror::Error;
use solitaire_core::card::{Rank, Suit}; use solitaire_core::card::{Rank, Suit};
#[cfg(not(target_arch = "wasm32"))]
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into}; pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
pub use loader::{CardThemeLoader, CardThemeLoaderError}; pub use loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest; pub use manifest::ThemeManifest;
@@ -41,11 +43,11 @@ pub use registry::{
/// Hashable lookup key into [`CardTheme::faces`]. /// Hashable lookup key into [`CardTheme::faces`].
/// ///
/// Distinct from `solitaire_core::Card`: the core type carries an `id` /// Distinct from `card_game::Card`, which also encodes a deck id: `CardKey`
/// and a `face_up` flag that vary per deal, neither of which is /// is just the (suit, rank) pair that uniquely identifies which artwork to
/// relevant to image lookup. `CardKey` is just the (suit, rank) pair /// draw. Serialised theme manifests address faces by
/// that uniquely identifies which artwork to draw. /// [`CardKey::manifest_name`] strings, not by serialising `CardKey` itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CardKey { pub struct CardKey {
pub suit: Suit, pub suit: Suit,
pub rank: Rank, pub rank: Rank,
+13 -4
View File
@@ -22,11 +22,15 @@
use std::path::Path; use std::path::Path;
use bevy::log::warn; use bevy::log::warn;
use bevy::prelude::{App, Plugin, Resource, Startup}; #[cfg(not(target_arch = "wasm32"))]
use bevy::prelude::Startup;
use bevy::prelude::{App, Plugin, Resource};
use serde::Deserialize; use serde::Deserialize;
use super::ThemeMeta; use super::ThemeMeta;
use crate::assets::{DARK_THEME_MANIFEST_URL, user_theme_dir}; use crate::assets::DARK_THEME_MANIFEST_URL;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_theme_dir;
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs /// One entry in the [`ThemeRegistry`] — the data the picker UI needs
/// to render a row and load the theme on selection. /// to render a row and load the theme on selection.
@@ -85,13 +89,18 @@ pub struct ThemeRegistryPlugin;
impl Plugin for ThemeRegistryPlugin { impl Plugin for ThemeRegistryPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<ThemeRegistry>() app.init_resource::<ThemeRegistry>();
.add_systems(Startup, build_registry_on_startup); // User-themes directory scan requires a filesystem. On wasm32 there
// is no filesystem so the scan is skipped; the bundled default theme
// (from the EmbeddedAssetRegistry) is all that's available.
#[cfg(not(target_arch = "wasm32"))]
app.add_systems(Startup, build_registry_on_startup);
} }
} }
/// Reads `user_theme_dir()` and replaces the registry's contents with /// Reads `user_theme_dir()` and replaces the registry's contents with
/// the bundled default plus every valid user theme. /// the bundled default plus every valid user theme.
#[cfg(not(target_arch = "wasm32"))]
fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegistry>) { fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegistry>) {
*registry = build_registry(&user_theme_dir()); *registry = build_registry(&user_theme_dir());
} }
+7 -6
View File
@@ -22,7 +22,8 @@
//! was closed, the file is treated as missing. //! was closed, the file is treated as missing.
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::Utc;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
@@ -181,7 +182,7 @@ fn advance_time_attack(
// No shared screen-state enum currently covers every overlay. Pause the // No shared screen-state enum currently covers every overlay. Pause the
// countdown whenever gameplay is blocked by a modal, the pause flag, or a // countdown whenever gameplay is blocked by a modal, the pause flag, or a
// just-won board state. // just-won board state.
if paused.is_some_and(|p| p.0) || game.0.is_won || !modal_scrims.is_empty() { if paused.is_some_and(|p| p.0) || game.0.is_won() || !modal_scrims.is_empty() {
return; return;
} }
session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0); session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0);
@@ -222,9 +223,9 @@ fn auto_deal_on_time_attack_win(
/// the system time predates the epoch (impossible under any sane clock, /// the system time predates the epoch (impossible under any sane clock,
/// but the fallback keeps the function infallible). /// but the fallback keeps the function infallible).
fn current_unix_secs() -> u64 { fn current_unix_secs() -> u64 {
SystemTime::now() // Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
.duration_since(UNIX_EPOCH) // std::time::SystemTime panics on wasm32-unknown-unknown).
.map_or(0, |d| d.as_secs()) Utc::now().timestamp().max(0) as u64
} }
/// Periodically persists the live `TimeAttackResource` to /// Periodically persists the live `TimeAttackResource` to
@@ -298,7 +299,7 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin; use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -0,0 +1,264 @@
//! Touch tap-to-select input mode.
//!
//! When [`TouchInputMode::TapToSelect`] is active (set via [`crate::settings_plugin`]),
//! a single tap on a face-up card **selects** it (showing a visual highlight) instead
//! of immediately auto-moving it. A second tap on a valid destination pile performs
//! the move; a second tap on the same pile (or an invalid target) cancels silently.
//!
//! In [`TouchInputMode::OneTap`] mode this plugin is fully passive — all resources
//! default to their empty state and no highlight is ever shown.
//!
//! ## State machine
//!
//! ```text
//! Idle ──(tap face-up card)──> Selected(pile, cards)
//! ↑ │
//! │ cancel (re-tap or │ second tap on destination
//! └── StateChangedEvent) ◄──────┤ → MoveRequestEvent; back to Idle
//! │
//! └── rejected / no destination → back to Idle
//! ```
//!
//! ## Interaction with the existing auto-move flow
//!
//! [`crate::input_plugin::handle_double_tap`] is the entry point: it reads
//! [`TouchSelectionState`] and, in `TapToSelect` mode, populates it on the first
//! tap instead of firing `MoveRequestEvent`. This plugin owns the highlight visual
//! and the state-clear reactions.
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::card::Card;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::ui_theme::ACCENT_PRIMARY;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// State for the tap-to-select touch flow.
///
/// `selected` is `Some((source_pile, card_ids))` while the player has
/// chosen a source but not yet tapped a destination. `None` is the idle state.
///
/// `card_ids` mirrors `DragState::cards` — the bottom-to-top ordered list of
/// card ids that will be moved (1 for a single card, multiple for a face-up run).
#[derive(Resource, Debug, Default)]
pub struct TouchSelectionState {
/// Currently selected source pile and the cards to move (bottom-to-top).
pub selected: Option<(KlondikePile, Vec<Card>)>,
}
impl TouchSelectionState {
/// Returns `true` when a source is selected.
pub fn has_selection(&self) -> bool {
self.selected.is_some()
}
/// Takes the current selection, leaving `selected` as `None`.
pub fn take(&mut self) -> Option<(KlondikePile, Vec<Card>)> {
self.selected.take()
}
/// Sets the current selection.
pub fn set(&mut self, pile: KlondikePile, cards: Vec<Card>) {
self.selected = Some((pile, cards));
}
/// Clears the selection without returning it.
pub fn clear(&mut self) {
self.selected = None;
}
}
/// Marker component placed on the highlight sprite child of a selected source card.
///
/// Despawned and respawned by [`update_touch_selection_highlight`] whenever
/// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it
/// is a no-op every frame that the selection is stable.
#[derive(Component)]
pub struct TouchSelectionHighlight;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers all resources and systems for the touch tap-to-select flow.
pub struct TouchSelectionPlugin;
impl Plugin for TouchSelectionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TouchSelectionState>().add_systems(
Update,
(
clear_touch_selection_on_state_change,
update_touch_selection_highlight,
)
.chain()
.after(GameMutation),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Clears [`TouchSelectionState`] whenever the board changes (undo, new game,
/// won, forfeit). This prevents stale selections surviving across game resets.
pub(crate) fn clear_touch_selection_on_state_change(
mut selection: ResMut<TouchSelectionState>,
mut state_events: MessageReader<StateChangedEvent>,
) {
if state_events.read().next().is_some() {
selection.clear();
}
}
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
///
/// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout
/// actually changes — not every frame. Existing highlights are despawned first,
/// then a fresh highlight is spawned on every card in the selected stack.
pub(crate) fn update_touch_selection_highlight(
mut commands: Commands,
selection: Res<TouchSelectionState>,
card_entities: Query<(Entity, &CardEntity)>,
highlights: Query<Entity, With<TouchSelectionHighlight>>,
layout: Option<Res<LayoutResource>>,
) {
// Skip when neither the selection nor the layout changed this frame.
let layout_changed = layout.as_ref().map(|l| l.is_changed()).unwrap_or(false);
if !selection.is_changed() && !layout_changed {
return;
}
// Despawn stale highlights first.
for entity in &highlights {
commands.entity(entity).despawn();
}
let Some((_, ref cards)) = selection.selected else {
return;
};
let Some(layout) = layout else {
return;
};
// Highlight every card in the selected stack (bottom-to-top order).
// The bottom card of the run is the most visually important anchor,
// but highlighting the whole run gives the player clear confirmation
// of how many cards are involved in the move.
let card_size = layout.0.card_size;
for card in cards {
spawn_touch_highlight(&mut commands, &card_entities, card, card_size);
}
}
/// Spawns a [`TouchSelectionHighlight`] sprite as a child of the matching card entity.
fn spawn_touch_highlight(
commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>,
card: &Card,
card_size: Vec2,
) {
for (entity, card_entity) in card_entities {
if card_entity.card == *card {
commands.entity(entity).with_children(|b| {
b.spawn((
TouchSelectionHighlight,
Sprite {
color: ACCENT_PRIMARY.with_alpha(0.55),
custom_size: Some(card_size + Vec2::splat(6.0)),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(),
));
});
return;
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use solitaire_core::Tableau;
use solitaire_core::card::{Card, Deck, Rank, Suit};
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
/// numeric ids. Identity is now the `Card` value.
fn test_cards() -> [Card; 3] {
[
Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Two),
Card::new(Deck::Deck1, Suit::Spades, Rank::Three),
]
}
#[test]
fn selection_state_default_is_idle() {
let state = TouchSelectionState::default();
assert!(!state.has_selection());
assert!(state.selected.is_none());
}
#[test]
fn set_and_take_roundtrip() {
let mut state = TouchSelectionState::default();
let cards = test_cards().to_vec();
state.set(KlondikePile::Tableau(Tableau::Tableau1), cards.clone());
assert!(state.has_selection());
let taken = state.take();
assert!(taken.is_some());
let (pile, taken_cards) = taken.unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(taken_cards, cards);
assert!(!state.has_selection());
}
#[test]
fn clear_removes_selection() {
let mut state = TouchSelectionState::default();
state.set(
KlondikePile::Stock,
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
);
state.clear();
assert!(!state.has_selection());
}
#[test]
fn take_on_idle_returns_none() {
let mut state = TouchSelectionState::default();
assert!(state.take().is_none());
assert!(!state.has_selection());
}
#[test]
fn set_overwrites_previous_selection() {
let mut state = TouchSelectionState::default();
state.set(
KlondikePile::Tableau(Tableau::Tableau1),
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
let second = vec![
Card::new(Deck::Deck1, Suit::Hearts, Rank::Seven),
Card::new(Deck::Deck1, Suit::Spades, Rank::Eight),
];
state.set(KlondikePile::Tableau(Tableau::Tableau4), second.clone());
let (pile, cards) = state.take().unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4));
assert_eq!(cards, second);
}
}
+7 -6
View File
@@ -216,13 +216,14 @@ where
// modal at `Z_PAUSE` (220) in some scenes. // modal at `Z_PAUSE` (220) in some scenes.
GlobalZIndex(z_panel), GlobalZIndex(z_panel),
ZIndex(z_panel), ZIndex(z_panel),
// B0004: ModalCard carries Transform (for the scale animation). // B0004: ModalCard carries Transform (for the scale animation)
// Bevy's GlobalTransform hook fires B0004 when a child has // and visibility-related UI components. Bevy validates that
// GlobalTransform but the parent does not. Adding Identity // GlobalTransform / InheritedVisibility parents carry the same
// Transform here gives the scrim GlobalTransform so the check // hierarchy components, so the scrim root explicitly carries the
// passes. UI layout still uses UiTransform; this has no layout // matching identity components. UI layout still uses UiTransform;
// effect. // this has no layout effect.
Transform::default(), Transform::default(),
Visibility::default(),
)) ))
.with_children(|root| { .with_children(|root| {
root.spawn(( root.spawn((

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