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