Compare commits

...

25 Commits

Author SHA1 Message Date
funman300 38b81a4004 fix(engine): poll decor-view size to catch fold resizes winit misses
Test / test (pull_request) Successful in 23m3s
winit's Android backend does not forward content-rect changes that happen
while backgrounded (open TODOs in winit 0.30 logged on every resume), so a
fold/unfold cycle can leave Bevy rendering and laying out for the previous
screen: tableau clipped off the left edge, bottom third empty (#130).

Add a continuous decor-view size poller (JNI, every 30 frames) that, on
mismatch with the cached Window resolution: writes the real physical size
into window.resolution, emits a synthetic WindowResized in logical pixels,
and re-arms the safe-area inset poller — covering both the fold-size and
inset-staleness cases in one mechanism, without relying on
AppLifecycle::WillResume being delivered (it may never be; a new evidence
log in rearm_on_resumed settles that question on-device).

Closes #130

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:49:17 -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
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
27 changed files with 863 additions and 451 deletions
+41
View File
@@ -6,6 +6,47 @@ 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 ## [0.41.1] — 2026-07-06
### Fixed ### Fixed
+28
View File
@@ -19,5 +19,33 @@ pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, K
// 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;
+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`,
+8 -1
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),
); );
} }
} }
+59 -17
View File
@@ -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,45 @@ 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_empty_indicator,
update_stock_count_badge update_stock_count_badge
.after(GameMutation)
.run_if(resource_changed::<GameStateResource>), .run_if(resource_changed::<GameStateResource>),
collect_resize_events.after(LayoutSystem::UpdateOnResize), collect_resize_events,
snap_cards_on_window_resize.after(collect_resize_events), snap_cards_on_window_resize,
), resize_android_corner_labels,
)
.chain()
.in_set(BoardVisuals)
.after(GameMutation),
); );
app.add_systems(Update, resize_android_corner_labels);
app.add_systems(PostUpdate, rebuild_card_entity_index); app.add_systems(PostUpdate, rebuild_card_entity_index);
} }
} }
+9 -54
View File
@@ -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
+72 -11
View File
@@ -63,6 +63,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>);
@@ -208,28 +231,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);
} }
} }
+78 -18
View File
@@ -36,7 +36,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 +54,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 +154,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 +475,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
.add_systems( // disjoint Text nodes, but Bevy can't prove it); update_hud also
Update, // reads AutoCompleteState, so the chain sits after the
apply_hud_visibility.before(LayoutSystem::UpdateOnResize), // auto-complete detect/drive chain (#143).
)
.add_systems(Update, restore_hud_on_modal)
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
.add_systems(Update, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems( .add_systems(
Update, Update,
(
update_hud,
update_selection_hud.run_if( update_selection_hud.run_if(
resource_exists_and_changed::<SelectionState> resource_exists_and_changed::<SelectionState>
.or(resource_exists_and_changed::<GameStateResource>), .or(resource_exists_and_changed::<GameStateResource>),
), ),
update_won_previously,
)
.chain()
.after(GameMutation)
.after(crate::auto_complete_plugin::AutoComplete)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
)
// HUD chrome visibility: modal-restore writes HudVisibility, the
// applier consumes it, and the layout recompute reads it — a
// fixed chain instead of three racing systems (#143).
.add_systems(
Update,
(restore_hud_on_modal, apply_hud_visibility)
.chain()
.before(LayoutSystem::UpdateOnResize),
)
.add_systems(
Update,
(
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
handle_avatar_button.ambiguous_with(HudButtons),
),
)
.add_systems(
Update,
announce_auto_complete
.after(GameMutation)
.after(crate::auto_complete_plugin::AutoComplete)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// Typography rescale touches HUD TextFont only, but orders after
// the board painters that resize card/label text (#143).
.add_systems(
Update,
update_hud_typography.after(crate::card_plugin::BoardVisuals),
) )
.add_systems(Update, update_hud_typography)
.add_systems( .add_systems(
Update, Update,
( (
@@ -492,24 +533,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 +574,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.
+9 -27
View File
@@ -28,6 +28,7 @@ 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::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Card, Suit}; use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -1329,13 +1330,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 +1357,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));
@@ -1692,7 +1693,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 +1707,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 +1729,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 +1743,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,26 +1783,7 @@ 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 {
+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;
+3 -24
View File
@@ -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,
+3 -21
View File
@@ -48,6 +48,7 @@ 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::{Foundation, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -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);
@@ -360,26 +361,7 @@ 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.
/// ///
+100 -1
View File
@@ -84,7 +84,8 @@ impl Plugin for SafeAreaInsetsPlugin {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
app.init_resource::<android::SafeAreaPollTries>() app.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets) .add_systems(Update, android::refresh_insets)
.add_systems(Update, android::rearm_on_resumed); .add_systems(Update, android::rearm_on_resumed)
.add_systems(Update, android::refresh_surface_size);
} }
} }
@@ -225,6 +226,7 @@ fn on_app_resumed(
mod android { mod android {
use super::{AppLifecycle, SafeAreaInsets}; use super::{AppLifecycle, SafeAreaInsets};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized;
/// Tracks how many frames `refresh_insets` has polled. Stored as a /// Tracks how many frames `refresh_insets` has polled. Stored as a
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0 /// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
@@ -299,11 +301,108 @@ mod android {
) { ) {
for event in lifecycle.read() { for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) { if matches!(event, AppLifecycle::WillResume) {
// Evidence line for #130: winit's Android backend has open
// TODOs around forwarding resume notifications, so whether
// this ever fires on a given device is an open question.
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
poll.0 = 0; poll.0 = 0;
} }
} }
} }
/// Polls the decor view's size via JNI and forces a relayout when it
/// disagrees with Bevy's cached `Window` resolution (#130).
///
/// winit's Android backend does not forward content-rect changes that
/// happen while the app is backgrounded (fold/unfold on foldables), so
/// after a fold cycle Bevy can keep rendering and laying out for the
/// previous screen's dimensions. Unlike `refresh_insets` this poller
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
/// it, because that event is itself delivered through the same unreliable
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
/// frames is cheap.
///
/// On a mismatch it:
/// 1. writes the real size into `window.resolution` so the renderer
/// reconfigures the surface and systems reading `window.width()` see
/// the truth,
/// 2. emits a synthetic `WindowResized` (logical pixels) so
/// `on_window_resized` in `table_plugin` recomputes the board layout,
/// 3. re-arms the inset poller, because a screen change almost always
/// moves the system bars too — covering the "re-poll never fires"
/// hole left open in #116.
pub(super) fn refresh_surface_size(
mut frame: Local<u32>,
mut windows: Query<(Entity, &mut Window)>,
mut resize_events: MessageWriter<WindowResized>,
mut poll: ResMut<SafeAreaPollTries>,
) {
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
*frame += 1;
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
return;
}
let Some((entity, mut window)) = windows.iter_mut().next() else {
return;
};
let (decor_w, decor_h) = match query_decor_size() {
Ok(size) => size,
Err(e) => {
// One-time note; the bridge simply isn't up yet during the
// first frames of a launch.
if *frame == POLL_INTERVAL_FRAMES {
warn!("safe_area: decor size query failed (will retry): {e}");
}
return;
}
};
if decor_w == 0 || decor_h == 0 {
return; // decor view not laid out yet
}
// Reads go through `Deref` and do not trip change detection; only
// mutate `window` once a mismatch is confirmed.
let cached_w = window.resolution.physical_width();
let cached_h = window.resolution.physical_height();
if decor_w == cached_w && decor_h == cached_h {
return;
}
info!(
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
);
window.resolution.set_physical_resolution(decor_w, decor_h);
let scale = window.scale_factor();
resize_events.write(WindowResized {
window: entity,
width: decor_w as f32 / scale,
height: decor_h as f32 / scale,
});
poll.0 = 0;
}
/// Physical pixel size of the activity's decor view — the ground truth
/// for the surface we are actually being displayed on, independent of
/// whatever winit last told Bevy.
fn query_decor_size() -> Result<(u32, u32), String> {
use solitaire_data::android_jni;
android_jni::with_activity_env(|env, activity| {
let window = env
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?;
let decor = env
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
.l()?;
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
Ok((w.max(0) as u32, h.max(0) as u32))
})
}
fn query_insets() -> Result<SafeAreaInsets, String> { fn query_insets() -> Result<SafeAreaInsets, String> {
use solitaire_data::android_jni; use solitaire_data::android_jni;
+108
View File
@@ -0,0 +1,108 @@
//! 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).",
);
}
}
+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 {
+24 -14
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
let rt = rt.0.clone(); .0
AsyncComputeTaskPool::get() .block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await });
.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
+23 -24
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::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Suit; use solitaire_core::Suit;
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),
// Marker painters: deterministic chain after the card
// paint pipeline — markers and cards share Sprite/
// Transform access (#143).
(
apply_theme_on_settings_change, apply_theme_on_settings_change,
apply_hint_pile_highlight, apply_hint_pile_highlight,
tick_hint_pile_highlights, tick_hint_pile_highlights,
sync_pile_marker_visibility, 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));
} }
@@ -576,31 +593,13 @@ 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.
@@ -940,7 +939,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() .iter()
.map(suit_symbol) .map(suit_symbol)
.collect(); .collect();
+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)))
+11 -58
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;
@@ -242,22 +242,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,36 +252,7 @@ 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
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
const fn suit_index(s: Suit) -> usize {
match s {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
}
}
/// Index used by [`CardImageSet::faces`] for a given rank.
/// Ace=0, Two=1 … King=12.
const fn rank_index(r: Rank) -> usize {
match r {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
}
}
/// Switches the active theme to the one served at /// Switches the active theme to the one served at
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>` /// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
@@ -446,21 +403,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]
+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
+9
View File
@@ -698,3 +698,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: 114880, 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: 9853, 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: 9866, 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: 9853, 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: 9853, 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: 9853, 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: 9853, 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: 9853, 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: 9853, 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: 9853, 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: 9863, 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: 9857, 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.
+21 -69
View File
@@ -19,7 +19,7 @@
//! is the contract. //! is the contract.
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; use solitaire_core::{KlondikeInstruction, KlondikePile};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError; use solitaire_core::error::MoveError;
@@ -145,21 +145,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] = solitaire_core::FOUNDATIONS
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)), .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 +342,9 @@ 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 =
game.pile(KlondikePile::Foundation(Foundation::Foundation1)), solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
game.pile(KlondikePile::Foundation(Foundation::Foundation2)), let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
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();
@@ -488,21 +465,9 @@ impl SolitaireGame {
.iter() .iter()
.map(CardSnapshot::from) .map(CardSnapshot::from)
.collect(), .collect(),
foundations: [ foundations: solitaire_core::FOUNDATIONS
cards(KlondikePile::Foundation(Foundation::Foundation1)), .map(|f| cards(KlondikePile::Foundation(f))),
cards(KlondikePile::Foundation(Foundation::Foundation2)), tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
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 +478,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}")),
} }