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