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 # Ferrous Solitaire — Architecture Document
> **Version:** 1.3 > **Version:** 1.4
> **Language:** Rust (Edition 2024) > **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable) > **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 │ ├── win_fanfare.wav
│ └── ambient_loop.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_sync/ # Shared API types — used by client and server
├── solitaire_data/ # Persistence, sync client, settings ├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins ├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) ├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) + web frontend
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player ├── solitaire_wasm/ # WebAssembly bindings — browser-side logic/replay + debug bridge
── solitaire_app/ # Main binary entry point ── 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 ## 3. Crate Responsibilities
### `solitaire_core` ### `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: Owns:
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`) - `GameState` — a wrapper around the upstream `Session<Klondike>`; the session
- Move validation logic is the single source of truth for board state and stats
- Scoring engine - `MoveError` and the `Result`-based mutation API
- Undo stack - `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 - Win / auto-complete detection
- Achievement unlock condition evaluation - Achievement unlock condition evaluation
- Seeded RNG for reproducible deals - Seeded deals (same seed ⇒ same layout, via the upstream dealer)
**Rules decisions:** **Rules decisions:**
- **Stock recycling is unlimited in every draw mode — by design.** Extra - **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. 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` ### `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`) ### Core Game Models (`solitaire_core`)
Since the card_game migration, the primitives are upstream types re-exported
through `solitaire_core`:
```rust ```rust
pub enum Suit { Clubs, Diamonds, Hearts, Spades } // From `card_game` (upstream — never edit):
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King } 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 { // From `klondike` (upstream — never edit):
pub id: u32, pub enum KlondikePile {
pub suit: Suit, Stock, // NB: no Waste variant — see below
pub rank: Rank, Foundation(Foundation), // 4 slots, any suit may claim any slot
pub face_up: bool, 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 { Owned by `solitaire_core`:
Stock,
Waste,
Foundation(Suit),
Tableau(usize), // 06
}
pub enum DrawMode { DrawOne, DrawThree }
```rust
/// Active game mode. Classic is the default; others unlock at level 5. /// Active game mode. Classic is the default; others unlock at level 5.
pub enum GameMode { Classic, Zen, Challenge, TimeAttack } pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
@@ -589,24 +619,30 @@ pub enum MoveError {
RuleViolation(String), RuleViolation(String),
UndoStackEmpty, UndoStackEmpty,
GameAlreadyWon, GameAlreadyWon,
StockEmpty,
} }
pub struct GameState { pub struct GameState {
pub piles: HashMap<PileType, Vec<Card>>,
pub draw_mode: DrawMode,
pub mode: GameMode, 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 elapsed_seconds: u64,
pub seed: u64, pub seed: u64, // same seed ⇒ same deal
pub is_won: bool, pub take_from_foundation: bool,
pub is_auto_completable: bool, session: Session<Klondike>, // private — the single source of truth
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
} }
``` ```
**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`) ### Persistence Models (`solitaire_data`)
```rust ```rust
@@ -644,7 +680,7 @@ pub struct AchievementRecord {
} }
pub struct Settings { pub struct Settings {
pub draw_mode: DrawMode, pub draw_mode: DrawStockConfig,
pub sfx_volume: f32, // 0.01.0 pub sfx_volume: f32, // 0.01.0
pub music_volume: f32, pub music_volume: f32,
pub animation_speed: AnimSpeed, pub animation_speed: AnimSpeed,
+59
View File
@@ -6,6 +6,65 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [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 ## [0.41.0] — 2026-07-06
> Consolidates everything shipped since v0.39.0, including the v0.40.0v0.40.3 > 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_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer) solitaire_server/ # Axum backend (optional sync layer)
solitaire_wasm/ # WASM bindings for browser-side replay player 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) assets/ # Runtime assets (except audio + default theme)
``` ```
+16
View File
@@ -156,3 +156,19 @@ opt-level = 3
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = "thin" 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 # 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 ## 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). - **Branch state:** `master` pushed to origin; latest work is the 2026-07-06
- **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. arc (PRs #121#131): scripted repo review, five issues filed and fixed,
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed. plugin module splits, two releases.
- **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. - **Latest tags:** `v0.41.1` (pile-marker child-resize fix + Android relayout
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing. 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 ## 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 v0.41.1 installed via adb and the full device checklist passed on hardware:
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile + fold/unfold layout on both screens (incl. the #116 resume path and the
clippy, release manifest sanity, a full local signed APK, the published release, and pile-marker fix), safe-area inset resolution, Draw-Three waste fan tap
Obtainium-facing delivery (public releases API, latest non-draft release, APK accuracy (#106), modal centring on both screens, drag-and-drop across all
downloadable anonymously). The only thing that cannot be done without hardware is pile types, text rendering, kill-and-restore, and the sync token flow.
running the app on a real phone. 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 ### 2. Matomo analytics live validation (independent — NOT a release blocker)
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)
Separate, ongoing task unrelated to the Android release. `Settings` has Separate, ongoing task unrelated to the Android release. `Settings` has
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via `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 ## 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 - **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
(the trigger), not the `tick_*` system. If the component is never inserted, the (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 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 fi
echo "Building solitaire_web (Bevy WASM app)..." 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..." echo "Running wasm-bindgen for solitaire_web..."
wasm-bindgen \ wasm-bindgen \
@@ -75,7 +77,7 @@ wasm-bindgen \
--out-name canvas \ --out-name canvas \
--target web \ --target web \
--no-typescript \ --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). # Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
# wasm-opt passes are skipped silently when the tool is not installed. # 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() self.session.history().len()
} }
fn cards_with_face( fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect() cards.into_iter().map(|card| (card, face_up)).collect()
} }
@@ -507,8 +504,10 @@ impl GameState {
Self::cards_with_face(cards.iter().cloned(), true) Self::cards_with_face(cards.iter().cloned(), true)
} }
KlondikePile::Tableau(tableau) => { KlondikePile::Tableau(tableau) => {
let mut cards = let mut cards = Self::cards_with_face(
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false); state.tableau_face_down_cards(tableau).iter().cloned(),
false,
);
cards.extend(Self::cards_with_face( cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().cloned(), state.tableau_face_up_cards(tableau).iter().cloned(),
true, true,
@@ -823,9 +822,7 @@ impl GameState {
) -> Option<(KlondikePile, KlondikePile, usize)> { ) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state(); let state = self.session.state().state().state();
match instruction { match instruction {
KlondikeInstruction::RotateStock => { KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::DstFoundation(dst_foundation) => { KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None; return None;
@@ -928,10 +925,7 @@ impl GameState {
/// ///
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the /// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over. /// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
pub fn apply_instruction( pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
&mut self,
instruction: KlondikeInstruction,
) -> Result<(), MoveError> {
if self.is_won() { if self.is_won() {
return Err(MoveError::GameAlreadyWon); return Err(MoveError::GameAlreadyWon);
} }
@@ -1296,12 +1290,13 @@ mod tests {
game.take_from_foundation = false; game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1)); assert!(!game.can_move_cards(&from, &to, 1));
assert!( assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
legal_pile_moves(&game) f,
.iter() KlondikePile::Foundation(_)
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) ) || !matches!(
|| !matches!(t, KlondikePile::Tableau(_))) t,
); KlondikePile::Tableau(_)
)));
assert!(game.move_cards(from, to, 1).is_err()); assert!(game.move_cards(from, to, 1).is_err());
} }
@@ -1360,9 +1355,18 @@ mod tests {
fn budget_is_passed_through_not_clamped() { fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the // This seed is Inconclusive at 1k states but Winnable at 5k — proving the
// budget reaches the solver unchanged. // budget reaches the solver unchanged.
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000); let easy = GameState::solve_fresh_deal(
let medium = 0xD1FF_0000_0000_0012,
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000); 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!(easy.is_err());
assert!(matches!(medium, Ok(Some(_)))); 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 // when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature. // appear in any public method signature.
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit}; 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 // Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module. // former `solitaire_data::solver` wrapper module.
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome}; 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)] #[cfg(test)]
mod proptest_tests; mod proptest_tests;
+9 -2
View File
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
); );
} }
for t in &tableaux { 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 cards
} }
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> { 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. /// 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 /// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are /// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
/// always present; user-supplied themes register under their own ids. /// always present; user-supplied themes register under their own ids.
/// Older `settings.json` files that stored `"default"` or `"classic"` /// Older `settings.json` files that stored `"default"` (the
/// are migrated to `"dark"` by [`Settings::sanitized`]. /// 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")] #[serde(default = "default_theme_id")]
pub selected_theme_id: String, pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been /// 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", "saved file must use schema version 5",
); );
let loaded = load_game_state_from(&path) let loaded =
.expect("a valid in-progress game must load without error"); 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 // The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON. // re-serialises to byte-identical JSON.
@@ -569,7 +569,11 @@ mod tests {
// Derived board reads match the live game (move count + recycle count are // Derived board reads match the live game (move count + recycle count are
// both rebuilt from the replayed forward history). // 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!( assert_eq!(
loaded.recycle_count(), loaded.recycle_count(),
gs.recycle_count(), gs.recycle_count(),
+35 -9
View File
@@ -77,6 +77,11 @@ pub struct SolitaireServerClient {
username: String, username: String,
/// Shared `reqwest` client (keeps connection pools alive across calls). /// Shared `reqwest` client (keeps connection pools alive across calls).
client: reqwest::Client, 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"))] #[cfg(not(target_arch = "wasm32"))]
@@ -90,6 +95,7 @@ impl SolitaireServerClient {
base_url: base_url.into().trim_end_matches('/').to_owned(), base_url: base_url.into().trim_end_matches('/').to_owned(),
username: username.into(), username: username.into(),
client: reqwest::Client::new(), 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 /// The server rotates refresh tokens on each call: the response includes a
/// new refresh token that replaces the old one. Both tokens are persisted /// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success. /// 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 = let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?; 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 { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once. // Token expired — refresh and retry once.
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once. // Token expired — refresh and retry once.
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -542,7 +568,7 @@ impl SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
+1 -2
View File
@@ -911,8 +911,7 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads // Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen. // GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode = app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 0, score: 0,
+5 -3
View File
@@ -74,9 +74,11 @@ pub const ALL_RANKS: [Rank; 13] = [
Rank::King, Rank::King,
]; ];
/// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches /// Iteration order for the SVG generator and the pin test only —
/// `card_plugin::load_card_images` so the suit index used here lines /// output files are keyed by `suit_filename`, so no runtime index
/// up with `CardImageSet.faces[suit]`. /// 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]; 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`, /// 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 /// Returns the absolute path of the user-themes directory on the
/// current platform. /// current platform.
/// ///
/// # Panics /// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
/// /// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on /// returns an empty path — callers treat that as "no user themes" and
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration. /// the bundled default theme still works. A warning naming the
/// Android always returns `Some`. The panic message names the /// [`set_user_theme_dir`] workaround is logged once. Android always
/// supported workaround ([`set_user_theme_dir`]). /// resolves.
pub fn user_theme_dir() -> PathBuf { pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() { if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone(); 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 /// Per-target-os resolution of the platform's data dir. Delegates
/// to [`solitaire_data::data_dir`] which encapsulates the /// to [`solitaire_data::data_dir`] which encapsulates the
/// per-target shape (desktop: `dirs::data_dir()`; android: the /// per-target shape (desktop: `dirs::data_dir()`; android: the
/// hardcoded `/data/data/<package>/files` sandbox path). Panics /// hardcoded `/data/data/<package>/files` sandbox path).
/// only when the underlying resolver returns `None`, which on ///
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration — /// When the resolver returns `None` — always on wasm32 (no
/// the panic message names the supported workaround. /// 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 { fn detected_platform_data_dir() -> PathBuf {
solitaire_data::data_dir().unwrap_or_else(|| { solitaire_data::data_dir().unwrap_or_else(|| {
// On wasm32, data_dir() always returns None — there is no filesystem.
// User themes are not supported in the browser build; return an empty
// path so callers produce a benign empty dir rather than panicking.
#[cfg(target_arch = "wasm32")]
{
PathBuf::new()
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
panic!( use std::sync::Once;
"user_theme_dir(): platform data directory is unavailable. \ static WARN_ONCE: Once = Once::new();
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \ WARN_ONCE.call_once(|| {
the OS reported no Application Support / AppData path. \ bevy::log::warn!(
As a workaround call solitaire_engine::assets::user_dir::\ "user_theme_dir(): platform data directory is unavailable; \
set_user_theme_dir() before App::run()." 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. /// Plugin that drives the auto-complete sequence.
pub struct AutoCompletePlugin; 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 { impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>() app.init_resource::<AutoCompleteState>()
@@ -58,7 +63,9 @@ impl Plugin for AutoCompletePlugin {
drive_auto_complete, drive_auto_complete,
) )
.chain() .chain()
.after(GameMutation), .in_set(AutoComplete)
.after(GameMutation)
.before(crate::card_plugin::BoardVisuals),
); );
} }
} }
@@ -167,9 +174,9 @@ mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Deck, Rank, Suit}; use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -207,7 +214,11 @@ mod tests {
} }
g.set_test_tableau_cards( g.set_test_tableau_cards(
Tableau::Tableau1, 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); g.set_test_auto_completable(true);
let expected = ( let expected = (
@@ -72,9 +72,7 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating. /// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BufferedInput { pub enum BufferedInput {
Move { Move { from: MoveRequestEvent },
from: MoveRequestEvent,
},
Draw, Draw,
Undo, Undo,
} }
+1 -4
View File
@@ -10,9 +10,7 @@ use crate::animation_plugin::EffectiveSlideDuration;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::resources::DragState; use crate::resources::DragState;
use crate::ui_theme::{ use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
};
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity. /// 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 // Task #28 — Hint highlight tick system
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -272,5 +271,3 @@ pub(super) fn find_top_card_at(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator // Task #28 — Stock-empty visual indicator
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use bevy::sprite::Anchor; use bevy::sprite::Anchor;
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
@@ -205,4 +204,3 @@ pub(super) fn add_android_corner_label(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems // 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::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::table_plugin::PileMarker; use crate::table_plugin::PileMarker;
use crate::ui_theme::{ use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE,
};
/// Coalesces every `WindowResized` event arriving this frame into the latest /// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`]. /// 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); 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::color::Color;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::{KlondikePile, Tableau};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; 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. /// high-contrast boost path.
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910); 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. /// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
/// ///
/// Loaded once at startup by [`load_card_images`]. When this resource is /// 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 { pub struct CardImageSet {
/// Per-card face images indexed by `[suit][rank]`. /// Per-card face images indexed by `[suit][rank]`.
/// ///
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3. /// Layout is pinned to the upstream declaration order — index with
/// Rank order: Ace=0, Two=1 … King=12. /// [`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], pub faces: [[Handle<Image>; 13]; 4],
/// One handle per unlockable card-back design (indices 04). These /// One handle per unlockable card-back design (indices 04). These
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed /// 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`. /// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
pub struct CardPlugin; 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 { impl Plugin for CardPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// PostStartup ensures TablePlugin's Startup system has inserted // PostStartup ensures TablePlugin's Startup system has inserted
@@ -539,33 +569,41 @@ impl Plugin for CardPlugin {
update_stock_empty_indicator_startup, 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( .add_systems(
Update, Update,
( (
update_tableau_fan_frac update_tableau_fan_frac,
.after(GameMutation) resync_cards_on_settings_change,
.before(sync_cards_on_change), sync_cards_on_change,
sync_cards_on_change.after(GameMutation), start_flip_anim,
resync_cards_on_settings_change.before(sync_cards_on_change),
start_flip_anim.after(GameMutation),
tick_flip_anim, tick_flip_anim,
update_drag_shadow, update_drag_shadow,
update_card_shadows_on_drag.after(sync_cards_on_change), update_card_shadows_on_drag,
tick_hint_highlight,
handle_right_click, handle_right_click,
tick_right_click_highlights, 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, clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation), tick_hint_highlight,
update_stock_count_badge update_stock_empty_indicator,
.after(GameMutation) update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
.run_if(resource_changed::<GameStateResource>), collect_resize_events,
collect_resize_events.after(LayoutSystem::UpdateOnResize), snap_cards_on_window_resize,
snap_cards_on_window_resize.after(collect_resize_events), 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); app.add_systems(PostUpdate, rebuild_card_entity_index);
} }
} }
+1 -6
View File
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -12,10 +11,7 @@ use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker}; use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{ use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
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, /// 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 /// 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 std::collections::{HashMap, HashSet};
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; 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::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
@@ -105,25 +105,12 @@ pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut comma
return; return;
}; };
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; // faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
const RANKS: [Rank; 13] = [ // card_plugin::mod; building from SUITS/RANKS order matches them.
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,
];
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| { 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 = let backs =
std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png"))); 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 { ) -> Sprite {
if let Some(set) = card_images { if let Some(set) = card_images {
let image = if face_up { let image = if face_up {
let suit_idx = match card.suit() { let suit_idx = suit_index(card.suit());
Suit::Clubs => 0, let rank_idx = rank_index(card.rank());
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,
};
set.faces[suit_idx][rank_idx].clone() set.faces[suit_idx][rank_idx].clone()
} else if let Some(theme_back) = &set.theme_back { } else if let Some(theme_back) = &set.theme_back {
// Active theme provides its own back — always wins over the // 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); let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
cards.extend(game.stock_cards()); cards.extend(game.stock_cards());
cards.extend(game.waste_cards()); cards.extend(game.waste_cards());
for foundation in [ for foundation in solitaire_core::FOUNDATIONS {
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
cards.extend(game.pile(KlondikePile::Foundation(foundation))); cards.extend(game.pile(KlondikePile::Foundation(foundation)));
} }
for tableau in [ for tableau in solitaire_core::TABLEAUS {
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
cards.extend(game.pile(KlondikePile::Tableau(tableau))); cards.extend(game.pile(KlondikePile::Tableau(tableau)));
} }
cards cards
@@ -745,4 +700,3 @@ pub(super) fn update_card_entity(
commands.entity(entity).insert(new_children_key); commands.entity(entity).insert(new_children_key);
} }
} }
+21 -34
View File
@@ -1,17 +1,16 @@
use super::*; 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::events::StateChangedEvent;
use crate::game_plugin::GamePlugin;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::resources::DragState; use crate::resources::DragState;
use crate::table_plugin::TablePlugin;
use crate::ui_theme::TEXT_PRIMARY_HC; 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 solitaire_core::{DrawStockConfig, game_state::GameState};
use std::collections::HashSet;
/// Convenience constructor — all unit tests use Deck1. /// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card { fn make_card(suit: Suit, rank: Rank) -> Card {
@@ -119,8 +118,7 @@ fn waste_draw_one_only_renders_top_card() {
for _ in 0..3 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3); assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); 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" "need at least 3 waste cards for this test"
); );
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); 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(); let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards"); assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); 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 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions 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) { fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height. // Any Entity will do — the snap system reads only width/height.
let window = Entity::from_raw_u32(0) let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder");
.expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized { app.world_mut().write_message(WindowResized {
window, window,
width, 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 // Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the // post-resize card width, so the in-place path is using the
// refreshed Layout. // refreshed Layout.
let expected_layout = let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
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; let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!( assert!(
(after - expected).abs() < 1e-3, (after - expected).abs() < 1e-3,
@@ -1046,7 +1039,8 @@ fn shadow_offset_increases_during_drag() {
q.iter(app.world()) q.iter(app.world())
.next() .next()
.expect("fixture should spawn at least one CardEntity") .expect("fixture should spawn at least one CardEntity")
.card.clone() .card
.clone()
}; };
// Pick a *different* card to act as the negative control — // 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 { fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
// Map every CardEntity to its (Entity, card). // Map every CardEntity to its (Entity, card).
let card_entity = { let card_entity = {
let mut q = app let mut q = app.world_mut().query::<(Entity, &CardEntity)>();
.world_mut()
.query::<(Entity, &CardEntity)>();
q.iter(app.world()) q.iter(app.world())
.find(|(_, c)| c.card == *card) .find(|(_, c)| c.card == *card)
.map(|(e, _)| e) .map(|(e, _)| e)
@@ -1198,8 +1190,7 @@ fn stock_badge_updates_when_stock_count_changes() {
assert_eq!(stock_badge_text(&mut app), "24"); assert_eq!(stock_badge_text(&mut app), "24");
{ {
let mut game = app.world_mut().resource_mut::<GameStateResource>(); let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock: Vec<Card> = let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let _ = stock.pop(); let _ = stock.pop();
game.0.set_test_stock_cards(stock); 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 // distinct dummy `Image`. We never render these; we only
// compare ids. // compare ids.
let mut images = Assets::<Image>::default(); let mut images = Assets::<Image>::default();
let backs: [Handle<Image>; 5] = let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
std::array::from_fn(|_| images.add(Image::default()));
CardImageSet { CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs, 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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
.iter() .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 stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout) let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter() .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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
.iter() .iter()
+8 -8
View File
@@ -35,8 +35,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::RightClickHighlight; use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
@@ -437,7 +437,8 @@ fn tableau_or_stack_pos(
base.x, base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
) )
} else if matches!(pile, 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 pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32; let slot = index.saturating_sub(visible_start) as f32;
@@ -581,7 +582,10 @@ mod tests {
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::{Card, Deck, Rank, Suit}; 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 /// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers /// registered, plus the resources the system needs. Callers
@@ -630,11 +634,7 @@ mod tests {
// — same colour family, illegal. Tableau(2) must NOT be // — same colour family, illegal. Tableau(2) must NOT be
// highlighted. // highlighted.
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic); let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
set_tableau_top( set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
&mut game,
2,
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
);
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five); let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
let mut app = overlay_test_app(game); let mut app = overlay_test_app(game);
+4 -1
View File
@@ -54,7 +54,10 @@ impl DifficultyIndexResource {
DifficultyLevel::Hard => &mut self.hard, DifficultyLevel::Hard => &mut self.hard,
DifficultyLevel::Expert => &mut self.expert, DifficultyLevel::Expert => &mut self.expert,
DifficultyLevel::Grandmaster => &mut self.grandmaster, 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()]; let seed = catalog[*cursor % catalog.len()];
*cursor = cursor.wrapping_add(1); *cursor = cursor.wrapping_add(1);
+1 -1
View File
@@ -2,8 +2,8 @@
use bevy::prelude::Message; use bevy::prelude::Message;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_core::{Card, Suit};
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse; use solitaire_sync::SyncResponse;
+8 -2
View File
@@ -852,7 +852,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin); .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 { app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true, reduce_motion_mode: true,
..Settings::default() ..Settings::default()
@@ -906,7 +909,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin); .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 { app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true, reduce_motion_mode: true,
..Settings::default() ..Settings::default()
+86 -15
View File
@@ -14,8 +14,13 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle; use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::{
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction}; DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
#[allow(deprecated)] #[allow(deprecated)]
use solitaire_data::latest_replay_path; use solitaire_data::latest_replay_path;
use solitaire_data::{ use solitaire_data::{
@@ -63,6 +68,29 @@ pub struct GameOverScreen;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct GameMutation; 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. /// Persistence path for the in-progress game state file. `None` disables I/O.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>); pub struct GameStatePath(pub Option<PathBuf>);
@@ -165,7 +193,9 @@ impl Plugin for GamePlugin {
) )
} else { } 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, None,
) )
}; };
@@ -208,28 +238,66 @@ impl Plugin for GamePlugin {
.add_message::<AppLifecycle>() .add_message::<AppLifecycle>()
// add_message is idempotent; SettingsPlugin also registers this. // add_message is idempotent; SettingsPlugin also registers this.
.add_message::<crate::settings_plugin::SettingsChangedEvent>() .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( .add_systems(
Update, Update,
(handle_new_game, handle_draw, handle_move, handle_undo) (handle_new_game, handle_draw, handle_move, handle_undo)
.chain() .chain()
.in_set(GameMutation), .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, record_replay_on_win.after(GameMutation))
.add_systems(Update, handle_confirm_input.after(GameMutation)) .add_systems(
.add_systems(Update, handle_confirm_button_input.after(GameMutation)) Update,
.add_systems(Update, handle_game_over_input.after(GameMutation)) (
.add_systems(Update, handle_game_over_button_input.after(GameMutation)) 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, // Restore prompt: spawn the modal once the splash is gone,
// route Continue / New Game intents back into the existing // route Continue / New Game intents back into the existing
// GameMutation flow. // GameMutation flow.
.add_systems(Update, spawn_restore_prompt_if_pending) // All pre-mutation game-state writers are chained: elapsed
.add_systems(Update, handle_restore_prompt.before(GameMutation)) // time ticks first, settings sync next, then the restore prompt —
.add_systems(Update, sync_settings_to_game.before(GameMutation)) // 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>() .init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time) .add_systems(Update, auto_save_game_state.after(GameMutation))
.add_systems(Update, auto_save_game_state)
.add_systems(Last, save_game_state_on_exit); .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 // or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at // the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk. // 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; return;
} }
+15 -8
View File
@@ -372,9 +372,7 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
}); });
app.update(); app.update();
let events = app let events = app.world().resource::<Messages<CardFlippedEvent>>();
.world()
.resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor(); let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect(); let fired: Vec<_> = cursor.read(events).collect();
assert!( assert!(
@@ -800,7 +798,10 @@ fn replay_recording_skips_undo() {
1, 1,
"only the draw is recorded; the undo does not erase it nor add a new entry", "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 /// 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(); app.update();
let history = load_replay_history_from(&path) let history =
.expect("a winning replay must be persisted to ReplayPath"); load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
assert_eq!( assert_eq!(
history.replays.len(), history.replays.len(),
1, 1,
@@ -1059,7 +1060,10 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
app.update(); app.update();
// Game state was reseeded — move_count is 0 on the new game. // 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] #[test]
@@ -1133,7 +1137,10 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
// The chosen seed is non-deterministic (system time), // The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly: // but the new game must have been started cleanly:
// move_count back to 0, undo stack empty. // 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!( assert_eq!(
app.world() app.world()
.resource::<GameStateResource>() .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), zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score), challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today, daily_today,
draw_mode: settings.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, font_res,
difficulty_expanded, difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty), last_difficulty: settings.and_then(|s| s.0.last_difficulty),
+5 -4
View File
@@ -3,8 +3,6 @@
use super::*; use super::*;
/// Auto-fade state for the action button bar. The bar fades out when /// 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 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 /// 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 /// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates. /// variable framerates.
#[cfg(not(target_os = "android"))] #[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 { let Ok(window) = windows.single() else {
return; 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, from.alpha + (to.alpha - from.alpha) * t,
) )
} }
@@ -3,8 +3,6 @@
use super::*; use super::*;
/// `Changed<Interaction>` filter ensures we only react on the frame the /// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button /// interaction state transitions, avoiding repeat events while the button
/// is held down. Each click handler fires the corresponding request event, /// 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 spawn::*;
use updates::*; use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the // On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes. // Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None. // The resource is never inserted on wasm, so every call resolves to None.
@@ -36,7 +35,7 @@ use crate::events::{
UndoRequestEvent, WinStreakMilestoneEvent, UndoRequestEvent, WinStreakMilestoneEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::{GameMutation, NewGameRequestWriters};
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use crate::input_plugin::TouchDragSet; use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT; 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_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim; use crate::ui_modal::ModalScrim;
use crate::ui_theme::SPACE_2; use crate::ui_theme::SPACE_2;
use crate::ui_theme::UiTextFx;
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
@@ -153,6 +153,13 @@ pub struct HudColumn;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HudActionBar; 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. /// 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. /// Used by `resize_action_bar_labels` to update font size on window resize.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -467,23 +474,56 @@ impl Plugin for HudPlugin {
// defensively so the HUD plugin works standalone in tests. // defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>() .add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar)) .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( .add_systems(
Update, 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) // HUD chrome visibility: modal-restore writes HudVisibility, the
.add_systems(Update, (update_hud_avatar, handle_avatar_button)) // applier consumes it, and the layout recompute reads it — a
.add_systems(Update, update_won_previously.after(GameMutation)) // fixed chain instead of three racing systems (#143).
.add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems( .add_systems(
Update, Update,
update_selection_hud.run_if( (restore_hud_on_modal, apply_hud_visibility)
resource_exists_and_changed::<SelectionState> .chain()
.or(resource_exists_and_changed::<GameStateResource>), .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( .add_systems(
Update, Update,
( (
@@ -492,24 +532,40 @@ impl Plugin for HudPlugin {
advance_score_floater, advance_score_floater,
) )
.chain() .chain()
.after(GameMutation), .after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
) )
.add_systems( .add_systems(
Update, Update,
(start_streak_flourish, advance_streak_flourish) (start_streak_flourish, advance_streak_flourish)
.chain() .chain()
.after(GameMutation), .after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
) )
.add_systems( .add_systems(
Update, Update,
( (
handle_new_game_button, handle_new_game_button
handle_undo_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_pause_button,
handle_help_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_modes_button,
handle_mode_option_click, handle_mode_option_click
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_modes_backdrop_click, handle_modes_backdrop_click,
close_modes_popover_on_escape, close_modes_popover_on_escape,
handle_menu_button, handle_menu_button,
@@ -517,7 +573,10 @@ impl Plugin for HudPlugin {
handle_menu_backdrop_click, handle_menu_backdrop_click,
close_menu_popover_on_escape, close_menu_popover_on_escape,
paint_action_buttons, paint_action_buttons,
), )
.chain()
.in_set(HudButtons)
.before(crate::ui_focus::FocusKeys),
) )
// Fade lives in `Last` so it always overrides whatever the // Fade lives in `Last` so it always overrides whatever the
// hover/paint pass set on `BackgroundColor` this frame. // hover/paint pass set on `BackgroundColor` this frame.
+3 -3
View File
@@ -2,7 +2,6 @@
use super::*; use super::*;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
@@ -292,7 +291,9 @@ pub(super) fn spawn_avatar_child(
const SIZE: f32 = 32.0; const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) { if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it. // 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. // Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| { commands.entity(parent).with_children(|b| {
b.spawn(( b.spawn((
@@ -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] #[test]
fn score_reflects_game_state() { fn score_reflects_game_state() {
let mut app = headless_app(); 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(); 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] #[test]
@@ -192,7 +199,9 @@ fn challenge_time_color_zero_is_danger() {
fn challenge_hud_empty_when_no_daily_resource() { fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty. // No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app(); let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -207,7 +216,9 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
target_score: None, target_score: None,
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00"); 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), target_score: Some(4000),
max_time_secs: None, max_time_secs: None,
}); });
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts"); 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), max_time_secs: Some(300),
}); });
// Mark the game as won — HudChallenge should be empty. // Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true); app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_won(true);
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -384,7 +400,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
set_manual_time_step(&mut app, 0.0); set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold) // Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater. // 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(); app.update();
// One floater should now exist. // One floater should now exist.
@@ -405,7 +424,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
#[test] #[test]
fn score_floater_despawns_after_full_lifetime() { fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app(); 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(); app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1); 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(); let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below // +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant. // 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(); app.update();
assert_eq!( assert_eq!(
count_with::<ScoreFloater>(&mut app), count_with::<ScoreFloater>(&mut app),
@@ -507,7 +532,10 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
..Settings::default() ..Settings::default()
})); }));
// +100 would normally create both a ScorePulse and a ScoreFloater. // +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(); app.update();
assert_eq!( assert_eq!(
count_with::<ScorePulse>(&mut app), count_with::<ScorePulse>(&mut app),
+1 -2
View File
@@ -3,9 +3,9 @@
use super::*; use super::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Suit; use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -609,4 +609,3 @@ pub(super) fn resize_action_bar_labels(
font.font_size = new_size; font.font_size = new_size;
} }
} }
+24 -41
View File
@@ -27,9 +27,10 @@ use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode}; 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::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::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning; 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. // Find the top face-up card in the source pile and highlight it.
let source_cards = pile_cards(game, from); 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 { if let Some(card) = top_card {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() { for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card == card { if card_entity.card == card {
@@ -830,9 +834,7 @@ fn end_drag(
let origin_cards = pile_cards(&game.0, &origin); let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() { if !origin_cards.is_empty() {
for card in &drag.cards { for card in &drag.cards {
let Some(stack_index) = let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else {
origin_cards.iter().position(|(c, _)| c == card)
else {
continue; continue;
}; };
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); 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); let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() { if !origin_cards.is_empty() {
for card in &drag.cards { for card in &drag.cards {
let Some(stack_index) = let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card)
origin_cards.iter().position(|(c, _)| c == card)
else { else {
continue; continue;
}; };
@@ -1173,7 +1174,8 @@ fn card_position(
y_offset -= layout.card_size.y * step; y_offset -= layout.card_size.y * step;
} }
Vec2::new(base.x, base.y + y_offset) Vec2::new(base.x, base.y + y_offset)
} else 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 // 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` // card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers — a // so clicking the visually rightmost (top) card actually registers — a
@@ -1247,7 +1249,10 @@ fn find_draggable_at(
} }
(i, i + 1) (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)); 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> { pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
for foundation in foundations() { for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation); let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(&source, &dest, 1) { if game.can_move_cards(&source, &dest, 1) {
return Some(dest); return Some(dest);
} }
} }
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(&source, &dest, 1) { if game.can_move_cards(&source, &dest, 1) {
return Some(dest); return Some(dest);
@@ -1356,7 +1361,7 @@ pub fn best_tableau_destination_for_stack(
game: &GameState, game: &GameState,
stack_count: usize, stack_count: usize,
) -> Option<(KlondikePile, usize)> { ) -> Option<(KlondikePile, usize)> {
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(from, &dest, stack_count) { if game.can_move_cards(from, &dest, stack_count) {
return Some((dest, stack_count)); return Some((dest, stack_count));
@@ -1549,8 +1554,7 @@ fn handle_double_tap(
return; return;
} }
let Some((found_card, found_face_up)) = let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
pile_cards.iter().find(|(c, _)| c == top_card)
else { else {
return; return;
}; };
@@ -1692,7 +1696,7 @@ pub(crate) fn hint_piles(
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> { fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
let sources: Vec<KlondikePile> = { let sources: Vec<KlondikePile> = {
let mut s = vec![KlondikePile::Stock]; let mut s = vec![KlondikePile::Stock];
for tableau in tableaus() { for tableau in TABLEAUS {
s.push(KlondikePile::Tableau(tableau)); s.push(KlondikePile::Tableau(tableau));
} }
s 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 { let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue; continue;
}; };
for foundation in foundations() { for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation); let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(from, &dest, 1) { if game.can_move_cards(from, &dest, 1) {
hints.push((*from, dest)); hints.push((*from, dest));
@@ -1728,7 +1732,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
if already_has_foundation_hint { if already_has_foundation_hint {
continue; continue;
} }
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(from, &dest, 1) { if game.can_move_cards(from, &dest, 1) {
hints.push((*from, dest)); 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 // should never hint Foundation→Foundation. Here we handle the return path
// separately so the guarded `take_from_foundation` rule is respected. // separately so the guarded `take_from_foundation` rule is respected.
if game.take_from_foundation { if game.take_from_foundation {
for foundation in foundations() { for foundation in FOUNDATIONS {
let from = KlondikePile::Foundation(foundation); let from = KlondikePile::Foundation(foundation);
let from_pile = pile_cards(game, &from); let from_pile = pile_cards(game, &from);
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else { let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue; continue;
}; };
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(&from, &dest, 1) { if game.can_move_cards(&from, &dest, 1) {
hints.push((from, dest)); 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 { const fn tableau_number(tableau: Tableau) -> u8 {
match tableau { match tableau {
Tableau::Tableau1 => 1, 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 mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game); clear_test_piles(&mut game);
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two), let waste = vec![
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five), Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)]; Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
];
game.set_test_waste_cards(waste.clone()); game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top 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"); 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.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer"); 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] #[test]
@@ -176,8 +182,7 @@ fn find_draggable_skips_face_down_cards() {
// face-up card, but the iterator should skip face-down cards and // 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 // the cursor sits above the face-up card's AABB, so the result
// is None. // is None.
let face_down_pos = let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let result = find_draggable_at(face_down_pos, &game, &layout); let result = find_draggable_at(face_down_pos, &game, &layout);
assert!(result.is_none(), "face-down cards should not be draggable"); 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 // 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 // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre. // base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
let face_up_pos = let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let result = find_draggable_at(face_up_pos, &game, &layout) let result = find_draggable_at(face_up_pos, &game, &layout)
.expect("clicking the face-up card's visible centre must initiate a drag"); .expect("clicking the face-up card's visible centre must initiate a drag");
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); 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 king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
game.set_test_tableau_cards( game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
Tableau::Tableau1,
vec![king, queen.clone(), jack.clone()],
);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// The Queen's geometric center (index 1) is inside the Jack's bounding box // 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 // (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 // 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. // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
let queen_center = let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375); 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"); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); 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(); let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, KlondikePile::Stock); 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] #[test]
@@ -558,8 +562,7 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
fn rejected_drag_animation_targets_origin_resting_position() { fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen 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 target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim = let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!( assert!(
(anim.end - target_pos).length() < 1e-6, (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() { fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0); let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0); let target_pos = Vec2::new(80.0, -120.0);
let anim = let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!( assert!(
(anim.start - drag_pos).length() < 1e-6, (anim.start - drag_pos).length() < 1e-6,
@@ -601,12 +603,8 @@ fn rejected_drag_animation_starts_from_drag_position() {
/// the call site honest. /// the call site honest.
#[test] #[test]
fn rejected_drag_animation_uses_correct_duration() { fn rejected_drag_animation_uses_correct_duration() {
let anim = build_drag_reject_animation( let anim =
Vec2::new(640.0, 200.0), build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert!( assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6, (anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \ "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. /// jittery rather than forgiving.
#[test] #[test]
fn rejected_drag_animation_uses_responsive_curve() { fn rejected_drag_animation_uses_responsive_curve() {
let anim = build_drag_reject_animation( let anim =
Vec2::new(640.0, 200.0), build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert_eq!( assert_eq!(
anim.curve, anim.curve,
MotionCurve::Responsive, MotionCurve::Responsive,
@@ -683,10 +677,16 @@ fn pressing_h_spawns_pending_hint_task() {
app.init_resource::<HintSolverConfig>(); app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>(); app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(LayoutResource( app.insert_resource(LayoutResource(compute_layout(
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), Vec2::new(1280.0, 800.0),
)); 0.0,
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne))); 0.0,
true,
)));
app.insert_resource(GameStateResource(GameState::new(
42,
DrawStockConfig::DrawOne,
)));
app.add_systems(Update, handle_keyboard_hint); app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame. // Simulate the H key being pressed this frame.
+2 -4
View File
@@ -862,10 +862,8 @@ fn handle_display_name_confirm(
.leaderboard_display_name .leaderboard_display_name
.clone() .clone()
.unwrap_or_else(|| { .unwrap_or_else(|| {
if let SyncBackend::SolitaireServer { if let SyncBackend::SolitaireServer { ref username, .. } =
ref username, settings.0.sync_backend
..
} = settings.0.sync_backend
{ {
username.chars().take(32).collect() username.chars().take(32).collect()
} else { } else {
+1
View File
@@ -43,6 +43,7 @@ pub mod replay_overlay;
pub mod replay_playback; pub mod replay_playback;
pub mod resources; pub mod resources;
pub mod safe_area; pub mod safe_area;
mod schedule_checks;
pub mod selection_plugin; pub mod selection_plugin;
pub mod settings_plugin; pub mod settings_plugin;
pub mod splash_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::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::splash_plugin::SplashRoot;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, spawn_modal_header,
@@ -36,7 +37,6 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
}; };
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING}; use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+4 -1
View File
@@ -969,7 +969,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.update(); app.update();
app app
} }
+25 -41
View File
@@ -178,9 +178,9 @@ mod tests {
use super::*; use super::*;
use crate::events::HintVisualEvent; use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig; use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Build a minimal Bevy app exercising only the polling system /// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches. /// and the resources/messages it touches.
@@ -220,32 +220,11 @@ mod tests {
] { ] {
game.set_test_foundation_cards(foundation, Vec::new()); game.set_test_foundation_cards(foundation, Vec::new());
} }
for tableau in [ for tableau in solitaire_core::TABLEAUS {
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new()); game.set_test_tableau_cards(tableau, Vec::new());
} }
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; let suits = Suit::SUITS;
let ranks_below_king = [ let ranks_below_king = &Rank::RANKS[..12]; // everything 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,
];
for (foundation, suit) in [ for (foundation, suit) in [
Foundation::Foundation1, Foundation::Foundation1,
Foundation::Foundation2, Foundation::Foundation2,
@@ -270,10 +249,7 @@ mod tests {
.into_iter() .into_iter()
.zip(suits.iter()) .zip(suits.iter())
{ {
game.set_test_tableau_cards( game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
tableau,
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
} }
game game
} }
@@ -288,9 +264,11 @@ mod tests {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() { while app.world().resource::<PendingHintTask>().is_pending() {
@@ -327,9 +305,11 @@ mod tests {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
assert!( assert!(
app.world().resource::<PendingHintTask>().is_pending(), app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn", "task is in flight after spawn",
@@ -365,18 +345,22 @@ mod tests {
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn. // First spawn.
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending(); let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present); assert!(first_handle_present);
// Second spawn. The `spawn` helper drops the prior task // Second spawn. The `spawn` helper drops the prior task
// before assigning the new one — at no point are two tasks // before assigning the new one — at no point are two tasks
// in flight. // in flight.
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
// Resource still pending (the second task), but the first // Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once // is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the // it's been overwritten — what we *can* assert is that the
+8 -27
View File
@@ -47,9 +47,10 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; 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::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent}; use crate::events::{MoveRejectedEvent, MoveRequestEvent};
@@ -254,13 +255,13 @@ pub fn legal_destinations_for_card(
game: &GameState, game: &GameState,
) -> Vec<KlondikePile> { ) -> Vec<KlondikePile> {
let mut out = Vec::new(); let mut out = Vec::new();
for foundation in foundations() { for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation); let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(source_pile, &dest, 1) { if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest); out.push(dest);
} }
} }
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source_pile, &dest, 1) { if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest); 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. /// Builds the `(destination, anchor)` list for a fresh radial open.
/// ///
/// `half_extents` is the window half-size in world space — icons are clamped /// `half_extents` is the window half-size in world space — icons are clamped
@@ -399,8 +378,10 @@ fn build_radial_destinations(
.map(|(i, d)| { .map(|(i, d)| {
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX); let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
let clamped = Vec2::new( let clamped = Vec2::new(
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin), raw.x
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin), .clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y
.clamp(-half_extents.y + margin, half_extents.y - margin),
); );
(d, clamped) (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). /// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String { pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card { 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(), None => "--".to_string(),
} }
} }
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game. /// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+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::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::CardEntityIndex; use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -487,8 +487,10 @@ fn handle_selection_keys(
1 1
}; };
let start = source_cards.len().saturating_sub(count); let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<Card> = let lifted_cards: Vec<Card> = source_cards[start..]
source_cards[start..].iter().map(|(c, _)| c.clone()).collect(); .iter()
.map(|(c, _)| c.clone())
.collect();
let Some((bottom, _)) = source_cards.get(start) else { let Some((bottom, _)) = source_cards.get(start) else {
return; return;
}; };
@@ -597,10 +599,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
/// This is intentionally separated from [`best_destination`] so the Enter /// This is intentionally separated from [`best_destination`] so the Enter
/// handler can attempt a foundation move first and fall through to a /// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move. /// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest( fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
for foundation in [ for foundation in [
Foundation::Foundation1, Foundation::Foundation1,
@@ -697,13 +696,7 @@ fn update_selection_highlight(
if let Some(ref pile) = source_pile if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0) && let Some(card) = top_face_up_card(pile, &game.0)
{ {
spawn_highlight_on_card( spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
&mut commands,
&card_index,
&card,
card_size,
source_color,
);
} }
// Destination highlight while lifted. // Destination highlight while lifted.
@@ -714,13 +707,7 @@ fn update_selection_highlight(
// in destination-pick mode and the focused index is observable // in destination-pick mode and the focused index is observable
// via the resource. // via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) { if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card( spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
&mut commands,
&card_index,
&card,
card_size,
dest_color,
);
} }
} }
} }
@@ -1047,10 +1034,7 @@ mod tests {
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let selected = app let selected = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
// The cycle order starts at Waste, but Waste is empty so the next // The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected. // available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1))); assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
@@ -1216,16 +1200,10 @@ mod tests {
drag.active_touch_id = None; drag.active_touch_id = None;
} }
let before = app let before = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let after = app let after = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
assert_eq!( assert_eq!(
before, after, before, after,
+33 -4
View File
@@ -79,6 +79,14 @@ pub struct PendingWindowGeometry {
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings); 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. /// Marker on the root Settings panel entity.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct SettingsPanel; pub struct SettingsPanel;
@@ -372,16 +380,37 @@ impl Plugin for SettingsPlugin {
// also runs cleanly under `MinimalPlugins` (tests). // also runs cleanly under `MinimalPlugins` (tests).
.add_message::<WindowResized>() .add_message::<WindowResized>()
.add_message::<WindowMoved>() .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( .add_systems(
Update, Update,
( (
handle_volume_keys, handle_volume_keys,
toggle_settings_screen,
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
record_window_geometry_changes, record_window_geometry_changes,
persist_window_geometry_after_debounce, 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 { if self.ui_enabled {
+2 -4
View File
@@ -1500,10 +1500,8 @@ mod tests {
#[test] #[test]
fn zen_win_event_updates_zen_best_score_only() { fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().0.mode =
.resource_mut::<GameStateResource>() solitaire_core::game_state::GameMode::Zen;
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 1800, 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 /// Upper bound on how long [`push_on_exit`] may block the closing app.
/// on [`AppExit`] without blocking the Bevy main thread. /// 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 /// This deliberately blocks: the previous detached-task version was almost
/// persistence remains the durable source of truth even if the final remote /// always cut short by process teardown, so the final session's push
/// push does not complete. /// 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( fn push_on_exit(
mut exit_events: MessageReader<AppExit>, mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
@@ -292,16 +301,17 @@ fn push_on_exit(
exit_events.clear(); exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0); let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone(); let result = rt.0.block_on(async {
let rt = rt.0.clone(); tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
AsyncComputeTaskPool::get() });
.spawn(async move { match result {
match rt.block_on(provider.push(&payload)) { Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
Ok(_) | Err(SyncError::UnsupportedPlatform) => {} Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
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()
.detach(); ),
}
} }
/// Update-schedule system: on each `GameWonEvent` push the just-completed /// 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). // In-memory contract: replays[0].share_url is now Some(url).
let live = app let live = app.world().resource::<ReplayHistoryResource>();
.world()
.resource::<ReplayHistoryResource>();
assert_eq!( assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()), live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()), Some(url.clone()),
+36 -36
View File
@@ -6,10 +6,12 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::KlondikePile;
use solitaire_core::Suit; use solitaire_core::Suit;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::hud_plugin::HudVisibility; use crate::hud_plugin::HudVisibility;
use crate::layout::{ use crate::layout::{
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_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. /// Registers the table background and pile-marker rendering.
pub struct TablePlugin; 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 { impl Plugin for TablePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// Register WindowResized so the plugin works under MinimalPlugins in // 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_safe_area_changed.before(LayoutSystem::UpdateOnResize),
on_window_resized.in_set(LayoutSystem::UpdateOnResize), on_window_resized.in_set(LayoutSystem::UpdateOnResize),
apply_theme_on_settings_change, // Marker painters: deterministic chain after the card
apply_hint_pile_highlight, // paint pipeline — markers and cards share Sprite/
tick_hint_pile_highlights, // Transform access (#143).
sync_pile_marker_visibility, (
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); let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
piles.push(KlondikePile::Stock); piles.push(KlondikePile::Stock);
for foundation in foundations() { for foundation in FOUNDATIONS {
piles.push(KlondikePile::Foundation(foundation)); piles.push(KlondikePile::Foundation(foundation));
} }
for tableau in tableaus() { for tableau in TABLEAUS {
piles.push(KlondikePile::Tableau(tableau)); piles.push(KlondikePile::Tableau(tableau));
} }
@@ -368,7 +385,11 @@ fn on_window_resized(
>, >,
mut marker_outlines: Query< mut marker_outlines: Query<
&mut Sprite, &mut Sprite,
(Without<PileMarker>, Without<TableBackground>, Without<Text2d>), (
Without<PileMarker>,
Without<TableBackground>,
Without<Text2d>,
),
>, >,
mut marker_labels: Query<&mut TextFont, With<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) // must be re-derived here too or a resize (fold/unfold, rotation)
// leaves them at the stale size — visible as oversized grey // leaves them at the stale size — visible as oversized grey
// frames on empty piles. // frames on empty piles.
let outline_size = let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let font_size = new_layout.card_size.x * 0.28; let font_size = new_layout.card_size.x * 0.28;
for child in children.into_iter().flatten() { for child in children.into_iter().flatten() {
if let Ok(mut outline) = marker_outlines.get_mut(*child) { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use solitaire_core::{Foundation, Tableau};
/// Minimal headless app — omits windowing so pile markers are spawned with /// Minimal headless app — omits windowing so pile markers are spawned with
/// the default 1280×800 layout and no camera is created. /// 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_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] #[test]
@@ -940,10 +943,7 @@ mod tests {
#[test] #[test]
fn suit_symbol_all_four_are_distinct() { fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs] let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
.iter()
.map(suit_symbol)
.collect();
let unique: std::collections::HashSet<&&str> = symbols.iter().collect(); let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct"); 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. /// Iterator over all 52 valid keys, in suit-major / rank-ascending order.
/// Used to enumerate the manifest's required entries. /// Used to enumerate the manifest's required entries.
pub fn all() -> impl Iterator<Item = CardKey> { pub fn all() -> impl Iterator<Item = CardKey> {
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; // Order-independent enumeration — consumers check completeness and
const RANKS: [Rank; 13] = [ // round-trips, never positions.
Rank::Ace, const SUITS: [Suit; 4] = Suit::SUITS;
Rank::Two, const RANKS: [Rank; 13] = Rank::RANKS;
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
];
SUITS SUITS
.into_iter() .into_iter()
.flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r))) .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::{ use crate::assets::{
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir, 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 crate::events::StateChangedEvent;
use super::loader::CardThemeLoader; 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 /// Kicks off the initial theme load — the one named by
/// `Settings::selected_theme_id` if available, falling back to the /// `Settings::selected_theme_id` if available, falling back to the
/// embedded default. The actual rasterisation runs asynchronously on /// fresh-install default from `solitaire_data::Settings`. The actual
/// the asset task pool; the sync system below picks up the /// rasterisation runs asynchronously on the asset task pool; the sync
/// `LoadedWithDependencies` event when every face + back is ready. /// system below picks up the `LoadedWithDependencies` event when every
/// face + back is ready.
fn load_initial_theme( fn load_initial_theme(
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>, settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let default_id = solitaire_data::Settings::default().selected_theme_id;
let id = settings let id = settings
.as_deref() .as_deref()
.map(|s| s.0.selected_theme_id.as_str()) .map(|s| s.0.selected_theme_id.as_str())
.unwrap_or("classic"); .unwrap_or(&default_id);
let url = bundled_theme_url(id) let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle)); commands.insert_resource(ActiveTheme(handle));
} }
@@ -162,10 +170,7 @@ fn react_to_settings_theme_change(
return; return;
} }
let url = bundled_theme_url(new_id) let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle)); 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 /// `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. /// 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) { 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 suit in Suit::SUITS {
for rank in [ for rank in Rank::RANKS {
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
] {
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) { if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone(); 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()); image_set.theme_back = Some(theme.back.clone());
} }
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors /// Switches the active theme to `theme_id` — the embedded source for
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3. /// the bundled `"dark"` / `"classic"` themes, `themes://` for user
const fn suit_index(s: Suit) -> usize { /// themes. Returns the new `Handle<CardTheme>` so callers can poll
match s { /// `Assets<CardTheme>` if they want to wait for the load before
Suit::Clubs => 0, /// changing UI state.
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.
/// ///
/// The handle is also written to the [`ActiveTheme`] resource — the /// The handle is also written to the [`ActiveTheme`] resource — the
/// per-frame sync system picks up the `LoadedWithDependencies` event /// per-frame sync system picks up the `LoadedWithDependencies` event
@@ -311,8 +272,7 @@ pub fn set_theme(
asset_server: &AssetServer, asset_server: &AssetServer,
theme_id: &str, theme_id: &str,
) -> Handle<CardTheme> { ) -> Handle<CardTheme> {
let url = format!("themes://{theme_id}/theme.ron"); let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle.clone())); commands.insert_resource(ActiveTheme(handle.clone()));
handle handle
} }
@@ -446,21 +406,17 @@ mod tests {
} }
#[test] #[test]
fn suit_index_ranges_match_card_plugin_layout() { fn suit_index_matches_upstream_suits_order() {
assert_eq!(suit_index(Suit::Clubs), 0); for (i, s) in Suit::SUITS.iter().enumerate() {
assert_eq!(suit_index(Suit::Diamonds), 1); assert_eq!(suit_index(*s), i, "faces outer layout = Suit::SUITS order");
assert_eq!(suit_index(Suit::Hearts), 2); }
assert_eq!(suit_index(Suit::Spades), 3);
} }
#[test] #[test]
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() { fn rank_index_matches_upstream_ranks_order() {
assert_eq!(rank_index(Rank::Ace), 0); for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(rank_index(Rank::Two), 1); assert_eq!(rank_index(*r), i, "faces inner layout = Rank::RANKS order");
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);
} }
#[test] #[test]
@@ -524,13 +480,43 @@ mod tests {
} }
#[test] #[test]
fn set_theme_url_format_matches_themes_source() { fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
// The format string is the only behavioural surface of // The bundled themes live in the binary, not in the user
// set_theme that doesn't require an App. We assert the URL // themes directory — resolving them to `themes://` would
// shape so a future refactor doesn't accidentally change the // NotFound and silently leave the previous theme active.
// path layout. assert_eq!(
let url = format!("themes://{}/theme.ron", "user_uploaded"); theme_manifest_url("dark"),
assert_eq!(url, "themes://user_uploaded/theme.ron"); 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 /// Test 1: the bundled dark theme always has embedded SVG bytes
@@ -28,8 +28,8 @@
use bevy::ecs::message::MessageReader; use bevy::ecs::message::MessageReader;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::KlondikePile;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent; 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. /// gains keyboard navigation without per-plugin wiring.
pub struct UiFocusPlugin; 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 { impl Plugin for UiFocusPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<FocusedButton>() app.init_resource::<FocusedButton>()
@@ -147,9 +154,19 @@ impl Plugin for UiFocusPlugin {
( (
sync_focus_on_mouse_click, sync_focus_on_mouse_click,
clear_hud_focus_on_unhover, clear_hud_focus_on_unhover,
handle_focus_keys, handle_focus_keys
update_focus_overlay, .in_set(FocusKeys)
pulse_focus_overlay, .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(), .chain(),
); );
+6 -1
View File
@@ -695,7 +695,12 @@ impl Plugin for UiModalPlugin {
advance_modal_enter, advance_modal_enter,
paint_modal_buttons, 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 // Click-outside-to-dismiss is independent of the open
// animation chain — it reads `just_pressed(Left)` and runs // 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 /// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather /// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray. /// than to a neutral gray.
pub const fn with_hc( pub const fn with_hc(default_color: Color, hc_color: Color) -> Self {
default_color: Color,
hc_color: Color,
) -> Self {
Self { Self {
default_color, default_color,
hc_color, hc_color,
@@ -698,3 +695,12 @@ mod tests {
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0); 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. /// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
pub const BCRYPT_COST: u32 = 12; 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> { async fn hash_password(password: String) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST)) tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST))
.await .await
@@ -208,6 +217,10 @@ pub async fn register(
let password_hash = hash_password(body.password).await?; let password_hash = hash_password(body.password).await?;
let now = Utc::now().to_rfc3339(); 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!( sqlx::query!(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
user_id, user_id,
@@ -216,7 +229,11 @@ pub async fn register(
now now
) )
.execute(&state.pool) .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 access_token = make_access_token(&user_id, &state.jwt_secret)?;
let (refresh_token, refresh_jti) = make_refresh_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) .fetch_optional(&state.pool)
.await?; .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 let row_id = row
.id .id
.ok_or_else(|| AppError::Internal("user id missing".into()))?; .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. // Tokens without jti predate rotation — require re-login.
let jti = claims.jti.ok_or(AppError::Unauthorized)?; let jti = claims.jti.ok_or(AppError::Unauthorized)?;
// Verify this jti is still live (not yet consumed or from a deleted account). // Consume the old token before issuing new ones, gating on the DELETE
// SQLite TEXT columns are always nullable in sqlx; flatten the double-Option. // actually removing a row. rows_affected == 0 covers both "jti never
let exists: Option<String> = // existed / account deleted" and "a concurrent refresh already consumed
sqlx::query_scalar!("SELECT jti FROM refresh_tokens WHERE jti = ?", jti) // it" — the previous SELECT-then-DELETE let two concurrent refreshes
.fetch_optional(&state.pool) // both pass the check and both mint fresh token pairs. The DELETE is
.await? // the mutex: whoever removes the row wins; everyone else gets 401.
.flatten(); // If the insert below fails, the user loses this session (must
// re-login) — safe by design.
if exists.is_none() { let deleted = sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
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)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
if deleted.rows_affected() != 1 {
return Err(AppError::Unauthorized);
}
let new_access = make_access_token(&claims.sub, &state.jwt_secret)?; let new_access = make_access_token(&claims.sub, &state.jwt_secret)?;
let (new_refresh, new_jti) = make_refresh_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`, /// 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. /// `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( pub async fn upload_avatar(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
@@ -390,6 +429,14 @@ pub async fn upload_avatar(
if body.len() > AVATAR_MAX_BYTES { if body.len() > AVATAR_MAX_BYTES {
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into())); return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
} }
// 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. // Write to avatars/ directory, replacing any previous file for this user.
tokio::fs::create_dir_all("avatars") tokio::fs::create_dir_all("avatars")
@@ -590,6 +637,46 @@ mod tests {
assert!(username_chars_ok("")); 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] #[test]
fn username_chars_ok_rejects_unicode_letters() { fn username_chars_ok_rejects_unicode_letters() {
// Non-ASCII characters must be rejected even if they look like 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 axum::{Json, extract::State};
use chrono::Utc; use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid; use uuid::Uuid;
use solitaire_sync::{ use solitaire_sync::{
@@ -26,13 +25,19 @@ struct SyncRow {
/// Load the stored `SyncPayload` for `user_id` from the database. /// Load the stored `SyncPayload` for `user_id` from the database.
/// Returns `None` if this user has not pushed any data yet. /// 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!( let row = sqlx::query_as!(
SyncRow, SyncRow,
"SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?", "SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
user_id user_id
) )
.fetch_optional(pool) .fetch_optional(exec)
.await?; .await?;
Ok(row) 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. /// Persist a `SyncPayload` for `user_id` using an upsert.
async fn store_payload( async fn store_payload(
pool: &SqlitePool, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
user_id: &str, user_id: &str,
payload: &SyncPayload, payload: &SyncPayload,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
@@ -92,7 +97,7 @@ async fn store_payload(
progress_json, progress_json,
now now
) )
.execute(pool) .execute(exec)
.await?; .await?;
Ok(()) Ok(())
@@ -159,12 +164,20 @@ pub async fn push(
return Err(AppError::BadRequest("user_id mismatch".into())); 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)?, Some(row) => row_to_payload(&row, &user.user_id)?,
None => { None => {
// First push — nothing to merge against; store directly. // First push — nothing to merge against; store directly.
store_payload(&state.pool, &user.user_id, &client_payload).await?; store_payload(&mut *tx, &user.user_id, &client_payload).await?;
update_leaderboard_if_opted_in(&state.pool, &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 { return Ok(Json(SyncResponse {
merged: client_payload, merged: client_payload,
server_time: Utc::now(), server_time: Utc::now(),
@@ -175,8 +188,9 @@ pub async fn push(
let (merged, conflicts) = merge(&client_payload, &server_payload); let (merged, conflicts) = merge(&client_payload, &server_payload);
store_payload(&state.pool, &user.user_id, &merged).await?; store_payload(&mut *tx, &user.user_id, &merged).await?;
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &merged).await?; update_leaderboard_if_opted_in(&mut tx, &user.user_id, &merged).await?;
tx.commit().await?;
Ok(Json(SyncResponse { Ok(Json(SyncResponse {
merged, merged,
@@ -188,16 +202,18 @@ pub async fn push(
/// If the user is opted in to the leaderboard, update their row with the /// 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`. /// 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 /// Runs on the caller's transaction connection, so the opt-in check and the
/// conditional UPDATE (WHERE EXISTS subquery) to avoid a TOCTOU race where /// update are atomic with the surrounding push — an opt-out between the check
/// the user opts out between the check and the write. /// 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( async fn update_leaderboard_if_opted_in(
pool: &SqlitePool, conn: &mut sqlx::SqliteConnection,
user_id: &str, user_id: &str,
payload: &SyncPayload, payload: &SyncPayload,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id) let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id)
.fetch_optional(pool) .fetch_optional(&mut *conn)
.await? .await?
.map(|r| r.leaderboard_opt_in) .map(|r| r.leaderboard_opt_in)
.unwrap_or(0); .unwrap_or(0);
@@ -231,7 +247,7 @@ async fn update_leaderboard_if_opted_in(
now, now,
user_id user_id
) )
.execute(pool) .execute(&mut *conn)
.await?; .await?;
Ok(()) Ok(())
+48 -48
View File
@@ -1649,63 +1649,63 @@ function __wbg_get_imports() {
return ret; return ret;
}, },
__wbindgen_cast_0000000000000001: function(arg0, arg1) { __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`. // 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__hfb2e9a2f0bbd9ecc); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000002: function(arg0, arg1) { __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`. // 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__h15aa57dbc666225a); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000003: function(arg0, arg1) { __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`. // 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__h876550298b312ff8); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000004: function(arg0, arg1) { __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`. // 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__h15aa57dbc666225a_3); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000005: function(arg0, arg1) { __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`. // 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__h15aa57dbc666225a_4); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000006: function(arg0, arg1) { __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`. // 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__h15aa57dbc666225a_5); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000007: function(arg0, arg1) { __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`. // 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__h15aa57dbc666225a_6); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000008: function(arg0, arg1) { __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`. // 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__h15aa57dbc666225a_7); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000009: function(arg0, arg1) { __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`. // 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__h15aa57dbc666225a_8); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000a: function(arg0, arg1) { __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`. // 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__h15aa57dbc666225a_9); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000b: function(arg0, arg1) { __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`. // 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__h657f46feffff6fe4); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000c: function(arg0, arg1) { __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`. // 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__h545edb23183e448a); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000d: function(arg0) { __wbindgen_cast_000000000000000d: function(arg0) {
@@ -1769,55 +1769,55 @@ function __wbg_get_imports() {
}; };
} }
function wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1) { function wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1); wasm.wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c(arg0, arg1);
} }
function 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__h15aa57dbc666225a(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354(arg0, arg1, arg2);
} }
function 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__h15aa57dbc666225a_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) { function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_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) { function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_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) { function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_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) { function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_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) { function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_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) { function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_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) { function wasm_bindgen__convert__closures_____invoke__hd94d76233321402f(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2); const ret = wasm.wasm_bindgen__convert__closures_____invoke__hd94d76233321402f(arg0, arg1, arg2);
if (ret[1]) { if (ret[1]) {
throw takeFromExternrefTable0(ret[0]); throw takeFromExternrefTable0(ret[0]);
} }
} }
function 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__h876550298b312ff8(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) { function wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(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. //! is the contract.
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError; 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::*; use wasm_bindgen::prelude::*;
/// Mirrors `solitaire_data::Replay` v3. /// Mirrors `solitaire_data::Replay` v3.
@@ -145,21 +148,10 @@ impl ReplayPlayer {
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> { let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect() self.game.pile(t).iter().map(CardSnapshot::from).collect()
}; };
let foundations: [Vec<CardSnapshot>; 4] = [ let foundations: [Vec<CardSnapshot>; 4] =
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)), solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)), let tableaus: [Vec<CardSnapshot>; 7] =
pile_cards(KlondikePile::Foundation(Foundation::Foundation3)), solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
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)),
];
StateSnapshot { StateSnapshot {
step_idx: self.step_idx, step_idx: self.step_idx,
total_steps: self.moves.len(), 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 { fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
let stock = game.stock_cards(); let stock = game.stock_cards();
let waste = game.waste_cards(); let waste = game.waste_cards();
let foundations = [ let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
game.pile(KlondikePile::Foundation(Foundation::Foundation1)), let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
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 mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
let mut duplicate_cards = Vec::new(); let mut duplicate_cards = Vec::new();
@@ -426,7 +405,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
false 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() let state_ok = duplicate_cards.is_empty()
&& missing_cards.is_empty() && missing_cards.is_empty()
@@ -488,21 +468,8 @@ impl SolitaireGame {
.iter() .iter()
.map(CardSnapshot::from) .map(CardSnapshot::from)
.collect(), .collect(),
foundations: [ foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
cards(KlondikePile::Foundation(Foundation::Foundation1)), tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
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)),
],
} }
} }
@@ -513,34 +480,21 @@ impl SolitaireGame {
let slot: u8 = s["foundation-".len()..] let slot: u8 = s["foundation-".len()..]
.parse() .parse()
.map_err(|_| format!("bad pile: {s}"))?; .map_err(|_| format!("bad pile: {s}"))?;
if slot >= 4 { let foundation = solitaire_core::FOUNDATIONS
return Err(format!("foundation slot out of range: {slot}")); .get(slot as usize)
} .copied()
Ok(KlondikePile::Foundation(match slot { .ok_or_else(|| format!("foundation slot out of range: {slot}"))?;
0 => Foundation::Foundation1, Ok(KlondikePile::Foundation(foundation))
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
3 => Foundation::Foundation4,
_ => return Err(format!("foundation slot out of range: {slot}")),
}))
} }
_ if s.starts_with("tableau-") => { _ if s.starts_with("tableau-") => {
let col: usize = s["tableau-".len()..] let col: usize = s["tableau-".len()..]
.parse() .parse()
.map_err(|_| format!("bad pile: {s}"))?; .map_err(|_| format!("bad pile: {s}"))?;
if col >= 7 { let tableau = solitaire_core::TABLEAUS
return Err(format!("tableau col out of range: {col}")); .get(col)
} .copied()
Ok(KlondikePile::Tableau(match col { .ok_or_else(|| format!("tableau col out of range: {col}"))?;
0 => Tableau::Tableau1, Ok(KlondikePile::Tableau(tableau))
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}")),
}))
} }
_ => Err(format!("unknown pile: {s}")), _ => Err(format!("unknown pile: {s}")),
} }
@@ -936,7 +890,9 @@ mod tests {
} }
let idx = pick_move_index(&legal_moves).unwrap_or_default(); let idx = pick_move_index(&legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) { 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 stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_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!( assert_eq!(
game.game.stock_cards().len(), game.game.stock_cards().len(),
@@ -1152,7 +1113,9 @@ mod tests {
"seed {seed}: stock must have at least 3 cards for this test" "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); let expected_drawn = stock_before.min(3);
assert_eq!( assert_eq!(
+1 -2
View File
@@ -41,8 +41,7 @@ pub fn start() {
// texture-dimension limit is now taken from the adapter (see // texture-dimension limit is now taken from the adapter (see
// the RenderPlugin below), so this is purely a quality/perf // the RenderPlugin below), so this is purely a quality/perf
// choice, no longer a crash-avoidance hack. // choice, no longer a crash-avoidance hack.
resolution: WindowResolution::default() resolution: WindowResolution::default().with_scale_factor_override(1.0),
.with_scale_factor_override(1.0),
..default() ..default()
}), }),
..default() ..default()