Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38b81a4004 | |||
| 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,66 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# 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
|
||||||
+71
-39
@@ -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,32 @@ 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,
|
|
||||||
pub rank: Rank,
|
|
||||||
pub face_up: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum PileType {
|
|
||||||
Stock,
|
Stock,
|
||||||
Waste,
|
Waste,
|
||||||
Foundation(Suit),
|
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
||||||
Tableau(usize), // 0–6
|
Tableau(Tableau), // 7 columns
|
||||||
}
|
}
|
||||||
|
pub enum DrawStockConfig { DrawOne, DrawThree }
|
||||||
|
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
||||||
|
serialized move format (schema v4+) */ }
|
||||||
|
```
|
||||||
|
|
||||||
pub enum DrawMode { DrawOne, DrawThree }
|
Owned by `solitaire_core`:
|
||||||
|
|
||||||
|
```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 +615,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 +676,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.
|
||||||
|
|||||||
@@ -19,5 +19,33 @@ pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, K
|
|||||||
// former `solitaire_data::solver` wrapper module.
|
// former `solitaire_data::solver` wrapper module.
|
||||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||||
|
|
||||||
|
/// All four foundation slots, in slot order.
|
||||||
|
///
|
||||||
|
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||||
|
/// `Foundation::ALL` (unlike `Suit::SUITS` / `Rank::RANKS` in `card_game`),
|
||||||
|
/// and inherent impls cannot be added to a foreign type, so the workspace
|
||||||
|
/// const lives here. Use this instead of hand-rolling `[Foundation; 4]`
|
||||||
|
/// arrays; scattered copies can silently diverge.
|
||||||
|
pub const FOUNDATIONS: [Foundation; 4] = [
|
||||||
|
Foundation::Foundation1,
|
||||||
|
Foundation::Foundation2,
|
||||||
|
Foundation::Foundation3,
|
||||||
|
Foundation::Foundation4,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// All seven tableau columns, in column order (left to right on screen).
|
||||||
|
///
|
||||||
|
/// Canonical iteration source for `Tableau` — see [`FOUNDATIONS`] for why
|
||||||
|
/// this lives here rather than upstream.
|
||||||
|
pub const TABLEAUS: [Tableau; 7] = [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
];
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod proptest_tests;
|
mod proptest_tests;
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,45 @@ impl Plugin for CardPlugin {
|
|||||||
update_stock_empty_indicator_startup,
|
update_stock_empty_indicator_startup,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Layout recompute (UpdateOnResize) always precedes board
|
||||||
|
// painting, and the painters run as ONE deterministic chain in
|
||||||
|
// data-flow order: layout refinement → card authority → anims →
|
||||||
|
// shadows → highlights → indicators → resize snapping → labels.
|
||||||
|
// Every painter mutates card/marker Sprite+Transform, so without
|
||||||
|
// the chain each pair is a scheduler ambiguity (#143). All
|
||||||
|
// members are cheap and mostly change-gated; sequential
|
||||||
|
// execution is not a cost that matters here.
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
LayoutSystem::UpdateOnResize.before(BoardVisuals),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_tableau_fan_frac
|
update_tableau_fan_frac,
|
||||||
.after(GameMutation)
|
resync_cards_on_settings_change,
|
||||||
.before(sync_cards_on_change),
|
sync_cards_on_change,
|
||||||
sync_cards_on_change.after(GameMutation),
|
start_flip_anim,
|
||||||
resync_cards_on_settings_change.before(sync_cards_on_change),
|
|
||||||
start_flip_anim.after(GameMutation),
|
|
||||||
tick_flip_anim,
|
tick_flip_anim,
|
||||||
update_drag_shadow,
|
update_drag_shadow,
|
||||||
update_card_shadows_on_drag.after(sync_cards_on_change),
|
update_card_shadows_on_drag,
|
||||||
tick_hint_highlight,
|
|
||||||
handle_right_click,
|
handle_right_click,
|
||||||
tick_right_click_highlights,
|
tick_right_click_highlights,
|
||||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
clear_right_click_highlights_on_state_change,
|
||||||
clear_right_click_highlights_on_pause,
|
clear_right_click_highlights_on_pause,
|
||||||
update_stock_empty_indicator.after(GameMutation),
|
tick_hint_highlight,
|
||||||
|
update_stock_empty_indicator,
|
||||||
update_stock_count_badge
|
update_stock_count_badge
|
||||||
.after(GameMutation)
|
|
||||||
.run_if(resource_changed::<GameStateResource>),
|
.run_if(resource_changed::<GameStateResource>),
|
||||||
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
collect_resize_events,
|
||||||
snap_cards_on_window_resize.after(collect_resize_events),
|
snap_cards_on_window_resize,
|
||||||
),
|
resize_android_corner_labels,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(BoardVisuals)
|
||||||
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.add_systems(Update, resize_android_corner_labels);
|
|
||||||
app.add_systems(PostUpdate, rebuild_card_entity_index);
|
app.add_systems(PostUpdate, rebuild_card_entity_index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -63,6 +63,29 @@ pub struct GameOverScreen;
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct GameMutation;
|
pub struct GameMutation;
|
||||||
|
|
||||||
|
/// System set for every writer of [`crate::events::NewGameRequestEvent`].
|
||||||
|
///
|
||||||
|
/// Many UI entry points fire this trigger (buttons, keyboard, modals,
|
||||||
|
/// mode pickers). Their relative append order within a frame is
|
||||||
|
/// meaningless — consumers drain the whole queue — so members are
|
||||||
|
/// registered `.in_set(NewGameRequestWriters).ambiguous_with(NewGameRequestWriters)`
|
||||||
|
/// to declare writer-vs-writer order irrelevant instead of leaving it as an
|
||||||
|
/// ambiguity (#143). Only ever combine with `.ambiguous_with` on the same
|
||||||
|
/// set; do NOT hang ordering edges off this set.
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct NewGameRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `UndoRequestEvent` — same rationale as
|
||||||
|
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
|
||||||
|
/// meaningless (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct UndoRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
||||||
|
/// arrival order and any same-frame order is fine (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct InfoToastWriters;
|
||||||
|
|
||||||
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct GameStatePath(pub Option<PathBuf>);
|
pub struct GameStatePath(pub Option<PathBuf>);
|
||||||
@@ -208,28 +231,66 @@ impl Plugin for GamePlugin {
|
|||||||
.add_message::<AppLifecycle>()
|
.add_message::<AppLifecycle>()
|
||||||
// add_message is idempotent; SettingsPlugin also registers this.
|
// add_message is idempotent; SettingsPlugin also registers this.
|
||||||
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
||||||
.add_systems(Update, poll_pending_new_game_seed.before(GameMutation))
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
poll_pending_new_game_seed
|
||||||
|
.before(GameMutation)
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(handle_new_game, handle_draw, handle_move, handle_undo)
|
(handle_new_game, handle_draw, handle_move, handle_undo)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(GameMutation),
|
.in_set(GameMutation),
|
||||||
)
|
)
|
||||||
.add_systems(Update, check_no_moves.after(GameMutation))
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
check_no_moves
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::card_plugin::BoardVisuals)
|
||||||
|
.in_set(InfoToastWriters)
|
||||||
|
.ambiguous_with(InfoToastWriters),
|
||||||
|
)
|
||||||
.add_systems(Update, record_replay_on_win.after(GameMutation))
|
.add_systems(Update, record_replay_on_win.after(GameMutation))
|
||||||
.add_systems(Update, handle_confirm_input.after(GameMutation))
|
.add_systems(
|
||||||
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
|
Update,
|
||||||
.add_systems(Update, handle_game_over_input.after(GameMutation))
|
(
|
||||||
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
|
handle_confirm_input,
|
||||||
|
handle_confirm_button_input,
|
||||||
|
handle_game_over_input,
|
||||||
|
handle_game_over_button_input,
|
||||||
|
)
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::ui_focus::FocusKeys)
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters)
|
||||||
|
.in_set(UndoRequestWriters)
|
||||||
|
.ambiguous_with(UndoRequestWriters),
|
||||||
|
)
|
||||||
// Restore prompt: spawn the modal once the splash is gone,
|
// Restore prompt: spawn the modal once the splash is gone,
|
||||||
// route Continue / New Game intents back into the existing
|
// route Continue / New Game intents back into the existing
|
||||||
// GameMutation flow.
|
// GameMutation flow.
|
||||||
.add_systems(Update, spawn_restore_prompt_if_pending)
|
// All pre-mutation game-state writers are chained: elapsed
|
||||||
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
// time ticks first, settings sync next, then the restore prompt —
|
||||||
.add_systems(Update, sync_settings_to_game.before(GameMutation))
|
// a deterministic spine instead of three unordered ResMut holders
|
||||||
|
// (ambiguity burn-down, #143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
tick_elapsed_time,
|
||||||
|
sync_settings_to_game,
|
||||||
|
spawn_restore_prompt_if_pending,
|
||||||
|
handle_restore_prompt
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
.before(GameMutation),
|
||||||
|
)
|
||||||
.init_resource::<AutoSaveTimer>()
|
.init_resource::<AutoSaveTimer>()
|
||||||
.add_systems(Update, tick_elapsed_time)
|
.add_systems(Update, auto_save_game_state.after(GameMutation))
|
||||||
.add_systems(Update, auto_save_game_state)
|
|
||||||
.add_systems(Last, save_game_state_on_exit);
|
.add_systems(Last, save_game_state_on_exit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ use crate::events::{
|
|||||||
UndoRequestEvent, WinStreakMilestoneEvent,
|
UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use crate::input_plugin::TouchDragSet;
|
use crate::input_plugin::TouchDragSet;
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
@@ -54,6 +54,7 @@ use crate::time_attack_plugin::TimeAttackResource;
|
|||||||
use crate::ui_focus::{FocusGroup, Focusable};
|
use crate::ui_focus::{FocusGroup, Focusable};
|
||||||
use crate::ui_modal::ModalScrim;
|
use crate::ui_modal::ModalScrim;
|
||||||
use crate::ui_theme::SPACE_2;
|
use crate::ui_theme::SPACE_2;
|
||||||
|
use crate::ui_theme::UiTextFx;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
||||||
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||||
@@ -153,6 +154,13 @@ pub struct HudColumn;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HudActionBar;
|
pub struct HudActionBar;
|
||||||
|
|
||||||
|
/// Set wrapping the chained HUD button/popover interaction systems. Other
|
||||||
|
/// keyboard consumers order themselves around it (e.g.
|
||||||
|
/// [`crate::ui_focus::FocusKeys`] runs after) so input-consumption order is
|
||||||
|
/// deterministic (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct HudButtons;
|
||||||
|
|
||||||
/// Marker on the text node inside each touch-layout action-bar button.
|
/// Marker on the text node inside each touch-layout action-bar button.
|
||||||
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -467,23 +475,56 @@ impl Plugin for HudPlugin {
|
|||||||
// defensively so the HUD plugin works standalone in tests.
|
// defensively so the HUD plugin works standalone in tests.
|
||||||
.add_message::<WindowResized>()
|
.add_message::<WindowResized>()
|
||||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
||||||
.add_systems(Update, update_hud.after(GameMutation))
|
// HUD text updaters run as one deterministic chain (they write
|
||||||
|
// 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 +533,40 @@ impl Plugin for HudPlugin {
|
|||||||
advance_score_floater,
|
advance_score_floater,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation)
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(start_streak_flourish, advance_streak_flourish)
|
(start_streak_flourish, advance_streak_flourish)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation)
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
handle_new_game_button,
|
handle_new_game_button
|
||||||
handle_undo_button,
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
handle_undo_button
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_help_button,
|
handle_help_button,
|
||||||
handle_hint_button,
|
handle_hint_button
|
||||||
|
.after(GameMutation)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
handle_modes_button,
|
handle_modes_button,
|
||||||
handle_mode_option_click,
|
handle_mode_option_click
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
handle_modes_backdrop_click,
|
handle_modes_backdrop_click,
|
||||||
close_modes_popover_on_escape,
|
close_modes_popover_on_escape,
|
||||||
handle_menu_button,
|
handle_menu_button,
|
||||||
@@ -517,7 +574,10 @@ impl Plugin for HudPlugin {
|
|||||||
handle_menu_backdrop_click,
|
handle_menu_backdrop_click,
|
||||||
close_menu_popover_on_escape,
|
close_menu_popover_on_escape,
|
||||||
paint_action_buttons,
|
paint_action_buttons,
|
||||||
),
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(HudButtons)
|
||||||
|
.before(crate::ui_focus::FocusKeys),
|
||||||
)
|
)
|
||||||
// Fade lives in `Last` so it always overrides whatever the
|
// Fade lives in `Last` so it always overrides whatever the
|
||||||
// hover/paint pass set on `BackgroundColor` this frame.
|
// hover/paint pass set on `BackgroundColor` this frame.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use bevy::window::PrimaryWindow;
|
|||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::window::{MonitorSelection, WindowMode};
|
use bevy::window::{MonitorSelection, WindowMode};
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
use solitaire_core::{Card, Suit};
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -1329,13 +1330,13 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
|
|||||||
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
||||||
let source = game.pile_containing_card(card.clone())?;
|
let source = game.pile_containing_card(card.clone())?;
|
||||||
|
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let dest = KlondikePile::Foundation(foundation);
|
let dest = KlondikePile::Foundation(foundation);
|
||||||
if game.can_move_cards(&source, &dest, 1) {
|
if game.can_move_cards(&source, &dest, 1) {
|
||||||
return Some(dest);
|
return Some(dest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(&source, &dest, 1) {
|
if game.can_move_cards(&source, &dest, 1) {
|
||||||
return Some(dest);
|
return Some(dest);
|
||||||
@@ -1356,7 +1357,7 @@ pub fn best_tableau_destination_for_stack(
|
|||||||
game: &GameState,
|
game: &GameState,
|
||||||
stack_count: usize,
|
stack_count: usize,
|
||||||
) -> Option<(KlondikePile, usize)> {
|
) -> Option<(KlondikePile, usize)> {
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(from, &dest, stack_count) {
|
if game.can_move_cards(from, &dest, stack_count) {
|
||||||
return Some((dest, stack_count));
|
return Some((dest, stack_count));
|
||||||
@@ -1692,7 +1693,7 @@ pub(crate) fn hint_piles(
|
|||||||
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
||||||
let sources: Vec<KlondikePile> = {
|
let sources: Vec<KlondikePile> = {
|
||||||
let mut s = vec![KlondikePile::Stock];
|
let mut s = vec![KlondikePile::Stock];
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
s.push(KlondikePile::Tableau(tableau));
|
s.push(KlondikePile::Tableau(tableau));
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
@@ -1706,7 +1707,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
|||||||
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let dest = KlondikePile::Foundation(foundation);
|
let dest = KlondikePile::Foundation(foundation);
|
||||||
if game.can_move_cards(from, &dest, 1) {
|
if game.can_move_cards(from, &dest, 1) {
|
||||||
hints.push((*from, dest));
|
hints.push((*from, dest));
|
||||||
@@ -1728,7 +1729,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
|||||||
if already_has_foundation_hint {
|
if already_has_foundation_hint {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(from, &dest, 1) {
|
if game.can_move_cards(from, &dest, 1) {
|
||||||
hints.push((*from, dest));
|
hints.push((*from, dest));
|
||||||
@@ -1742,13 +1743,13 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
|||||||
// should never hint Foundation→Foundation. Here we handle the return path
|
// should never hint Foundation→Foundation. Here we handle the return path
|
||||||
// separately so the guarded `take_from_foundation` rule is respected.
|
// separately so the guarded `take_from_foundation` rule is respected.
|
||||||
if game.take_from_foundation {
|
if game.take_from_foundation {
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let from = KlondikePile::Foundation(foundation);
|
let from = KlondikePile::Foundation(foundation);
|
||||||
let from_pile = pile_cards(game, &from);
|
let from_pile = pile_cards(game, &from);
|
||||||
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(&from, &dest, 1) {
|
if game.can_move_cards(&from, &dest, 1) {
|
||||||
hints.push((from, dest));
|
hints.push((from, dest));
|
||||||
@@ -1782,26 +1783,7 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
|
||||||
[
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableaus() -> [Tableau; 7] {
|
|
||||||
[
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableau_number(tableau: Tableau) -> u8 {
|
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||||
match tableau {
|
match tableau {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ use bevy::math::Vec2;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -254,13 +255,13 @@ pub fn legal_destinations_for_card(
|
|||||||
game: &GameState,
|
game: &GameState,
|
||||||
) -> Vec<KlondikePile> {
|
) -> Vec<KlondikePile> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let dest = KlondikePile::Foundation(foundation);
|
let dest = KlondikePile::Foundation(foundation);
|
||||||
if game.can_move_cards(source_pile, &dest, 1) {
|
if game.can_move_cards(source_pile, &dest, 1) {
|
||||||
out.push(dest);
|
out.push(dest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(source_pile, &dest, 1) {
|
if game.can_move_cards(source_pile, &dest, 1) {
|
||||||
out.push(dest);
|
out.push(dest);
|
||||||
@@ -360,26 +361,7 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
|
||||||
[
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableaus() -> [Tableau; 7] {
|
|
||||||
[
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.init_resource::<android::SafeAreaPollTries>()
|
app.init_resource::<android::SafeAreaPollTries>()
|
||||||
.add_systems(Update, android::refresh_insets)
|
.add_systems(Update, android::refresh_insets)
|
||||||
.add_systems(Update, android::rearm_on_resumed);
|
.add_systems(Update, android::rearm_on_resumed)
|
||||||
|
.add_systems(Update, android::refresh_surface_size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +226,7 @@ fn on_app_resumed(
|
|||||||
mod android {
|
mod android {
|
||||||
use super::{AppLifecycle, SafeAreaInsets};
|
use super::{AppLifecycle, SafeAreaInsets};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
|
||||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||||
@@ -299,11 +301,108 @@ mod android {
|
|||||||
) {
|
) {
|
||||||
for event in lifecycle.read() {
|
for event in lifecycle.read() {
|
||||||
if matches!(event, AppLifecycle::WillResume) {
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
|
// Evidence line for #130: winit's Android backend has open
|
||||||
|
// TODOs around forwarding resume notifications, so whether
|
||||||
|
// this ever fires on a given device is an open question.
|
||||||
|
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
|
||||||
poll.0 = 0;
|
poll.0 = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Polls the decor view's size via JNI and forces a relayout when it
|
||||||
|
/// disagrees with Bevy's cached `Window` resolution (#130).
|
||||||
|
///
|
||||||
|
/// winit's Android backend does not forward content-rect changes that
|
||||||
|
/// happen while the app is backgrounded (fold/unfold on foldables), so
|
||||||
|
/// after a fold cycle Bevy can keep rendering and laying out for the
|
||||||
|
/// previous screen's dimensions. Unlike `refresh_insets` this poller
|
||||||
|
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
|
||||||
|
/// it, because that event is itself delivered through the same unreliable
|
||||||
|
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
|
||||||
|
/// frames is cheap.
|
||||||
|
///
|
||||||
|
/// On a mismatch it:
|
||||||
|
/// 1. writes the real size into `window.resolution` so the renderer
|
||||||
|
/// reconfigures the surface and systems reading `window.width()` see
|
||||||
|
/// the truth,
|
||||||
|
/// 2. emits a synthetic `WindowResized` (logical pixels) so
|
||||||
|
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||||
|
/// 3. re-arms the inset poller, because a screen change almost always
|
||||||
|
/// moves the system bars too — covering the "re-poll never fires"
|
||||||
|
/// hole left open in #116.
|
||||||
|
pub(super) fn refresh_surface_size(
|
||||||
|
mut frame: Local<u32>,
|
||||||
|
mut windows: Query<(Entity, &mut Window)>,
|
||||||
|
mut resize_events: MessageWriter<WindowResized>,
|
||||||
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
|
) {
|
||||||
|
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||||
|
|
||||||
|
*frame += 1;
|
||||||
|
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((entity, mut window)) = windows.iter_mut().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (decor_w, decor_h) = match query_decor_size() {
|
||||||
|
Ok(size) => size,
|
||||||
|
Err(e) => {
|
||||||
|
// One-time note; the bridge simply isn't up yet during the
|
||||||
|
// first frames of a launch.
|
||||||
|
if *frame == POLL_INTERVAL_FRAMES {
|
||||||
|
warn!("safe_area: decor size query failed (will retry): {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if decor_w == 0 || decor_h == 0 {
|
||||||
|
return; // decor view not laid out yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads go through `Deref` and do not trip change detection; only
|
||||||
|
// mutate `window` once a mismatch is confirmed.
|
||||||
|
let cached_w = window.resolution.physical_width();
|
||||||
|
let cached_h = window.resolution.physical_height();
|
||||||
|
if decor_w == cached_w && decor_h == cached_h {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
|
||||||
|
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
|
||||||
|
);
|
||||||
|
window.resolution.set_physical_resolution(decor_w, decor_h);
|
||||||
|
let scale = window.scale_factor();
|
||||||
|
resize_events.write(WindowResized {
|
||||||
|
window: entity,
|
||||||
|
width: decor_w as f32 / scale,
|
||||||
|
height: decor_h as f32 / scale,
|
||||||
|
});
|
||||||
|
poll.0 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical pixel size of the activity's decor view — the ground truth
|
||||||
|
/// for the surface we are actually being displayed on, independent of
|
||||||
|
/// whatever winit last told Bevy.
|
||||||
|
fn query_decor_size() -> Result<(u32, u32), String> {
|
||||||
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
android_jni::with_activity_env(|env, activity| {
|
||||||
|
let window = env
|
||||||
|
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let decor = env
|
||||||
|
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
|
||||||
|
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
|
||||||
|
Ok((w.max(0) as u32, h.max(0) as u32))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use solitaire_data::android_jni;
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! Schedule hygiene checks (issue #143).
|
||||||
|
//!
|
||||||
|
//! Bevy runs systems with conflicting data access in nondeterministic order
|
||||||
|
//! unless an ordering edge exists. Nothing enforced this historically, and
|
||||||
|
//! the headless gameplay cluster carries a large legacy backlog of such
|
||||||
|
//! pairs — most benign (event/resource writers that never observably race),
|
||||||
|
//! but unproven. Rather than a permanently red hard-error test or no test
|
||||||
|
//! at all, this is a RATCHET: the count may only go down. New conflicting
|
||||||
|
//! pairs fail CI immediately; the backlog can be triaged incrementally
|
||||||
|
//! (add `.before`/`.after` where order matters, `.ambiguous_with` where it
|
||||||
|
//! provably doesn't, then lower `AMBIGUITY_BASELINE`).
|
||||||
|
//!
|
||||||
|
//! To see the offending pair names, add the `bevy_debug` / debug feature to
|
||||||
|
//! the bevy dev-dependency (system names are stripped otherwise) and set
|
||||||
|
//! `ambiguity_detection: LogLevel::Error` manually.
|
||||||
|
//!
|
||||||
|
//! Scope note: `CoreGamePlugin` (the full composition) performs real
|
||||||
|
//! storage/platform I/O in `build` and cannot run in a unit test; the
|
||||||
|
//! cluster below is the same one the engine's headless behaviour tests use.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::auto_complete_plugin::AutoCompletePlugin;
|
||||||
|
use crate::card_plugin::CardPlugin;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::hud_plugin::HudPlugin;
|
||||||
|
use crate::settings_plugin::SettingsPlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use crate::ui_focus::UiFocusPlugin;
|
||||||
|
use crate::ui_modal::UiModalPlugin;
|
||||||
|
|
||||||
|
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
|
||||||
|
/// same day (#143, PRs #146–#149) — this is now a hard gate. If your
|
||||||
|
/// change trips this assertion you have added a pair of systems with
|
||||||
|
/// conflicting data access and no ordering edge: add `.before`/`.after`
|
||||||
|
/// where order matters, or `.ambiguous_with` the relevant domain set
|
||||||
|
/// (BoardVisuals, MarkerVisuals, UiTextFx, HudButtons, writer sets)
|
||||||
|
/// where it provably does not. Do not raise this constant.
|
||||||
|
const AMBIGUITY_BASELINE: usize = 0;
|
||||||
|
|
||||||
|
fn cluster_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(CardPlugin)
|
||||||
|
.add_plugins(HudPlugin)
|
||||||
|
.add_plugins(AutoCompletePlugin)
|
||||||
|
.add_plugins(UiModalPlugin)
|
||||||
|
.add_plugins(UiFocusPlugin)
|
||||||
|
.add_plugins(SettingsPlugin::headless());
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_schedule_ambiguities_do_not_grow() {
|
||||||
|
let mut app = cluster_app();
|
||||||
|
app.edit_schedule(Update, |s| {
|
||||||
|
s.set_build_settings(ScheduleBuildSettings {
|
||||||
|
ambiguity_detection: LogLevel::Error,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// With LogLevel::Error the first schedule build panics when any
|
||||||
|
// ambiguity exists, and the panic message begins with the pair
|
||||||
|
// count. Catch it and ratchet on that count. (Parsing a panic
|
||||||
|
// message is brittle by design — if a Bevy upgrade rewords it,
|
||||||
|
// this test fails loudly and the parse below needs one-line
|
||||||
|
// maintenance, which is preferable to losing the ratchet.)
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
app.update();
|
||||||
|
}));
|
||||||
|
|
||||||
|
let count = match result {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(payload) => {
|
||||||
|
let msg = payload
|
||||||
|
.downcast_ref::<String>()
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let parsed = msg
|
||||||
|
.split(" pairs of systems")
|
||||||
|
.next()
|
||||||
|
.and_then(|prefix| prefix.split_whitespace().last())
|
||||||
|
.and_then(|n| n.parse::<usize>().ok());
|
||||||
|
parsed.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"ambiguity panic message no longer parseable (Bevy upgrade?): {msg}"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
count, AMBIGUITY_BASELINE,
|
||||||
|
"system-order ambiguities changed from the enforced baseline. \
|
||||||
|
Add .before/.after or .ambiguous_with at the new registration site \
|
||||||
|
(or, if the count legitimately dropped below a nonzero baseline, \
|
||||||
|
lower AMBIGUITY_BASELINE).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -272,12 +272,21 @@ fn poll_pull_result(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Last-schedule system: starts a best-effort push of the current local state
|
/// Upper bound on how long [`push_on_exit`] may block the closing app.
|
||||||
/// on [`AppExit`] without blocking the Bevy main thread.
|
/// Long enough for one healthy round-trip; short enough that quitting
|
||||||
|
/// never feels hung when the server is unreachable.
|
||||||
|
const EXIT_PUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Last-schedule system: pushes the current local state on [`AppExit`],
|
||||||
|
/// blocking the main thread for at most [`EXIT_PUSH_TIMEOUT`].
|
||||||
///
|
///
|
||||||
/// The detached task may be cut short by process teardown, so local atomic
|
/// This deliberately blocks: the previous detached-task version was almost
|
||||||
/// persistence remains the durable source of truth even if the final remote
|
/// always cut short by process teardown, so the final session's push
|
||||||
/// push does not complete.
|
/// silently never happened (2026-07-06 review, finding M3). A bounded wait
|
||||||
|
/// during the app's final frame is invisible to the player and lets the
|
||||||
|
/// round-trip actually complete on a healthy network. On timeout or error
|
||||||
|
/// the push is skipped — local atomic persistence remains the durable
|
||||||
|
/// source of truth and the next launch's pull/push converges.
|
||||||
fn push_on_exit(
|
fn push_on_exit(
|
||||||
mut exit_events: MessageReader<AppExit>,
|
mut exit_events: MessageReader<AppExit>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
@@ -292,16 +301,17 @@ fn push_on_exit(
|
|||||||
exit_events.clear();
|
exit_events.clear();
|
||||||
|
|
||||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
let provider = provider.0.clone();
|
let result = rt
|
||||||
let rt = rt.0.clone();
|
.0
|
||||||
AsyncComputeTaskPool::get()
|
.block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await });
|
||||||
.spawn(async move {
|
match result {
|
||||||
match rt.block_on(provider.push(&payload)) {
|
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
|
||||||
Ok(_) | Err(SyncError::UnsupportedPlatform) => {}
|
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
|
||||||
Err(e) => warn!("sync push on exit failed: {e}"),
|
Err(_) => warn!(
|
||||||
}
|
"sync push on exit timed out after {}s; will sync on next launch",
|
||||||
})
|
EXIT_PUSH_TIMEOUT.as_secs()
|
||||||
.detach();
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update-schedule system: on each `GameWonEvent` push the just-completed
|
/// Update-schedule system: on each `GameWonEvent` push the just-completed
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
use solitaire_core::Suit;
|
use solitaire_core::Suit;
|
||||||
|
|
||||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::hud_plugin::HudVisibility;
|
use crate::hud_plugin::HudVisibility;
|
||||||
use crate::layout::{
|
use crate::layout::{
|
||||||
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
||||||
@@ -83,6 +85,13 @@ pub struct HintPileHighlight {
|
|||||||
/// Registers the table background and pile-marker rendering.
|
/// Registers the table background and pile-marker rendering.
|
||||||
pub struct TablePlugin;
|
pub struct TablePlugin;
|
||||||
|
|
||||||
|
/// Set wrapping the pile-marker painter chain (theme, hint highlights,
|
||||||
|
/// visibility). Runs after [`crate::card_plugin::BoardVisuals`]; chrome-fx
|
||||||
|
/// systems that touch `Visibility` on UI entities declare themselves
|
||||||
|
/// ambiguous with it (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MarkerVisuals;
|
||||||
|
|
||||||
impl Plugin for TablePlugin {
|
impl Plugin for TablePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register WindowResized so the plugin works under MinimalPlugins in
|
// Register WindowResized so the plugin works under MinimalPlugins in
|
||||||
@@ -99,10 +108,18 @@ impl Plugin for TablePlugin {
|
|||||||
(
|
(
|
||||||
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
||||||
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,31 +593,13 @@ fn pile_cards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
|
||||||
[
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableaus() -> [Tableau; 7] {
|
|
||||||
[
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use solitaire_core::{Foundation, Tableau};
|
||||||
|
|
||||||
/// Minimal headless app — omits windowing so pile markers are spawned with
|
/// Minimal headless app — omits windowing so pile markers are spawned with
|
||||||
/// the default 1280×800 layout and no camera is created.
|
/// the default 1280×800 layout and no camera is created.
|
||||||
@@ -940,7 +939,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_symbol_all_four_are_distinct() {
|
fn suit_symbol_all_four_are_distinct() {
|
||||||
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
|
let symbols: Vec<&str> = Suit::SUITS
|
||||||
.iter()
|
.iter()
|
||||||
.map(suit_symbol)
|
.map(suit_symbol)
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -242,22 +242,8 @@ fn sync_card_image_set_with_active_theme(
|
|||||||
/// `theme_back` when present, so writing here is sufficient to make
|
/// `theme_back` when present, so writing here is sufficient to make
|
||||||
/// every face-down card pick up the theme's art on the next sync.
|
/// every face-down card pick up the theme's art on the next sync.
|
||||||
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
for suit in Suit::SUITS {
|
||||||
for rank in [
|
for rank in Rank::RANKS {
|
||||||
Rank::Ace,
|
|
||||||
Rank::Two,
|
|
||||||
Rank::Three,
|
|
||||||
Rank::Four,
|
|
||||||
Rank::Five,
|
|
||||||
Rank::Six,
|
|
||||||
Rank::Seven,
|
|
||||||
Rank::Eight,
|
|
||||||
Rank::Nine,
|
|
||||||
Rank::Ten,
|
|
||||||
Rank::Jack,
|
|
||||||
Rank::Queen,
|
|
||||||
Rank::King,
|
|
||||||
] {
|
|
||||||
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
|
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
|
||||||
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
|
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
|
||||||
}
|
}
|
||||||
@@ -266,36 +252,7 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
|
|||||||
image_set.theme_back = Some(theme.back.clone());
|
image_set.theme_back = Some(theme.back.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
|
|
||||||
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
|
||||||
const fn suit_index(s: Suit) -> usize {
|
|
||||||
match s {
|
|
||||||
Suit::Clubs => 0,
|
|
||||||
Suit::Diamonds => 1,
|
|
||||||
Suit::Hearts => 2,
|
|
||||||
Suit::Spades => 3,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Index used by [`CardImageSet::faces`] for a given rank.
|
|
||||||
/// Ace=0, Two=1 … King=12.
|
|
||||||
const fn rank_index(r: Rank) -> usize {
|
|
||||||
match r {
|
|
||||||
Rank::Ace => 0,
|
|
||||||
Rank::Two => 1,
|
|
||||||
Rank::Three => 2,
|
|
||||||
Rank::Four => 3,
|
|
||||||
Rank::Five => 4,
|
|
||||||
Rank::Six => 5,
|
|
||||||
Rank::Seven => 6,
|
|
||||||
Rank::Eight => 7,
|
|
||||||
Rank::Nine => 8,
|
|
||||||
Rank::Ten => 9,
|
|
||||||
Rank::Jack => 10,
|
|
||||||
Rank::Queen => 11,
|
|
||||||
Rank::King => 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switches the active theme to the one served at
|
/// Switches the active theme to the one served at
|
||||||
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
|
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
|
||||||
@@ -446,21 +403,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_index_ranges_match_card_plugin_layout() {
|
fn suit_index_matches_upstream_suits_order() {
|
||||||
assert_eq!(suit_index(Suit::Clubs), 0);
|
for (i, s) in Suit::SUITS.iter().enumerate() {
|
||||||
assert_eq!(suit_index(Suit::Diamonds), 1);
|
assert_eq!(suit_index(*s), i, "faces outer layout = Suit::SUITS order");
|
||||||
assert_eq!(suit_index(Suit::Hearts), 2);
|
}
|
||||||
assert_eq!(suit_index(Suit::Spades), 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() {
|
fn rank_index_matches_upstream_ranks_order() {
|
||||||
assert_eq!(rank_index(Rank::Ace), 0);
|
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||||
assert_eq!(rank_index(Rank::Two), 1);
|
assert_eq!(rank_index(*r), i, "faces inner layout = Rank::RANKS order");
|
||||||
assert_eq!(rank_index(Rank::Ten), 9);
|
}
|
||||||
assert_eq!(rank_index(Rank::Jack), 10);
|
|
||||||
assert_eq!(rank_index(Rank::Queen), 11);
|
|
||||||
assert_eq!(rank_index(Rank::King), 12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -698,3 +698,12 @@ mod tests {
|
|||||||
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0);
|
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// System set for text/UI visual effects that animate `Transform`/`Sprite`
|
||||||
|
/// on chrome entities (HUD score pulse, streak flourish, modal enter, focus
|
||||||
|
/// ring). These never touch board entities, so members are declared
|
||||||
|
/// `.ambiguous_with(BoardVisuals)` and `.ambiguous_with(UiTextFx)` — the
|
||||||
|
/// entity domains are disjoint by construction and relative order within a
|
||||||
|
/// frame is invisible (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct UiTextFx;
|
||||||
|
|||||||
+104
-17
@@ -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.
+21
-69
@@ -19,7 +19,7 @@
|
|||||||
//! is the contract.
|
//! is the contract.
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::error::MoveError;
|
use solitaire_core::error::MoveError;
|
||||||
@@ -145,21 +145,10 @@ impl ReplayPlayer {
|
|||||||
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||||
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||||
};
|
};
|
||||||
let foundations: [Vec<CardSnapshot>; 4] = [
|
let foundations: [Vec<CardSnapshot>; 4] = solitaire_core::FOUNDATIONS
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
.map(|f| pile_cards(KlondikePile::Foundation(f)));
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
let tableaus: [Vec<CardSnapshot>; 7] =
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation3)),
|
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation4)),
|
|
||||||
];
|
|
||||||
let tableaus: [Vec<CardSnapshot>; 7] = [
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau1)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau2)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau3)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau4)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau5)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau6)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau7)),
|
|
||||||
];
|
|
||||||
StateSnapshot {
|
StateSnapshot {
|
||||||
step_idx: self.step_idx,
|
step_idx: self.step_idx,
|
||||||
total_steps: self.moves.len(),
|
total_steps: self.moves.len(),
|
||||||
@@ -353,21 +342,9 @@ fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
|
|||||||
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
|
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
|
||||||
let stock = game.stock_cards();
|
let stock = game.stock_cards();
|
||||||
let waste = game.waste_cards();
|
let waste = game.waste_cards();
|
||||||
let foundations = [
|
let foundations =
|
||||||
game.pile(KlondikePile::Foundation(Foundation::Foundation1)),
|
solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
|
||||||
game.pile(KlondikePile::Foundation(Foundation::Foundation2)),
|
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
|
||||||
game.pile(KlondikePile::Foundation(Foundation::Foundation3)),
|
|
||||||
game.pile(KlondikePile::Foundation(Foundation::Foundation4)),
|
|
||||||
];
|
|
||||||
let tableaus = [
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau1)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau2)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau3)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau4)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau5)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau6)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
|
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
|
||||||
let mut duplicate_cards = Vec::new();
|
let mut duplicate_cards = Vec::new();
|
||||||
@@ -488,21 +465,9 @@ impl SolitaireGame {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(CardSnapshot::from)
|
.map(CardSnapshot::from)
|
||||||
.collect(),
|
.collect(),
|
||||||
foundations: [
|
foundations: solitaire_core::FOUNDATIONS
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
.map(|f| cards(KlondikePile::Foundation(f))),
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation3)),
|
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation4)),
|
|
||||||
],
|
|
||||||
tableaus: [
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau1)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau2)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau3)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau4)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau5)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau6)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau7)),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,34 +478,21 @@ impl SolitaireGame {
|
|||||||
let slot: u8 = s["foundation-".len()..]
|
let slot: u8 = s["foundation-".len()..]
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| format!("bad pile: {s}"))?;
|
.map_err(|_| format!("bad pile: {s}"))?;
|
||||||
if slot >= 4 {
|
let foundation = solitaire_core::FOUNDATIONS
|
||||||
return Err(format!("foundation slot out of range: {slot}"));
|
.get(slot as usize)
|
||||||
}
|
.copied()
|
||||||
Ok(KlondikePile::Foundation(match slot {
|
.ok_or_else(|| format!("foundation slot out of range: {slot}"))?;
|
||||||
0 => Foundation::Foundation1,
|
Ok(KlondikePile::Foundation(foundation))
|
||||||
1 => Foundation::Foundation2,
|
|
||||||
2 => Foundation::Foundation3,
|
|
||||||
3 => Foundation::Foundation4,
|
|
||||||
_ => return Err(format!("foundation slot out of range: {slot}")),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
_ if s.starts_with("tableau-") => {
|
_ if s.starts_with("tableau-") => {
|
||||||
let col: usize = s["tableau-".len()..]
|
let col: usize = s["tableau-".len()..]
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| format!("bad pile: {s}"))?;
|
.map_err(|_| format!("bad pile: {s}"))?;
|
||||||
if col >= 7 {
|
let tableau = solitaire_core::TABLEAUS
|
||||||
return Err(format!("tableau col out of range: {col}"));
|
.get(col)
|
||||||
}
|
.copied()
|
||||||
Ok(KlondikePile::Tableau(match col {
|
.ok_or_else(|| format!("tableau col out of range: {col}"))?;
|
||||||
0 => Tableau::Tableau1,
|
Ok(KlondikePile::Tableau(tableau))
|
||||||
1 => Tableau::Tableau2,
|
|
||||||
2 => Tableau::Tableau3,
|
|
||||||
3 => Tableau::Tableau4,
|
|
||||||
4 => Tableau::Tableau5,
|
|
||||||
5 => Tableau::Tableau6,
|
|
||||||
6 => Tableau::Tableau7,
|
|
||||||
_ => return Err(format!("tableau col out of range: {col}")),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
_ => Err(format!("unknown pile: {s}")),
|
_ => Err(format!("unknown pile: {s}")),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user