Compare commits

..

40 Commits

Author SHA1 Message Date
funman300 018b69285d fix(engine): unify light/dark theme manifest URL resolution
Test / test (pull_request) Successful in 13m4s
- set_theme resolved every id to themes:// — the bundled dark/classic
  themes live at embedded://, so switching to them via the public API
  would NotFound and silently keep the old theme. All three load paths
  now share one theme_manifest_url resolver.
- load_initial_theme's settings-absent fallback was "classic", a
  leftover from v0.33 when classic was the default; it now derives from
  Settings::default() so it can't drift from the data crate again, with
  a test pinning the default id to a bundled theme.
- settings.rs doc no longer claims "classic" is migrated to "dark";
  only the pre-rename "default" id is rewritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:25:35 -07:00
funman300 abf1312cf5 fix(data): serialise token refreshes so overlapping 401s can't force a logout
Test / test (pull_request) Successful in 15m33s
Refresh tokens are single-use since the rotation change (PR #136). Two
in-flight requests hitting 401 together (replay upload racing a manual
sync) both called /api/auth/refresh; the second presented an already-
consumed token, got rejected, and surfaced as a spurious 'session
expired' re-login prompt.

refresh_token() now takes the stale access token that earned the 401
and runs behind a tokio::sync::Mutex; a loser of the lock race that
finds the stored token already rotated returns Ok and retries with it
instead of spending the new refresh token.

Untested caveat: exercising the race in a unit test needs an in-memory
auth_tokens double (the real keyring is unavailable on headless Linux
runners) — noted for a future test-support addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:04:30 -07:00
funman300 8b09c51271 docs(architecture): correct KlondikePile sketch — upstream has no Waste variant
§8 documented a Waste variant that doesn't exist in klondike 0.4; the
real convention (Stock denotes the waste pile in pile-coordinate space)
is now stated where the enum is sketched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:01:42 -07:00
funman300 f61573513c fix(engine): remove latent unreachable! panic in difficulty seed cursor
The Random arm was only unreachable because seeds_for(Random) returns
None in a different function — a future catalog change would turn it
into a shipped runtime panic. Returning a system-time seed is the
correct Random behaviour either way. CLAUDE.md §2.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:01:25 -07:00
funman300 757c35e4a0 fix(engine): degrade gracefully when the platform data dir is missing
user_theme_dir() panicked on desktop when dirs::data_dir() returned
None (broken $HOME / $XDG_*). Return an empty path instead — exactly
what the wasm32 branch already did — so theme discovery reports 'no
user themes' and the bundled default keeps working. Warns once with
the set_user_theme_dir() workaround. CLAUDE.md §2.3: no panic! in
runtime logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:00:20 -07:00
funman300 113a933170 style: cargo fmt under rustfmt 1.9 and gate formatting in CI
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:59:20 -07:00
funman300 18bb1fa0be fix(ci): cap test-build debuginfo so the linker fits runner memory
Two runs (447, 486) died with 'ld terminated with signal 9' linking the
solitaire_engine test binary — the runner OOMs on full dev debuginfo for
the Bevy dependency graph. line-tables-only preserves file:line panic
backtraces at a fraction of the link footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:58:43 -07:00
funman300 c0cd7c2c15 Merge pull request 'docs(changelog): cut v0.42.0 section' (#151) from docs/changelog-v0.42.0 into master
Android Release / build-apk (push) Successful in 5m20s
2026-07-07 04:37:30 +00:00
funman300 ac002d8255 docs(changelog): cut v0.42.0 section
Test / test (pull_request) Successful in 24m8s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:37:29 -07:00
funman300 0fc1fa139e Merge pull request 'docs(changelog): record the July 6 evening arc' (#150) from docs/changelog-evening-arc into master 2026-07-07 04:14:51 +00:00
funman300 4f0c5bb808 docs(changelog): record the July 6 evening arc
Test / test (pull_request) Failing after 15m12s
CI gate, ambiguity gate + same-day 302-to-zero burn-down, 36% smaller
browser canvas, Quaternions API adoption, sync/auth concurrency fixes,
bounded exit push, server auth hardening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:14:50 -07:00
funman300 a6b22df666 Merge pull request 'refactor(engine): ambiguity burn-down batch 4 — ZERO ambiguities, gate enforced' (#149) from refactor/ambiguity-tail into master
Build and Deploy / build-and-push (push) Successful in 2m18s
Test / test (push) Failing after 13m51s
Web WASM Rebuild / rebuild (push) Successful in 8m37s
2026-07-07 04:14:00 +00:00
funman300 b402c01918 refactor(engine): ambiguity burn-down batch 4 — zero ambiguities, gate enforced
Test / test (pull_request) Failing after 17m5s
Clears the final 47 pairs and turns the ratchet into a hard gate
(AMBIGUITY_BASELINE = 0, assert_eq):

- HudButtons: the 14 HUD button/popover handlers run as one chain,
  before ui_focus::FocusKeys — Esc/keyboard consumption order is now
  defined (restore prompt → buttons/popovers → focus navigation →
  settings toggle) instead of scheduler-dependent.
- HUD text updaters (update_hud, update_selection_hud,
  update_won_previously) chained, in UiTextFx, after the new
  AutoComplete set (update_hud reads AutoCompleteState).
- restore_hud_on_modal → apply_hud_visibility chained before
  UpdateOnResize: HudVisibility writes, application, and the layout
  read happen in a fixed order.
- New writer sets UndoRequestWriters / InfoToastWriters (same
  self-ambiguous pattern as NewGameRequestWriters).
- Logic-before-paint: check_no_moves and the AutoComplete chain order
  before BoardVisuals; SettingsMutation after UpdateOnResize.
- MarkerVisuals set wraps the table painter chain; chrome fx declare
  disjointness from it.
- update_hud_typography after BoardVisuals; avatar/settings-toggle
  interaction handlers declared disjoint from HudButtons.

302 → 198 → 171 → 47 → 0 in four batches, one day. New systems now
fail CI unless they declare their ordering or their disjointness.

Closes #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:13:58 -07:00
funman300 be478acde7 Merge pull request 'refactor(engine): ambiguity burn-down batch 3 — board paint chain (171 → 47)' (#148) from refactor/ambiguity-board-visuals into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m20s
Test / test (push) Successful in 25m2s
2026-07-07 03:57:20 +00:00
funman300 379873765d refactor(engine): ambiguity burn-down batch 3 — board paint chain, 171 → 47
Test / test (pull_request) Failing after 17m32s
The Sprite/Transform cluster (112 pairs) was every board painter
racing every other one. Two structural moves:

- BoardVisuals set (card_plugin): all card/marker painters run as ONE
  deterministic chain in data-flow order — layout refinement → card
  authority (sync_cards_on_change) → flip anims → shadows → highlights
  → stock indicators → resize snapping → corner labels — with
  LayoutSystem::UpdateOnResize ordered before the whole set and the
  table plugin's marker painters chained after it. Paint order is now
  identical every frame instead of scheduler-dependent.

- UiTextFx set (ui_theme): chrome text effects (score pulse/floater,
  streak flourish, modal enter, focus-ring pulse) animate Transform on
  UI entities only; they are declared ambiguous with BoardVisuals and
  with each other — the entity domains are disjoint by construction.

All chain members are cheap and change-gated; sequential execution is
not a measurable cost for a card game. Existing ordering constraints
(fan-frac before sync, shadows after sync, snap after collect) are
preserved inside the chain.

Baseline ratchets 171 → 47. Remaining: long tail of small subjects
(input, event writers, AutoCompleteState, HudVisibility).

Refs #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:57:15 -07:00
funman300 713a292057 Merge pull request 'refactor(engine): ambiguity burn-down batch 2 — SettingsResource cleared (198 → 171)' (#147) from refactor/ambiguity-settings into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m17s
Test / test (push) Successful in 19m18s
2026-07-07 03:48:11 +00:00
funman300 58c2dfd0a9 refactor(engine): ambiguity burn-down batch 2 — SettingsResource cleared, 198 → 171
Test / test (pull_request) Successful in 28m7s
New SettingsMutation set: the per-frame settings mutators
(handle_volume_keys → record_window_geometry_changes →
persist_window_geometry_after_debounce) run as a deterministic chain
ordered before GameMutation, so every reader already after GameMutation
observes the current frame's settings transitively. The four readers
outside that ordering (modal enter-speed chain, focus-ring pulse, HUD
avatar, and the game plugin's pre-mutation chain) are ordered after the
set explicitly.

SettingsResource ambiguities: 21 → 0. Baseline ratchets 198 → 171.

Refs #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:48:08 -07:00
funman300 710555bd7e Merge pull request 'refactor(engine): first ambiguity burn-down batch — 302 → 198 pairs' (#146) from refactor/ambiguity-burndown into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 1m53s
Test / test (push) Successful in 20m32s
2026-07-07 03:37:53 +00:00
funman300 42a5f3bc3b refactor(engine): first ambiguity burn-down batch — 302 → 198 pairs
Test / test (pull_request) Failing after 15m16s
Two structural fixes from the #143 backlog:

- Game-state ordering spine: the three pre-mutation GameStateResource
  writers (tick_elapsed_time, sync_settings_to_game,
  handle_restore_prompt) are now a deterministic chain before
  GameMutation, and the remaining unordered readers
  (update_selection_hud, handle_hint_button, tick_hint_highlight,
  handle_right_click, snap_cards_on_window_resize,
  sync_pile_marker_visibility, auto_save_game_state) are ordered after
  it. Readers now see the current frame's moves deterministically
  instead of racing the mutators.

- NewGameRequestWriters set: every in-cluster writer of
  NewGameRequestEvent (buttons, modals, mode picker, seed poller,
  restore prompt) is registered in a shared set marked ambiguous with
  itself — writer-vs-writer append order is meaningless since consumers
  drain the whole queue. Out-of-cluster writers (home, challenge,
  time-attack, win-summary, play-by-seed, difficulty, stats plugins)
  can join the set when the test cluster grows.

AMBIGUITY_BASELINE ratchets 302 → 198. Remaining backlog is dominated
by the Sprite (72) / Transform (48) visual-domain cluster, which needs
per-domain set architecture — next batch.

Refs #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:37:51 -07:00
funman300 19647b5209 Merge pull request 'test(engine): ratchet on Bevy system-order ambiguities' (#145) from test/schedule-ambiguity into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m6s
Test / test (push) Successful in 19m47s
2026-07-07 03:22:29 +00:00
funman300 d8a255869c test(engine): ratchet on Bevy system-order ambiguities
Test / test (pull_request) Failing after 16m20s
First measurement of schedule hygiene (issue #143): the headless
gameplay cluster (Game/Table/Card/Hud/AutoComplete/UiModal/UiFocus/
Settings) carries 302 system pairs with conflicting data access and no
ordering edge. Too many to triage in one pass and most are likely
benign event/resource writers — but unproven, and nothing stopped the
count from growing.

New schedule_checks test builds the cluster with ambiguity detection
promoted to error, parses the reported pair count, and asserts it never
exceeds AMBIGUITY_BASELINE (302). New ambiguous pairs now fail CI at
the PR; the legacy backlog can be burned down incrementally by adding
.before/.after or .ambiguous_with and lowering the baseline.

Refs #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:22:27 -07:00
funman300 f0336d784d Merge pull request 'fix(server): login timing pad, 409 on register race, avatar magic-byte check' (#144) from fix/server-low-findings into master
Build and Deploy / build-and-push (push) Successful in 6m5s
Test / test (push) Failing after 16m42s
Web E2E / web-e2e (push) Successful in 4m58s
2026-07-07 03:15:03 +00:00
funman300 a218999243 fix(server): login timing pad, 409 on register race, avatar magic-byte check
Test / test (pull_request) Failing after 17m26s
Closes the three actionable server Low findings from the 2026-07-06
review:

- #139: unknown-username logins now verify against a static bcrypt
  dummy hash so both failure paths pay the same cost — response timing
  no longer reveals which usernames exist.
- #140: register maps a unique-constraint violation to UsernameTaken
  (409) — the SELECT pre-check stays as the friendly fast path, the
  constraint is the arbiter for the concurrent case.
- #141: avatar uploads must start with the magic bytes of the declared
  image type; the stored extension (which decides how the file is
  re-served) is now backed by content, not the Content-Type header.

Unit tests: real/spoofed/truncated signatures for all four formats,
and the dummy hash's validity at BCRYPT_COST.

Closes #139
Closes #140
Closes #141

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:15:01 -07:00
funman300 55fa7df2bf Merge pull request 'fix(engine): bounded blocking sync push on exit' (#138) from fix/exit-push-bounded into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m5s
Test / test (push) Successful in 19m38s
2026-07-07 03:05:16 +00:00
funman300 b2341c652b Merge pull request 'refactor(core,engine,wasm): canonical FOUNDATIONS/TABLEAUS consts; adopt upstream SUITS/RANKS' (#137) from refactor/quat-enum-consts into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 6m3s
Test / test (push) Successful in 26m20s
Web E2E / web-e2e (push) Successful in 4m56s
2026-07-07 03:05:11 +00:00
funman300 021c5d6ad8 Merge pull request 'fix(server): transactional sync push; single-use refresh rotation' (#136) from fix/sync-auth-races into master
Build and Deploy / build-and-push (push) Successful in 5m24s
Test / test (push) Successful in 17m41s
Web E2E / web-e2e (push) Successful in 4m41s
2026-07-07 03:04:41 +00:00
funman300 d1264a7797 fix(engine): bounded blocking sync push on exit
Test / test (pull_request) Successful in 24m6s
The exit push spawned a detached task on AppExit, which process
teardown almost always killed before the network round-trip completed —
the final session's sync silently never happened (2026-07-06 review,
finding M3). Local persistence meant no data loss, but stats stayed
unsynced until the next launch.

push_on_exit now blocks the closing app's final frame for at most
EXIT_PUSH_TIMEOUT (2s) via tokio::time::timeout: long enough for one
healthy round-trip, short enough that quitting never feels hung when
the server is unreachable. Timeout and errors are logged and skipped —
the next launch's pull/push converges as before.

Closes review finding M3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:03:41 -07:00
funman300 7a5f03987d refactor(core,engine,wasm): canonical FOUNDATIONS/TABLEAUS consts; adopt upstream SUITS/RANKS
Review findings 1+2 (Quat-underuse lens, 2026-07-06):

- solitaire_core gains pub const FOUNDATIONS / TABLEAUS — the canonical
  iteration source for the upstream pile enums (upstream klondike has no
  Foundation::ALL, and inherent impls cannot be added to foreign types).
  Deletes three identical private const-fn copies (radial_menu,
  table_plugin, input_plugin) and the hand-enumerated variants in
  card_plugin::sync::all_cards and solitaire_wasm.

- Hand-rolled [Suit; 4] / [Rank; 13] arrays replaced with upstream
  Suit::SUITS / Rank::RANKS. The order-sensitive CardImageSet indexing
  is re-keyed through canonical card_plugin::{suit_index, rank_index}
  helpers that match upstream order, with regression tests asserting
  the correspondence — one ordering everywhere instead of three
  divergent local ones.

Net -177 lines. No behaviour change; all consumers go through the
canonical helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:58:33 -07:00
funman300 8eb316751d fix(server): transactional sync push; single-use refresh rotation
Two concurrency fixes from the 2026-07-06 review (findings M1, M2):

sync push (M1): the load→merge→store cycle ran as three separate DB
operations. Two devices pushing concurrently both read the same stored
payload, merged independently, and the second store overwrote the first
merge — the server visibly regressed until the losing device pushed
again. The whole cycle (including the leaderboard update) now runs in
one transaction; SQLite serialises the writers. The leaderboard
helper's stale docstring (claiming a single conditional UPDATE that was
actually two statements) is corrected — the transaction now provides
the atomicity it described.

refresh rotation (M2): SELECT-then-DELETE let two concurrent refreshes
with the same token both pass the liveness check and both mint fresh
token pairs. The SELECT is gone; rotation now gates on the DELETE's
rows_affected — whoever removes the jti row wins, everyone else gets
401. Sequential reuse was already covered by
consumed_refresh_token_is_rejected, which still passes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:00:00 -07:00
funman300 d0c1db6c1d Merge pull request 'ci: add workspace clippy + test gate workflow' (#135) from ci/test-workflow into master
Test / test (push) Successful in 28m59s
2026-07-06 23:29:10 +00:00
funman300 f6e57b759e ci: pin toolchain to 1.95.0 and install Bevy native deps
Test / test (pull_request) Successful in 27m47s
Review feedback on #135: floating 'stable' + -D warnings lets every new
clippy release redden master with new lints — pin 1.95.0 like the
web-wasm-rebuild workflow. And ubuntu-latest lacks the ALSA/udev/X11/
Wayland dev packages the Bevy crates link against; install the standard
Bevy CI set (the project's own builder images have no desktop-Bevy
precedent — android-builder targets the NDK and web-e2e only builds the
server).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:28:28 -07:00
funman300 c9adcaa5e4 ci: add workspace clippy + test gate workflow
Test / test (pull_request) Failing after 1m35s
Until now no CI workflow ran the test suite or clippy at all — the
android-release, docker-build, web-e2e, web-wasm-rebuild, and
builder-image workflows cover packaging and e2e, but a direct push to
master (including the web-wasm-rebuild bot commit) never executed
cargo test or clippy. The gate discipline in CLAUDE.md §6 existed only
on developer machines.

test.yml runs the exact §6 commands (clippy --workspace --all-targets
-D warnings; cargo test --workspace) on master pushes and PRs, with
SQLX_OFFLINE against the checked-in .sqlx cache, path-filtered so
docs-only merges don't burn CI. Toolchain/cache mirror web-e2e.yml.

Found during the 2026-07-06 large-scale review (finding H1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:21:19 -07:00
Gitea CI 0d5204b5ec chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m30s
Web E2E / web-e2e (push) Successful in 4m38s
2026-07-06 23:05:53 +00:00
funman300 15bb136c79 Merge pull request 'perf(web): size-focused wasm-release profile for the canvas build (36.2 → 23.2 MB)' (#134) from perf/wasm-size-profile into master
Build and Deploy / build-and-push (push) Successful in 7m4s
Web E2E / web-e2e (push) Successful in 4m51s
Web WASM Rebuild / rebuild (push) Successful in 9m30s
2026-07-06 22:44:33 +00:00
funman300 16a1139eab perf(web): size-focused wasm-release profile for the canvas build
canvas_bg.wasm shipped at 36.2 MB — plain release (opt-level 3, thin
LTO) piped through wasm-opt -O2. Download size, not throughput, is the
binding constraint for the browser canvas, so solitaire_web now builds
with a dedicated wasm-release profile: opt-level "s", fat LTO, one
codegen unit. Local result: 23.2 MB after the unchanged wasm-opt -O2
pass — 35.9% smaller.

The binaryen pass stays at -O2 (the -Oz grey-screen miscompile note in
build_wasm.sh still applies). profile.strip is deliberately NOT set: on
wasm it also removes the target_features custom section, which makes
wasm-opt reject the module ('all used features should be allowed' on
trunc_sat).

Verified with the new artifact: all 5 play_canvas e2e specs pass (debug
bridge, draw3 param, apply/undo, replay diagnostics, 40-seed autoplay
invariants). Headless pixel verification is inconclusive in this
environment — wgpu cannot create a usable adapter locally and panics
identically on the OLD artifact too — so visual parity should be
confirmed against production after the next deploy.

pkg/ artifacts are intentionally not committed: the web-wasm-rebuild
workflow is the single source of truth and will regenerate them with
this profile on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:44:16 -07:00
funman300 710cabcf0d Merge pull request 'docs(architecture): bring source-of-truth docs to post-migration reality' (#133) from docs/architecture-post-migration into master 2026-07-06 22:26:18 +00:00
funman300 5acd8e4cd0 docs(architecture): bring source-of-truth docs to post-migration reality
ARCHITECTURE.md predated the card_game migration (PR #88, 2026-06-22)
and still documented the pre-migration core: local Card with face_up,
PileType, stored score/undo/recycle, and a snapshot undo stack. Rewrites
the Core Game Models section around the upstream card_game/klondike
types and the derived-stats/replay-undo model, updates the
solitaire_core crate section (deps + ownership), adds solitaire_web and
solitaire_assetgen to the workspace tree, and corrects the
solitaire_app and Settings entries. Version bumped to 1.4.

Also adds the two missing crates to the CLAUDE.md crate map (AGENTS.md,
its gitignored local twin, was regenerated in place) and records the
full device checklist passing on the Fold 7 in SESSION_HANDOFF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:26:06 -07:00
funman300 652c9290b9 Merge pull request 'docs: add v0.41.1 changelog section and refresh session handoff' (#132) from docs/handoff-v0.41.1 into master 2026-07-06 22:19:37 +00:00
funman300 beddbcf94d docs: add v0.41.1 changelog section and refresh session handoff
CHANGELOG gains the missing [0.41.1] section (pile-marker child-resize
fix + Android relayout diagnostics). SESSION_HANDOFF is brought forward
from the stale v0.40.0 state to today's: v0.41.x releases, the July 6
review arc (PRs #121-#131), Fold 7 on-device verification results, the
remaining device-checklist items, and new architectural notes (plugin
submodule pattern, marker child-resize rule, Fold 7 inset quirks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:19:35 -07:00
Gitea CI 3c6b6e8c22 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m59s
Web E2E / web-e2e (push) Successful in 4m7s
2026-07-06 21:38:35 +00:00
67 changed files with 1467 additions and 872 deletions
+76
View File
@@ -0,0 +1,76 @@
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
# locally, run on every master push and pull request. Until this workflow
# existed, nothing in CI ran the test suite at all — a direct push to
# master (or the web-wasm-rebuild bot commit) was entirely unguarded.
name: Test
on:
push:
branches: [master]
paths:
- 'solitaire_app/**'
- 'solitaire_assetgen/**'
- 'solitaire_core/**'
- 'solitaire_data/**'
- 'solitaire_engine/**'
- 'solitaire_server/src/**'
- 'solitaire_server/tests/**'
- 'solitaire_server/migrations/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.cargo/**'
- '.sqlx/**'
- '.gitea/workflows/test.yml'
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
# Full debuginfo made the solitaire_engine test-binary link peak past the
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
# line-tables-only keeps file:line in panic backtraces while cutting the
# link's memory footprint enough to fit the runner.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
components: clippy, rustfmt
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
# Native link deps for the Bevy crates (engine/app/web) on a bare
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
- name: Install Bevy native dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
- name: Format check
run: cargo fmt --check
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
# same as the web-e2e workflow's server prebuild.
- name: Clippy (deny warnings)
env:
SQLX_OFFLINE: 'true'
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test
env:
SQLX_OFFLINE: 'true'
run: cargo test --workspace
+77 -41
View File
@@ -1,9 +1,11 @@
# Ferrous Solitaire — Architecture Document
> **Version:** 1.3
> **Version:** 1.4
> **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable)
> **Last Updated:** 2026-05-12
> **Last Updated:** 2026-07-06 — post card_game/klondike migration (PR #88):
> core card/pile types come from the upstream `card_game` workspace and
> score/undo/recycle are derived from the upstream session, not stored.
---
@@ -82,13 +84,15 @@ ferrous_solitaire/
│ ├── win_fanfare.wav
│ └── ambient_loop.wav
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── solitaire_core/ # Pure Rust game rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
├── solitaire_sync/ # Shared API types — used by client and server
├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
── solitaire_app/ # Main binary entry point
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) + web frontend
├── solitaire_wasm/ # WebAssembly bindings — browser-side logic/replay + debug bridge
── solitaire_web/ # Bevy WASM canvas build for the browser /play route
├── solitaire_assetgen/ # One-shot generator for card/background PNG assets
└── solitaire_app/ # Main binary entry point (desktop + Android cdylib)
```
---
@@ -96,18 +100,30 @@ ferrous_solitaire/
## 3. Crate Responsibilities
### `solitaire_core`
**Dependencies:** `rand`, `serde`, `chrono` only.
**Dependencies:** `serde`, `thiserror`, plus the upstream `card_game` and
`klondike` crates (pinned via the Quaternions registry — never edit upstream).
The entire game rules engine. No Bevy, no network, no file I/O. Designed to be tested in isolation with `cargo test -p solitaire_core`.
The game rules layer. No Bevy, no network, no file I/O. Designed to be tested
in isolation with `cargo test -p solitaire_core`.
Since the card_game migration (2026-06-22, PR #88) the primitive types are
**upstream**: `Card`, `Deck`, `Suit`, `Rank`, `Session` come from `card_game`;
`Klondike`, `KlondikePile`, `KlondikeInstruction`, `DrawStockConfig`,
`Foundation`, `Tableau` come from `klondike`. `solitaire_core` re-exports them
so downstream crates import from one place and never depend on the upstream
crates directly.
Owns:
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`)
- Move validation logic
- Scoring engine
- Undo stack
- `GameState` — a wrapper around the upstream `Session<Klondike>`; the session
is the single source of truth for board state and stats
- `MoveError` and the `Result`-based mutation API
- `KlondikeConfig` adaptation (`klondike_adapter`) — draw mode, scoring,
take-from-foundation
- `GameMode` (Classic / Zen / Challenge / TimeAttack) and mode-aware scoring
- Solvability check API (`SolveOutcome`, delegating to `Session::solve`)
- Win / auto-complete detection
- Achievement unlock condition evaluation
- Seeded RNG for reproducible deals
- Seeded deals (same seed ⇒ same layout, via the upstream dealer)
**Rules decisions:**
- **Stock recycling is unlimited in every draw mode — by design.** Extra
@@ -189,9 +205,13 @@ Owns:
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
### `solitaire_app`
**Dependencies:** `bevy`, `solitaire_engine`.
**Dependencies:** `bevy`, `solitaire_engine`, `solitaire_data` (+ `jni` on Android).
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
Thin entry point (desktop binary + Android `cdylib`). Registers all Bevy
plugins and sets initial window properties. The one crate in the workspace
allowed `unsafe`: the Android entry point reconstructs the raw JNI handles and
hands them to the safe `solitaire_data::android_jni` bridge; everything else
is `forbid(unsafe_code)`.
---
@@ -559,26 +579,36 @@ This ensures all players worldwide get the same challenge for a given date, rega
### Core Game Models (`solitaire_core`)
Since the card_game migration, the primitives are upstream types re-exported
through `solitaire_core`:
```rust
pub enum Suit { Clubs, Diamonds, Hearts, Spades }
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
// From `card_game` (upstream — never edit):
pub enum Suit { /* Clubs, Diamonds, Hearts, Spades */ }
pub enum Rank { /* Ace ..= King */ }
pub struct Card { /* deck + suit + rank; identity type, no face_up flag —
facing is positional, tracked by the Klondike board */ }
pub struct Session<G> { /* replayable instruction log + derived stats */ }
pub struct Card {
pub id: u32,
pub suit: Suit,
pub rank: Rank,
pub face_up: bool,
// From `klondike` (upstream — never edit):
pub enum KlondikePile {
Stock, // NB: no Waste variant — see below
Foundation(Foundation), // 4 slots, any suit may claim any slot
Tableau(Tableau), // 7 columns
}
// Pile-coordinate convention: upstream has no `Waste` variant. In
// pile-coordinate space `KlondikePile::Stock` denotes the face-up
// *waste* pile (the only stock-side pile cards move out of); use
// `GameState::stock_cards()` / `waste_cards()` when the face-down
// draw stack must be distinguished. Documented on `GameState::pile`.
pub enum DrawStockConfig { DrawOne, DrawThree }
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
serialized move format (schema v4+) */ }
```
pub enum PileType {
Stock,
Waste,
Foundation(Suit),
Tableau(usize), // 06
}
pub enum DrawMode { DrawOne, DrawThree }
Owned by `solitaire_core`:
```rust
/// Active game mode. Classic is the default; others unlock at level 5.
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
@@ -589,24 +619,30 @@ pub enum MoveError {
RuleViolation(String),
UndoStackEmpty,
GameAlreadyWon,
StockEmpty,
}
pub struct GameState {
pub piles: HashMap<PileType, Vec<Card>>,
pub draw_mode: DrawMode,
pub mode: GameMode,
pub score: i32,
pub move_count: u32,
pub undo_count: u32, // number of undos used in this game
pub recycle_count: u32, // number of stock recycles
pub elapsed_seconds: u64,
pub seed: u64,
pub is_won: bool,
pub is_auto_completable: bool,
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
pub seed: u64, // same seed ⇒ same deal
pub take_from_foundation: bool,
session: Session<Klondike>, // private — the single source of truth
}
```
**Derived, not stored:** `score()`, `move_count()`, `undo_count()`,
`recycle_count()`, `is_won()`, `is_auto_completable()`, and all pile
accessors read through the session. Undo replays the instruction log
(no snapshot stack); the 15 undo penalty is applied by the upstream
score formula via the session config. Persistence (schema v5) saves
`saved_moves` as upstream `KlondikeInstruction`s and rebuilds the
session by replay on load — older files carrying `score`/`undo_count`/
`recycle_count` keys load fine, the extra fields are ignored.
**Rules decision:** stock recycling is unlimited in every draw mode
(see the "Rules decisions" note in §3 `solitaire_core`).
### Persistence Models (`solitaire_data`)
```rust
@@ -644,7 +680,7 @@ pub struct AchievementRecord {
}
pub struct Settings {
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
pub sfx_volume: f32, // 0.01.0
pub music_volume: f32,
pub animation_speed: AnimSpeed,
+59
View File
@@ -6,6 +6,65 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.42.0] — 2026-07-06
### Added
- **CI workspace gate.** New `test.yml` workflow runs clippy (deny warnings)
and the full test suite on every master push and PR — previously no CI ran
tests at all. Caught its own first bug (missing Bevy native deps) on its
own PR. (#135)
- **Schedule ambiguity gate.** A headless test builds the gameplay plugin
cluster with Bevy ambiguity detection promoted to error. The initial
measurement found 302 system pairs with conflicting data access and no
ordering; four burn-down batches (PRs #146#149) took it to ZERO the same
day, and the gate now enforces 0. Keyboard consumption, board painting,
and HUD updates all have deterministic order for the first time.
### Changed
- **Browser canvas 36% smaller.** `canvas_bg.wasm` shrank 36.2 MB → 23.2 MB
via a size-focused `wasm-release` profile (fat LTO, single codegen unit,
opt-level "s"); verified visually identical in production. (#134)
- **Quaternions API adoption.** Canonical `FOUNDATIONS`/`TABLEAUS` consts in
`solitaire_core` replace five scattered enum lists; upstream
`Suit::SUITS`/`Rank::RANKS` replace nine hand-rolled arrays, with the
texture-atlas indexing re-keyed through tested canonical helpers. Net
177 lines. (#137)
### Fixed
- **Sync push race.** The server's load→merge→store cycle now runs in one
transaction; concurrent pushes from two devices can no longer overwrite
each other's merge. (#136)
- **Refresh-token rotation is single-use under concurrency** — rotation
gates on the DELETE's row count, so a stolen-then-replayed refresh token
loses the race and gets 401. (#136)
- **Exit sync push actually completes.** Was a detached task killed by
process teardown; now a bounded 2-second blocking wait on the app's final
frame. (#138)
- **Server auth hardening.** Login timing no longer reveals whether a
username exists; concurrent duplicate registration returns 409 instead of
500; avatar uploads are magic-byte checked. (#144, issues #139#141)
## [0.41.1] — 2026-07-06
### Fixed
- **Oversized pile-marker frames after fold/unfold.** `on_window_resized`
resized the marker fill sprite but never its children, so the outline frame
and the "A"/"K" watermark kept their spawn-time size after any resize —
rendering as oversized grey slabs over empty foundation slots on foldables
(found during Galaxy Fold 7 on-device verification of v0.41.0, fixed and
re-verified on the same device). Both children are now re-derived from the
new layout on every relayout.
### Added
- **Android relayout diagnostics.** Every layout recompute on Android now
logs its window dimensions and insets (`layout: resize to WxH …`) — the
evidence channel for foldable layout reports (#130).
## [0.41.0] — 2026-07-06
> Consolidates everything shipped since v0.39.0, including the v0.40.0v0.40.3
+3 -1
View File
@@ -30,7 +30,9 @@ solitaire_data/ # Persistence + sync client
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer)
solitaire_wasm/ # WASM bindings for browser-side replay player
solitaire_app/ # Entry binary
solitaire_web/ # Bevy WASM canvas build for the browser /play route
solitaire_assetgen/ # One-shot card/background PNG asset generator
solitaire_app/ # Entry binary (desktop + Android cdylib)
assets/ # Runtime assets (except audio + default theme)
```
+16
View File
@@ -156,3 +156,19 @@ opt-level = 3
[profile.release]
opt-level = 3
lto = "thin"
# Size-focused profile for the browser canvas build (solitaire_web →
# canvas_bg.wasm). Download size is the constraint on the web, not peak
# throughput: fat LTO + one codegen unit + opt-level "s" cut the Bevy wasm
# bundle substantially versus plain release. This is rustc-side sizing only —
# the binaryen pass in build_wasm.sh stays at wasm-opt -O2 because -Oz has
# miscompiled Bevy's render pipeline before (grey screen on first load).
[profile.wasm-release]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
# No `strip`: on wasm it also removes the target_features custom section,
# which makes wasm-opt reject the module ("all used features should be
# allowed" on trunc_sat). wasm-opt drops the name section in its output
# anyway, so strip buys nothing here.
+74 -30
View File
@@ -1,16 +1,57 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
**Last updated:** 2026-07-06 — v0.41.0 + v0.41.1 released and verified on a
physical Galaxy Fold 7; issue tracker empty except low-priority #130.
---
## Current state
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105#108).
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
- **Branch state:** `master` pushed to origin; latest work is the 2026-07-06
arc (PRs #121#131): scripted repo review, five issues filed and fixed,
plugin module splits, two releases.
- **Latest tags:** `v0.41.1` (pile-marker child-resize fix + Android relayout
logging) on top of `v0.41.0` (consolidated release for everything since
v0.39.0). Both released via tag push → CI signed APK; both verified
installed on hardware (`versionCode 4101`).
- **Working tree:** clean. Local `scripts/*.go` helpers are intentionally
gitignored (`.gitignore:43`); `scripts/watch_deploy.sh` is now committed.
- **Latest verification:** workspace clippy `--all-targets -D warnings`,
full test suite, `cargo ndk` clippy for `aarch64-linux-android`, CI release
builds green, and an on-device pass on the Fold 7 (fold/unfold layout,
safe-area resume, marker fix).
- **Issue tracker:** #116/#117/#118/#119/#120 all closed 2026-07-06. #130
(transient tableau clip after fold) open at low priority — did not
reproduce in repeat testing; v0.41.1's relayout logging is the evidence
channel if it recurs.
---
## 2026-07-06 session summary (v0.40.3 → v0.41.1)
- **Scripted repo review** (cratemap/todoctx/cargoclip/testfail): clippy
clean, tests green, error/SQL policies compliant. Five issues filed and
all resolved same-day.
- **#116 safe-area re-poll after resume** (PR #121): `refresh_insets` now
gates on the poll counter, settles per cycle, and rewrites insets only on
change. Verified on Fold 7 — note both Fold screens report identical
insets (top=110 bottom=0), so the re-poll path is a no-op on this device.
- **#117 Draw-1 recycle** (PR #122): unlimited recycling documented as an
intentional rules decision (ARCHITECTURE.md "Rules decisions" +
`draw_one_recycling_is_unlimited_by_design` lock-in test). A hard limit
would invalidate the difficulty seed catalog and the winnable-deal solver.
- **#118 module splits** (PRs #124/#125/#127/#128/#129): card, hud,
settings, game, and input plugins are now module directories; tests in
sibling `tests.rs`, runtime code split along system boundaries
(`pub(super)` items, mod.rs glob-imports children). Largest runtime file
is now `card_plugin/sync.rs` at 748 lines (was `card_plugin.rs` at 4,129).
- **v0.41.1 pile-marker fix** (PR #131): marker outline + "A"/"K" watermark
children are re-derived from the layout on every resize — previously
spawn-time-sized, rendering as oversized grey slabs on empty foundations
after fold/unfold. Found via photo evidence, fixed, re-verified on device.
- **Obtainium note:** reported "no suitable release" for v0.41.0 even though
the anonymous releases API, `releases/latest`, and the APK download were
all verified fine — client-side issue; sideload via adb was used instead.
---
@@ -127,32 +168,17 @@ Three bugs fixed:
## Open punch list
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
### 1. Physical-device smoke test — DONE (2026-07-06, Galaxy Fold 7)
This is the **single outstanding task** for the v0.40.0 Android release. Everything
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
clippy, release manifest sanity, a full local signed APK, the published release, and
Obtainium-facing delivery (public releases API, latest non-draft release, APK
downloadable anonymously). The only thing that cannot be done without hardware is
running the app on a real phone.
v0.41.1 installed via adb and the full device checklist passed on hardware:
fold/unfold layout on both screens (incl. the #116 resume path and the
pile-marker fix), safe-area inset resolution, Draw-Three waste fan tap
accuracy (#106), modal centring on both screens, drag-and-drop across all
pile types, text rendering, kill-and-restore, and the sync token flow.
Reminder for future gates: AVD is not a substitute — `adb shell input tap`
doesn't deliver real touch events.
Install the published APK on a real Android device (not AVD) and run the checklist
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
The signed release APK is published (grab it from the release page, or use the local
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
`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.
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
### 2. Matomo analytics live validation (independent — NOT a release blocker)
Separate, ongoing task unrelated to the Android release. `Settings` has
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
@@ -164,6 +190,24 @@ validation checklist and the current web/WASM decision notes.
## Architectural notes for next session
- **Plugin submodule pattern (2026-07-06 splits):** big plugins are module
directories: `mod.rs` holds types/markers/plugin-build and glob-imports the
children (`use input::*;`); children hold `pub(super)` systems and start
with `use super::*;` plus their own external imports. Tests (`tests.rs`)
may need explicit imports for names no longer used by `mod.rs` itself.
- **Marker child-resize rule:** anything spawned as a *child* of a
layout-sized entity (outline frames, watermark text) must be re-derived in
`on_window_resized` too — resizing only the parent sprite leaves children
at spawn-time size (the v0.41.1 foldable bug).
- **Fold 7 quirks:** both screens report identical safe-area insets
(top=110, bottom=0), so inset-driven relayout never fires on fold; layout
correctness across folds rides entirely on `WindowResized`. winit 0.30
logs `TODO: find a way to notify application of content rect change` on
resume — see #130 if a stale-width layout ever reproduces; v0.41.1 logs
every Android relayout (`layout: resize to WxH`) for exactly this.
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
(the trigger), not the `tick_*` system. If the component is never inserted, the
tick path never runs. See `hud_plugin.rs::detect_score_change` and
+4 -2
View File
@@ -67,7 +67,9 @@ if ! command -v wasm-bindgen &> /dev/null; then
fi
echo "Building solitaire_web (Bevy WASM app)..."
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
# wasm-release is the size-focused profile (fat LTO, CGU=1, opt-level "s") —
# see [profile.wasm-release] in Cargo.toml. Download size is the constraint.
cargo build --profile wasm-release --target wasm32-unknown-unknown -p solitaire_web
echo "Running wasm-bindgen for solitaire_web..."
wasm-bindgen \
@@ -75,7 +77,7 @@ wasm-bindgen \
--out-name canvas \
--target web \
--no-typescript \
"$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm"
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-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.
+26 -22
View File
@@ -439,10 +439,7 @@ impl GameState {
self.session.history().len()
}
fn cards_with_face(
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect()
}
@@ -507,8 +504,10 @@ impl GameState {
Self::cards_with_face(cards.iter().cloned(), true)
}
KlondikePile::Tableau(tableau) => {
let mut cards =
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
let mut cards = Self::cards_with_face(
state.tableau_face_down_cards(tableau).iter().cloned(),
false,
);
cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().cloned(),
true,
@@ -823,9 +822,7 @@ impl GameState {
) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state();
match instruction {
KlondikeInstruction::RotateStock => {
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None;
@@ -928,10 +925,7 @@ impl GameState {
///
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
pub fn apply_instruction(
&mut self,
instruction: KlondikeInstruction,
) -> Result<(), MoveError> {
pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
}
@@ -1296,12 +1290,13 @@ mod tests {
game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1));
assert!(
legal_pile_moves(&game)
.iter()
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|| !matches!(t, KlondikePile::Tableau(_)))
);
assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
f,
KlondikePile::Foundation(_)
) || !matches!(
t,
KlondikePile::Tableau(_)
)));
assert!(game.move_cards(from, to, 1).is_err());
}
@@ -1360,9 +1355,18 @@ mod tests {
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 = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
let medium =
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
let easy = GameState::solve_fresh_deal(
0xD1FF_0000_0000_0012,
DrawStockConfig::DrawOne,
1_000,
1_000,
);
let medium = GameState::solve_fresh_deal(
0xD1FF_0000_0000_0012,
DrawStockConfig::DrawOne,
5_000,
5_000,
);
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
+31 -1
View File
@@ -13,11 +13,41 @@ pub mod scoring;
// when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature.
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
pub use klondike::{
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
};
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module.
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
/// All four foundation slots, in slot order.
///
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
/// `Foundation::ALL` (unlike `Suit::SUITS` / `Rank::RANKS` in `card_game`),
/// and inherent impls cannot be added to a foreign type, so the workspace
/// const lives here. Use this instead of hand-rolling `[Foundation; 4]`
/// arrays; scattered copies can silently diverge.
pub const FOUNDATIONS: [Foundation; 4] = [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
];
/// All seven tableau columns, in column order (left to right on screen).
///
/// Canonical iteration source for `Tableau` — see [`FOUNDATIONS`] for why
/// this lives here rather than upstream.
pub const TABLEAUS: [Tableau; 7] = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
#[cfg(test)]
mod proptest_tests;
+9 -2
View File
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
);
}
for t in &tableaux {
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
cards.extend(
game.pile(KlondikePile::Tableau(*t))
.iter()
.map(|(c, _)| c.clone()),
);
}
cards
}
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
prop_oneof![
Just(DrawStockConfig::DrawOne),
Just(DrawStockConfig::DrawThree)
]
}
/// Apply a sequence of random actions to a game, silently ignoring errors.
+4 -2
View File
@@ -161,8 +161,10 @@ pub struct Settings {
/// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
/// always present; user-supplied themes register under their own ids.
/// Older `settings.json` files that stored `"default"` or `"classic"`
/// are migrated to `"dark"` by [`Settings::sanitized`].
/// Older `settings.json` files that stored `"default"` (the
/// pre-rename id of the dark theme) are migrated to `"dark"` by
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
/// and is never rewritten.
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been
+7 -3
View File
@@ -553,8 +553,8 @@ mod tests {
"saved file must use schema version 5",
);
let loaded = load_game_state_from(&path)
.expect("a valid in-progress game must load without error");
let loaded =
load_game_state_from(&path).expect("a valid in-progress game must load without error");
// The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON.
@@ -569,7 +569,11 @@ mod tests {
// Derived board reads match the live game (move count + recycle count are
// both rebuilt from the replayed forward history).
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
assert_eq!(
loaded.move_count(),
gs.move_count(),
"move_count round-trips"
);
assert_eq!(
loaded.recycle_count(),
gs.recycle_count(),
+35 -9
View File
@@ -77,6 +77,11 @@ pub struct SolitaireServerClient {
username: String,
/// Shared `reqwest` client (keeps connection pools alive across calls).
client: reqwest::Client,
/// Serialises token refreshes. The server rotates refresh tokens and each
/// is single-use, so two overlapping 401-retries must not both spend one:
/// the loser's (already-consumed) token would be rejected and surface as
/// a spurious "session expired" to the player. See [`Self::refresh_token`].
refresh_lock: tokio::sync::Mutex<()>,
}
#[cfg(not(target_arch = "wasm32"))]
@@ -90,6 +95,7 @@ impl SolitaireServerClient {
base_url: base_url.into().trim_end_matches('/').to_owned(),
username: username.into(),
client: reqwest::Client::new(),
refresh_lock: tokio::sync::Mutex::new(()),
}
}
@@ -172,7 +178,27 @@ impl SolitaireServerClient {
/// The server rotates refresh tokens on each call: the response includes a
/// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success.
async fn refresh_token(&self) -> Result<(), SyncError> {
///
/// `stale_access` is the access token that just earned the caller a 401.
/// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use
/// rotated refresh tokens, two overlapping 401-retries (e.g. a replay
/// upload racing a manual sync) must not both call `/api/auth/refresh` —
/// the second call would present an already-consumed token, get rejected,
/// and force a re-login for no user-visible reason. Whoever loses the lock
/// race checks whether the stored access token has already moved past
/// `stale_access`; if so the refresh already happened and there is nothing
/// left to do.
async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> {
let _guard = self.refresh_lock.lock().await;
if let Ok(current) = load_access_token(&self.username)
&& current != stale_access
{
// Another task refreshed while we waited on the lock; retry with
// the token it stored rather than spending the new refresh token.
return Ok(());
}
let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
@@ -231,7 +257,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -542,7 +568,7 @@ impl SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
+1 -2
View File
@@ -911,8 +911,7 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode =
GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
+5 -3
View File
@@ -74,9 +74,11 @@ pub const ALL_RANKS: [Rank; 13] = [
Rank::King,
];
/// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches
/// `card_plugin::load_card_images` so the suit index used here lines
/// up with `CardImageSet.faces[suit]`.
/// Iteration order for the SVG generator and the pin test only —
/// output files are keyed by `suit_filename`, so no runtime index
/// depends on this order. Kept local (not `Suit::SUITS`) because
/// reordering would churn the pinned snapshot ordering for no
/// benefit.
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
+27 -24
View File
@@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
/// Returns the absolute path of the user-themes directory on the
/// current platform.
///
/// # Panics
///
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
/// Android always returns `Some`. The panic message names the
/// supported workaround ([`set_user_theme_dir`]).
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
/// returns an empty path — callers treat that as "no user themes" and
/// the bundled default theme still works. A warning naming the
/// [`set_user_theme_dir`] workaround is logged once. Android always
/// resolves.
pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone();
@@ -76,29 +76,32 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
/// Per-target-os resolution of the platform's data dir. Delegates
/// to [`solitaire_data::data_dir`] which encapsulates the
/// per-target shape (desktop: `dirs::data_dir()`; android: the
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
/// only when the underlying resolver returns `None`, which on
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
/// the panic message names the supported workaround.
/// hardcoded `/data/data/<package>/files` sandbox path).
///
/// When the resolver returns `None` — always on wasm32 (no
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
/// desktop — this degrades to an empty path, which downstream theme
/// scanning treats as "no user themes"; the bundled default theme is
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
/// themes must not take the whole game down with it.
fn detected_platform_data_dir() -> PathBuf {
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!(
"user_theme_dir(): platform data directory is unavailable. \
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
the OS reported no Application Support / AppData path. \
As a workaround call solitaire_engine::assets::user_dir::\
set_user_theme_dir() before App::run()."
)
use std::sync::Once;
static WARN_ONCE: Once = Once::new();
WARN_ONCE.call_once(|| {
bevy::log::warn!(
"user_theme_dir(): platform data directory is unavailable; \
user themes are disabled. On Linux check $XDG_DATA_HOME or \
$HOME; on macOS / Windows the OS reported no Application \
Support / AppData path. As a workaround call \
solitaire_engine::assets::user_dir::set_user_theme_dir() \
before App::run()."
);
});
}
PathBuf::new()
})
}
+14 -3
View File
@@ -46,6 +46,11 @@ pub struct AutoCompleteState {
/// Plugin that drives the auto-complete sequence.
pub struct AutoCompletePlugin;
/// Set wrapping the auto-complete detect/drive chain; HUD readers of
/// [`AutoCompleteState`] order themselves after it (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AutoComplete;
impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>()
@@ -58,7 +63,9 @@ impl Plugin for AutoCompletePlugin {
drive_auto_complete,
)
.chain()
.after(GameMutation),
.in_set(AutoComplete)
.after(GameMutation)
.before(crate::card_plugin::BoardVisuals),
);
}
}
@@ -167,9 +174,9 @@ mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
fn headless_app() -> App {
let mut app = App::new();
@@ -207,7 +214,11 @@ mod tests {
}
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
vec![solitaire_core::Card::new(
Deck::Deck1,
Suit::Clubs,
Rank::Ace,
)],
);
g.set_test_auto_completable(true);
let expected = (
@@ -72,9 +72,7 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move {
from: MoveRequestEvent,
},
Move { from: MoveRequestEvent },
Draw,
Undo,
}
+1 -4
View File
@@ -10,9 +10,7 @@ use crate::animation_plugin::EffectiveSlideDuration;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::layout::LayoutResource;
use crate::resources::DragState;
use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
};
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
///
@@ -197,4 +195,3 @@ pub(super) fn update_card_shadows_on_drag(
// ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system
// ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@
use super::*;
use bevy::color::Color;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
@@ -272,5 +271,3 @@ pub(super) fn find_top_card_at(
// ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator
// ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@
use super::*;
use bevy::color::Color;
use bevy::sprite::Anchor;
use solitaire_core::{Card, Rank, Suit};
@@ -205,4 +204,3 @@ pub(super) fn add_android_corner_label(
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems
// ---------------------------------------------------------------------------
+1 -6
View File
@@ -15,10 +15,7 @@ use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource;
use crate::table_plugin::PileMarker;
use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE,
};
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
/// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`].
@@ -347,5 +344,3 @@ pub(super) fn fill_tableau_fan_on_startup(
};
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
+58 -20
View File
@@ -14,8 +14,8 @@ use std::collections::HashMap;
use bevy::color::Color;
use bevy::prelude::*;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::Card;
use solitaire_core::{KlondikePile, Tableau};
use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
@@ -137,6 +137,23 @@ pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.408, 0.408);
/// high-contrast boost path.
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);
/// Canonical outer index of `s` in [`CardImageSet::faces`].
///
/// Derived from the upstream `card_game::Suit` discriminants (0..=3 in
/// `Suit::SUITS` order), so every reader and writer of `faces` computes
/// the same layout from the same source. Three hand-rolled copies of this
/// mapping once lived in card_plugin and theme/plugin and were one
/// reorder away from drawing the wrong art.
pub(crate) const fn suit_index(s: solitaire_core::Suit) -> usize {
s as usize
}
/// Canonical inner index of `r` in [`CardImageSet::faces`] — upstream
/// `card_game::Rank` discriminants are 1..=13 in `Rank::RANKS` order.
pub(crate) const fn rank_index(r: solitaire_core::Rank) -> usize {
r as usize - 1
}
/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
///
/// Loaded once at startup by [`load_card_images`]. When this resource is
@@ -146,8 +163,10 @@ pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);
pub struct CardImageSet {
/// Per-card face images indexed by `[suit][rank]`.
///
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
/// Rank order: Ace=0, Two=1 … King=12.
/// Layout is pinned to the upstream declaration order — index with
/// [`suit_index`] / [`rank_index`], never a hand-rolled match.
/// Suit order: `Suit::SUITS` (Spades=0, Hearts=1, Clubs=2, Diamonds=3).
/// Rank order: `Rank::RANKS` (Ace=0 … King=12).
pub faces: [[Handle<Image>; 13]; 4],
/// One handle per unlockable card-back design (indices 04). These
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
@@ -514,6 +533,17 @@ fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
pub struct CardPlugin;
/// System set for everything that paints the board: card sprites, pile
/// markers, shadows, highlights, badges. Members mutate `Sprite` /
/// `Transform` on board entities and run as a deterministic chain (see the
/// registration in [`CardPlugin`]'s `build`); table-plugin marker painters
/// order themselves after this set. UI-domain systems that touch `Sprite`/
/// `Transform` on non-board entities (HUD text pulses, modal cards) declare
/// `.ambiguous_with(BoardVisuals)` instead — the entity domains are
/// disjoint by design (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct BoardVisuals;
impl Plugin for CardPlugin {
fn build(&self, app: &mut App) {
// PostStartup ensures TablePlugin's Startup system has inserted
@@ -539,33 +569,41 @@ impl Plugin for CardPlugin {
update_stock_empty_indicator_startup,
),
)
// Layout recompute (UpdateOnResize) always precedes board
// painting, and the painters run as ONE deterministic chain in
// data-flow order: layout refinement → card authority → anims →
// shadows → highlights → indicators → resize snapping → labels.
// Every painter mutates card/marker Sprite+Transform, so without
// the chain each pair is a scheduler ambiguity (#143). All
// members are cheap and mostly change-gated; sequential
// execution is not a cost that matters here.
.configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
.add_systems(
Update,
(
update_tableau_fan_frac
.after(GameMutation)
.before(sync_cards_on_change),
sync_cards_on_change.after(GameMutation),
resync_cards_on_settings_change.before(sync_cards_on_change),
start_flip_anim.after(GameMutation),
update_tableau_fan_frac,
resync_cards_on_settings_change,
sync_cards_on_change,
start_flip_anim,
tick_flip_anim,
update_drag_shadow,
update_card_shadows_on_drag.after(sync_cards_on_change),
tick_hint_highlight,
update_card_shadows_on_drag,
handle_right_click,
tick_right_click_highlights,
clear_right_click_highlights_on_state_change.after(GameMutation),
clear_right_click_highlights_on_state_change,
clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation),
update_stock_count_badge
.after(GameMutation)
.run_if(resource_changed::<GameStateResource>),
collect_resize_events.after(LayoutSystem::UpdateOnResize),
snap_cards_on_window_resize.after(collect_resize_events),
),
tick_hint_highlight,
update_stock_empty_indicator,
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
collect_resize_events,
snap_cards_on_window_resize,
resize_android_corner_labels,
)
.chain()
.in_set(BoardVisuals)
.after(GameMutation),
);
app.add_systems(Update, resize_android_corner_labels);
app.add_systems(PostUpdate, rebuild_card_entity_index);
}
}
+1 -6
View File
@@ -2,7 +2,6 @@
use super::*;
use bevy::color::Color;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState;
@@ -12,10 +11,7 @@ use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource;
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{
STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY,
TYPE_BODY, Z_STOCK_BADGE,
};
use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
/// to signal to the player that there are no more cards to draw. Pure white
@@ -288,4 +284,3 @@ pub(super) fn update_stock_count_badge(
}
}
}
+10 -56
View File
@@ -6,9 +6,9 @@ use super::*;
use std::collections::{HashMap, HashSet};
use bevy::color::Color;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
@@ -105,25 +105,12 @@ pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut comma
return;
};
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const 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,
];
// faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
// card_plugin::mod; building from SUITS/RANKS order matches them.
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
std::array::from_fn(|ri| asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si])))
std::array::from_fn(|ri| {
asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::SUITS[si]))
})
});
let backs =
std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
@@ -149,27 +136,8 @@ pub(super) fn card_sprite(
) -> Sprite {
if let Some(set) = card_images {
let image = if face_up {
let suit_idx = match card.suit() {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
let rank_idx = match card.rank() {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
};
let suit_idx = suit_index(card.suit());
let rank_idx = rank_index(card.rank());
set.faces[suit_idx][rank_idx].clone()
} else if let Some(theme_back) = &set.theme_back {
// Active theme provides its own back — always wins over the
@@ -519,23 +487,10 @@ pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
cards.extend(game.stock_cards());
cards.extend(game.waste_cards());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
for foundation in solitaire_core::FOUNDATIONS {
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
for tableau in solitaire_core::TABLEAUS {
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
}
cards
@@ -745,4 +700,3 @@ pub(super) fn update_card_entity(
commands.entity(entity).insert(new_children_key);
}
}
+21 -34
View File
@@ -1,17 +1,16 @@
use super::*;
use crate::game_plugin::GamePlugin;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::table_plugin::TablePlugin;
use solitaire_core::Deck;
use bevy::window::WindowResized;
use solitaire_core::{Card, Rank, Suit};
use std::collections::HashSet;
use crate::events::StateChangedEvent;
use crate::game_plugin::GamePlugin;
use crate::layout::LayoutResource;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::resources::DragState;
use crate::table_plugin::TablePlugin;
use crate::ui_theme::TEXT_PRIMARY_HC;
use bevy::window::WindowResized;
use solitaire_core::Deck;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use std::collections::HashSet;
/// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card {
@@ -119,8 +118,7 @@ fn waste_draw_one_only_renders_top_card() {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -163,8 +161,7 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() {
"need at least 3 waste cards for this test"
);
let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -216,8 +213,7 @@ fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -254,8 +250,7 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions
@@ -745,8 +740,7 @@ fn advance_past_resize_throttle(app: &mut App) {
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height.
let window = Entity::from_raw_u32(0)
.expect("Entity::from_raw_u32(0) is a valid placeholder");
let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized {
window,
width,
@@ -928,8 +922,7 @@ fn resize_in_place_updates_card_label_font_size() {
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the
// refreshed Layout.
let expected_layout =
crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!(
(after - expected).abs() < 1e-3,
@@ -1046,7 +1039,8 @@ fn shadow_offset_increases_during_drag() {
q.iter(app.world())
.next()
.expect("fixture should spawn at least one CardEntity")
.card.clone()
.card
.clone()
};
// Pick a *different* card to act as the negative control —
@@ -1101,9 +1095,7 @@ fn shadow_offset_increases_during_drag() {
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
// Map every CardEntity to its (Entity, card).
let card_entity = {
let mut q = app
.world_mut()
.query::<(Entity, &CardEntity)>();
let mut q = app.world_mut().query::<(Entity, &CardEntity)>();
q.iter(app.world())
.find(|(_, c)| c.card == *card)
.map(|(e, _)| e)
@@ -1198,8 +1190,7 @@ fn stock_badge_updates_when_stock_count_changes() {
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock: Vec<Card> =
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let _ = stock.pop();
game.0.set_test_stock_cards(stock);
}
@@ -1234,8 +1225,7 @@ fn image_set_with_distinct_back_handles() -> CardImageSet {
// distinct dummy `Image`. We never render these; we only
// compare ids.
let mut images = Assets::<Image>::default();
let backs: [Handle<Image>; 5] =
std::array::from_fn(|_| images.add(Image::default()));
let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs,
@@ -1493,8 +1483,7 @@ fn waste_pile_cards_have_strictly_increasing_z() {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
@@ -1543,8 +1532,7 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() {
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter()
@@ -1573,8 +1561,7 @@ fn waste_pile_draw_one_cards_have_distinct_z() {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
+8 -8
View File
@@ -35,8 +35,8 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
@@ -437,7 +437,8 @@ fn tableau_or_stack_pos(
base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
{
let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32;
@@ -581,7 +582,10 @@ mod tests {
use crate::layout::compute_layout;
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
/// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers
@@ -630,11 +634,7 @@ mod tests {
// — same colour family, illegal. Tableau(2) must NOT be
// highlighted.
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
);
set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
let mut app = overlay_test_app(game);
+4 -1
View File
@@ -54,7 +54,10 @@ impl DifficultyIndexResource {
DifficultyLevel::Hard => &mut self.hard,
DifficultyLevel::Expert => &mut self.expert,
DifficultyLevel::Grandmaster => &mut self.grandmaster,
DifficultyLevel::Random => unreachable!("Random has no catalog"),
// Random has no catalog today, so seeds_for() already returned
// None above; if it ever gains one, a time seed is still the
// right answer for "Random" — never a reachable panic.
DifficultyLevel::Random => return seed_from_system_time(),
};
let seed = catalog[*cursor % catalog.len()];
*cursor = cursor.wrapping_add(1);
+1 -1
View File
@@ -2,8 +2,8 @@
use bevy::prelude::Message;
use solitaire_core::KlondikePile;
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode;
use solitaire_core::{Card, Suit};
use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
+8 -2
View File
@@ -852,7 +852,10 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
@@ -906,7 +909,10 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
+86 -15
View File
@@ -14,8 +14,13 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
use solitaire_core::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use solitaire_data::{
@@ -63,6 +68,29 @@ pub struct GameOverScreen;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct GameMutation;
/// System set for every writer of [`crate::events::NewGameRequestEvent`].
///
/// Many UI entry points fire this trigger (buttons, keyboard, modals,
/// mode pickers). Their relative append order within a frame is
/// meaningless — consumers drain the whole queue — so members are
/// registered `.in_set(NewGameRequestWriters).ambiguous_with(NewGameRequestWriters)`
/// to declare writer-vs-writer order irrelevant instead of leaving it as an
/// ambiguity (#143). Only ever combine with `.ambiguous_with` on the same
/// set; do NOT hang ordering edges off this set.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct NewGameRequestWriters;
/// Self-ambiguous set for writers of `UndoRequestEvent` — same rationale as
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
/// meaningless (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct UndoRequestWriters;
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
/// arrival order and any same-frame order is fine (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct InfoToastWriters;
/// Persistence path for the in-progress game state file. `None` disables I/O.
#[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>);
@@ -165,7 +193,9 @@ impl Plugin for GamePlugin {
)
} else {
(
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
saved.unwrap_or_else(|| {
GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)
}),
None,
)
};
@@ -208,28 +238,66 @@ impl Plugin for GamePlugin {
.add_message::<AppLifecycle>()
// add_message is idempotent; SettingsPlugin also registers this.
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
.add_systems(Update, poll_pending_new_game_seed.before(GameMutation))
.add_systems(
Update,
poll_pending_new_game_seed
.before(GameMutation)
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
)
.add_systems(
Update,
(handle_new_game, handle_draw, handle_move, handle_undo)
.chain()
.in_set(GameMutation),
)
.add_systems(Update, check_no_moves.after(GameMutation))
.add_systems(
Update,
check_no_moves
.after(GameMutation)
.before(crate::card_plugin::BoardVisuals)
.in_set(InfoToastWriters)
.ambiguous_with(InfoToastWriters),
)
.add_systems(Update, record_replay_on_win.after(GameMutation))
.add_systems(Update, handle_confirm_input.after(GameMutation))
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
.add_systems(Update, handle_game_over_input.after(GameMutation))
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
.add_systems(
Update,
(
handle_confirm_input,
handle_confirm_button_input,
handle_game_over_input,
handle_game_over_button_input,
)
.after(GameMutation)
.before(crate::ui_focus::FocusKeys)
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters)
.in_set(UndoRequestWriters)
.ambiguous_with(UndoRequestWriters),
)
// Restore prompt: spawn the modal once the splash is gone,
// route Continue / New Game intents back into the existing
// GameMutation flow.
.add_systems(Update, spawn_restore_prompt_if_pending)
.add_systems(Update, handle_restore_prompt.before(GameMutation))
.add_systems(Update, sync_settings_to_game.before(GameMutation))
// All pre-mutation game-state writers are chained: elapsed
// time ticks first, settings sync next, then the restore prompt —
// a deterministic spine instead of three unordered ResMut holders
// (ambiguity burn-down, #143).
.add_systems(
Update,
(
tick_elapsed_time,
sync_settings_to_game,
spawn_restore_prompt_if_pending,
handle_restore_prompt
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
)
.chain()
.after(crate::settings_plugin::SettingsMutation)
.before(GameMutation),
)
.init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time)
.add_systems(Update, auto_save_game_state)
.add_systems(Update, auto_save_game_state.after(GameMutation))
.add_systems(Last, save_game_state_on_exit);
}
}
@@ -1263,7 +1331,10 @@ fn auto_save_game_state(
// or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk.
if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
if paused.is_some_and(|p| p.0)
|| game.0.is_won()
|| game.0.move_count() == 0
|| pending.0.is_some()
{
return;
}
+15 -8
View File
@@ -372,9 +372,7 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
});
app.update();
let events = app
.world()
.resource::<Messages<CardFlippedEvent>>();
let events = app.world().resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert!(
@@ -800,7 +798,10 @@ fn replay_recording_skips_undo() {
1,
"only the draw is recorded; the undo does not erase it nor add a new entry",
);
assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
assert!(matches!(
recording.moves[0],
KlondikeInstruction::RotateStock
));
}
/// Starting a new game wipes the recording so the next deal begins
@@ -872,8 +873,8 @@ fn replay_recording_freezes_into_replay_on_game_won() {
});
app.update();
let history = load_replay_history_from(&path)
.expect("a winning replay must be persisted to ReplayPath");
let history =
load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
assert_eq!(
history.replays.len(),
1,
@@ -1059,7 +1060,10 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
app.update();
// Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0
);
}
#[test]
@@ -1133,7 +1137,10 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0
);
assert_eq!(
app.world()
.resource::<GameStateResource>()
+3 -1
View File
@@ -432,7 +432,9 @@ fn build_home_context<'a>(
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
draw_mode: settings
.map(|s| s.0.draw_mode)
.unwrap_or(DrawStockConfig::DrawOne),
font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
+5 -4
View File
@@ -3,8 +3,6 @@
use super::*;
/// Auto-fade state for the action button bar. The bar fades out when
/// the cursor is in the play area (below the HUD band) and back in when
/// the cursor approaches the top of the window — same UX as a video
@@ -49,7 +47,11 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
#[cfg(not(target_os = "android"))]
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
pub(super) fn update_action_fade(
windows: Query<&Window>,
time: Res<Time>,
mut fade: ResMut<HudActionFade>,
) {
let Ok(window) = windows.single() else {
return;
};
@@ -427,4 +429,3 @@ pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
from.alpha + (to.alpha - from.alpha) * t,
)
}
@@ -3,8 +3,6 @@
use super::*;
/// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button
/// is held down. Each click handler fires the corresponding request event,
@@ -613,4 +611,3 @@ pub(super) fn toggle_hud_on_tap(
}
}
}
+78 -19
View File
@@ -19,7 +19,6 @@ use interaction::*;
use spawn::*;
use updates::*;
// 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.
@@ -36,7 +35,7 @@ use crate::events::{
UndoRequestEvent, WinStreakMilestoneEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
#[cfg(target_os = "android")]
use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT;
@@ -54,6 +53,7 @@ use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::SPACE_2;
use crate::ui_theme::UiTextFx;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
@@ -153,6 +153,13 @@ pub struct HudColumn;
#[derive(Component, Debug)]
pub struct HudActionBar;
/// Set wrapping the chained HUD button/popover interaction systems. Other
/// keyboard consumers order themselves around it (e.g.
/// [`crate::ui_focus::FocusKeys`] runs after) so input-consumption order is
/// deterministic (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct HudButtons;
/// Marker on the text node inside each touch-layout action-bar button.
/// Used by `resize_action_bar_labels` to update font size on window resize.
#[derive(Component, Debug)]
@@ -467,23 +474,56 @@ impl Plugin for HudPlugin {
// defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
.add_systems(Update, update_hud.after(GameMutation))
// HUD text updaters run as one deterministic chain (they write
// disjoint Text nodes, but Bevy can't prove it); update_hud also
// reads AutoCompleteState, so the chain sits after the
// auto-complete detect/drive chain (#143).
.add_systems(
Update,
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
(
update_hud,
update_selection_hud.run_if(
resource_exists_and_changed::<SelectionState>
.or(resource_exists_and_changed::<GameStateResource>),
),
update_won_previously,
)
.chain()
.after(GameMutation)
.after(crate::auto_complete_plugin::AutoComplete)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
)
.add_systems(Update, restore_hud_on_modal)
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
.add_systems(Update, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation))
// HUD chrome visibility: modal-restore writes HudVisibility, the
// applier consumes it, and the layout recompute reads it — a
// fixed chain instead of three racing systems (#143).
.add_systems(
Update,
update_selection_hud.run_if(
resource_exists_and_changed::<SelectionState>
.or(resource_exists_and_changed::<GameStateResource>),
(restore_hud_on_modal, apply_hud_visibility)
.chain()
.before(LayoutSystem::UpdateOnResize),
)
.add_systems(
Update,
(
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
handle_avatar_button.ambiguous_with(HudButtons),
),
)
.add_systems(Update, update_hud_typography)
.add_systems(
Update,
announce_auto_complete
.after(GameMutation)
.after(crate::auto_complete_plugin::AutoComplete)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// Typography rescale touches HUD TextFont only, but orders after
// the board painters that resize card/label text (#143).
.add_systems(
Update,
update_hud_typography.after(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(
@@ -492,24 +532,40 @@ impl Plugin for HudPlugin {
advance_score_floater,
)
.chain()
.after(GameMutation),
.after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(start_streak_flourish, advance_streak_flourish)
.chain()
.after(GameMutation),
.after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(
handle_new_game_button,
handle_undo_button,
handle_new_game_button
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_undo_button
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation),
handle_pause_button,
handle_help_button,
handle_hint_button,
handle_hint_button
.after(GameMutation)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_modes_button,
handle_mode_option_click,
handle_mode_option_click
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_modes_backdrop_click,
close_modes_popover_on_escape,
handle_menu_button,
@@ -517,7 +573,10 @@ impl Plugin for HudPlugin {
handle_menu_backdrop_click,
close_menu_popover_on_escape,
paint_action_buttons,
),
)
.chain()
.in_set(HudButtons)
.before(crate::ui_focus::FocusKeys),
)
// Fade lives in `Last` so it always overrides whatever the
// hover/paint pass set on `BackgroundColor` this frame.
+3 -3
View File
@@ -2,7 +2,6 @@
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource;
@@ -292,7 +291,9 @@ pub(super) fn spawn_avatar_child(
const SIZE: f32 = 32.0;
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));
commands
.entity(parent)
.insert(BackgroundColor(ACCENT_PRIMARY));
// Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| {
b.spawn((
@@ -549,4 +550,3 @@ pub(super) fn spawn_action_button<M: Component>(
}
});
}
+38 -10
View File
@@ -40,9 +40,16 @@ fn read_hud_text<M: Component>(app: &mut App) -> String {
#[test]
fn score_reflects_game_state() {
let mut app = headless_app();
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
let score = app
.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(20);
app.update();
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
assert_eq!(
read_hud_text::<HudScore>(&mut app),
format!("Score: {score}")
);
}
#[test]
@@ -192,7 +199,9 @@ fn challenge_time_color_zero_is_danger() {
fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -207,7 +216,9 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
target_score: None,
max_time_secs: Some(300),
});
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
}
@@ -222,7 +233,9 @@ fn challenge_hud_shows_score_goal_when_resource_present() {
target_score: Some(4000),
max_time_secs: None,
});
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
}
@@ -238,7 +251,10 @@ fn challenge_hud_clears_on_win() {
max_time_secs: Some(300),
});
// Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_won(true);
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -384,7 +400,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater.
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
// One floater should now exist.
@@ -405,7 +424,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
#[test]
fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
@@ -431,7 +453,10 @@ fn score_increase_below_threshold_does_not_spawn_floater() {
let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant.
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(5);
app.update();
assert_eq!(
count_with::<ScoreFloater>(&mut app),
@@ -507,7 +532,10 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
+1 -2
View File
@@ -3,9 +3,9 @@
use super::*;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState;
@@ -609,4 +609,3 @@ pub(super) fn resize_action_bar_labels(
font.font_size = new_size;
}
}
+24 -41
View File
@@ -27,9 +27,10 @@ use bevy::prelude::*;
use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::{Card, Suit};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning;
@@ -393,7 +394,10 @@ pub fn emit_hint_visuals(
// Find the top face-up card in the source pile and highlight it.
let source_cards = pile_cards(game, from);
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
let top_card = source_cards
.last()
.filter(|(_, face_up)| *face_up)
.map(|(c, _)| c.clone());
if let Some(card) = top_card {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card == card {
@@ -830,9 +834,7 @@ fn end_drag(
let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() {
for card in &drag.cards {
let Some(stack_index) =
origin_cards.iter().position(|(c, _)| c == card)
else {
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else {
continue;
};
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
@@ -1069,8 +1071,7 @@ fn touch_end_drag(
let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() {
for card in &drag.cards {
let Some(stack_index) =
origin_cards.iter().position(|(c, _)| c == card)
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card)
else {
continue;
};
@@ -1173,7 +1174,8 @@ fn card_position(
y_offset -= layout.card_size.y * step;
}
Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
{
// In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers — a
@@ -1247,7 +1249,10 @@ fn find_draggable_at(
}
(i, i + 1)
};
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
let cards: Vec<Card> = pile_cards[start..end]
.iter()
.map(|(c, _)| c.clone())
.collect();
return Some((pile, start, cards));
}
}
@@ -1329,13 +1334,13 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?;
for foundation in foundations() {
for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(&source, &dest, 1) {
return Some(dest);
}
}
for tableau in tableaus() {
for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(&source, &dest, 1) {
return Some(dest);
@@ -1356,7 +1361,7 @@ pub fn best_tableau_destination_for_stack(
game: &GameState,
stack_count: usize,
) -> Option<(KlondikePile, usize)> {
for tableau in tableaus() {
for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(from, &dest, stack_count) {
return Some((dest, stack_count));
@@ -1549,8 +1554,7 @@ fn handle_double_tap(
return;
}
let Some((found_card, found_face_up)) =
pile_cards.iter().find(|(c, _)| c == top_card)
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
else {
return;
};
@@ -1692,7 +1696,7 @@ pub(crate) fn hint_piles(
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
let sources: Vec<KlondikePile> = {
let mut s = vec![KlondikePile::Stock];
for tableau in tableaus() {
for tableau in TABLEAUS {
s.push(KlondikePile::Tableau(tableau));
}
s
@@ -1706,7 +1710,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue;
};
for foundation in foundations() {
for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(from, &dest, 1) {
hints.push((*from, dest));
@@ -1728,7 +1732,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
if already_has_foundation_hint {
continue;
}
for tableau in tableaus() {
for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(from, &dest, 1) {
hints.push((*from, dest));
@@ -1742,13 +1746,13 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
// should never hint Foundation→Foundation. Here we handle the return path
// separately so the guarded `take_from_foundation` rule is respected.
if game.take_from_foundation {
for foundation in foundations() {
for foundation in FOUNDATIONS {
let from = KlondikePile::Foundation(foundation);
let from_pile = pile_cards(game, &from);
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue;
};
for tableau in tableaus() {
for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(&from, &dest, 1) {
hints.push((from, dest));
@@ -1782,27 +1786,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
}
}
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,
]
}
const fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
+35 -35
View File
@@ -89,9 +89,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
let waste = vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
];
game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top
@@ -99,7 +101,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
assert_eq!(
result.2,
vec![waste[top_index].clone()],
"drags the top card only"
);
}
#[test]
@@ -176,8 +182,7 @@ fn find_draggable_skips_face_down_cards() {
// face-up card, but the iterator should skip face-down cards and
// the cursor sits above the face-up card's AABB, so the result
// is None.
let face_down_pos =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let result = find_draggable_at(face_down_pos, &game, &layout);
assert!(result.is_none(), "face-down cards should not be draggable");
}
@@ -195,8 +200,7 @@ fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
let face_up_pos =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let result = find_draggable_at(face_up_pos, &game, &layout)
.expect("clicking the face-up card's visible centre must initiate a drag");
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
@@ -213,18 +217,14 @@ fn find_draggable_returns_run_when_picking_mid_stack() {
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![king, queen.clone(), jack.clone()],
);
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// The Queen's geometric center (index 1) is inside the Jack's bounding box
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
let queen_center =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
@@ -322,7 +322,11 @@ fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
);
let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, KlondikePile::Stock);
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
assert_eq!(
ids,
vec![four_clubs],
"only the top card is draggable from waste"
);
}
#[test]
@@ -558,8 +562,7 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim =
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!(
(anim.end - target_pos).length() < 1e-6,
@@ -577,8 +580,7 @@ fn rejected_drag_animation_targets_origin_resting_position() {
fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0);
let anim =
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!(
(anim.start - drag_pos).length() < 1e-6,
@@ -601,12 +603,8 @@ fn rejected_drag_animation_starts_from_drag_position() {
/// the call site honest.
#[test]
fn rejected_drag_animation_uses_correct_duration() {
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
let anim =
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
@@ -620,12 +618,8 @@ fn rejected_drag_animation_uses_correct_duration() {
/// jittery rather than forgiving.
#[test]
fn rejected_drag_animation_uses_responsive_curve() {
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
let anim =
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
assert_eq!(
anim.curve,
MotionCurve::Responsive,
@@ -683,10 +677,16 @@ fn pressing_h_spawns_pending_hint_task() {
app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(LayoutResource(
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
));
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
app.insert_resource(LayoutResource(compute_layout(
Vec2::new(1280.0, 800.0),
0.0,
0.0,
true,
)));
app.insert_resource(GameStateResource(GameState::new(
42,
DrawStockConfig::DrawOne,
)));
app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame.
+2 -4
View File
@@ -862,10 +862,8 @@ fn handle_display_name_confirm(
.leaderboard_display_name
.clone()
.unwrap_or_else(|| {
if let SyncBackend::SolitaireServer {
ref username,
..
} = settings.0.sync_backend
if let SyncBackend::SolitaireServer { ref username, .. } =
settings.0.sync_backend
{
username.chars().take(32).collect()
} else {
+1
View File
@@ -43,6 +43,7 @@ pub mod replay_overlay;
pub mod replay_playback;
pub mod resources;
pub mod safe_area;
mod schedule_checks;
pub mod selection_plugin;
pub mod settings_plugin;
pub mod splash_plugin;
+1 -1
View File
@@ -27,6 +27,7 @@ use solitaire_data::{Settings, save_settings_to};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::splash_plugin::SplashRoot;
use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header,
@@ -36,7 +37,6 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// ---------------------------------------------------------------------------
+4 -1
View File
@@ -969,7 +969,10 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.update();
app
}
+25 -41
View File
@@ -178,9 +178,9 @@ mod tests {
use super::*;
use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches.
@@ -220,32 +220,11 @@ mod tests {
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
for tableau in solitaire_core::TABLEAUS {
game.set_test_tableau_cards(tableau, Vec::new());
}
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
];
let suits = Suit::SUITS;
let ranks_below_king = &Rank::RANKS[..12]; // everything below King
for (foundation, suit) in [
Foundation::Foundation1,
Foundation::Foundation2,
@@ -270,10 +249,7 @@ mod tests {
.into_iter()
.zip(suits.iter())
{
game.set_test_tableau_cards(
tableau,
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
}
game
}
@@ -288,9 +264,11 @@ mod tests {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() {
@@ -327,9 +305,11 @@ mod tests {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
assert!(
app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn",
@@ -365,18 +345,22 @@ mod tests {
let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn.
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present);
// Second spawn. The `spawn` helper drops the prior task
// before assigning the new one — at no point are two tasks
// in flight.
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
// Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the
+8 -27
View File
@@ -47,9 +47,10 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
@@ -254,13 +255,13 @@ pub fn legal_destinations_for_card(
game: &GameState,
) -> Vec<KlondikePile> {
let mut out = Vec::new();
for foundation in foundations() {
for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest);
}
}
for tableau in tableaus() {
for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest);
@@ -359,28 +360,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
}
}
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.
///
/// `half_extents` is the window half-size in world space — icons are clamped
@@ -399,8 +378,10 @@ fn build_radial_destinations(
.map(|(i, d)| {
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
let clamped = Vec2::new(
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
raw.x
.clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y
.clamp(-half_extents.y + margin, half_extents.y - margin),
);
(d, clamped)
})
@@ -246,7 +246,11 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
/// 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())),
Some((c, _)) => format!(
"{}{}",
format_rank_short(c.rank()),
format_suit_glyph(c.suit())
),
None => "--".to_string(),
}
}
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+106
View File
@@ -0,0 +1,106 @@
//! Schedule hygiene checks (issue #143).
//!
//! Bevy runs systems with conflicting data access in nondeterministic order
//! unless an ordering edge exists. Nothing enforced this historically, and
//! the headless gameplay cluster carries a large legacy backlog of such
//! pairs — most benign (event/resource writers that never observably race),
//! but unproven. Rather than a permanently red hard-error test or no test
//! at all, this is a RATCHET: the count may only go down. New conflicting
//! pairs fail CI immediately; the backlog can be triaged incrementally
//! (add `.before`/`.after` where order matters, `.ambiguous_with` where it
//! provably doesn't, then lower `AMBIGUITY_BASELINE`).
//!
//! To see the offending pair names, add the `bevy_debug` / debug feature to
//! the bevy dev-dependency (system names are stripped otherwise) and set
//! `ambiguity_detection: LogLevel::Error` manually.
//!
//! Scope note: `CoreGamePlugin` (the full composition) performs real
//! storage/platform I/O in `build` and cannot run in a unit test; the
//! cluster below is the same one the engine's headless behaviour tests use.
#[cfg(test)]
mod tests {
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
use bevy::prelude::*;
use crate::auto_complete_plugin::AutoCompletePlugin;
use crate::card_plugin::CardPlugin;
use crate::game_plugin::GamePlugin;
use crate::hud_plugin::HudPlugin;
use crate::settings_plugin::SettingsPlugin;
use crate::table_plugin::TablePlugin;
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
/// same day (#143, PRs #146#149) — this is now a hard gate. If your
/// change trips this assertion you have added a pair of systems with
/// conflicting data access and no ordering edge: add `.before`/`.after`
/// where order matters, or `.ambiguous_with` the relevant domain set
/// (BoardVisuals, MarkerVisuals, UiTextFx, HudButtons, writer sets)
/// where it provably does not. Do not raise this constant.
const AMBIGUITY_BASELINE: usize = 0;
fn cluster_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(HudPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(SettingsPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app
}
#[test]
fn update_schedule_ambiguities_do_not_grow() {
let mut app = cluster_app();
app.edit_schedule(Update, |s| {
s.set_build_settings(ScheduleBuildSettings {
ambiguity_detection: LogLevel::Error,
..Default::default()
});
});
// With LogLevel::Error the first schedule build panics when any
// ambiguity exists, and the panic message begins with the pair
// count. Catch it and ratchet on that count. (Parsing a panic
// message is brittle by design — if a Bevy upgrade rewords it,
// this test fails loudly and the parse below needs one-line
// maintenance, which is preferable to losing the ratchet.)
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
app.update();
}));
let count = match result {
Ok(()) => 0,
Err(payload) => {
let msg = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string()))
.unwrap_or_default();
let parsed = msg
.split(" pairs of systems")
.next()
.and_then(|prefix| prefix.split_whitespace().last())
.and_then(|n| n.parse::<usize>().ok());
parsed.unwrap_or_else(|| {
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
})
}
};
assert_eq!(
count, AMBIGUITY_BASELINE,
"system-order ambiguities changed from the enforced baseline. \
Add .before/.after or .ambiguous_with at the new registration site \
(or, if the count legitimately dropped below a nonzero baseline, \
lower AMBIGUITY_BASELINE).",
);
}
}
+11 -33
View File
@@ -37,9 +37,9 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -487,8 +487,10 @@ fn handle_selection_keys(
1
};
let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<Card> =
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
let lifted_cards: Vec<Card> = source_cards[start..]
.iter()
.map(|(c, _)| c.clone())
.collect();
let Some((bottom, _)) = source_cards.get(start) else {
return;
};
@@ -597,10 +599,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
/// This is intentionally separated from [`best_destination`] so the Enter
/// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest(
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?;
for foundation in [
Foundation::Foundation1,
@@ -697,13 +696,7 @@ fn update_selection_highlight(
if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0)
{
spawn_highlight_on_card(
&mut commands,
&card_index,
&card,
card_size,
source_color,
);
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
}
// Destination highlight while lifted.
@@ -714,13 +707,7 @@ fn update_selection_highlight(
// in destination-pick mode and the focused index is observable
// via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card(
&mut commands,
&card_index,
&card,
card_size,
dest_color,
);
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
}
}
}
@@ -1047,10 +1034,7 @@ mod tests {
press_key(&mut app, KeyCode::Tab);
app.update();
let selected = app
.world()
.resource::<SelectionState>()
.selected_pile;
let selected = app.world().resource::<SelectionState>().selected_pile;
// The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
@@ -1216,16 +1200,10 @@ mod tests {
drag.active_touch_id = None;
}
let before = app
.world()
.resource::<SelectionState>()
.selected_pile;
let before = app.world().resource::<SelectionState>().selected_pile;
press_key(&mut app, KeyCode::Tab);
app.update();
let after = app
.world()
.resource::<SelectionState>()
.selected_pile;
let after = app.world().resource::<SelectionState>().selected_pile;
assert_eq!(
before, after,
+33 -4
View File
@@ -79,6 +79,14 @@ pub struct PendingWindowGeometry {
#[derive(Message, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
/// System set for the systems that mutate [`SettingsResource`] every frame
/// (hotkeys and window-geometry persistence). Ordered before
/// [`crate::game_plugin::GameMutation`]; readers of settings should sit
/// after this set (directly, or transitively via `.after(GameMutation)`)
/// so they observe the current frame's settings deterministically (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsMutation;
/// Marker on the root Settings panel entity.
#[derive(Component, Debug)]
pub struct SettingsPanel;
@@ -372,16 +380,37 @@ impl Plugin for SettingsPlugin {
// also runs cleanly under `MinimalPlugins` (tests).
.add_message::<WindowResized>()
.add_message::<WindowMoved>()
// Settings changes land before game logic runs: the mutator
// chain (volume keys → geometry record → geometry persist) is a
// deterministic spine, and the whole set precedes GameMutation so
// every reader already ordered after GameMutation sees this
// frame's settings transitively (ambiguity burn-down, #143).
.configure_sets(
Update,
SettingsMutation
.after(crate::layout::LayoutSystem::UpdateOnResize)
.before(crate::game_plugin::GameMutation),
)
.add_systems(
Update,
(
handle_volume_keys,
toggle_settings_screen,
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
record_window_geometry_changes,
persist_window_geometry_after_debounce,
),
)
.chain()
.in_set(SettingsMutation),
)
.add_systems(
Update,
(
toggle_settings_screen
.before(crate::ui_focus::FocusKeys)
.ambiguous_with(crate::hud_plugin::HudButtons),
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
)
.chain(),
);
if self.ui_enabled {
+2 -4
View File
@@ -1500,10 +1500,8 @@ mod tests {
#[test]
fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 1800,
+26 -18
View File
@@ -272,12 +272,21 @@ fn poll_pull_result(
}
}
/// Last-schedule system: starts a best-effort push of the current local state
/// on [`AppExit`] without blocking the Bevy main thread.
/// Upper bound on how long [`push_on_exit`] may block the closing app.
/// Long enough for one healthy round-trip; short enough that quitting
/// never feels hung when the server is unreachable.
const EXIT_PUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// Last-schedule system: pushes the current local state on [`AppExit`],
/// blocking the main thread for at most [`EXIT_PUSH_TIMEOUT`].
///
/// The detached task may be cut short by process teardown, so local atomic
/// persistence remains the durable source of truth even if the final remote
/// push does not complete.
/// This deliberately blocks: the previous detached-task version was almost
/// always cut short by process teardown, so the final session's push
/// silently never happened (2026-07-06 review, finding M3). A bounded wait
/// during the app's final frame is invisible to the player and lets the
/// round-trip actually complete on a healthy network. On timeout or error
/// the push is skipped — local atomic persistence remains the durable
/// source of truth and the next launch's pull/push converges.
fn push_on_exit(
mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>,
@@ -292,16 +301,17 @@ fn push_on_exit(
exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone();
let rt = rt.0.clone();
AsyncComputeTaskPool::get()
.spawn(async move {
match rt.block_on(provider.push(&payload)) {
Ok(_) | Err(SyncError::UnsupportedPlatform) => {}
Err(e) => warn!("sync push on exit failed: {e}"),
}
})
.detach();
let result = rt.0.block_on(async {
tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
});
match result {
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
Err(_) => warn!(
"sync push on exit timed out after {}s; will sync on next launch",
EXIT_PUSH_TIMEOUT.as_secs()
),
}
}
/// Update-schedule system: on each `GameWonEvent` push the just-completed
@@ -657,9 +667,7 @@ mod tests {
);
// In-memory contract: replays[0].share_url is now Some(url).
let live = app
.world()
.resource::<ReplayHistoryResource>();
let live = app.world().resource::<ReplayHistoryResource>();
assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()),
+36 -36
View File
@@ -6,10 +6,12 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::KlondikePile;
use solitaire_core::Suit;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::hud_plugin::HudVisibility;
use crate::layout::{
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
@@ -83,6 +85,13 @@ pub struct HintPileHighlight {
/// Registers the table background and pile-marker rendering.
pub struct TablePlugin;
/// Set wrapping the pile-marker painter chain (theme, hint highlights,
/// visibility). Runs after [`crate::card_plugin::BoardVisuals`]; chrome-fx
/// systems that touch `Visibility` on UI entities declare themselves
/// ambiguous with it (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct MarkerVisuals;
impl Plugin for TablePlugin {
fn build(&self, app: &mut App) {
// Register WindowResized so the plugin works under MinimalPlugins in
@@ -99,10 +108,18 @@ impl Plugin for TablePlugin {
(
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
apply_theme_on_settings_change,
apply_hint_pile_highlight,
tick_hint_pile_highlights,
sync_pile_marker_visibility,
// Marker painters: deterministic chain after the card
// paint pipeline — markers and cards share Sprite/
// Transform access (#143).
(
apply_theme_on_settings_change,
apply_hint_pile_highlight,
tick_hint_pile_highlights,
sync_pile_marker_visibility.after(GameMutation),
)
.chain()
.in_set(MarkerVisuals)
.after(crate::card_plugin::BoardVisuals),
),
);
}
@@ -280,10 +297,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
piles.push(KlondikePile::Stock);
for foundation in foundations() {
for foundation in FOUNDATIONS {
piles.push(KlondikePile::Foundation(foundation));
}
for tableau in tableaus() {
for tableau in TABLEAUS {
piles.push(KlondikePile::Tableau(tableau));
}
@@ -368,7 +385,11 @@ fn on_window_resized(
>,
mut marker_outlines: Query<
&mut Sprite,
(Without<PileMarker>, Without<TableBackground>, Without<Text2d>),
(
Without<PileMarker>,
Without<TableBackground>,
Without<Text2d>,
),
>,
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
) {
@@ -420,8 +441,7 @@ fn on_window_resized(
// must be re-derived here too or a resize (fold/unfold, rotation)
// leaves them at the stale size — visible as oversized grey
// frames on empty piles.
let outline_size =
new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let font_size = new_layout.card_size.x * 0.28;
for child in children.into_iter().flatten() {
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
@@ -576,31 +596,11 @@ fn pile_cards(
}
}
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)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use solitaire_core::{Foundation, Tableau};
/// Minimal headless app — omits windowing so pile markers are spawned with
/// the default 1280×800 layout and no camera is created.
@@ -704,7 +704,10 @@ mod tests {
}
}
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
assert!(labels >= 11, "tableau + foundation markers carry watermarks");
assert!(
labels >= 11,
"tableau + foundation markers carry watermarks"
);
}
#[test]
@@ -940,10 +943,7 @@ mod tests {
#[test]
fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
.iter()
.map(suit_symbol)
.collect();
let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
}
+4 -16
View File
@@ -62,22 +62,10 @@ impl CardKey {
/// Iterator over all 52 valid keys, in suit-major / rank-ascending order.
/// Used to enumerate the manifest's required entries.
pub fn all() -> impl Iterator<Item = CardKey> {
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const 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,
];
// Order-independent enumeration — consumers check completeness and
// round-trips, never positions.
const SUITS: [Suit; 4] = Suit::SUITS;
const RANKS: [Rank; 13] = Rank::RANKS;
SUITS
.into_iter()
.flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r)))
+71 -85
View File
@@ -17,7 +17,7 @@ use solitaire_core::{Rank, Suit};
use crate::assets::{
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
};
use crate::card_plugin::CardImageSet;
use crate::card_plugin::{CardImageSet, rank_index, suit_index};
use crate::events::StateChangedEvent;
use super::loader::CardThemeLoader;
@@ -116,24 +116,32 @@ impl Plugin for ThemePlugin {
}
}
/// Resolves the manifest URL for `theme_id`: the bundled themes
/// (`"dark"`, `"classic"`) load from the embedded source; any other id
/// is a user theme served from the `themes://` directory source.
fn theme_manifest_url(theme_id: &str) -> String {
bundled_theme_url(theme_id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{theme_id}/theme.ron"))
}
/// Kicks off the initial theme load — the one named by
/// `Settings::selected_theme_id` if available, falling back to the
/// embedded default. The actual rasterisation runs asynchronously on
/// the asset task pool; the sync system below picks up the
/// `LoadedWithDependencies` event when every face + back is ready.
/// fresh-install default from `solitaire_data::Settings`. The actual
/// rasterisation runs asynchronously on the asset task pool; the sync
/// system below picks up the `LoadedWithDependencies` event when every
/// face + back is ready.
fn load_initial_theme(
asset_server: Res<AssetServer>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut commands: Commands,
) {
let default_id = solitaire_data::Settings::default().selected_theme_id;
let id = settings
.as_deref()
.map(|s| s.0.selected_theme_id.as_str())
.unwrap_or("classic");
let url = bundled_theme_url(id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
.unwrap_or(&default_id);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
commands.insert_resource(ActiveTheme(handle));
}
@@ -162,10 +170,7 @@ fn react_to_settings_theme_change(
return;
}
let url = bundled_theme_url(new_id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
commands.insert_resource(ActiveTheme(handle));
}
@@ -242,22 +247,8 @@ fn sync_card_image_set_with_active_theme(
/// `theme_back` when present, so writing here is sufficient to make
/// every face-down card pick up the theme's art on the next sync.
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
for rank in [
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
] {
for suit in Suit::SUITS {
for rank in Rank::RANKS {
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
}
@@ -266,41 +257,11 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
image_set.theme_back = Some(theme.back.clone());
}
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
const fn suit_index(s: Suit) -> usize {
match s {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
}
}
/// Index used by [`CardImageSet::faces`] for a given rank.
/// Ace=0, Two=1 … King=12.
const fn rank_index(r: Rank) -> usize {
match r {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
}
}
/// Switches the active theme to the one served at
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
/// so callers can poll `Assets<CardTheme>` if they want to wait for
/// the load before changing UI state.
/// Switches the active theme to `theme_id` — the embedded source for
/// the bundled `"dark"` / `"classic"` themes, `themes://` for user
/// themes. Returns the new `Handle<CardTheme>` so callers can poll
/// `Assets<CardTheme>` if they want to wait for the load before
/// changing UI state.
///
/// The handle is also written to the [`ActiveTheme`] resource — the
/// per-frame sync system picks up the `LoadedWithDependencies` event
@@ -311,8 +272,7 @@ pub fn set_theme(
asset_server: &AssetServer,
theme_id: &str,
) -> Handle<CardTheme> {
let url = format!("themes://{theme_id}/theme.ron");
let handle: Handle<CardTheme> = asset_server.load(url);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
commands.insert_resource(ActiveTheme(handle.clone()));
handle
}
@@ -446,21 +406,17 @@ mod tests {
}
#[test]
fn suit_index_ranges_match_card_plugin_layout() {
assert_eq!(suit_index(Suit::Clubs), 0);
assert_eq!(suit_index(Suit::Diamonds), 1);
assert_eq!(suit_index(Suit::Hearts), 2);
assert_eq!(suit_index(Suit::Spades), 3);
fn suit_index_matches_upstream_suits_order() {
for (i, s) in Suit::SUITS.iter().enumerate() {
assert_eq!(suit_index(*s), i, "faces outer layout = Suit::SUITS order");
}
}
#[test]
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() {
assert_eq!(rank_index(Rank::Ace), 0);
assert_eq!(rank_index(Rank::Two), 1);
assert_eq!(rank_index(Rank::Ten), 9);
assert_eq!(rank_index(Rank::Jack), 10);
assert_eq!(rank_index(Rank::Queen), 11);
assert_eq!(rank_index(Rank::King), 12);
fn rank_index_matches_upstream_ranks_order() {
for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(rank_index(*r), i, "faces inner layout = Rank::RANKS order");
}
}
#[test]
@@ -524,13 +480,43 @@ mod tests {
}
#[test]
fn set_theme_url_format_matches_themes_source() {
// The format string is the only behavioural surface of
// set_theme that doesn't require an App. We assert the URL
// shape so a future refactor doesn't accidentally change the
// path layout.
let url = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url, "themes://user_uploaded/theme.ron");
fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
// The bundled themes live in the binary, not in the user
// themes directory — resolving them to `themes://` would
// NotFound and silently leave the previous theme active.
assert_eq!(
theme_manifest_url("dark"),
crate::assets::DARK_THEME_MANIFEST_URL
);
assert_eq!(
theme_manifest_url("classic"),
crate::assets::CLASSIC_THEME_MANIFEST_URL
);
}
#[test]
fn theme_manifest_url_routes_user_ids_to_themes_source() {
// We assert the URL shape so a future refactor doesn't
// accidentally change the path layout of the user-themes
// source.
assert_eq!(
theme_manifest_url("user_uploaded"),
"themes://user_uploaded/theme.ron"
);
}
#[test]
fn initial_theme_fallback_matches_settings_default() {
// `load_initial_theme` falls back to the data-crate default
// when `SettingsResource` is absent. That default must always
// name a bundled theme, or a fresh minimal setup would resolve
// to a nonexistent user theme (see the v0.33.0 "classic"
// fallback drift in CHANGELOG.md).
let default_id = solitaire_data::Settings::default().selected_theme_id;
assert!(
bundled_theme_url(&default_id).is_some(),
"default theme id {default_id:?} must be a bundled theme"
);
}
/// Test 1: the bundled dark theme always has embedded SVG bytes
@@ -28,8 +28,8 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;
+20 -3
View File
@@ -117,6 +117,13 @@ pub struct FocusedButton(pub Option<Entity>);
/// gains keyboard navigation without per-plugin wiring.
pub struct UiFocusPlugin;
/// Set on [`handle_focus_keys`], the focus-ring keyboard navigator. It runs
/// AFTER every app-level keyboard consumer (HUD buttons/popovers, restore
/// prompt, settings toggle) so Esc/Tab consumption order is defined instead
/// of scheduler-dependent (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct FocusKeys;
impl Plugin for UiFocusPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<FocusedButton>()
@@ -147,9 +154,19 @@ impl Plugin for UiFocusPlugin {
(
sync_focus_on_mouse_click,
clear_hud_focus_on_unhover,
handle_focus_keys,
update_focus_overlay,
pulse_focus_overlay,
handle_focus_keys
.in_set(FocusKeys)
.after(crate::game_plugin::GameMutation),
update_focus_overlay
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::table_plugin::MarkerVisuals),
pulse_focus_overlay
.after(crate::settings_plugin::SettingsMutation)
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
)
.chain(),
);
+6 -1
View File
@@ -695,7 +695,12 @@ impl Plugin for UiModalPlugin {
advance_modal_enter,
paint_modal_buttons,
)
.chain(),
.chain()
.after(crate::settings_plugin::SettingsMutation)
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::hud_plugin::HudButtons),
);
// Click-outside-to-dismiss is independent of the open
// animation chain — it reads `just_pressed(Left)` and runs
+10 -4
View File
@@ -304,10 +304,7 @@ impl HighContrastBackground {
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray.
pub const fn with_hc(
default_color: Color,
hc_color: Color,
) -> Self {
pub const fn with_hc(default_color: Color, hc_color: Color) -> Self {
Self {
default_color,
hc_color,
@@ -698,3 +695,12 @@ mod tests {
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0);
}
}
/// System set for text/UI visual effects that animate `Transform`/`Sprite`
/// on chrome entities (HUD score pulse, streak flourish, modal enter, focus
/// ring). These never touch board entities, so members are declared
/// `.ambiguous_with(BoardVisuals)` and `.ambiguous_with(UiTextFx)` — the
/// entity domains are disjoint by construction and relative order within a
/// frame is invisible (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct UiTextFx;
+104 -17
View File
@@ -74,6 +74,15 @@ struct UserRow {
/// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
pub const BCRYPT_COST: u32 = 12;
/// Static bcrypt hash used to equalise login timing when the username does
/// not exist (issue #139: user-enumeration timing oracle). Both login paths
/// must pay the same bcrypt cost; this hash is verified against when there
/// is no real one. Computed once at first use with the same [`BCRYPT_COST`]
/// as real hashes. `None` only if bcrypt itself fails on a constant input —
/// in that case the dummy verify is skipped rather than panicking.
static DUMMY_PASSWORD_HASH: std::sync::LazyLock<Option<String>> =
std::sync::LazyLock::new(|| hash("ferrous-dummy-timing-pad", BCRYPT_COST).ok());
async fn hash_password(password: String) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST))
.await
@@ -208,6 +217,10 @@ pub async fn register(
let password_hash = hash_password(body.password).await?;
let now = Utc::now().to_rfc3339();
// The SELECT above is a friendly fast path; the UNIQUE constraint is the
// real arbiter. A concurrent registration that slips between the two
// surfaces as a unique violation here — map it to the same 409 the
// fast path produces instead of a raw 500 (issue #140).
sqlx::query!(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
user_id,
@@ -216,7 +229,11 @@ pub async fn register(
now
)
.execute(&state.pool)
.await?;
.await
.map_err(|e| match &e {
sqlx::Error::Database(db) if db.is_unique_violation() => AppError::UsernameTaken,
_ => AppError::from(e),
})?;
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
@@ -242,7 +259,16 @@ pub async fn login(
.fetch_optional(&state.pool)
.await?;
let row = row.ok_or(AppError::InvalidCredentials)?;
let Some(row) = row else {
// Unknown username: burn a bcrypt verify against a static dummy hash
// so this path costs the same as a wrong-password attempt. Returning
// immediately here would let response timing reveal which usernames
// exist (issue #139).
if let Some(dummy) = DUMMY_PASSWORD_HASH.as_ref() {
let _ = verify_password(body.password, dummy.clone()).await;
}
return Err(AppError::InvalidCredentials);
};
let row_id = row
.id
.ok_or_else(|| AppError::Internal("user id missing".into()))?;
@@ -283,23 +309,20 @@ pub async fn refresh(
// Tokens without jti predate rotation — require re-login.
let jti = claims.jti.ok_or(AppError::Unauthorized)?;
// Verify this jti is still live (not yet consumed or from a deleted account).
// SQLite TEXT columns are always nullable in sqlx; flatten the double-Option.
let exists: Option<String> =
sqlx::query_scalar!("SELECT jti FROM refresh_tokens WHERE jti = ?", jti)
.fetch_optional(&state.pool)
.await?
.flatten();
if exists.is_none() {
return Err(AppError::Unauthorized);
}
// Consume the old token before issuing new ones. If the insert below
// fails, the user loses this session (must re-login) — safe by design.
sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
// Consume the old token before issuing new ones, gating on the DELETE
// actually removing a row. rows_affected == 0 covers both "jti never
// existed / account deleted" and "a concurrent refresh already consumed
// it" — the previous SELECT-then-DELETE let two concurrent refreshes
// both pass the check and both mint fresh token pairs. The DELETE is
// the mutex: whoever removes the row wins; everyone else gets 401.
// If the insert below fails, the user loses this session (must
// re-login) — safe by design.
let deleted = sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
.execute(&state.pool)
.await?;
if deleted.rows_affected() != 1 {
return Err(AppError::Unauthorized);
}
let new_access = make_access_token(&claims.sub, &state.jwt_secret)?;
let (new_refresh, new_jti) = make_refresh_token(&claims.sub, &state.jwt_secret)?;
@@ -365,6 +388,22 @@ const AVATAR_MAX_BYTES: usize = 1024 * 1024;
///
/// The `Content-Type` header must be one of `image/jpeg`, `image/png`,
/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place.
/// Returns `true` when `body` begins with the magic bytes of the image type
/// implied by `ext` (the extension derived from the declared Content-Type).
///
/// Avatars are re-served under the stored extension, so the extension must be
/// derived from the content itself — a client header is not evidence of what
/// the bytes are (issue #141).
fn magic_bytes_match(ext: &str, body: &[u8]) -> bool {
match ext {
"png" => body.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
"jpg" => body.starts_with(&[0xFF, 0xD8, 0xFF]),
"gif" => body.starts_with(b"GIF87a") || body.starts_with(b"GIF89a"),
"webp" => body.len() >= 12 && body.starts_with(b"RIFF") && &body[8..12] == b"WEBP",
_ => false,
}
}
pub async fn upload_avatar(
State(state): State<AppState>,
user: AuthenticatedUser,
@@ -390,6 +429,14 @@ pub async fn upload_avatar(
if body.len() > AVATAR_MAX_BYTES {
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
}
// The stored extension decides how the file is re-served, so it must be
// backed by the bytes, not just the client's Content-Type header
// (issue #141).
if !magic_bytes_match(ext, &body) {
return Err(AppError::BadRequest(
"avatar bytes do not match the declared image type".into(),
));
}
// Write to avatars/ directory, replacing any previous file for this user.
tokio::fs::create_dir_all("avatars")
@@ -590,6 +637,46 @@ mod tests {
assert!(username_chars_ok(""));
}
#[test]
fn magic_bytes_match_accepts_real_signatures() {
assert!(magic_bytes_match(
"png",
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00]
));
assert!(magic_bytes_match("jpg", &[0xFF, 0xD8, 0xFF, 0xE0, 0x00]));
assert!(magic_bytes_match("gif", b"GIF89a\x00\x00"));
assert!(magic_bytes_match("gif", b"GIF87a\x00\x00"));
assert!(magic_bytes_match("webp", b"RIFF\x00\x00\x00\x00WEBPVP8 "));
}
#[test]
fn magic_bytes_match_rejects_mismatched_or_bogus_content() {
// HTML declared as PNG — the exact spoof the check exists to stop.
assert!(!magic_bytes_match("png", b"<html><script>"));
// Real PNG bytes declared as JPEG: extension must match the bytes.
assert!(!magic_bytes_match(
"jpg",
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
));
// Truncated / empty bodies never match.
assert!(!magic_bytes_match("png", &[0x89, b'P']));
assert!(!magic_bytes_match("webp", b"RIFF1234WEB"));
assert!(!magic_bytes_match("gif", b""));
// Unknown extensions are never accepted.
assert!(!magic_bytes_match("svg", b"<svg xmlns="));
}
#[test]
fn dummy_password_hash_is_available_and_verifiable() {
// The login timing pad (issue #139) must be a real bcrypt hash so the
// unknown-user path pays a genuine verify at BCRYPT_COST.
let dummy = DUMMY_PASSWORD_HASH
.as_ref()
.expect("bcrypt of a constant input must succeed");
assert!(verify("ferrous-dummy-timing-pad", dummy).unwrap_or(false));
assert!(!verify("wrong-password", dummy).unwrap_or(true));
}
#[test]
fn username_chars_ok_rejects_unicode_letters() {
// Non-ASCII characters must be rejected even if they look like letters.
+32 -16
View File
@@ -5,7 +5,6 @@
use axum::{Json, extract::State};
use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
use solitaire_sync::{
@@ -26,13 +25,19 @@ struct SyncRow {
/// Load the stored `SyncPayload` for `user_id` from the database.
/// Returns `None` if this user has not pushed any data yet.
async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRow>, AppError> {
///
/// Executor-generic so `push` can run it inside its transaction while
/// `pull` keeps passing the pool directly.
async fn load_sync_row(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
user_id: &str,
) -> Result<Option<SyncRow>, AppError> {
let row = sqlx::query_as!(
SyncRow,
"SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
user_id
)
.fetch_optional(pool)
.fetch_optional(exec)
.await?;
Ok(row)
}
@@ -69,7 +74,7 @@ fn row_to_payload(row: &SyncRow, user_id: &str) -> Result<SyncPayload, AppError>
/// Persist a `SyncPayload` for `user_id` using an upsert.
async fn store_payload(
pool: &SqlitePool,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
user_id: &str,
payload: &SyncPayload,
) -> Result<(), AppError> {
@@ -92,7 +97,7 @@ async fn store_payload(
progress_json,
now
)
.execute(pool)
.execute(exec)
.await?;
Ok(())
@@ -159,12 +164,20 @@ pub async fn push(
return Err(AppError::BadRequest("user_id mismatch".into()));
}
let server_payload = match load_sync_row(&state.pool, &user.user_id).await? {
// The whole read-merge-write cycle runs in ONE transaction. Without it,
// two devices pushing concurrently both read the same stored payload,
// merge independently, and the second store overwrites the first merge —
// the server visibly regresses until the losing device pushes again.
// SQLite serialises writers, so the second transaction simply waits.
let mut tx = state.pool.begin().await?;
let server_payload = match load_sync_row(&mut *tx, &user.user_id).await? {
Some(row) => row_to_payload(&row, &user.user_id)?,
None => {
// First push — nothing to merge against; store directly.
store_payload(&state.pool, &user.user_id, &client_payload).await?;
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &client_payload).await?;
store_payload(&mut *tx, &user.user_id, &client_payload).await?;
update_leaderboard_if_opted_in(&mut tx, &user.user_id, &client_payload).await?;
tx.commit().await?;
return Ok(Json(SyncResponse {
merged: client_payload,
server_time: Utc::now(),
@@ -175,8 +188,9 @@ pub async fn push(
let (merged, conflicts) = merge(&client_payload, &server_payload);
store_payload(&state.pool, &user.user_id, &merged).await?;
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &merged).await?;
store_payload(&mut *tx, &user.user_id, &merged).await?;
update_leaderboard_if_opted_in(&mut tx, &user.user_id, &merged).await?;
tx.commit().await?;
Ok(Json(SyncResponse {
merged,
@@ -188,16 +202,18 @@ pub async fn push(
/// If the user is opted in to the leaderboard, update their row with the
/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`.
///
/// The opt-in check and the update are performed atomically in a single
/// conditional UPDATE (WHERE EXISTS subquery) to avoid a TOCTOU race where
/// the user opts out between the check and the write.
/// Runs on the caller's transaction connection, so the opt-in check and the
/// update are atomic with the surrounding push — an opt-out between the check
/// and the write can no longer interleave. (An earlier doc comment claimed
/// this was a single conditional UPDATE; it has always been two statements —
/// the enclosing transaction is what actually provides the atomicity.)
async fn update_leaderboard_if_opted_in(
pool: &SqlitePool,
conn: &mut sqlx::SqliteConnection,
user_id: &str,
payload: &SyncPayload,
) -> Result<(), AppError> {
let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id)
.fetch_optional(pool)
.fetch_optional(&mut *conn)
.await?
.map(|r| r.leaderboard_opt_in)
.unwrap_or(0);
@@ -231,7 +247,7 @@ async fn update_leaderboard_if_opted_in(
now,
user_id
)
.execute(pool)
.execute(&mut *conn)
.await?;
Ok(())
+48 -48
View File
@@ -1649,63 +1649,63 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114842, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61868, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9815, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9828, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h876550298b312ff8);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7314, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9815, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9815, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9815, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9815, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9815, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9815, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9815, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9825, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9819, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h545edb23183e448a);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7313, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret;
},
__wbindgen_cast_000000000000000d: function(arg0) {
@@ -1769,55 +1769,55 @@ function __wbg_get_imports() {
};
}
function wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__hd94d76233321402f(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hd94d76233321402f(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h876550298b312ff8(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h876550298b312ff8(arg0, arg1, arg2, arg3);
function wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e(arg0, arg1, arg2, arg3);
}
function wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
function wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
}
Binary file not shown.
+39 -76
View File
@@ -19,11 +19,14 @@
//! is the contract.
use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::{KlondikeInstruction, KlondikePile};
use wasm_bindgen::prelude::*;
/// Mirrors `solitaire_data::Replay` v3.
@@ -145,21 +148,10 @@ impl ReplayPlayer {
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect()
};
let foundations: [Vec<CardSnapshot>; 4] = [
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation3)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation4)),
];
let tableaus: [Vec<CardSnapshot>; 7] = [
pile_cards(KlondikePile::Tableau(Tableau::Tableau1)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau2)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau3)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau4)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau5)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau6)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau7)),
];
let foundations: [Vec<CardSnapshot>; 4] =
solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
let tableaus: [Vec<CardSnapshot>; 7] =
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
StateSnapshot {
step_idx: self.step_idx,
total_steps: self.moves.len(),
@@ -353,21 +345,8 @@ fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
let stock = game.stock_cards();
let waste = game.waste_cards();
let foundations = [
game.pile(KlondikePile::Foundation(Foundation::Foundation1)),
game.pile(KlondikePile::Foundation(Foundation::Foundation2)),
game.pile(KlondikePile::Foundation(Foundation::Foundation3)),
game.pile(KlondikePile::Foundation(Foundation::Foundation4)),
];
let tableaus = [
game.pile(KlondikePile::Tableau(Tableau::Tableau1)),
game.pile(KlondikePile::Tableau(Tableau::Tableau2)),
game.pile(KlondikePile::Tableau(Tableau::Tableau3)),
game.pile(KlondikePile::Tableau(Tableau::Tableau4)),
game.pile(KlondikePile::Tableau(Tableau::Tableau5)),
game.pile(KlondikePile::Tableau(Tableau::Tableau6)),
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
];
let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
let mut duplicate_cards = Vec::new();
@@ -426,7 +405,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
false
});
let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let soft_lock =
!game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let state_ok = duplicate_cards.is_empty()
&& missing_cards.is_empty()
@@ -488,21 +468,8 @@ impl SolitaireGame {
.iter()
.map(CardSnapshot::from)
.collect(),
foundations: [
cards(KlondikePile::Foundation(Foundation::Foundation1)),
cards(KlondikePile::Foundation(Foundation::Foundation2)),
cards(KlondikePile::Foundation(Foundation::Foundation3)),
cards(KlondikePile::Foundation(Foundation::Foundation4)),
],
tableaus: [
cards(KlondikePile::Tableau(Tableau::Tableau1)),
cards(KlondikePile::Tableau(Tableau::Tableau2)),
cards(KlondikePile::Tableau(Tableau::Tableau3)),
cards(KlondikePile::Tableau(Tableau::Tableau4)),
cards(KlondikePile::Tableau(Tableau::Tableau5)),
cards(KlondikePile::Tableau(Tableau::Tableau6)),
cards(KlondikePile::Tableau(Tableau::Tableau7)),
],
foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
}
}
@@ -513,34 +480,21 @@ impl SolitaireGame {
let slot: u8 = s["foundation-".len()..]
.parse()
.map_err(|_| format!("bad pile: {s}"))?;
if slot >= 4 {
return Err(format!("foundation slot out of range: {slot}"));
}
Ok(KlondikePile::Foundation(match slot {
0 => Foundation::Foundation1,
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
3 => Foundation::Foundation4,
_ => return Err(format!("foundation slot out of range: {slot}")),
}))
let foundation = solitaire_core::FOUNDATIONS
.get(slot as usize)
.copied()
.ok_or_else(|| format!("foundation slot out of range: {slot}"))?;
Ok(KlondikePile::Foundation(foundation))
}
_ if s.starts_with("tableau-") => {
let col: usize = s["tableau-".len()..]
.parse()
.map_err(|_| format!("bad pile: {s}"))?;
if col >= 7 {
return Err(format!("tableau col out of range: {col}"));
}
Ok(KlondikePile::Tableau(match col {
0 => Tableau::Tableau1,
1 => Tableau::Tableau2,
2 => Tableau::Tableau3,
3 => Tableau::Tableau4,
4 => Tableau::Tableau5,
5 => Tableau::Tableau6,
6 => Tableau::Tableau7,
_ => return Err(format!("tableau col out of range: {col}")),
}))
let tableau = solitaire_core::TABLEAUS
.get(col)
.copied()
.ok_or_else(|| format!("tableau col out of range: {col}"))?;
Ok(KlondikePile::Tableau(tableau))
}
_ => Err(format!("unknown pile: {s}")),
}
@@ -936,7 +890,9 @@ mod tests {
}
let idx = pick_move_index(&legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) {
panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}");
panic!(
"failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"
);
}
}
@@ -1121,9 +1077,14 @@ mod tests {
let stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_cards().len();
assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start");
assert!(
stock_before > 0,
"seed {seed}: stock must be non-empty at start"
);
game.game.draw().expect("draw must succeed when stock is non-empty");
game.game
.draw()
.expect("draw must succeed when stock is non-empty");
assert_eq!(
game.game.stock_cards().len(),
@@ -1152,7 +1113,9 @@ mod tests {
"seed {seed}: stock must have at least 3 cards for this test"
);
game.game.draw().expect("draw must succeed when stock has cards");
game.game
.draw()
.expect("draw must succeed when stock has cards");
let expected_drawn = stock_before.min(3);
assert_eq!(
+1 -2
View File
@@ -41,8 +41,7 @@ pub fn start() {
// texture-dimension limit is now taken from the adapter (see
// the RenderPlugin below), so this is purely a quality/perf
// choice, no longer a crash-avoidance hack.
resolution: WindowResolution::default()
.with_scale_factor_override(1.0),
resolution: WindowResolution::default().with_scale_factor_override(1.0),
..default()
}),
..default()