Compare commits
245 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8d8eee9a | |||
| 15c924c3dc | |||
| ede58f9666 | |||
| ccfb9394e0 | |||
| b5c1ba4867 | |||
| d179d9d582 | |||
| db1cc58f3a | |||
| 255b781420 | |||
| 5b5d587818 | |||
| 2b2e7a7f2c | |||
| 5c4d440b31 | |||
| 374858ab6d | |||
| 38b82d4858 | |||
| 4700bd7912 | |||
| d4448bf0cd | |||
| e1d91bee73 | |||
| 9dcd25b3e7 | |||
| 2ef0bce1ea | |||
| 326ef6894a | |||
| caaafe34e0 | |||
| 2254693a7a | |||
| 4ba646738c | |||
| 251d35bc28 | |||
| 1d2b6dc5de | |||
| fe3c3aed31 | |||
| d0e4ce796b | |||
| 9f038250d9 | |||
| 0c69d6859d | |||
| 50d6d41d85 | |||
| 9e4d4a6716 | |||
| ea1014285d | |||
| 4f849a23b8 | |||
| 2c2f0b592a | |||
| 299f6bfea7 | |||
| 8c0eb3dfab | |||
| a39e04329e | |||
| 6f27e775f2 | |||
| 358bbc7eb5 | |||
| 444f8d7e33 | |||
| a80547c514 | |||
| d87397b382 | |||
| 018b69285d | |||
| a4ad848c93 | |||
| ff8c00d2f4 | |||
| abf1312cf5 | |||
| 8b09c51271 | |||
| f61573513c | |||
| 757c35e4a0 | |||
| 113a933170 | |||
| 18bb1fa0be | |||
| 38b81a4004 | |||
| ae7af9adf4 | |||
| 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 | |||
| dbe2addc30 | |||
| c286593415 | |||
| ddba5c0b26 | |||
| 5b37f35eb8 | |||
| d1e87765af | |||
| b3b53c4adf | |||
| ba76936aba | |||
| 59ba7ba4c3 | |||
| 1ca1efb3b6 | |||
| c5ad487256 | |||
| 8274581edf | |||
| 704e70f60a | |||
| b81b314197 | |||
| a2375d2fd9 | |||
| 3f5e4f4290 | |||
| 0797e9a993 | |||
| c53b42342b | |||
| 123aa6d099 | |||
| 331d932c4b | |||
| 716e5f04cf | |||
| ef9d914b99 | |||
| 005efa2ee4 | |||
| 1190ed3ce6 | |||
| 2869e1c34e | |||
| 783f01628e | |||
| 94a6feb5db | |||
| 3daaf47689 | |||
| c00656ec8f | |||
| aea2167eee | |||
| 0ed91ea24c | |||
| 18af49c0f3 | |||
| e208245036 | |||
| 0247efcb07 | |||
| 1d5266a811 | |||
| 26283b5478 | |||
| 3627e9f9cf | |||
| b81a79c51c | |||
| 968721eeb4 | |||
| 780e82ca4b | |||
| 207747db4b | |||
| c66baceb10 | |||
| 329f224ffd | |||
| 2fc190ee42 | |||
| 060efaee7b | |||
| ef599ffa17 | |||
| 22334e0dd5 | |||
| 942b9c2161 | |||
| fde863a4e4 | |||
| 0cf5fc4293 | |||
| 79ddfbc034 | |||
| 7919365775 | |||
| f88b6f61d0 | |||
| 189e0afd24 | |||
| a32d666751 | |||
| a5902ac0af | |||
| c3b83f30d1 | |||
| a46505fe45 | |||
| 1602f1952d | |||
| 81893788c1 | |||
| c232444ef0 | |||
| 56e05caaa9 | |||
| 090b5e789e | |||
| 1e0619897a | |||
| 1b5dfa3e27 | |||
| 4850e9417e | |||
| 8a2a22ff1b | |||
| b3b282ec2d | |||
| 7b5d69e164 | |||
| 923a67dc7b | |||
| d4ad184324 | |||
| 0b7a24a75b | |||
| fc45b6d261 | |||
| 2328643223 | |||
| 5b2b234c54 | |||
| 9c473d6a51 | |||
| b3a8575bbd | |||
| 5091d1b397 | |||
| da165f1622 | |||
| 43076a48d6 | |||
| 5af69d7551 | |||
| 6c9259beff | |||
| dde65a7e30 | |||
| e3b8a403ef | |||
| 9299176b2d | |||
| 6a9352cde1 | |||
| 8995a8ae9c | |||
| 0d5c9cdb1d | |||
| ceb9c950a1 | |||
| 9bbb57134f | |||
| e0a858d4e8 | |||
| 5c992cbdca | |||
| d045781119 | |||
| f0871c03e8 | |||
| e841a7ab4f | |||
| 424c8b2d50 | |||
| 372b6423d8 | |||
| 9e3c6b06b0 | |||
| f0832f3dfa | |||
| ef1efdc3b5 | |||
| dc4cf45ea0 | |||
| 0d3f037672 | |||
| cac77a54a6 | |||
| 2d0359c2ee | |||
| 056459619b | |||
| 1438fd6265 | |||
| 920f2c8597 | |||
| 37a21b9b42 | |||
| 712ed6be80 | |||
| 324003562b | |||
| a69a774edf | |||
| df4887fb36 | |||
| 159774f811 | |||
| b3c4d08dfc | |||
| f313cfd8b7 | |||
| 7fe6ac6c1c | |||
| 6193d31497 | |||
| 26f1b00186 | |||
| 56e3b62269 | |||
| 9bcf13d8f2 | |||
| 7dbf34c163 | |||
| 7fa91b6fb4 | |||
| becfda0f6c | |||
| fa786bafcf | |||
| d864d985c8 | |||
| ae1ecc8559 | |||
| 5e8735886f | |||
| 8bd2fb89eb | |||
| 2b1ad2161a | |||
| 2cf728210e | |||
| 8b262afcd2 | |||
| 8b736cae3c | |||
| de7ae16830 | |||
| d45b7cb82b | |||
| 763fdb486f | |||
| 1cdb78caf2 | |||
| baf524ec75 | |||
| 9ff0585454 | |||
| 64f975ed6d | |||
| 20e5222148 | |||
| 44e90ff582 | |||
| 0bae839e3b | |||
| c68cf96488 | |||
| a92ac066a6 | |||
| f464aab543 | |||
| 835a48fe9d | |||
| 9260ca7994 | |||
| ca612f51f1 | |||
| dba154cf92 | |||
| 258abd198e | |||
| 389fdd1fb0 | |||
| 6309d3325f | |||
| 862f7e4b48 | |||
| 6496e130f3 | |||
| d4796fa252 | |||
| 57c4b5aacf | |||
| f1914b4398 | |||
| 0a6eb8c610 | |||
| bb92bb333b | |||
| 38e4c0341e | |||
| ccf280ea50 |
@@ -0,0 +1,5 @@
|
||||
[registries.Quaternions]
|
||||
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
|
||||
@@ -6,10 +6,16 @@ on:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'solitaire_server/**'
|
||||
- 'solitaire_wasm/**'
|
||||
- 'solitaire_web/**'
|
||||
- 'solitaire_sync/**'
|
||||
- 'solitaire_core/**'
|
||||
- 'solitaire_data/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build_wasm.sh'
|
||||
- 'solitaire_server/Dockerfile'
|
||||
- '.gitea/workflows/docker-build.yml'
|
||||
|
||||
env:
|
||||
@@ -32,6 +38,10 @@ jobs:
|
||||
id: meta
|
||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||
# the Dockerfile's wasm-builder stage builds them from source inside
|
||||
# this image build, so the deployed image always ships fresh wasm.
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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 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: rust-host
|
||||
|
||||
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
||||
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
||||
# line-tables-only keeps file:line in panic backtraces while cutting the
|
||||
# link's memory footprint enough to fit the runner.
|
||||
#
|
||||
# CARGO_BUILD_JOBS=2: with one job per core, cargo links several large
|
||||
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
||||
# two+ simultaneous ld processes OOM-killed the runner again even at
|
||||
# line-tables-only. Two jobs keeps at most two links in flight — the
|
||||
# compile-throughput cost is small next to the cache-warm build.
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
CARGO_BUILD_JOBS: '2'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust 1.95.0
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
||||
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
||||
- name: Install Bevy native dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
|
||||
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||
# same as the web-e2e workflow's server prebuild.
|
||||
- name: Clippy (deny warnings)
|
||||
env:
|
||||
SQLX_OFFLINE: 'true'
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
SQLX_OFFLINE: 'true'
|
||||
run: cargo test --workspace
|
||||
@@ -0,0 +1,87 @@
|
||||
name: Web E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'solitaire_server/web/**'
|
||||
- 'solitaire_server/src/**'
|
||||
- 'solitaire_server/e2e/**'
|
||||
- 'solitaire_wasm/**'
|
||||
- 'solitaire_web/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'solitaire_data/**'
|
||||
- 'solitaire_core/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build_wasm.sh'
|
||||
- '.gitea/workflows/web-e2e.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
web-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||
# build them here so the served pages have real wasm to load. Tool
|
||||
# versions are pinned; keep in sync with solitaire_server/Dockerfile.
|
||||
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
|
||||
|
||||
- name: Install binaryen 130 (wasm-opt, pinned)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sSL \
|
||||
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||
| tar xz
|
||||
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build WASM artifacts
|
||||
run: ./build_wasm.sh
|
||||
|
||||
# Prebuild the server so Playwright's `webServer` (which runs
|
||||
# `cargo run -p solitaire_server`) starts from a compiled binary instead
|
||||
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
|
||||
# its 120s startup window — the timeout that was failing every run.
|
||||
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB).
|
||||
- name: Prebuild server
|
||||
env:
|
||||
SQLX_OFFLINE: 'true'
|
||||
run: cargo build -p solitaire_server --quiet
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: solitaire_server/e2e/package-lock.json
|
||||
|
||||
- name: Install e2e dependencies
|
||||
working-directory: solitaire_server/e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
working-directory: solitaire_server/e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run web e2e tests
|
||||
working-directory: solitaire_server/e2e
|
||||
run: npm test
|
||||
|
||||
- name: Run cycle regression gate
|
||||
working-directory: solitaire_server/e2e
|
||||
run: npm run review:cycles:regression
|
||||
+20
@@ -15,6 +15,11 @@ agentdb.rvf.lock
|
||||
# IDE project files
|
||||
.idea/
|
||||
|
||||
# Browser e2e harness artifacts
|
||||
solitaire_server/e2e/node_modules/
|
||||
solitaire_server/e2e/playwright-report/
|
||||
solitaire_server/e2e/test-results/
|
||||
|
||||
# Android signing keystores — never commit
|
||||
*.jks
|
||||
*.jks.bak
|
||||
@@ -25,3 +30,18 @@ agentdb.rvf.lock
|
||||
deploy/matomo-secret.yaml
|
||||
deploy/*-secret.yaml
|
||||
deploy/*-auth-secret.yaml
|
||||
|
||||
# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo
|
||||
/.agents/
|
||||
/.codex/
|
||||
/AGENTS.md
|
||||
# claude-flow scratch dirs, anywhere in the tree (e.g. solitaire_engine/src/)
|
||||
.claude-flow/
|
||||
|
||||
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
||||
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
||||
scripts/*.go
|
||||
|
||||
# WASM bundles — built by build_wasm.sh locally and by the Docker wasm-builder
|
||||
# stage / web-e2e workflow in CI; never committed (issue #156)
|
||||
solitaire_server/web/pkg/
|
||||
|
||||
+91
-43
@@ -1,9 +1,11 @@
|
||||
# Ferrous Solitaire — Architecture Document
|
||||
|
||||
> **Version:** 1.3
|
||||
> **Version:** 1.4
|
||||
> **Language:** Rust (Edition 2024)
|
||||
> **Engine:** Bevy (latest stable)
|
||||
> **Last Updated:** 2026-05-12
|
||||
> **Last Updated:** 2026-07-06 — post card_game/klondike migration (PR #88):
|
||||
> core card/pile types come from the upstream `card_game` workspace and
|
||||
> score/undo/recycle are derived from the upstream session, not stored.
|
||||
|
||||
---
|
||||
|
||||
@@ -82,13 +84,15 @@ ferrous_solitaire/
|
||||
│ ├── win_fanfare.wav
|
||||
│ └── ambient_loop.wav
|
||||
│
|
||||
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
||||
├── solitaire_core/ # Pure Rust game rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
|
||||
├── solitaire_sync/ # Shared API types — used by client and server
|
||||
├── solitaire_data/ # Persistence, sync client, settings
|
||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
||||
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
|
||||
└── solitaire_app/ # Main binary entry point
|
||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) + web frontend
|
||||
├── solitaire_wasm/ # WebAssembly bindings — browser-side logic/replay + debug bridge
|
||||
├── solitaire_web/ # Bevy WASM canvas build for the browser /play route
|
||||
├── solitaire_assetgen/ # One-shot generator for card/background PNG assets
|
||||
└── solitaire_app/ # Main binary entry point (desktop + Android cdylib)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -96,18 +100,42 @@ ferrous_solitaire/
|
||||
## 3. Crate Responsibilities
|
||||
|
||||
### `solitaire_core`
|
||||
**Dependencies:** `rand`, `serde`, `chrono` only.
|
||||
**Dependencies:** `serde`, `thiserror`, plus the upstream `card_game` and
|
||||
`klondike` crates (pinned via the Quaternions registry — never edit upstream).
|
||||
|
||||
The entire game rules engine. No Bevy, no network, no file I/O. Designed to be tested in isolation with `cargo test -p solitaire_core`.
|
||||
The game rules layer. No Bevy, no network, no file I/O. Designed to be tested
|
||||
in isolation with `cargo test -p solitaire_core`.
|
||||
|
||||
Since the card_game migration (2026-06-22, PR #88) the primitive types are
|
||||
**upstream**: `Card`, `Deck`, `Suit`, `Rank`, `Session` come from `card_game`;
|
||||
`Klondike`, `KlondikePile`, `KlondikeInstruction`, `DrawStockConfig`,
|
||||
`Foundation`, `Tableau` come from `klondike`. `solitaire_core` re-exports them
|
||||
so downstream crates import from one place and never depend on the upstream
|
||||
crates directly.
|
||||
|
||||
Owns:
|
||||
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`)
|
||||
- Move validation logic
|
||||
- Scoring engine
|
||||
- Undo stack
|
||||
- `GameState` — a wrapper around the upstream `Session<Klondike>`; the session
|
||||
is the single source of truth for board state and stats
|
||||
- `MoveError` and the `Result`-based mutation API
|
||||
- `KlondikeConfig` adaptation (`klondike_adapter`) — draw mode, scoring,
|
||||
take-from-foundation
|
||||
- `GameMode` (Classic / Zen / Challenge / TimeAttack) and mode-aware scoring
|
||||
- Solvability check API (`SolveOutcome`, delegating to `Session::solve`)
|
||||
- Win / auto-complete detection
|
||||
- Achievement unlock condition evaluation
|
||||
- Seeded RNG for reproducible deals
|
||||
- Seeded deals (same seed ⇒ same layout, via the upstream dealer)
|
||||
|
||||
**Rules decisions:**
|
||||
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
||||
passes through the stock are discouraged via the upstream score penalty
|
||||
(applied by the `card_game`/`klondike` session), never blocked with a
|
||||
`MoveError`. This matches mainstream digital solitaire (unlimited redeals
|
||||
in Draw-1) rather than strict tournament rules (3-pass cap). It is load-
|
||||
bearing: the difficulty seed catalog and the winnable-deal solver are
|
||||
verified under unlimited recycling, so introducing a hard pass limit would
|
||||
invalidate both. Locked in by the
|
||||
`draw_one_recycling_is_unlimited_by_design` test in
|
||||
`solitaire_core/src/game_state.rs`. (Decision record: Gitea issue #117.)
|
||||
|
||||
### `solitaire_sync`
|
||||
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
||||
@@ -165,7 +193,7 @@ Owns:
|
||||
### `solitaire_wasm`
|
||||
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||||
|
||||
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
||||
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build` (`build_wasm.sh`); the output lands in `solitaire_server/web/pkg/` — gitignored, built in CI (Docker `wasm-builder` stage, web-e2e workflow) — and is served statically by the server.
|
||||
|
||||
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
||||
|
||||
@@ -177,9 +205,13 @@ Owns:
|
||||
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||||
|
||||
### `solitaire_app`
|
||||
**Dependencies:** `bevy`, `solitaire_engine`.
|
||||
**Dependencies:** `bevy`, `solitaire_engine`, `solitaire_data` (+ `jni` on Android).
|
||||
|
||||
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
|
||||
Thin entry point (desktop binary + Android `cdylib`). Registers all Bevy
|
||||
plugins and sets initial window properties. The one crate in the workspace
|
||||
allowed `unsafe`: the Android entry point reconstructs the raw JNI handles and
|
||||
hands them to the safe `solitaire_data::android_jni` bridge; everything else
|
||||
is `forbid(unsafe_code)`.
|
||||
|
||||
---
|
||||
|
||||
@@ -547,26 +579,36 @@ This ensures all players worldwide get the same challenge for a given date, rega
|
||||
|
||||
### Core Game Models (`solitaire_core`)
|
||||
|
||||
Since the card_game migration, the primitives are upstream types re-exported
|
||||
through `solitaire_core`:
|
||||
|
||||
```rust
|
||||
pub enum Suit { Clubs, Diamonds, Hearts, Spades }
|
||||
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
|
||||
// From `card_game` (upstream — never edit):
|
||||
pub enum Suit { /* Clubs, Diamonds, Hearts, Spades */ }
|
||||
pub enum Rank { /* Ace ..= King */ }
|
||||
pub struct Card { /* deck + suit + rank; identity type, no face_up flag —
|
||||
facing is positional, tracked by the Klondike board */ }
|
||||
pub struct Session<G> { /* replayable instruction log + derived stats */ }
|
||||
|
||||
pub struct Card {
|
||||
pub id: u32,
|
||||
pub suit: Suit,
|
||||
pub rank: Rank,
|
||||
pub face_up: bool,
|
||||
// From `klondike` (upstream — never edit):
|
||||
pub enum KlondikePile {
|
||||
Stock, // NB: no Waste variant — see below
|
||||
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
||||
Tableau(Tableau), // 7 columns
|
||||
}
|
||||
// Pile-coordinate convention: upstream has no `Waste` variant. In
|
||||
// pile-coordinate space `KlondikePile::Stock` denotes the face-up
|
||||
// *waste* pile (the only stock-side pile cards move out of); use
|
||||
// `GameState::stock_cards()` / `waste_cards()` when the face-down
|
||||
// draw stack must be distinguished. Documented on `GameState::pile`.
|
||||
pub enum DrawStockConfig { DrawOne, DrawThree }
|
||||
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
||||
serialized move format (schema v4+) */ }
|
||||
```
|
||||
|
||||
pub enum PileType {
|
||||
Stock,
|
||||
Waste,
|
||||
Foundation(Suit),
|
||||
Tableau(usize), // 0–6
|
||||
}
|
||||
|
||||
pub enum DrawMode { DrawOne, DrawThree }
|
||||
Owned by `solitaire_core`:
|
||||
|
||||
```rust
|
||||
/// Active game mode. Classic is the default; others unlock at level 5.
|
||||
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
||||
|
||||
@@ -577,24 +619,30 @@ pub enum MoveError {
|
||||
RuleViolation(String),
|
||||
UndoStackEmpty,
|
||||
GameAlreadyWon,
|
||||
StockEmpty,
|
||||
}
|
||||
|
||||
pub struct GameState {
|
||||
pub piles: HashMap<PileType, Vec<Card>>,
|
||||
pub draw_mode: DrawMode,
|
||||
pub mode: GameMode,
|
||||
pub score: i32,
|
||||
pub move_count: u32,
|
||||
pub undo_count: u32, // number of undos used in this game
|
||||
pub recycle_count: u32, // number of stock recycles
|
||||
pub elapsed_seconds: u64,
|
||||
pub seed: u64,
|
||||
pub is_won: bool,
|
||||
pub is_auto_completable: bool,
|
||||
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
|
||||
pub seed: u64, // same seed ⇒ same deal
|
||||
pub take_from_foundation: bool,
|
||||
session: Session<Klondike>, // private — the single source of truth
|
||||
}
|
||||
```
|
||||
|
||||
**Derived, not stored:** `score()`, `move_count()`, `undo_count()`,
|
||||
`recycle_count()`, `is_won()`, `is_auto_completable()`, and all pile
|
||||
accessors read through the session. Undo replays the instruction log
|
||||
(no snapshot stack); the −15 undo penalty is applied by the upstream
|
||||
score formula via the session config. Persistence (schema v5) saves
|
||||
`saved_moves` as upstream `KlondikeInstruction`s and rebuilds the
|
||||
session by replay on load — older files carrying `score`/`undo_count`/
|
||||
`recycle_count` keys load fine, the extra fields are ignored.
|
||||
|
||||
**Rules decision:** stock recycling is unlimited in every draw mode
|
||||
(see the "Rules decisions" note in §3 `solitaire_core`).
|
||||
|
||||
### Persistence Models (`solitaire_data`)
|
||||
|
||||
```rust
|
||||
@@ -632,7 +680,7 @@ pub struct AchievementRecord {
|
||||
}
|
||||
|
||||
pub struct Settings {
|
||||
pub draw_mode: DrawMode,
|
||||
pub draw_mode: DrawStockConfig,
|
||||
pub sfx_volume: f32, // 0.0–1.0
|
||||
pub music_volume: f32,
|
||||
pub animation_speed: AnimSpeed,
|
||||
@@ -697,7 +745,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
||||
| Method | Path | Auth | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||||
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
||||
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output — gitignored, produced by `build_wasm.sh` / CI) |
|
||||
|
||||
### Account Management
|
||||
|
||||
|
||||
+373
@@ -6,6 +6,379 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.42.0] — 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- **CI workspace gate.** New `test.yml` workflow runs clippy (deny warnings)
|
||||
and the full test suite on every master push and PR — previously no CI ran
|
||||
tests at all. Caught its own first bug (missing Bevy native deps) on its
|
||||
own PR. (#135)
|
||||
- **Schedule ambiguity gate.** A headless test builds the gameplay plugin
|
||||
cluster with Bevy ambiguity detection promoted to error. The initial
|
||||
measurement found 302 system pairs with conflicting data access and no
|
||||
ordering; four burn-down batches (PRs #146–#149) took it to ZERO the same
|
||||
day, and the gate now enforces 0. Keyboard consumption, board painting,
|
||||
and HUD updates all have deterministic order for the first time.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Browser canvas 36% smaller.** `canvas_bg.wasm` shrank 36.2 MB → 23.2 MB
|
||||
via a size-focused `wasm-release` profile (fat LTO, single codegen unit,
|
||||
opt-level "s"); verified visually identical in production. (#134)
|
||||
- **Quaternions API adoption.** Canonical `FOUNDATIONS`/`TABLEAUS` consts in
|
||||
`solitaire_core` replace five scattered enum lists; upstream
|
||||
`Suit::SUITS`/`Rank::RANKS` replace nine hand-rolled arrays, with the
|
||||
texture-atlas indexing re-keyed through tested canonical helpers. Net
|
||||
−177 lines. (#137)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sync push race.** The server's load→merge→store cycle now runs in one
|
||||
transaction; concurrent pushes from two devices can no longer overwrite
|
||||
each other's merge. (#136)
|
||||
- **Refresh-token rotation is single-use under concurrency** — rotation
|
||||
gates on the DELETE's row count, so a stolen-then-replayed refresh token
|
||||
loses the race and gets 401. (#136)
|
||||
- **Exit sync push actually completes.** Was a detached task killed by
|
||||
process teardown; now a bounded 2-second blocking wait on the app's final
|
||||
frame. (#138)
|
||||
- **Server auth hardening.** Login timing no longer reveals whether a
|
||||
username exists; concurrent duplicate registration returns 409 instead of
|
||||
500; avatar uploads are magic-byte checked. (#144, issues #139–#141)
|
||||
|
||||
## [0.41.1] — 2026-07-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Oversized pile-marker frames after fold/unfold.** `on_window_resized`
|
||||
resized the marker fill sprite but never its children, so the outline frame
|
||||
and the "A"/"K" watermark kept their spawn-time size after any resize —
|
||||
rendering as oversized grey slabs over empty foundation slots on foldables
|
||||
(found during Galaxy Fold 7 on-device verification of v0.41.0, fixed and
|
||||
re-verified on the same device). Both children are now re-derived from the
|
||||
new layout on every relayout.
|
||||
|
||||
### Added
|
||||
|
||||
- **Android relayout diagnostics.** Every layout recompute on Android now
|
||||
logs its window dimensions and insets (`layout: resize to WxH …`) — the
|
||||
evidence channel for foldable layout reports (#130).
|
||||
|
||||
## [0.41.0] — 2026-07-06
|
||||
|
||||
> Consolidates everything shipped since v0.39.0, including the v0.40.0–v0.40.3
|
||||
> patch tags (which were cut from this section without renaming it at the time).
|
||||
|
||||
### Added
|
||||
|
||||
- **Rules decision record: unlimited stock recycling.** Documented in
|
||||
`ARCHITECTURE.md` that unlimited recycling with score penalties (matching
|
||||
mainstream digital solitaire) is intentional, and locked it in with a core
|
||||
test — the difficulty seed catalog and winnable-deal solver are verified
|
||||
under this rule. Resolves the last open finding from the June 500-game
|
||||
audit (issue #117).
|
||||
- **Analytics validation runbook.** Documented native Matomo live validation,
|
||||
expected event payloads, and the current web/WASM analytics split.
|
||||
- **Android smoke-test runbook.** Updated the Android doc with the current
|
||||
platform status, support matrix, and a physical-device
|
||||
launch/touch/safe-area checklist.
|
||||
- **Browser Bevy canvas route and automation support.** Added the `solitaire_web`
|
||||
Bevy WASM build, wired `/play` to the Bevy canvas, added a
|
||||
`window.__FERROUS_DEBUG__` bridge, and introduced Playwright coverage for the
|
||||
web routes and interactive canvas behavior.
|
||||
- **Card-game / klondike integration.** Began replacing in-house card and pile
|
||||
internals with upstream `card_game` / `klondike` types, including adapter
|
||||
work, GameMode-aware scoring, upstream instruction serde, `KlondikePile`
|
||||
migration, and documentation for the in-place rewrite phases.
|
||||
- **Android keystore integration.** Added Android Keystore JNI wiring via
|
||||
`OnceLock` and improved Android token handling around the app directory.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Engine plugin modules restructured.** The five oversized plugin files
|
||||
(card, hud, settings, game, input) are now module directories with their
|
||||
test suites in sibling `tests.rs` files — no behaviour change; first phase
|
||||
of the module-split plan (#118).
|
||||
- **Core type ownership.** Routed all klondike/card imports through
|
||||
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
|
||||
types.
|
||||
- **Web/WASM build reliability.** Rebuilt WASM packages, cleaned up wasm32 build
|
||||
warnings, added a Binaryen `wasm-opt` pass, pinned upstream git dependencies,
|
||||
and added a CI guard for canvas WASM drift.
|
||||
- **Difficulty seed catalog.** Regenerated the difficulty seed list for the
|
||||
latest verified catalog.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Safe-area insets now re-polled after app resume.** The inset poller settled
|
||||
permanently once insets first resolved, so inset changes while backgrounded
|
||||
(fold/unfold, rotation, gesture ↔ 3-button nav switch) kept stale layout until
|
||||
the app was killed. Each resume now re-arms a fresh poll window; the cached
|
||||
value is rewritten only when it actually changed, so unchanged resumes still
|
||||
cause no relayout flash. (#116)
|
||||
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
|
||||
each column's total depth (face-down cards included) and refills on the
|
||||
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
|
||||
resize and fold/unfold), so a near-square viewport such as an unfolded Galaxy
|
||||
Fold no longer leaves the bottom of the screen empty. The fan spread cap was
|
||||
raised so very tall / narrow viewports (e.g. a foldable cover screen) fill
|
||||
further.
|
||||
- **Card-move animation jank.** A move now rebuilds a card's child visuals only
|
||||
when its appearance changes (flip / resize / accessibility) instead of
|
||||
despawning and respawning every card's children each `StateChangedEvent`,
|
||||
removing the per-move spike that stuttered the slide animation on
|
||||
high-resolution devices.
|
||||
- **Android and modal safe-area layout.** Modal cards now center within the
|
||||
usable area between status and gesture bars, additional modal-spawn guards were
|
||||
added, and Android build scripts now auto-discover SDK/NDK paths and strip
|
||||
native libraries.
|
||||
- **Core scoring and undo correctness.** Fixed recycle-count drift, undo score
|
||||
compounding, foundation-to-tableau instruction coverage, and several
|
||||
illegal-move paths discovered during the card-game migration.
|
||||
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
|
||||
clicks, delayed first-run onboarding until splash teardown, and kept dragged
|
||||
stacks above all piles.
|
||||
- **Draw-Three waste fan hit testing on Android.** The renderer and the click
|
||||
hit-test now share a single `waste_fan_step` / `tableau_col_step` source. They
|
||||
previously diverged under Android's tighter column spacing, shifting the top
|
||||
fanned waste card's hit target onto the card beneath it, so dragging the visible
|
||||
card played the wrong one.
|
||||
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
|
||||
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
|
||||
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
|
||||
file paths to async I/O where needed, and validated `JWT_SECRET` at startup.
|
||||
- **CI and deployment workflow.** Fixed deploy-branch handling, Docker registry
|
||||
secret usage, and related release automation issues.
|
||||
|
||||
### Tests
|
||||
|
||||
- Ran an Android AVD `Pixel_7` launch smoke for the x86_64 debug APK,
|
||||
including install, NativeActivity launch, safe-area log validation, screenshot
|
||||
render check, onboarding input, and crash-log review.
|
||||
- Added direct coverage for Android/touch card corner labels using Unicode suit
|
||||
glyphs.
|
||||
- Added schema-v3 persistence round-trip coverage, foundation-to-tableau
|
||||
instruction coverage, expanded WASM unit tests, and Playwright E2E specs for
|
||||
browser routes and game-canvas behavior.
|
||||
|
||||
## [0.39.0] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No-legal-moves detection and banner.** Corrected no-move detection across
|
||||
engine, WASM, and web paths, then surfaced the state to players with an
|
||||
in-game banner instead of silently leaving the board stuck.
|
||||
- **Release/deploy automation.** Updated deployment automation so kustomization
|
||||
changes are pushed to the deploy branch instead of the main development
|
||||
branch.
|
||||
|
||||
## [0.38.0] — 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Klondike scoring parity.** Added tableau flip bonuses and stock recycle
|
||||
penalties to align scoring with standard Klondike expectations.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Core rule enforcement.** Auto-complete now requires an empty waste pile,
|
||||
waste-origin moves reject multi-card transfers, foundation-to-foundation moves
|
||||
are blocked, and undo restores score from the snapshot baseline.
|
||||
- **Modal lifecycle guards.** Added missing `ModalScrim` guards to New Game,
|
||||
restore prompt, and no-moves modal spawn sites.
|
||||
- **Runtime and server robustness.** Tokio runtime setup degrades gracefully
|
||||
instead of panicking; web replay submission casing/date formatting now matches
|
||||
server expectations; avatar routes are publicly reachable when intended.
|
||||
- **Android token and sync merge correctness.** Android tokens are namespaced
|
||||
under the application directory, stored per user, and migrated safely; sync
|
||||
merges preserve draw-one / draw-three win invariants.
|
||||
|
||||
## [0.37.0] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Foundation-to-tableau default.** Made `take_from_foundation` default to true
|
||||
across clients so restored, startup, and web games use the same supported move
|
||||
rules.
|
||||
|
||||
## [0.36.12] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Foundation-to-tableau default.** Set `take_from_foundation` true by default
|
||||
in core so every client inherits the intended house rule without special-case
|
||||
setup.
|
||||
|
||||
## [0.36.11] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Web foundation moves.** Enabled take-from-foundation moves in the web game
|
||||
client.
|
||||
|
||||
## [0.36.10] — 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Web resume flow.** Browser games now persist state across page refreshes and
|
||||
can resume through a dialog instead of starting over.
|
||||
|
||||
## [0.36.9] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Settings sync connection flow.** Clicking Connect from Settings now opens the
|
||||
sync-setup modal.
|
||||
|
||||
## [0.36.8] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Restored/startup foundation moves.** Enabled take-from-foundation behavior
|
||||
for restored and startup games, not only newly-created sessions.
|
||||
|
||||
## [0.36.7] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Remaining Android UI issues.** Resolved the final Android UI defects from
|
||||
the review pass, including action-bar/tableau interaction and safe visual
|
||||
spacing.
|
||||
|
||||
## [0.36.6] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Action-bar layout reservation.** Reserved action-bar height in layout so
|
||||
tableau columns do not extend behind bottom controls.
|
||||
|
||||
## [0.36.5] — 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Responsive Android action-bar glyphs.** Action-bar glyph font size now scales
|
||||
dynamically on Android to fit available space.
|
||||
|
||||
## [0.36.4] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Classic card labels and HUD overlap.** Corrected classic-card corner-label
|
||||
colors and fixed HUD-band overlap in the Android layout.
|
||||
|
||||
## [0.36.3] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Core, animation, and modal review fixes.** Added the foundation-to-tableau
|
||||
score penalty, hardened solver win validation, guarded zero-duration card
|
||||
animations, aligned initial and dynamic tableau fan spacing, and added missing
|
||||
modal guards for play-by-seed and win-summary paths.
|
||||
- **Pause, messages, credentials, and server validation.** Auto-complete respects
|
||||
pause state, standalone plugins register their events, sync passwords are
|
||||
cleared from ECS buffers after auth task spawn, and avatar MIME validation uses
|
||||
exact matches.
|
||||
- **Foundation pile rendering.** Raised stack fan z-order above corner labels to
|
||||
prevent bleed-through.
|
||||
- **Android release workflow.** Added a manual `workflow_dispatch` trigger to
|
||||
the Android release workflow.
|
||||
|
||||
## [0.36.2] — 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Comprehensive review fixes.** Addressed 26 issues across core rules, replay
|
||||
controls, modal guards, sync payload timing, server replay casing, time-attack
|
||||
overlays, theme refresh, auth overlays, stats ordering, animations, cursor
|
||||
fallbacks, achievements, server temp-file cleanup, and runtime fallback paths.
|
||||
- **Animation and Android label polish.** Cancelled stale win-cascade animations
|
||||
on new game, refreshed Android corner labels on resize, lifted animating cards
|
||||
above lower z-layers, and froze the web timer when auto-complete starts.
|
||||
- **Web package and tooling updates.** Rebuilt the WASM package for
|
||||
foundation-to-tableau moves, added ruflo scaffolding, and ignored ruflo runtime
|
||||
state files.
|
||||
- **Leaderboard test stability.** Made opt-in / opt-out tests robust under
|
||||
parallel test execution.
|
||||
|
||||
## [0.36.1] — 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android HUD gesture conflict.** Stock taps no longer toggle HUD visibility on
|
||||
Android.
|
||||
|
||||
## [0.36.0] — 2026-05-18
|
||||
|
||||
### Changed
|
||||
|
||||
- **Rank model cleanup.** `Rank` now uses explicit discriminants and checked
|
||||
arithmetic, making rank conversions and sequencing more robust.
|
||||
- **Instruction generation.** Refined `possible_instructions` alongside the rank
|
||||
arithmetic cleanup.
|
||||
- **Session handoff.** Recreated `SESSION_HANDOFF.md` to reflect the `0.35.1`
|
||||
state.
|
||||
|
||||
## [0.35.1] — 2026-05-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Leaderboard profile sync.** Fixed three leaderboard/profile issues: wrong
|
||||
toast type for failures, stale display-name label after update, and display
|
||||
name not syncing to the server.
|
||||
|
||||
## [0.35.0] — 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Reduced-motion support.** Decorative motion animations are now gated behind
|
||||
`reduce_motion_mode`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Performance and runtime cleanup.** Shared a single Tokio runtime across
|
||||
network tasks and gated frame-hot ECS systems on resource changes.
|
||||
- **Core/data refactors.** Consolidated the application directory name, added
|
||||
`#[must_use]` to pure helpers, derived `Copy` for `DrawMode`, removed
|
||||
redundant clones, added missing derives to `AchievementContext`, and used
|
||||
saturating move-count arithmetic.
|
||||
- **HUD z-layer naming.** Replaced raw HUD popover z-index arithmetic with named
|
||||
layer constants.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android UI and font safety.** Wired FiraMono to stock-empty labels, removed
|
||||
raw physical safe-area pixels from HUD spawns, replaced unsupported chevrons,
|
||||
corrected the Android help hint label, and fixed touch/drop-zone behavior.
|
||||
- **Engine modal and panic hardening.** Eliminated several runtime panics, added
|
||||
required transforms to modal scrims, constrained dismiss hit-tests, and guarded
|
||||
home overlay respawns.
|
||||
- **Sync/data/server correctness.** Deterministic pile serialization, undo skip
|
||||
handling, byte URL encoding, merge timestamp handling, auth-guarded avatar
|
||||
serving, atomic server writes, and user-id assertions were corrected.
|
||||
- **Display-name and token-file boundaries.** Enforced the 32-character display
|
||||
name limit in the sync client and aligned Android keystore temp-file cleanup
|
||||
with the cleanup glob.
|
||||
- **WASM error reporting.** `state()` and `step()` now return `Result` so errors
|
||||
surface as JavaScript exceptions.
|
||||
- **Sync and leaderboard toasts.** Pull failures and leaderboard opt-in /
|
||||
opt-out failures now produce the intended warning/error feedback.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Corrected stale focus-ring color documentation.
|
||||
|
||||
## [0.34.0] — 2026-05-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android waste fan and resume layout.** Corrected Android waste-pile fan
|
||||
overlap and a layout desynchronization after resume.
|
||||
- **Card-face artwork.** Fixed the wrong bottom-right suit symbol on the jack,
|
||||
queen, and king of spades.
|
||||
- **Android corner-label font coverage.** Wired FiraMono into Android corner
|
||||
labels and added `CardImageSet` tests to guard the asset path behavior.
|
||||
|
||||
## [0.33.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -30,7 +30,9 @@ solitaire_data/ # Persistence + sync client
|
||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||
solitaire_server/ # Axum backend (optional sync layer)
|
||||
solitaire_wasm/ # WASM bindings for browser-side replay player
|
||||
solitaire_app/ # Entry binary
|
||||
solitaire_web/ # Bevy WASM canvas build for the browser /play route
|
||||
solitaire_assetgen/ # One-shot card/background PNG asset generator
|
||||
solitaire_app/ # Entry binary (desktop + Android cdylib)
|
||||
assets/ # Runtime assets (except audio + default theme)
|
||||
```
|
||||
|
||||
@@ -208,9 +210,14 @@ Embed via `include_bytes!()` only when ALL of the following are true:
|
||||
Currently embedded:
|
||||
* **Audio** — all `.wav` files in `audio_plugin.rs`
|
||||
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
|
||||
* **Bundled UI font** — `assets/fonts/main.ttf` (FiraMono) via `include_bytes!`
|
||||
in `font_plugin.rs` and `assets/svg_loader.rs`; it is the canonical UI face
|
||||
and must always be present, so it is embedded rather than `AssetServer`-loaded
|
||||
|
||||
Do NOT embed card face PNGs, background images, or user fonts —
|
||||
these are loaded via `AssetServer` so art can be swapped without recompile.
|
||||
Do NOT embed card face PNGs or background images — these are loaded via
|
||||
`AssetServer` so art can be swapped without recompile. User-supplied fonts
|
||||
(if ever added) likewise go through `AssetServer`; only the bundled FiraMono
|
||||
face above is embedded.
|
||||
|
||||
---
|
||||
|
||||
@@ -430,9 +437,11 @@ explicitly replacing the current one (despawn first, then spawn).
|
||||
|
||||
## 14.3 Safe area
|
||||
|
||||
Every `ModalScrim` automatically receives `padding.bottom` equal to the
|
||||
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
|
||||
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
|
||||
Every `ModalScrim` automatically receives `padding.top` equal to the logical
|
||||
status-bar height and `padding.bottom` equal to the logical gesture-bar height
|
||||
via `apply_safe_area_to_modal_scrims` in `SafeAreaInsetsPlugin`. This centres
|
||||
the modal card within the usable area between both system bars. Do not manually
|
||||
add top or bottom padding to scrim nodes.
|
||||
|
||||
## 14.4 Z-ordering
|
||||
|
||||
|
||||
Generated
+428
-15
@@ -364,6 +364,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4"
|
||||
|
||||
[[package]]
|
||||
name = "as-raw-xcb-connection"
|
||||
version = "1.0.1"
|
||||
@@ -717,6 +723,28 @@ dependencies = [
|
||||
"android-activity",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_anti_alias"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_derive",
|
||||
"bevy_diagnostic",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_math",
|
||||
"bevy_reflect",
|
||||
"bevy_render",
|
||||
"bevy_shader",
|
||||
"bevy_utils",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_app"
|
||||
version = "0.18.1"
|
||||
@@ -878,6 +906,35 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_dev_tools"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_diagnostic",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_input",
|
||||
"bevy_math",
|
||||
"bevy_picking",
|
||||
"bevy_reflect",
|
||||
"bevy_render",
|
||||
"bevy_shader",
|
||||
"bevy_state",
|
||||
"bevy_text",
|
||||
"bevy_time",
|
||||
"bevy_transform",
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
"bevy_window",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_diagnostic"
|
||||
version = "0.18.1"
|
||||
@@ -901,7 +958,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bevy_ecs_macros",
|
||||
"bevy_platform",
|
||||
"bevy_ptr",
|
||||
@@ -945,6 +1002,36 @@ dependencies = [
|
||||
"encase_derive_impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_feathers"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"bevy_a11y",
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_derive",
|
||||
"bevy_ecs",
|
||||
"bevy_input_focus",
|
||||
"bevy_log",
|
||||
"bevy_math",
|
||||
"bevy_picking",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_render",
|
||||
"bevy_shader",
|
||||
"bevy_text",
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
"bevy_ui_widgets",
|
||||
"bevy_window",
|
||||
"smol_str",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_gizmos"
|
||||
version = "0.18.1"
|
||||
@@ -1067,14 +1154,17 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754"
|
||||
dependencies = [
|
||||
"bevy_a11y",
|
||||
"bevy_android",
|
||||
"bevy_anti_alias",
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_derive",
|
||||
"bevy_dev_tools",
|
||||
"bevy_diagnostic",
|
||||
"bevy_ecs",
|
||||
"bevy_feathers",
|
||||
"bevy_gizmos_render",
|
||||
"bevy_image",
|
||||
"bevy_input",
|
||||
@@ -1082,6 +1172,7 @@ dependencies = [
|
||||
"bevy_log",
|
||||
"bevy_math",
|
||||
"bevy_mesh",
|
||||
"bevy_pbr",
|
||||
"bevy_platform",
|
||||
"bevy_ptr",
|
||||
"bevy_reflect",
|
||||
@@ -1101,6 +1192,27 @@ dependencies = [
|
||||
"bevy_winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_light"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_math",
|
||||
"bevy_mesh",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_log"
|
||||
version = "0.18.1"
|
||||
@@ -1138,7 +1250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bevy_reflect",
|
||||
"derive_more",
|
||||
"glam 0.30.10",
|
||||
@@ -1161,7 +1273,9 @@ dependencies = [
|
||||
"bevy_asset",
|
||||
"bevy_derive",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_math",
|
||||
"bevy_mikktspace",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_transform",
|
||||
@@ -1174,6 +1288,71 @@ dependencies = [
|
||||
"wgpu-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_mikktspace"
|
||||
version = "0.17.0-dev"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59"
|
||||
|
||||
[[package]]
|
||||
name = "bevy_pbr"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_derive",
|
||||
"bevy_diagnostic",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_light",
|
||||
"bevy_log",
|
||||
"bevy_math",
|
||||
"bevy_mesh",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_render",
|
||||
"bevy_shader",
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"bitflags 2.11.1",
|
||||
"bytemuck",
|
||||
"derive_more",
|
||||
"fixedbitset",
|
||||
"nonmax",
|
||||
"offset-allocator",
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_picking"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_derive",
|
||||
"bevy_ecs",
|
||||
"bevy_input",
|
||||
"bevy_math",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_time",
|
||||
"bevy_transform",
|
||||
"bevy_window",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_platform"
|
||||
version = "0.18.1"
|
||||
@@ -1500,6 +1679,7 @@ dependencies = [
|
||||
"bevy_input",
|
||||
"bevy_input_focus",
|
||||
"bevy_math",
|
||||
"bevy_picking",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_sprite",
|
||||
@@ -1512,6 +1692,7 @@ dependencies = [
|
||||
"taffy",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1545,6 +1726,26 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_ui_widgets"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"bevy_a11y",
|
||||
"bevy_app",
|
||||
"bevy_camera",
|
||||
"bevy_ecs",
|
||||
"bevy_input",
|
||||
"bevy_input_focus",
|
||||
"bevy_log",
|
||||
"bevy_math",
|
||||
"bevy_picking",
|
||||
"bevy_reflect",
|
||||
"bevy_ui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_utils"
|
||||
version = "0.18.1"
|
||||
@@ -1672,6 +1873,7 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -1703,7 +1905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
@@ -1879,6 +2081,17 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "card_game"
|
||||
version = "0.4.1"
|
||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
checksum = "983728ead19f51d96931725706e62293bd133ac3d836097dd7d745e929f7811b"
|
||||
dependencies = [
|
||||
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
@@ -1939,6 +2152,17 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -3457,6 +3681,17 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
|
||||
dependencies = [
|
||||
"khronos_api",
|
||||
"log",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.30.10"
|
||||
@@ -3485,6 +3720,27 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"slotmap",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin_wgl_sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
|
||||
dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
@@ -4051,7 +4307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
"quick-error 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4309,6 +4565,23 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libloading",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos_api"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kira"
|
||||
version = "0.12.0"
|
||||
@@ -4326,13 +4599,25 @@ dependencies = [
|
||||
"triple_buffer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "klondike"
|
||||
version = "0.4.0"
|
||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
checksum = "d5c82b0c3abd7da07b4a1c4221a809e6e2ffd475ae0e67180fbfef35a9cfe769"
|
||||
dependencies = [
|
||||
"card_game",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"euclid",
|
||||
"smallvec",
|
||||
]
|
||||
@@ -4740,7 +5025,7 @@ version = "27.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bit-set",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
@@ -5778,6 +6063,25 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.11.1",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.3"
|
||||
@@ -5822,6 +6126,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -5947,6 +6257,16 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -5985,6 +6305,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.5.1"
|
||||
@@ -6004,6 +6330,15 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -6493,6 +6828,18 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
@@ -6959,6 +7306,7 @@ name = "solitaire_app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"jni 0.21.1",
|
||||
"keyring",
|
||||
"solitaire_data",
|
||||
"solitaire_engine",
|
||||
@@ -6980,7 +7328,10 @@ dependencies = [
|
||||
name = "solitaire_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rand 0.9.4",
|
||||
"card_game",
|
||||
"klondike",
|
||||
"proptest",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -6991,22 +7342,26 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"bevy",
|
||||
"card_game",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
"jsonwebtoken",
|
||||
"keyring-core",
|
||||
"klondike",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solitaire_core",
|
||||
"solitaire_server",
|
||||
"solitaire_sync",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7051,10 +7406,13 @@ dependencies = [
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"jsonwebtoken",
|
||||
"ron",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solitaire_sync",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower",
|
||||
@@ -7063,6 +7421,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7071,6 +7430,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
@@ -7090,6 +7450,18 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solitaire_web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"console_error_panic_hook",
|
||||
"getrandom 0.3.4",
|
||||
"solitaire_data",
|
||||
"solitaire_engine",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -7502,7 +7874,7 @@ version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
@@ -7601,7 +7973,7 @@ version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"grid",
|
||||
"serde",
|
||||
"slotmap",
|
||||
@@ -7870,7 +8242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
@@ -7884,7 +8256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
@@ -8533,6 +8905,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "uncased"
|
||||
version = "0.9.10"
|
||||
@@ -8739,6 +9117,15 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -9044,12 +9431,13 @@ version = "27.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"document-features",
|
||||
"hashbrown 0.16.1",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
"portable-atomic",
|
||||
@@ -9057,6 +9445,8 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"wgpu-core",
|
||||
"wgpu-hal",
|
||||
"wgpu-types",
|
||||
@@ -9068,7 +9458,7 @@ version = "27.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.11.1",
|
||||
@@ -9088,6 +9478,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"wgpu-core-deps-apple",
|
||||
"wgpu-core-deps-wasm",
|
||||
"wgpu-core-deps-windows-linux-android",
|
||||
"wgpu-hal",
|
||||
"wgpu-types",
|
||||
@@ -9102,6 +9493,15 @@ dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-wasm"
|
||||
version = "27.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "27.0.0"
|
||||
@@ -9118,7 +9518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"ash",
|
||||
"bit-set",
|
||||
"bitflags 2.11.1",
|
||||
@@ -9127,15 +9527,20 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"core-graphics-types 0.2.0",
|
||||
"glow",
|
||||
"glutin_wgl_sys",
|
||||
"gpu-alloc",
|
||||
"gpu-allocator",
|
||||
"gpu-descriptor",
|
||||
"hashbrown 0.16.1",
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"metal",
|
||||
"naga",
|
||||
"ndk-sys",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
@@ -9148,6 +9553,8 @@ dependencies = [
|
||||
"renderdoc-sys",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"wgpu-types",
|
||||
"windows 0.58.0",
|
||||
"windows-core 0.58.0",
|
||||
@@ -10030,6 +10437,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
|
||||
[[package]]
|
||||
name = "xmlwriter"
|
||||
version = "0.1.0"
|
||||
|
||||
+37
-1
@@ -8,6 +8,7 @@ members = [
|
||||
"solitaire_app",
|
||||
"solitaire_assetgen",
|
||||
"solitaire_wasm",
|
||||
"solitaire_web",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -17,11 +18,27 @@ version = "0.1.0"
|
||||
license = "MIT"
|
||||
rust-version = "1.95"
|
||||
|
||||
# Pedantic correctness lints applied across every member crate via
|
||||
# `[lints] workspace = true`.
|
||||
[workspace.lints.rust]
|
||||
# Workspace-wide ban on `unsafe`. The sole exception is `solitaire_app`,
|
||||
# which sets its own `deny`-level lints (see its Cargo.toml) because the
|
||||
# Android cdylib entry point must reconstruct raw JNI handles. Every other
|
||||
# crate reaches Android JNI through the safe `solitaire_data::android_jni`
|
||||
# bridge and stays fully unsafe-free.
|
||||
unsafe_code = "forbid"
|
||||
single_use_lifetimes = "warn"
|
||||
trivial_casts = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
unused_qualifications = "warn"
|
||||
variant_size_differences = "warn"
|
||||
unexpected_cfgs = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||
thiserror = "2"
|
||||
rand = "0.9"
|
||||
async-trait = "0.1"
|
||||
@@ -30,6 +47,7 @@ dirs = "6"
|
||||
keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
sha2 = "0.10"
|
||||
arboard = { version = "3", default-features = false }
|
||||
jni = { version = "0.21", default-features = false }
|
||||
|
||||
@@ -37,6 +55,8 @@ solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
klondike = { version = "0.4.0", registry = "Quaternions", features = ["serde"] }
|
||||
card_game = { version = "0.4.1", registry = "Quaternions", features = ["serde"] }
|
||||
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
@@ -137,3 +157,19 @@ opt-level = 3
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
||||
# Size-focused profile for the browser canvas build (solitaire_web →
|
||||
# canvas_bg.wasm). Download size is the constraint on the web, not peak
|
||||
# throughput: fat LTO + one codegen unit + opt-level "s" cut the Bevy wasm
|
||||
# bundle substantially versus plain release. This is rustc-side sizing only —
|
||||
# the binaryen pass in build_wasm.sh stays at wasm-opt -O2 because -Oz has
|
||||
# miscompiled Bevy's render pipeline before (grey screen on first load).
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
# No `strip`: on wasm it also removes the target_features custom section,
|
||||
# which makes wasm-opt reject the module ("all used features should be
|
||||
# allowed" on trunc_sat). wasm-opt drops the name section in its output
|
||||
# anyway, so strip buys nothing here.
|
||||
|
||||
@@ -118,8 +118,28 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
|
||||
|
||||
# Lint
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Browser e2e smoke (starts solitaire_server automatically)
|
||||
cd solitaire_server/e2e
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
npm test
|
||||
|
||||
# Seed-batch cycle regression gate (thresholded)
|
||||
npm run review:cycles:regression
|
||||
|
||||
# Loop-aware candidate benchmark (writes test-results/cycle-candidate.json)
|
||||
npm run review:cycles:candidate
|
||||
```
|
||||
|
||||
For layered engine-vs-UI automation design (Rust unit tests, wasm debug-API
|
||||
integration tests, and Playwright UI validation), see
|
||||
[docs/testing-architecture.md](docs/testing-architecture.md).
|
||||
|
||||
For Quaternions (`klondike` / `card_game`) dependency upgrades, use
|
||||
[`scripts/update_quaternions_deps.sh`](scripts/update_quaternions_deps.sh) and
|
||||
the runbook in [docs/card-game-integration.md](docs/card-game-integration.md).
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||
|
||||
@@ -44,6 +44,26 @@ docker compose up -d
|
||||
```
|
||||
|
||||
|
||||
## Theme store
|
||||
|
||||
The server can offer card-art themes for in-game download. Drop theme
|
||||
`.zip` archives (the same format the game's Settings → Import accepts:
|
||||
a `theme.ron` manifest plus 53 SVGs) into the directory named by
|
||||
`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then
|
||||
restart the server — the catalog is scanned once at startup. An
|
||||
optional `<theme-id>.png` in the same directory becomes the theme's
|
||||
store preview.
|
||||
|
||||
Endpoints (public, no auth):
|
||||
|
||||
- `GET /api/themes` — catalog JSON (id, name, author, size, sha256)
|
||||
- `GET /api/themes/<id>/download` — the archive
|
||||
- `GET /api/themes/<id>/preview` — the preview PNG, if present
|
||||
|
||||
Archives that are oversized (> 20 MiB), unreadable, or have a
|
||||
malformed `theme.ron` are skipped with a warning in the server log;
|
||||
they never fail startup.
|
||||
|
||||
## Admin — Password Reset
|
||||
|
||||
If a player loses access to their account, the server binary includes a
|
||||
|
||||
+140
-29
@@ -1,16 +1,103 @@
|
||||
# Ferrous Solitaire — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
|
||||
**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
|
||||
|
||||
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
|
||||
- **Latest tag:** `v0.35.1`
|
||||
- **Working tree:** clean
|
||||
- **Build:** `cargo clippy --workspace -- -D warnings` clean
|
||||
- **Tests:** 1277 passing / 0 failing across the workspace
|
||||
- **Branch state:** `master` pushed to origin; latest work is the 2026-07-06
|
||||
arc (PRs #121–#131): scripted repo review, five issues filed and fixed,
|
||||
plugin module splits, two releases.
|
||||
- **Latest tags:** `v0.41.1` (pile-marker child-resize fix + Android relayout
|
||||
logging) on top of `v0.41.0` (consolidated release for everything since
|
||||
v0.39.0). Both released via tag push → CI signed APK; both verified
|
||||
installed on hardware (`versionCode 4101`).
|
||||
- **Working tree:** clean. Local `scripts/*.go` helpers are intentionally
|
||||
gitignored (`.gitignore:43`); `scripts/watch_deploy.sh` is now committed.
|
||||
- **Latest verification:** workspace clippy `--all-targets -D warnings`,
|
||||
full test suite, `cargo ndk` clippy for `aarch64-linux-android`, CI release
|
||||
builds green, and an on-device pass on the Fold 7 (fold/unfold layout,
|
||||
safe-area resume, marker fix).
|
||||
- **Issue tracker:** #116/#117/#118/#119/#120 all closed 2026-07-06. #130
|
||||
(transient tableau clip after fold) open at low priority — did not
|
||||
reproduce in repeat testing; v0.41.1's relayout logging is the evidence
|
||||
channel if it recurs.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-06 session summary (v0.40.3 → v0.41.1)
|
||||
|
||||
- **Scripted repo review** (cratemap/todoctx/cargoclip/testfail): clippy
|
||||
clean, tests green, error/SQL policies compliant. Five issues filed and
|
||||
all resolved same-day.
|
||||
- **#116 safe-area re-poll after resume** (PR #121): `refresh_insets` now
|
||||
gates on the poll counter, settles per cycle, and rewrites insets only on
|
||||
change. Verified on Fold 7 — note both Fold screens report identical
|
||||
insets (top=110 bottom=0), so the re-poll path is a no-op on this device.
|
||||
- **#117 Draw-1 recycle** (PR #122): unlimited recycling documented as an
|
||||
intentional rules decision (ARCHITECTURE.md "Rules decisions" +
|
||||
`draw_one_recycling_is_unlimited_by_design` lock-in test). A hard limit
|
||||
would invalidate the difficulty seed catalog and the winnable-deal solver.
|
||||
- **#118 module splits** (PRs #124/#125/#127/#128/#129): card, hud,
|
||||
settings, game, and input plugins are now module directories; tests in
|
||||
sibling `tests.rs`, runtime code split along system boundaries
|
||||
(`pub(super)` items, mod.rs glob-imports children). Largest runtime file
|
||||
is now `card_plugin/sync.rs` at 748 lines (was `card_plugin.rs` at 4,129).
|
||||
- **v0.41.1 pile-marker fix** (PR #131): marker outline + "A"/"K" watermark
|
||||
children are re-derived from the layout on every resize — previously
|
||||
spawn-time-sized, rendering as oversized grey slabs on empty foundations
|
||||
after fold/unfold. Found via photo evidence, fixed, re-verified on device.
|
||||
- **Obtainium note:** reported "no suitable release" for v0.41.0 even though
|
||||
the anonymous releases API, `releases/latest`, and the APK download were
|
||||
all verified fine — client-side issue; sideload via adb was used instead.
|
||||
|
||||
---
|
||||
|
||||
## v0.40.0 release (2026-06-25)
|
||||
|
||||
Released via tag push → `.gitea/workflows/android-release.yml` built and signed the
|
||||
arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`,
|
||||
29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo
|
||||
pick it up automatically.
|
||||
|
||||
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
|
||||
|
||||
| PR | Summary |
|
||||
|----|---------|
|
||||
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
|
||||
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
|
||||
| #108 | **docs(android):** NDK reference updated `26.3.11579264` → `30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
|
||||
|
||||
Pre-release validation performed locally this session: workspace clippy/test/build
|
||||
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
|
||||
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
|
||||
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
|
||||
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
|
||||
proving the `build_android_apk.sh` packaging pipeline end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## What shipped since v0.39.0
|
||||
|
||||
- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`.
|
||||
- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up:
|
||||
- `5e87358` integrates upstream deps cleanly.
|
||||
- `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types.
|
||||
- `d864d98` routes klondike/card imports through `solitaire_core`.
|
||||
- `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs.
|
||||
- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed.
|
||||
- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`.
|
||||
- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels.
|
||||
- Native analytics and Android physical-device validation now have runbooks in
|
||||
`docs/analytics-validation.md` and `docs/ANDROID.md`.
|
||||
|
||||
---
|
||||
|
||||
## Historical notes before v0.39.0
|
||||
|
||||
See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work.
|
||||
|
||||
---
|
||||
|
||||
@@ -81,37 +168,46 @@ Three bugs fixed:
|
||||
|
||||
## Open punch list
|
||||
|
||||
### 1. CHANGELOG documentation debt
|
||||
### 1. Physical-device smoke test — DONE (2026-07-06, Galaxy Fold 7)
|
||||
|
||||
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
|
||||
are missing. Low priority (git log is authoritative) but worth closing before the
|
||||
next release.
|
||||
v0.41.1 installed via adb and the full device checklist passed on hardware:
|
||||
fold/unfold layout on both screens (incl. the #116 resume path and the
|
||||
pile-marker fix), safe-area inset resolution, Draw-Three waste fan tap
|
||||
accuracy (#106), modal centring on both screens, drag-and-drop across all
|
||||
pile types, text rendering, kill-and-restore, and the sync token flow.
|
||||
Reminder for future gates: AVD is not a substitute — `adb shell input tap`
|
||||
doesn't deliver real touch events.
|
||||
|
||||
### 2. Android APK launch verification (Option A)
|
||||
### 2. Matomo analytics live validation (independent — NOT a release blocker)
|
||||
|
||||
Physical device test: install the latest APK on a real Android device (not AVD),
|
||||
confirm:
|
||||
- App launches without crash
|
||||
- Safe area insets arrive and shift HUD correctly after ~3 frames
|
||||
- All modal Done buttons are above the gesture bar
|
||||
- Drag-and-drop works on all pile types
|
||||
- Leaderboard panel opens and the "Public name" label updates correctly after
|
||||
using "Set Name"
|
||||
|
||||
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.
|
||||
|
||||
### 3. Matomo analytics wiring
|
||||
|
||||
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
|
||||
engine code consumes them — the analytics toggle in Settings is a no-op. If
|
||||
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
|
||||
and wired to `GameStateResource` events.
|
||||
Separate, ongoing task unrelated to the Android release. `Settings` has
|
||||
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
||||
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
|
||||
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
|
||||
validation checklist and the current web/WASM decision notes.
|
||||
|
||||
---
|
||||
|
||||
## Architectural notes for next session
|
||||
|
||||
- **Plugin submodule pattern (2026-07-06 splits):** big plugins are module
|
||||
directories: `mod.rs` holds types/markers/plugin-build and glob-imports the
|
||||
children (`use input::*;`); children hold `pub(super)` systems and start
|
||||
with `use super::*;` plus their own external imports. Tests (`tests.rs`)
|
||||
may need explicit imports for names no longer used by `mod.rs` itself.
|
||||
|
||||
- **Marker child-resize rule:** anything spawned as a *child* of a
|
||||
layout-sized entity (outline frames, watermark text) must be re-derived in
|
||||
`on_window_resized` too — resizing only the parent sprite leaves children
|
||||
at spawn-time size (the v0.41.1 foldable bug).
|
||||
|
||||
- **Fold 7 quirks:** both screens report identical safe-area insets
|
||||
(top=110, bottom=0), so inset-driven relayout never fires on fold; layout
|
||||
correctness across folds rides entirely on `WindowResized`. winit 0.30
|
||||
logs `TODO: find a way to notify application of content rect change` on
|
||||
resume — see #130 if a stale-width layout ever reproduces; v0.41.1 logs
|
||||
every Android relayout (`layout: resize to WxH`) for exactly this.
|
||||
|
||||
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
|
||||
(the trigger), not the `tick_*` system. If the component is never inserted, the
|
||||
tick path never runs. See `hud_plugin.rs::detect_score_change` and
|
||||
@@ -128,3 +224,18 @@ and wired to `GameStateResource` events.
|
||||
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
||||
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
||||
with `input.release(key); input.clear()` between updates.
|
||||
|
||||
- **`/play` debug bridge design:** `play.html` runs two independent WASM instances in
|
||||
`Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__`
|
||||
(logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge
|
||||
operates its own `SolitaireGame` — moves applied through the bridge do NOT affect
|
||||
the Bevy visual game. This is intentional for automation/invariant checking.
|
||||
|
||||
- **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)`
|
||||
is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit
|
||||
on HiDPI displays, causing an immediate wgpu panic on the first resize event.
|
||||
|
||||
- **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js`
|
||||
target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play`
|
||||
using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js`
|
||||
supports both via `--route play-classic|play`.
|
||||
|
||||
+67
-7
@@ -1,24 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild the solitaire_wasm crate and install the output into
|
||||
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||
# Rebuild WASM artifacts and install them into solitaire_server/web/pkg/.
|
||||
#
|
||||
# Two artifacts are produced:
|
||||
# solitaire_wasm.* — thin replay-viewer + interactive JS API (wasm-pack)
|
||||
# canvas.* — full Bevy WASM app for play.html (cargo + wasm-bindgen)
|
||||
#
|
||||
# Prerequisites:
|
||||
# cargo install wasm-pack
|
||||
# cargo install wasm-pack wasm-bindgen-cli
|
||||
# rustup target add wasm32-unknown-unknown
|
||||
# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm
|
||||
#
|
||||
# Run from the repo root:
|
||||
# ./build_wasm.sh
|
||||
#
|
||||
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||
# committed to git so self-hosters who don't touch the WASM crate can
|
||||
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||
# solitaire_core/.
|
||||
# The generated pkg/ files are NOT committed to git (issue #156). CI builds
|
||||
# them where needed: the Docker image's wasm-builder stage for deployment,
|
||||
# and the web-e2e workflow for browser tests. Run this script locally before
|
||||
# serving /web or /play from a source checkout.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||
|
||||
# Reproducible builds. The wasm artifacts otherwise bake in machine-specific
|
||||
# absolute source paths (the cargo registry, the rustup std sources, and this
|
||||
# checkout), so a rebuild on a different machine produces different bytes and
|
||||
# the CI freshness gate (rebuild-and-diff) false-positives. Remap all three
|
||||
# prefixes to fixed names so the output is byte-identical anywhere.
|
||||
#
|
||||
# We must use CARGO_ENCODED_RUSTFLAGS (not RUSTFLAGS) and re-state the
|
||||
# getrandom backend cfg here: a `*_RUSTFLAGS` env var *replaces* — does not
|
||||
# merge with — the `[target.wasm32-unknown-unknown] rustflags` in
|
||||
# .cargo/config.toml, so dropping that cfg would break the wasm getrandom build.
|
||||
# Keep this `--cfg` in sync with .cargo/config.toml.
|
||||
CARGO_HOME_DIR="${CARGO_HOME:-$HOME/.cargo}"
|
||||
RUSTUP_HOME_DIR="${RUSTUP_HOME:-$HOME/.rustup}"
|
||||
US=$'\x1f' # unit separator: CARGO_ENCODED_RUSTFLAGS arg delimiter
|
||||
export CARGO_ENCODED_RUSTFLAGS="--cfg${US}getrandom_backend=\"wasm_js\"${US}--remap-path-prefix=${CARGO_HOME_DIR}=/cargo${US}--remap-path-prefix=${RUSTUP_HOME_DIR}=/rustup${US}--remap-path-prefix=${REPO_ROOT}=/build"
|
||||
|
||||
if ! command -v wasm-pack &> /dev/null; then
|
||||
echo "error: wasm-pack not found." >&2
|
||||
echo " Install with: cargo install wasm-pack" >&2
|
||||
@@ -36,5 +56,45 @@ wasm-pack build \
|
||||
# Remove them — we manage the output directory ourselves.
|
||||
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bevy WASM app (solitaire_web → canvas.js + canvas_bg.wasm)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ! command -v wasm-bindgen &> /dev/null; then
|
||||
echo "error: wasm-bindgen not found." >&2
|
||||
echo " Install with: cargo install wasm-bindgen-cli" >&2
|
||||
echo " The CLI version must match the wasm-bindgen crate dep." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building solitaire_web (Bevy WASM app)..."
|
||||
# wasm-release is the size-focused profile (fat LTO, CGU=1, opt-level "s") —
|
||||
# see [profile.wasm-release] in Cargo.toml. Download size is the constraint.
|
||||
cargo build --profile wasm-release --target wasm32-unknown-unknown -p solitaire_web
|
||||
|
||||
echo "Running wasm-bindgen for solitaire_web..."
|
||||
wasm-bindgen \
|
||||
--out-dir "$OUT_DIR" \
|
||||
--out-name canvas \
|
||||
--target web \
|
||||
--no-typescript \
|
||||
"${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
||||
|
||||
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||
if command -v wasm-opt &> /dev/null; then
|
||||
echo "Running wasm-opt on canvas_bg.wasm..."
|
||||
# Use -O2 (not -Oz): Bevy's render pipeline uses deep call stacks and
|
||||
# complex memory patterns that wasm-opt -Oz can miscompile, resulting
|
||||
# in a grey screen on first load. -O2 is speed-optimised and avoids
|
||||
# the size-focused transforms that trigger the regression.
|
||||
wasm-opt -O2 \
|
||||
-o "$OUT_DIR/canvas_bg.wasm" \
|
||||
"$OUT_DIR/canvas_bg.wasm"
|
||||
else
|
||||
echo "note: wasm-opt not found; skipping size optimisation."
|
||||
echo " Install with: cargo install wasm-opt (or via binaryen)"
|
||||
fi
|
||||
|
||||
echo "Done. Output:"
|
||||
ls -lh "$OUT_DIR"
|
||||
|
||||
@@ -34,6 +34,11 @@ spec:
|
||||
key: jwt-secret
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
# Theme-store catalog directory on the persistent volume.
|
||||
# Scanned once at startup — after dropping new theme zips
|
||||
# into /data/theme_store, restart the deployment.
|
||||
- name: THEME_STORE_DIR
|
||||
value: /data/theme_store
|
||||
volumeMounts:
|
||||
- name: db-data
|
||||
mountPath: /data
|
||||
|
||||
@@ -6,8 +6,13 @@ services:
|
||||
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
||||
# regardless of what .env contains.
|
||||
DATABASE_URL: sqlite:///data/solitaire.db
|
||||
# Theme-store catalog directory (scanned once at startup; the
|
||||
# host ./theme_store folder is where the operator drops theme
|
||||
# zips + preview PNGs).
|
||||
THEME_STORE_DIR: /theme_store
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./theme_store:/theme_store:ro
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "${SERVER_PORT:-8080}"
|
||||
|
||||
+57
-22
@@ -2,13 +2,13 @@
|
||||
|
||||
This doc captures the toolchain install + build invocation for the
|
||||
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
|
||||
later sections document what's known to compile, what's stubbed, and
|
||||
the next milestones.
|
||||
later sections document physical-device validation, supported platform
|
||||
surfaces, and remaining Android follow-ups.
|
||||
|
||||
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
||||
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
|
||||
> NOT yet been verified to launch on a device or emulator — that's
|
||||
> the next milestone.
|
||||
> **Status (2026-06-09):** Android build plumbing, app-directory storage,
|
||||
> JNI keystore wiring, and safe-area layout fixes have landed. The remaining
|
||||
> release gate is a physical-device smoke test; AVD tap injection does not
|
||||
> exercise the real touch path reliably enough for launch verification.
|
||||
|
||||
---
|
||||
|
||||
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
|
||||
echo ''
|
||||
echo '# Android dev'
|
||||
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
|
||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
|
||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.14904198"'
|
||||
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
|
||||
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
|
||||
} >> ~/.bashrc
|
||||
@@ -49,10 +49,15 @@ sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-34" \
|
||||
"build-tools;34.0.0" \
|
||||
"ndk;26.3.11579264" \
|
||||
"ndk;30.0.14904198" \
|
||||
"emulator" \
|
||||
"system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# The exact NDK/build-tools versions above are not load-bearing — newer ones
|
||||
# work (verified on NDK 30.0.14904198 / build-tools 37.0.0). `scripts/build_android_apk.sh`
|
||||
# auto-discovers the newest installed NDK and build-tools, so set ANDROID_NDK_HOME
|
||||
# (step 3) to whatever version you actually install here.
|
||||
|
||||
# 6. AVD for testing (one-time).
|
||||
echo no | avdmanager create avd \
|
||||
-n bevy_test \
|
||||
@@ -163,8 +168,8 @@ accepted workaround.
|
||||
Physical device:
|
||||
|
||||
```bash
|
||||
adb devices # confirm connection
|
||||
adb install target/debug/apk/ferrous-solitaire.apk
|
||||
adb devices # confirm connection
|
||||
adb install -r target/debug/apk/ferrous-solitaire.apk
|
||||
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
||||
```
|
||||
@@ -185,35 +190,65 @@ AVD.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's wired vs. what's stubbed
|
||||
## 4. Physical-device smoke test
|
||||
|
||||
The first build pass (commit `fb8b2ac`) gates four desktop-only
|
||||
crates / call sites so the workspace cross-compiles. Each gate is
|
||||
documented at its call site.
|
||||
Run this on a real phone, preferably a modern 64-bit ARM device with gesture
|
||||
navigation enabled.
|
||||
|
||||
Build and install:
|
||||
|
||||
```bash
|
||||
cargo apk build -p solitaire_app --target aarch64-linux-android --lib
|
||||
adb install -r target/debug/apk/ferrous-solitaire.apk
|
||||
adb logcat -c
|
||||
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic|WindowInsets"
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- App launches without panic or ANR.
|
||||
- Safe-area insets arrive after the first few frames and shift HUD/modal
|
||||
content away from the status and gesture bars.
|
||||
- Every modal's Done button remains above the gesture bar:
|
||||
Settings, Help, Pause, Win Summary, and Leaderboard-related dialogs.
|
||||
- Drag-and-drop works on tableau, waste, foundation, and stock/recycle paths.
|
||||
- Tap-to-select and one-tap modes both respond correctly on card stacks.
|
||||
- Leaderboard panel opens, "Set Name" saves, and the "Public name" label updates
|
||||
while the panel remains open.
|
||||
- Rotate the device once, then repeat one modal and one drag operation.
|
||||
- Close and relaunch the app; settings/progress still load.
|
||||
|
||||
Record the device model, Android version, APK commit, and pass/fail notes in the
|
||||
release notes or session handoff. If a failure occurs, keep the filtered logcat
|
||||
and note the exact screen/control path that reproduced it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Platform support matrix
|
||||
|
||||
Desktop-only crates and call sites are gated so the workspace cross-compiles.
|
||||
Each gate is documented at its call site.
|
||||
|
||||
| Surface | Desktop | Android |
|
||||
|---------|---------|---------|
|
||||
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
|
||||
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
|
||||
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
|
||||
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Android Keystore via JNI |
|
||||
| Data directory | Platform data dir | Android app files dir |
|
||||
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
|
||||
|
||||
What's NOT yet ported / not yet measured:
|
||||
Remaining Android follow-ups:
|
||||
|
||||
- `dirs::data_dir()` returns `None` on Android. Callers in
|
||||
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
||||
`achievements.rs`, `settings.rs` all need an Android-aware
|
||||
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
|
||||
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
||||
app lifecycle (suspend / resume), font scaling.
|
||||
- Android Keystore via JNI for `auth_tokens`.
|
||||
- JNI ClipboardManager for share links.
|
||||
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
|
||||
in older docs doesn't yet exist).
|
||||
|
||||
---
|
||||
|
||||
## 5. Iteration loop
|
||||
## 6. Iteration loop
|
||||
|
||||
```bash
|
||||
# Edit code…
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Analytics Validation Runbook
|
||||
|
||||
Ferrous Solitaire currently has two analytics paths:
|
||||
|
||||
- Native desktop/Android gameplay events use `solitaire_engine::AnalyticsPlugin`
|
||||
and `solitaire_data::MatomoClient`.
|
||||
- Hosted web pages include Matomo page-view snippets in
|
||||
`solitaire_server/web/*.html`.
|
||||
|
||||
The Bevy `/play` WASM canvas does not emit the native gameplay events because
|
||||
`AnalyticsPlugin` is intentionally gated out on `wasm32`; it depends on the
|
||||
native Tokio/reqwest stack.
|
||||
|
||||
## Native Matomo Validation
|
||||
|
||||
Use this when a deployed Matomo instance and a native build are available.
|
||||
|
||||
1. Configure `settings.json` with a Matomo URL and site ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"analytics_enabled": true,
|
||||
"matomo_url": "https://analytics.example.com",
|
||||
"matomo_site_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
2. Launch the native app and open Settings.
|
||||
3. Confirm the Privacy section appears and "Share usage data" is `ON`.
|
||||
4. Start a new confirmed game.
|
||||
5. Win or forfeit the game.
|
||||
6. Unlock an achievement if practical, or use an existing achievement path that
|
||||
is easy to trigger in a test profile.
|
||||
7. Wait at least 60 seconds, or close after the win/forfeit path has fired its
|
||||
immediate flush.
|
||||
8. In Matomo, confirm the following custom events arrived:
|
||||
|
||||
| Category | Action | Name |
|
||||
| --- | --- | --- |
|
||||
| `Game` | `Start` | `classic`, `zen`, `challenge`, `time_attack`, or `difficulty` |
|
||||
| `Game` | `Won` | empty |
|
||||
| `Game` | `Forfeit` | empty |
|
||||
| `Achievement` | `Unlocked` | achievement id |
|
||||
|
||||
## Web/WASM Decision
|
||||
|
||||
Keep the current split unless the project explicitly needs in-canvas gameplay
|
||||
events for `/play`.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `/`, `/play-classic`, `/account`, `/leaderboard`, and `/replays` emit Matomo
|
||||
page views through the hosted HTML snippets.
|
||||
- `/play` hosts the Bevy canvas but does not emit gameplay events from the
|
||||
engine.
|
||||
- The browser Content-Security-Policy already allows the deployed Matomo host
|
||||
for scripts, images, and connections.
|
||||
|
||||
If gameplay events are needed on `/play`, add a small `wasm32`-only analytics
|
||||
bridge instead of trying to compile the native plugin:
|
||||
|
||||
- keep the same event contract as native (`Game / Start`, `Game / Won`,
|
||||
`Game / Forfeit`, `Achievement / Unlocked`);
|
||||
- read `Settings::analytics_enabled`, `matomo_url`, and `matomo_site_id`;
|
||||
- send through browser APIs or the existing `_paq` queue;
|
||||
- keep the Settings opt-in behavior identical to native;
|
||||
- add Playwright coverage that stubs Matomo and verifies emitted payloads.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Integrating `card_game` / `klondike` as the Solitaire Core
|
||||
|
||||
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
|
||||
|
||||
**Approach:** Integration is complete. Upstream `card_game` / `klondike` now owns
|
||||
authoritative Klondike rules, session history, undo snapshots, and solving.
|
||||
Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes,
|
||||
and typed UI errors in `solitaire_core`.
|
||||
|
||||
---
|
||||
|
||||
## What `card_game` + `klondike` Already Has
|
||||
|
||||
### `card_game` crate (generic primitives) — v0.4.0
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation |
|
||||
| `Suit`, `Rank`, `Deck` enums | Full A→K, 4 suits, up to 4 deck IDs |
|
||||
| `Stack<CAP>` | Const-generic `ArrayVec` wrapper |
|
||||
| `Pile<DN, UP>` | Face-down + face-up stacks; `flip_up`, `pop_flip_up` |
|
||||
| `Game` trait | `possible_instructions`, `is_instruction_valid`, `process_instruction`, `is_win` |
|
||||
| `Session` | Wraps a `Game`; snapshot-based undo (O(1)), score including undo penalty |
|
||||
| `Session::solve()` | Built-in DFS solver with move/state budgets; returns `Solution<G>` or `SolveError` |
|
||||
| `StateSnapshot<G>` | Pre-move state + instruction; used by snapshot history and `Solution` |
|
||||
| `SessionState::score()` | = `game_score + undos × undo_penalty` (−15 by default via `SessionConfig`) |
|
||||
| `SessionConfig` | `undo_penalty`, `solve_moves_budget`, `solve_states_budget` |
|
||||
|
||||
### `klondike` crate (Klondike rules) — v0.3.0
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| 7 tableau + 4 foundation + 1 stock | Fully dealt from a seeded RNG |
|
||||
| Draw-1 / Draw-3 config | `KlondikeConfig::draw_stock` (`DrawStockConfig`) |
|
||||
| `MoveFromFoundationConfig` | `Allowed` (upstream default) / `Disallowed`; controls foundation → tableau rule |
|
||||
| `ScoringConfig` | Configurable deltas: `move_to_foundation` (+10), `flip_up_bonus` (+5), `move_to_tableau` (+5), `move_from_foundation` (−15), `recycle` (0 by default) |
|
||||
| `KlondikeStats::score(&config)` | Computes score from per-event counters × `ScoringConfig` deltas |
|
||||
| `KlondikeStats` counters | `move_to_foundation_count`, `flip_up_bonus_count`, `move_to_tableau_count`, `move_from_foundation_count`, `recycle_count`, `moves` |
|
||||
| Foundation placement (Ace start, suit-matched A→K) | ✅ |
|
||||
| Tableau placement (alternating colour, K on empty) | ✅ |
|
||||
| Multi-card stack moves (via `SkipCards`) | ✅ |
|
||||
| `RotateStock` (recycle waste → stock) | ✅ |
|
||||
| `is_win_trivial` (all face-down cards cleared) | Auto-complete trigger |
|
||||
| `get_auto_move` / `get_sorted_moves` | Priority-ranked move suggestion (take `&KlondikeConfig`) |
|
||||
| Benchmark suite (`klondike-bench`) | 1 000-game throughput test |
|
||||
| CLI display (`klondike-cli`) | Terminal renderer |
|
||||
|
||||
---
|
||||
|
||||
## What Ferrous Solitaire's `solitaire_core` Still Owns
|
||||
|
||||
### 1. Scoring — remaining adapter responsibilities
|
||||
Ferrous uses **Windows XP Standard** scoring. The upstream library handles the
|
||||
per-move counters and configurable deltas; Ferrous adds the product-specific
|
||||
parts in `GameState` / `KlondikeAdapter`.
|
||||
|
||||
| Event | Delta | Handled by |
|
||||
|---|---|---|
|
||||
| Any card → foundation | +10 | `KlondikeStats` / `ScoringConfig::move_to_foundation` ✅ |
|
||||
| Waste → tableau | +5 | `KlondikeStats` / `ScoringConfig::move_to_tableau` ✅ |
|
||||
| Flip face-down tableau card | +5 | `KlondikeStats` / `ScoringConfig::flip_up_bonus` ✅ |
|
||||
| Foundation → tableau | −15 | `KlondikeStats` / `ScoringConfig::move_from_foundation` ✅ |
|
||||
| Undo | −15 | `SessionStats` / `SessionConfig::undo_penalty` ✅ |
|
||||
| Recycle (Draw-1, after 1st free) | −100 | **Our adapter** — see below |
|
||||
| Recycle (Draw-3, after 3rd free) | −20 | **Our adapter** — see below |
|
||||
| Score floor | `score.max(0)` always | **Our adapter** |
|
||||
| Time bonus on win | `700_000 / elapsed_seconds` | **Our adapter** (not wasm-portable) |
|
||||
|
||||
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
||||
|
||||
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty.
|
||||
|
||||
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
||||
|
||||
**In our wrapper:** `KlondikeAdapter::config_for` configures the upstream rules
|
||||
and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor,
|
||||
time bonus, game-mode suppression, and undo score restoration.
|
||||
|
||||
### 2. Game Modes
|
||||
Ferrous has three modes that alter scoring and undo behaviour:
|
||||
|
||||
| Mode | Scoring | Undo |
|
||||
|---|---|---|
|
||||
| **Classic** | Full WXP scoring (table above) | Allowed (−15 penalty) |
|
||||
| **Zen** | All deltas suppressed — score stays 0 | Allowed (no penalty) |
|
||||
| **Challenge** | Full WXP scoring | **Disabled** — returns an error |
|
||||
|
||||
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
|
||||
|
||||
**In our wrapper:** `GameMode` lives on `solitaire_core::GameState`; undo and
|
||||
scoring behavior are applied before/after delegating legal moves to the upstream
|
||||
session.
|
||||
|
||||
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
||||
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
||||
- `MovesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
||||
- `StatesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
||||
|
||||
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
|
||||
|
||||
The old local DFS has been replaced. `solitaire_core::solver` is now a small
|
||||
adapter around `Session::solve()` that preserves the engine-facing
|
||||
`SolverResult`, `SolverConfig`, and first-move payload contract.
|
||||
|
||||
**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested
|
||||
budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget
|
||||
errors → Inconclusive.
|
||||
|
||||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
||||
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
||||
|
||||
**Default behaviour:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire **also defaults to Allowed** (`take_from_foundation: true` in `GameState`, `Settings`). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (`take_from_foundation = false`), which maps to `Disallowed`.
|
||||
|
||||
**In our wrapper:** `KlondikeAdapter::config_for(draw_mode, take_from_foundation)` constructs `KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }`. No custom intercept needed — `klondike` enforces the rule automatically.
|
||||
|
||||
### 5. JSON Serialisation / Persistence
|
||||
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch.
|
||||
|
||||
**Upstream serde status (rev 99b49e62):** At this revision, `klondike` and `card_game` both enable a `serde` feature. All nine instruction/pile types (`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`, `TableauStack`, `Foundation`, `Tableau`, `SkipCards`) derive `serde::Serialize` + `serde::Deserialize` under that feature. The workspace `Cargo.toml` enables `features = ["serde"]`.
|
||||
|
||||
**Schema v4 (current):** `saved_moves` serialises as `Vec<KlondikeInstruction>` using upstream named-variant serde. Example: `{"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}`.
|
||||
|
||||
**Schema v3 (legacy, auto-migrated):** `saved_moves` used local `SavedInstruction` mirror types with u8 indices. Example: `{"DstFoundation": {"src": "Stock", "foundation": 0}}`. On load, an `AnyInstruction` untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The `SavedInstruction` bridge types are retained in `solitaire_core::klondike_adapter` for this migration path and for backward-compatible `solitaire_data::ReplayMove` / WASM replay formats.
|
||||
|
||||
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
|
||||
|
||||
**In our wrapper:** `GameState::Serialize` emits schema v4 (upstream instruction types). `GameState::Deserialize` accepts v3 (auto-migrates) and v4 (direct). Schema version field lives on our wrapper.
|
||||
|
||||
### 6. Typed Move Errors
|
||||
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
|
||||
|
||||
```
|
||||
GameAlreadyWon
|
||||
UndoStackEmpty
|
||||
StockEmpty
|
||||
InvalidSource
|
||||
InvalidDestination
|
||||
RuleViolation(String)
|
||||
```
|
||||
|
||||
`KlondikeInstruction` is always constructed by game code from valid entity layout, so invalid moves are only detectable at `solitaire_core`'s construction boundary — the error lives there, not inside `klondike`.
|
||||
|
||||
**In our wrapper:** `MoveError` variants are generated when `solitaire_core` fails to construct a `KlondikeInstruction` from the player's requested move. No translation of `is_instruction_valid`'s bool return is required; by the time an instruction reaches `klondike`, it is already known to be structurally valid.
|
||||
|
||||
### 7. Waste Pile as Separate Concept
|
||||
Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into `Stock` (the face-up half of the stock `Pile`). The engine's UI and scoring logic reference the waste pile directly; the mapping needs to be explicit.
|
||||
|
||||
**In our wrapper:** Project the face-up half of `klondike`'s stock `Pile` as `PileType::Waste` when building pile snapshots for the engine.
|
||||
|
||||
### 8. Undo Stack Approach *(resolved — not an issue)*
|
||||
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
|
||||
|
||||
**Resolution:** `GameState` uses `Session`'s built-in snapshot history. Ferrous
|
||||
keeps parallel score/recycle metadata so undo can restore product-specific score
|
||||
state that upstream snapshots do not own.
|
||||
|
||||
---
|
||||
|
||||
## Integration Path (All work in `solitaire_core`)
|
||||
|
||||
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
||||
|
||||
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
|
||||
2. ✅ **Map pile types** — project `klondike`'s stock face-up half as the engine's waste pile and expose renderer-facing pile snapshots.
|
||||
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (gap 4, upstream).
|
||||
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
|
||||
5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
|
||||
6. ✅ **Replace solver** — call `session.solve()` with budgets from `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
||||
7. ✅ **Implement `serde`** — serialise schema v4 with upstream `KlondikeInstruction`; auto-migrate schema v3 via `SavedInstruction` compatibility types.
|
||||
|
||||
---
|
||||
|
||||
## Quaternions Upgrade Runbook
|
||||
|
||||
Use this sequence whenever upgrading `klondike` / `card_game` from the
|
||||
Quaternions registry:
|
||||
|
||||
1. Review upstream changes/releases:
|
||||
- <https://git.aleshym.co/Quaternions/card_game>
|
||||
- <https://git.aleshym.co/Quaternions/klondike>
|
||||
2. Run:
|
||||
```bash
|
||||
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
|
||||
```
|
||||
3. If the script passes, inspect the resulting `Cargo.lock` diff and land the
|
||||
upgrade with the normal PR flow.
|
||||
|
||||
The script enforces:
|
||||
- lockfile update to requested versions
|
||||
- `cargo test --workspace`
|
||||
- `cargo clippy --workspace -- -D warnings`
|
||||
- deterministic replay/debug-API smoke tests in `solitaire_wasm`
|
||||
|
||||
---
|
||||
|
||||
## What Does NOT Need to Change
|
||||
|
||||
- The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`.
|
||||
- The `solitaire_sync` merge logic — operates on a `SyncPayload` DTO, independent of core card types.
|
||||
- The `solitaire_server` — speaks only `SyncPayload` JSON, unaffected.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Quaternions' repo: <https://git.aleshym.co/Quaternions/card_game>
|
||||
- `card_game v0.4.0` release commit: `fa098f0d`
|
||||
- `klondike v0.3.0` release commit: `f4c4e350`
|
||||
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
||||
- Upstream solver PR: #14
|
||||
- `solitaire_core` source: `solitaire_core/src/`
|
||||
- Scoring implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs`
|
||||
- Architecture overview: `ARCHITECTURE.md`
|
||||
@@ -0,0 +1,408 @@
|
||||
# In-Place card_game / klondike Rewrite Plan
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Upstream rev:** `99b49e62`
|
||||
**Status:** All phases complete (0–3). recycle_count drift and score compound error on undo fixed in `56e3b62`.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Is Already Integrated
|
||||
|
||||
The integration is substantially complete. `solitaire_core` already delegates all
|
||||
authoritative Klondike logic to the upstream crates.
|
||||
|
||||
| Area | Status | Location |
|
||||
|---|---|---|
|
||||
| `Session<Klondike>` ownership | ✅ complete | `GameState.session` |
|
||||
| `draw()` → `session.process_instruction(RotateStock)` | ✅ complete | `game_state.rs` |
|
||||
| `move_cards()` → `session.process_instruction(KlondikeInstruction)` | ✅ complete | `game_state.rs` |
|
||||
| `undo()` → `session.undo()` | ✅ complete | `game_state.rs` |
|
||||
| `possible_instructions()` → `session.state().state().get_sorted_moves()` | ✅ complete | `game_state.rs` |
|
||||
| `can_move_cards()` → `session.state().state().is_instruction_valid()` | ✅ complete | `game_state.rs` |
|
||||
| `solver.rs` → `session.solve()` | ✅ complete | `solver.rs` |
|
||||
| `Suit`, `Rank` → re-export from `card_game` | ✅ complete | `card.rs` |
|
||||
| `Foundation`, `Klondike`, `KlondikePile`, `Session`, `Tableau` → `solitaire_core::lib` | ✅ complete | `lib.rs` |
|
||||
| Move legality enforcement | ✅ upstream (`is_instruction_valid`) | `klondike/src/lib.rs` |
|
||||
| Foundation placement rules (Ace start, suit match) | ✅ upstream | `klondike/src/lib.rs` |
|
||||
| Tableau placement rules (alternating colour, King on empty) | ✅ upstream | `klondike/src/lib.rs` |
|
||||
| Multi-card stack moves via `SkipCards` | ✅ upstream | `klondike/src/lib.rs` |
|
||||
| Session history / snapshot undo | ✅ upstream | `card_game/src/lib.rs` |
|
||||
| DFS solver with budget limits | ✅ upstream | `card_game/src/lib.rs` |
|
||||
| Instruction history → `SavedInstruction` serde mirrors | ✅ in adapter | `klondike_adapter.rs` |
|
||||
| Schema v3 save/load (instruction replay) | ✅ complete | `game_state.rs`, `storage.rs` |
|
||||
| `take_from_foundation` house rule → `MoveFromFoundationConfig` | ✅ complete | `klondike_adapter.rs` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Duplicated / Replaceable Logic
|
||||
|
||||
These are local implementations that either replicate upstream or could be removed.
|
||||
|
||||
### 2a. `SavedInstruction` mirror types (~300 lines, `klondike_adapter.rs`)
|
||||
|
||||
**What:** A full hand-written serde mirror for every upstream klondike instruction type
|
||||
(`SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
|
||||
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
|
||||
`SavedSkipCards`, `InvalidSavedInstruction`) plus ~20 `From`/`TryFrom` conversion impls.
|
||||
|
||||
**Why written:** At the time, upstream klondike had no serde feature.
|
||||
|
||||
**Current upstream status:** At rev `99b49e62`, the `serde` feature is present and active.
|
||||
`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`,
|
||||
`TableauStack`, `Tableau`, `Foundation`, `SkipCards` all derive
|
||||
`#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]`.
|
||||
|
||||
**Blocker — JSON format incompatibility:**
|
||||
| Field | Local `SavedInstruction` JSON | Upstream `KlondikeInstruction` JSON |
|
||||
|---|---|---|
|
||||
| Tableau index | `{ "Tableau": 0 }` (u8) | `{ "Tableau": "Tableau1" }` (named) |
|
||||
| Foundation slot | `{ "Foundation": 0 }` (u8) | `{ "Foundation": "Foundation1" }` (named) |
|
||||
| Skip count | `{ "skip_cards": 0 }` (u8) | `{ "skip_cards": "Skip0" }` (named) |
|
||||
|
||||
Switching to direct upstream serde **changes the `saved_moves` JSON shape** stored in
|
||||
`game_state.json`. Any existing v3 save file would fail to deserialize after the switch.
|
||||
This requires either:
|
||||
- A schema bump to v4 **with a migration** (deserialize v3 manually then re-save as v4), or
|
||||
- A schema bump to v4 **with graceful fallback** (v3 files rejected → fresh game).
|
||||
|
||||
**Recommendation:** Schema v4 with graceful fallback (v3 saves start fresh). Migration
|
||||
is feasible but adds ~100 lines of throwaway code; the in-progress game loss is modest
|
||||
since schema v3 was never shipped to users (it landed in the current dev branch, not a
|
||||
release).
|
||||
|
||||
### 2b. `GameState::check_win()` (~15 lines)
|
||||
|
||||
**What:** Iterates all four foundation slots checking 13-card A→K sequences.
|
||||
**Upstream equivalent:** `session.state().state().is_win()` on `Klondike`.
|
||||
**Status:** Local check is correct but redundant. Trivially replaceable with no format change.
|
||||
**Risk:** None — only affects `is_won` flag update path.
|
||||
|
||||
### 2c. `GameState::check_auto_complete()` (~15 lines)
|
||||
|
||||
**What:** Checks stock empty, waste empty, all tableau cards face-up.
|
||||
**Upstream equivalent:** `session.state().state().is_win_trivial()` on `Klondike`.
|
||||
**Semantic difference:** Upstream `is_win_trivial` checks `stock.is_empty()` (both faces)
|
||||
and all `tableau.face_down().is_empty()`. Ferrous additionally checks `waste.is_empty()`.
|
||||
These are logically equivalent for a valid game state (waste = stock face-up half).
|
||||
**Risk:** Low — validated by existing auto-complete engine tests.
|
||||
|
||||
### 2c. `recycle_count` drift on undo (existing bug, not new)
|
||||
|
||||
**What:** `GameState.recycle_count` is incremented in `draw()` when stock is empty.
|
||||
`undo()` does not decrement it. After undoing a recycle, `recycle_count` is stale and
|
||||
may cause incorrect future penalty application.
|
||||
**Upstream:** `KlondikeStats.recycle_count()` has the same problem — it is cumulative
|
||||
and not restored on undo (stats are not part of the session snapshot, only game state is).
|
||||
**Fix approach:** After each undo, recompute `recycle_count` by scanning
|
||||
`session.history()` for `RotateStock` instructions that caused recycling.
|
||||
**Priority:** Medium — affects scoring correctness in rare paths. File as a separate bug.
|
||||
|
||||
---
|
||||
|
||||
## 3. What Must Remain Ferrous-Specific
|
||||
|
||||
These responsibilities are product-layer, not Klondike-rules-layer, and must stay in `solitaire_core`.
|
||||
|
||||
| Responsibility | Why upstream cannot own it |
|
||||
|---|---|
|
||||
| WXP recycle penalties (free allowance + -100/-20) | `ScoringConfig::recycle` is a flat delta; no free-allowance concept exists upstream |
|
||||
| Score floor (`score.max(0)`) | Not modelled upstream |
|
||||
| Time bonus (`700_000 / elapsed_seconds`) | Not modelled upstream |
|
||||
| `DrawMode` / `GameMode` enums | Product concept; not in upstream |
|
||||
| Challenge mode undo block | Product rule |
|
||||
| Zen mode scoring suppression | Product rule |
|
||||
| `MoveError` variants for UI feedback | Upstream returns `bool`; Ferrous needs typed errors |
|
||||
| `card::Card` projection (adds `id`, `face_up`) | Renderer requires stable `id` and face orientation |
|
||||
| `Pile` DTO for engine sync | Renderer-facing snapshot type |
|
||||
| `stock_cards()` / `waste_cards()` distinction | Engine models waste as a separate pile; upstream uses stock face-up half |
|
||||
| `recycle_count` tracking | Needed for free-allowance penalty calculation |
|
||||
| Persistence format + schema versioning | Product concern |
|
||||
| `SavedInstruction` (currently) or upstream serde (after migration) | Either way, Ferrous owns the save contract |
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Audit Findings
|
||||
|
||||
### Finding 1 — Upstream serde claim in docs is stale
|
||||
|
||||
`docs/card-game-integration.md` (last section "JSON Serialisation") states:
|
||||
|
||||
> Current verification (2026-06-01): klondike v0.3.0 and card_game v0.4.0 crate manifests
|
||||
> expose no serde dependency/feature.
|
||||
|
||||
**This is wrong at rev 99b49e62.** The `serde` feature is present and active. All nine
|
||||
instruction/pile types have `#[cfg_attr(feature = "serde", derive(...))]`. The doc must
|
||||
be updated.
|
||||
|
||||
### Finding 2 — `take_from_foundation` default: docs vs code
|
||||
|
||||
`docs/card-game-integration.md` says:
|
||||
> Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the
|
||||
> default, with the house rule as an opt-in.
|
||||
|
||||
**The code and settings say the opposite:** `Settings::take_from_foundation` defaults to
|
||||
`true` (Allowed); `GameState.take_from_foundation` also initializes to `true`. Multiple
|
||||
tests assert this is the intended behavior. The upstream default is also `Allowed`.
|
||||
|
||||
**Resolution:** The docs are wrong. Default = Allowed (house rule on by default for
|
||||
beginner-friendliness) is intentional. Update the docs; do not change the code.
|
||||
|
||||
### Finding 3 — `KlondikeStats` cumulative vs session-history-aware counts
|
||||
|
||||
`KlondikeStats.moves()` and `KlondikeStats.recycle_count()` accumulate monotonically.
|
||||
They are NOT restored when `Session::undo()` is called (only `Klondike` game state is
|
||||
restored from the snapshot, not the stats). Ferrous correctly uses
|
||||
`session.history().len()` for `move_count` (history-aware). But `recycle_count` is
|
||||
stored separately in `GameState` and also not decremented on undo — making them
|
||||
equivalent in this one bug.
|
||||
|
||||
### Finding 4 — `SkipCards as usize` cast is correct
|
||||
|
||||
Upstream `SkipCards` has no explicit discriminants, so `Skip0 = 0 .. Skip12 = 12`.
|
||||
`skip_cards as usize` in `solver.rs` and `game_state.rs` is correct.
|
||||
|
||||
---
|
||||
|
||||
## 5. Staged Migration
|
||||
|
||||
### Phase 0 — Doc fixes only (no code change)
|
||||
|
||||
Files: `docs/card-game-integration.md`
|
||||
|
||||
- Correct the serde claim (upstream has serde at rev 99b49e62).
|
||||
- Correct the `take_from_foundation` default description.
|
||||
- Update integration status table.
|
||||
|
||||
### Phase 1 — Delegate `is_win` / `is_win_trivial` (safe, no format change)
|
||||
|
||||
Files: `solitaire_core/src/game_state.rs`
|
||||
|
||||
Replace local `check_win()` and `check_auto_complete()` with upstream delegation:
|
||||
|
||||
```rust
|
||||
// before
|
||||
pub fn check_win(&self) -> bool { ... 40 lines ... }
|
||||
|
||||
// after
|
||||
pub fn check_win(&self) -> bool {
|
||||
self.session.state().state().is_win()
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// before
|
||||
pub fn check_auto_complete(&self) -> bool { ... 15 lines ... }
|
||||
|
||||
// after
|
||||
pub fn check_auto_complete(&self) -> bool {
|
||||
self.session.state().state().is_win_trivial()
|
||||
}
|
||||
```
|
||||
|
||||
**Risk:** Very low. Both methods are tested by existing integration tests. The semantic
|
||||
difference in `check_auto_complete` (upstream vs Ferrous definition) is equivalent for
|
||||
valid game states.
|
||||
|
||||
### Phase 2 — Replace `SavedInstruction` with upstream serde (schema v4)
|
||||
|
||||
Files:
|
||||
- `solitaire_core/src/klondike_adapter.rs` (remove ~300 lines)
|
||||
- `solitaire_core/src/game_state.rs` (update `Serialize`/`Deserialize` impls)
|
||||
- `solitaire_core/src/proptest_tests.rs` (remove now-redundant SavedInstruction tests)
|
||||
- `solitaire_data/src/storage.rs` (add schema v4 rejection test)
|
||||
- `solitaire_data/src/replay.rs` (no change — uses `SavedKlondikePile` independently)
|
||||
- `solitaire_wasm/src/lib.rs` (uses `SavedKlondikePileStack` in its own mirror — evaluate)
|
||||
|
||||
**Steps:**
|
||||
1. In `game_state.rs`, change `PersistedGameState.saved_moves` from
|
||||
`Vec<SavedInstruction>` to `Vec<KlondikeInstruction>` (upstream serde now works).
|
||||
2. Update `GameState::Serialize` to emit `KlondikeInstruction` directly.
|
||||
3. Update `GameState::Deserialize` to parse `KlondikeInstruction` directly.
|
||||
4. Increment `GAME_STATE_SCHEMA_VERSION` to 4.
|
||||
5. In `GameState::Deserialize`, reject schema != 4 with graceful fallback (already
|
||||
handled by `load_game_state_from` returning `None` on serde error or wrong version).
|
||||
6. Delete `SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
|
||||
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
|
||||
`SavedSkipCards`, `InvalidSavedInstruction` from `klondike_adapter.rs`.
|
||||
7. Delete the 20 `From`/`TryFrom` impls.
|
||||
8. Remove `SavedInstruction` proptest and boundary tests (no longer needed).
|
||||
9. Add schema v4 round-trip test and v3 rejection test.
|
||||
|
||||
**Note on `solitaire_data::replay.rs`:**
|
||||
`replay.rs` uses `SavedKlondikePile` independently (for `ReplayMove`). This is a
|
||||
separate type from the game-state save format and is NOT changed by this phase.
|
||||
`ReplayMove` has its own schema (`REPLAY_SCHEMA_VERSION`) and can keep using the local
|
||||
mirror types.
|
||||
|
||||
**Note on `solitaire_wasm/src/lib.rs`:**
|
||||
Uses `SavedKlondikePileStack` in its own `ReplayMove` mirror. Same as above — separate
|
||||
type, not affected.
|
||||
|
||||
### Pre-Phase 3 — Undo Field Audit (completed 2026-06-08)
|
||||
|
||||
Full audit of every Ferrous-owned field in `GameState` for undo correctness.
|
||||
|
||||
| Field | Correctly updated by `undo()`? | Notes |
|
||||
|---|---|---|
|
||||
| `score` | ✅ By design | −15 WXP undo penalty applied; Zen: stays 0 |
|
||||
| `move_count` | ✅ Correct | Recomputed from `session.history().len()` |
|
||||
| `is_won` | ✅ Correct | Recomputed; undo blocked on won game |
|
||||
| `is_auto_completable` | ✅ Correct | Recomputed |
|
||||
| `undo_count` | ✅ By design | Total undos ever, intentionally non-reversible |
|
||||
| `elapsed_seconds` | ✅ Intentional | Timer is independent of moves |
|
||||
| `seed` / `draw_mode` / `mode` / `take_from_foundation` | ✅ Immutable | |
|
||||
| **`recycle_count`** | ❌ **Bug** | Not decremented — see below |
|
||||
|
||||
**`recycle_count` drift bug:**
|
||||
|
||||
`draw()` increments `recycle_count` when `stock.face_down().is_empty()` (the rotation
|
||||
is a recycle, not just a draw). `undo()` calls `session.undo()` which restores the
|
||||
`Klondike` card state, but does NOT decrement `recycle_count`.
|
||||
|
||||
Consequence: if the player recycles, undoes it, then recycles again, `recycle_count`
|
||||
is `2` instead of `1` — the free-recycle allowance is consumed even though the first
|
||||
recycle was undone. On Draw-1, the 2nd recycle costs −100; after the undo-and-replay
|
||||
bug the player pays −100 for what should be their still-free recycle.
|
||||
|
||||
**Score compound effect:** When `undo()` is applied to a recycle that incurred a
|
||||
penalty, the penalty amount (`score_after_recycle - 100`) is already in `self.score`.
|
||||
`apply_undo_score` then adds `−15` on top. The recycle penalty is never reversed.
|
||||
|
||||
**Fix approach for Phase 3:**
|
||||
- After `session.undo()`, recompute `recycle_count` by scanning the new
|
||||
`session.history()` for `RotateStock` snapshots where
|
||||
`snapshot.state().state().stock().face_down().is_empty()` (indicating the rotation
|
||||
was a recycle, not a draw from a populated stock).
|
||||
- Restore `score` to `snapshot_score` **before** the undone move, then apply only
|
||||
the −15 undo penalty. This requires reading the score stored in `StateSnapshot`
|
||||
or keeping a pre-move score stack alongside the session history.
|
||||
|
||||
**Simpler alternative:** Store `(score_before, recycle_count_before)` in `GameState`
|
||||
alongside each `session.process_instruction` call, mirroring the snapshot stack.
|
||||
Undo pops this alongside the session undo.
|
||||
|
||||
### Phase 3 — Fix `recycle_count` drift on undo (optional, post-approval)
|
||||
|
||||
Files: `solitaire_core/src/game_state.rs`
|
||||
|
||||
After `session.undo()`, recompute `recycle_count` by scanning `session.history()` for
|
||||
`RotateStock` snapshots where the pre-instruction stock face-down was empty (indicating
|
||||
a recycle). Also correct the score: restore to the pre-undone-move score and apply only
|
||||
the −15 undo penalty.
|
||||
|
||||
**Tests to add:**
|
||||
- `recycle_count_decrements_when_recycle_is_undone`
|
||||
- `score_recycle_penalty_is_reversed_on_undo`
|
||||
|
||||
**Risk:** Medium — changes observable scoring behavior. The fix is strictly more
|
||||
correct, but any golden-file or regression test that recorded the old (buggy) score
|
||||
after undo-of-recycle will need updating.
|
||||
|
||||
---
|
||||
|
||||
## 6. Files Likely to Change Per Phase
|
||||
|
||||
| Phase | Files |
|
||||
|---|---|
|
||||
| Phase 0 | `docs/card-game-integration.md` |
|
||||
| Phase 1 | `solitaire_core/src/game_state.rs` |
|
||||
| Phase 2 | `solitaire_core/src/klondike_adapter.rs`, `solitaire_core/src/game_state.rs`, `solitaire_core/src/proptest_tests.rs`, `solitaire_data/src/storage.rs` |
|
||||
| Phase 3 | `solitaire_core/src/game_state.rs`, new test module |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks
|
||||
|
||||
### R1 — Save file format break (Phase 2, HIGH)
|
||||
Users with v3 saves lose their in-progress game. Mitigated by the fact that v3 is
|
||||
not in any shipped release (dev branch only). Graceful fallback (start fresh) is
|
||||
acceptable; a migration shim is possible but not required.
|
||||
|
||||
### R2 — `solitaire_wasm` / `solitaire_data::replay` breakage (Phase 2, MEDIUM)
|
||||
`SavedKlondikePile` and `SavedKlondikePileStack` are also used in `replay.rs` and
|
||||
`wasm/src/lib.rs`. These are separate from the game-state save format and must be
|
||||
left in place. Plan is to keep them in `klondike_adapter.rs` (or relocate to
|
||||
`replay.rs`) after the game-state mirror types are deleted.
|
||||
|
||||
### R3 — `check_auto_complete` semantic drift (Phase 1, LOW)
|
||||
Upstream `is_win_trivial` checks `stock.is_empty()` (no cards at all in stock)
|
||||
whereas Ferrous also checks waste. These are equivalent for a valid game state but
|
||||
could differ under test-support pile overrides. Existing auto-complete tests will
|
||||
catch any regression.
|
||||
|
||||
### R4 — `SkipCards as usize` cast correctness
|
||||
Already verified: enums have implicit 0..12 discriminants. No risk.
|
||||
|
||||
### R5 — Upstream changes after rev pin
|
||||
The workspace is pinned to `rev = "99b49e62"`. No upstream drift risk until explicitly
|
||||
re-pinned.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Plan
|
||||
|
||||
### Phase 1 tests (all currently pass)
|
||||
- `game_state::tests::take_from_foundation_allows_legal_return_move`
|
||||
- `game_state::tests::take_from_foundation_disabled_blocks_return_move_everywhere`
|
||||
- `proptest_tests::*` (card conservation, deal determinism, undo invariant, legal moves)
|
||||
|
||||
### Phase 2 tests to add
|
||||
- `storage::tests::game_state_v4_mid_game_round_trip` — verify upstream serde round-trip
|
||||
after migrating to `KlondikeInstruction` directly
|
||||
- `storage::tests::save_format_v3_is_rejected` — v3 files must return `None`
|
||||
- Update `game_state::tests::*` — all existing tests must continue to pass
|
||||
|
||||
### Phase 2 tests to remove
|
||||
- `proptest_tests::saved_instruction_round_trip` — no longer needed (no mirror types)
|
||||
- `proptest_tests::saved_instruction_boundary_tests::*` — no longer needed
|
||||
|
||||
### Phase 3 tests to add
|
||||
- `game_state::tests::recycle_count_decrements_on_undo` — after recycling and undoing,
|
||||
`recycle_count` must reflect the correct post-undo count
|
||||
|
||||
---
|
||||
|
||||
## 9. Validation Commands
|
||||
|
||||
Run after each phase:
|
||||
|
||||
```bash
|
||||
# Targeted (fast)
|
||||
cargo test -p solitaire_core
|
||||
cargo clippy -p solitaire_core -- -D warnings
|
||||
|
||||
# Broader
|
||||
cargo test -p solitaire_wasm
|
||||
cargo test -p solitaire_data
|
||||
|
||||
# Full workspace (run before declaring phase complete)
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: What Would Be Removed vs Kept
|
||||
|
||||
### Removed after all phases complete
|
||||
| Code | Lines est. | Reason |
|
||||
|---|---|---|
|
||||
| `SavedInstruction` + 8 mirror types | ~150 | Upstream serde now available |
|
||||
| 20 `From`/`TryFrom` impls | ~150 | Upstream serde now available |
|
||||
| `InvalidSavedInstruction` error type | ~10 | Upstream serde now available |
|
||||
| `check_win()` local impl | ~20 | Replaced by `is_win()` delegation |
|
||||
| `check_auto_complete()` local impl | ~15 | Replaced by `is_win_trivial()` delegation |
|
||||
| `SavedInstruction` proptest + boundary tests | ~60 | Mirror types removed |
|
||||
|
||||
**Total: ~400 lines removed from `solitaire_core`**
|
||||
|
||||
### Remains Ferrous-specific
|
||||
- `KlondikeAdapter` scoring helpers (recycle penalties, score floor, time bonus, Zen/mode suppression)
|
||||
- `DrawMode`, `GameMode`, `DifficultyLevel`
|
||||
- `MoveError` and all boundary-checking logic
|
||||
- `card::Card` (id + face_up projection)
|
||||
- `Pile` DTO
|
||||
- `stock_cards()` / `waste_cards()` projections
|
||||
- Persistence format (`GameState` serde, schema version, `PersistedGameState`)
|
||||
- `solitaire_data::replay` types (`ReplayMove`, `SavedKlondikePile` mirror — unchanged)
|
||||
- `solitaire_wasm` replay mirror types (unchanged)
|
||||
@@ -0,0 +1,115 @@
|
||||
# Testing Architecture — Engine-first Validation
|
||||
|
||||
Ferrous Solitaire validation is split into three layers with clear ownership:
|
||||
|
||||
1. **Rust unit tests (`solitaire_core`)**
|
||||
- move generation and legality
|
||||
- deal generation determinism
|
||||
- scoring and penalties
|
||||
- undo semantics
|
||||
- win detection
|
||||
|
||||
2. **Engine integration tests (`solitaire_wasm` debug API)**
|
||||
- autonomous game execution without UI/pointer simulation
|
||||
- invariant checks after every move
|
||||
- deterministic seed replay
|
||||
- high-volume seeded runs (including long-running soak tests)
|
||||
|
||||
3. **Playwright UI tests**
|
||||
- verify rendering vs engine state
|
||||
- drag/drop and keyboard UX behavior
|
||||
- responsive layout behavior
|
||||
- browser-compatibility checks
|
||||
|
||||
## Source of truth
|
||||
|
||||
The Rust engine is authoritative. Browser tests must interact with the game via
|
||||
debug API hooks, not via pixel/OCR solving or hardcoded screen coordinates.
|
||||
|
||||
## Debug API surfaces
|
||||
|
||||
Two automation surfaces are exposed:
|
||||
|
||||
- `solitaire_wasm::SolitaireGame` methods:
|
||||
- `debug_snapshot()`
|
||||
- `debug_legal_moves()`
|
||||
- `debug_move_history()`
|
||||
- `debug_apply_legal_move(index)`
|
||||
- `debug_apply_move_json(json)`
|
||||
- Browser bridge on `game.html`:
|
||||
- `window.__FERROUS_DEBUG__.snapshot()`
|
||||
- `window.__FERROUS_DEBUG__.legalMoves()`
|
||||
- `window.__FERROUS_DEBUG__.moveHistory()`
|
||||
- `window.__FERROUS_DEBUG__.applyLegalMove(index)`
|
||||
- `window.__FERROUS_DEBUG__.applyMove(move)`
|
||||
- `window.__FERROUS_DEBUG__.failureReport()`
|
||||
- `window.__FERROUS_DEBUG__.runAutoplay(options)`
|
||||
|
||||
## Required failure payload
|
||||
|
||||
Every automation failure should capture:
|
||||
|
||||
- seed
|
||||
- move history
|
||||
- current game state
|
||||
- screenshot
|
||||
- browser trace
|
||||
- console logs
|
||||
|
||||
`failureReport()` provides the engine-side fields (`seed`, `moveHistory`,
|
||||
`currentState`) so UI harnesses only need to attach browser artifacts.
|
||||
|
||||
## Execution guidance
|
||||
|
||||
- Fast verification:
|
||||
- `cargo test -p solitaire_core -p solitaire_wasm`
|
||||
- Full verification:
|
||||
- `cargo test --workspace`
|
||||
- `cargo clippy --workspace -- -D warnings`
|
||||
- Long unattended soak:
|
||||
- `cargo test -p solitaire_wasm debug_api_autonomous_thousands_seed_soak -- --ignored`
|
||||
|
||||
### Browser e2e harness
|
||||
|
||||
The Playwright suite lives under `solitaire_server/e2e/` and boots
|
||||
`solitaire_server` via Playwright `webServer` config.
|
||||
|
||||
- Install + run:
|
||||
- `cd solitaire_server/e2e`
|
||||
- `npm ci`
|
||||
- `npx playwright install chromium`
|
||||
- `npm test`
|
||||
- Cycle metrics batch run:
|
||||
- `cd solitaire_server/e2e`
|
||||
- `npm run review:cycles -- --games 1000 --steps 350 --policy baseline --max-visits 1 --out /tmp/cycle-baseline.json`
|
||||
- `npm run review:cycles -- --games 1000 --steps 350 --policy loop_aware --max-visits 2 --out /tmp/cycle-loop-aware.json`
|
||||
- `npm run review:cycles:regression` (thresholded gate, writes `test-results/cycle-regression.json`)
|
||||
- `npm run review:cycles:candidate` (loop-aware candidate run, writes `test-results/cycle-candidate.json`)
|
||||
|
||||
### Cycle-risk regression baseline and guardrails
|
||||
|
||||
- Current regression gate command:
|
||||
- `npm run review:cycles:regression`
|
||||
- config: `games=240`, `steps=350`, `policy=baseline`, `max-visits=1`
|
||||
- Current guardrail thresholds:
|
||||
- `all.cycle_rate_pct <= 86`
|
||||
- `draw1.cycle_rate_pct <= 76`
|
||||
- `draw3.cycle_rate_pct <= 95`
|
||||
- `all.win_rate_pct >= 14`
|
||||
- zero invariant/apply/page/console issue counts
|
||||
- Baseline sample (240 games):
|
||||
- overall: `win_rate=15.8%`, `cycle_rate=84.2%`
|
||||
- draw-one: `win_rate=25.8%`, `cycle_rate=74.2%`
|
||||
- draw-three: `win_rate=5.8%`, `cycle_rate=94.2%`
|
||||
- Candidate loop-aware sample (240 games, lookahead via simulated move + restore):
|
||||
- overall: `win_rate=20.4%`, `cycle_rate=32.5%`
|
||||
- draw-one: `win_rate=33.3%`, `cycle_rate=16.7%`
|
||||
- draw-three: `win_rate=7.5%`, `cycle_rate=48.3%`
|
||||
- no invariant/apply/page/console issues in the sampled run
|
||||
- Additional 500-game candidate soak:
|
||||
- overall: `win_rate=20.2%`, `cycle_rate=28.6%`, `step_budget=51.2%`
|
||||
- draw-three remains the dominant risk (`cycle_rate=45.2%`)
|
||||
- Fix applied: cycle metrics regression now supports explicit
|
||||
`max_step_budget_rate_*` thresholds. Candidate command now enforces
|
||||
`max_step_budget_rate_all <= 60` to prevent silent drift from cycles into
|
||||
step-budget stalls.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Menu UX Redesign — July 2026
|
||||
|
||||
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
|
||||
out of scope — this is about **structure and interaction**, not colors or type.
|
||||
|
||||
## Diagnosis (from code survey, 2026-07-07)
|
||||
|
||||
| Surface | Today | Pain |
|
||||
| --- | --- | --- |
|
||||
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
|
||||
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
|
||||
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
|
||||
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
|
||||
|
||||
## Phase A — Settings tabs (highest pain, lowest risk)
|
||||
|
||||
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
|
||||
Accessibility · Account`.
|
||||
- *Appearance* = today's Cosmetic (card back, background, card theme,
|
||||
theme store, import).
|
||||
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
|
||||
high-contrast, reduce-motion, touch input mode, tooltip delay.
|
||||
- *Account* = Sync + Privacy merged.
|
||||
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
|
||||
unfolded Fold, desktop): left rail, rows in two columns.
|
||||
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
|
||||
cover screen without scrolling.
|
||||
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
|
||||
resource. The `SettingsButton` enum, input handlers, and persistence are
|
||||
untouched, so the 25 settings tests and the ambiguity gate stay green.
|
||||
|
||||
## Phase B — Home becomes a real home
|
||||
|
||||
- **Hierarchy, top to bottom:**
|
||||
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
|
||||
2. Hero `New Game` — one tap, reuses last mode + deal options.
|
||||
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
|
||||
Seed) — smaller cards, glyph + name + one-line description on wide only.
|
||||
4. Stats strip moves to the bottom (or right pane when unfolded).
|
||||
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
|
||||
level into a disclosure on the Classic card / New Game hero — they are
|
||||
Classic-mode concerns, not global ones.
|
||||
- Cancel becomes `Back to table` and only renders when a game exists.
|
||||
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
|
||||
daily/weekly right.
|
||||
|
||||
## Phase C — HUD menu consolidation
|
||||
|
||||
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
|
||||
path opens Home).
|
||||
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
|
||||
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
|
||||
section dividers in the existing popover, not a new widget.
|
||||
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
|
||||
modal (most already do via `ScrimDismissible`; sweep the stragglers).
|
||||
|
||||
## Phase D — Validation gates
|
||||
|
||||
- Optional Stitch mockups (Terminal design-system asset
|
||||
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
|
||||
Home + Settings before implementing Phase B. Phase A is safe to build
|
||||
straight in-engine.
|
||||
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
|
||||
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
|
||||
|
||||
## Sequencing & risk
|
||||
|
||||
| Phase | Size | Risk | Depends on |
|
||||
| --- | --- | --- | --- |
|
||||
| A — Settings tabs | ~1 session | Low (layout-only) | — |
|
||||
| B — Home hierarchy | 1–2 sessions | Medium (touches launch flow) | D mockups if wanted |
|
||||
| C — Menu grouping | small | Low | B (Modes removal) |
|
||||
|
||||
## Phase E — "You" hub (reuses the Phase A tab component)
|
||||
|
||||
Profile, Stats, Achievements, and the replay browser are four separate
|
||||
modals today, reached through the HUD popover one at a time. Fold them
|
||||
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
|
||||
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
|
||||
four destinations and makes the tab component pay for itself twice.
|
||||
Existing per-screen systems keep their markers/handlers; only the outer
|
||||
shell changes (same trick as Phase A).
|
||||
|
||||
## Phase F — Mobile ergonomics (Fold-first)
|
||||
|
||||
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
|
||||
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
|
||||
score-only band stays on top. Desktop keeps the current top band.
|
||||
Biggest one-handed-play win available; folded posture is tall and
|
||||
top-heavy today.
|
||||
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
|
||||
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
|
||||
- Both are additive; game stays fully playable with the top band alone
|
||||
(UI-first rule §3.3 satisfied).
|
||||
|
||||
## Phase G — Post-win flow
|
||||
|
||||
Win summary today is a stats dump with a close. Give it an action
|
||||
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
|
||||
(the upload already returns a share URL; surface it here with copy
|
||||
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
|
||||
4 taps to 1.
|
||||
|
||||
## Phase H — Feedback polish (small, bundling candidates)
|
||||
|
||||
- Unify queued vs. immediate toast styling (two subtly different styles
|
||||
exist today) and anchor both to one position; consider a 5-item toast
|
||||
history on the stats screen.
|
||||
- Theme-store modal: render the preview PNGs the server already serves
|
||||
(v1 is text-only rows).
|
||||
- Hint: optional ghost-motion preview of the suggested move instead of a
|
||||
static highlight (auto-disabled under reduce-motion).
|
||||
|
||||
## Phase I — Onboarding & discoverability
|
||||
|
||||
The first-run onboarding exists, but everything learned after it is
|
||||
invisible: the radial menu (long-press/right-click), hint cycling,
|
||||
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
|
||||
fired by the situation, not a tour (first stall → hint tip; first
|
||||
long-press-able stack → radial tip; each shows once, stored in
|
||||
Settings like `shown_achievement_onboarding` already is). Plus a
|
||||
one-shot **"What's new"** card on first launch after a version bump —
|
||||
ObtainX updates are silent today, so shipped features go unnoticed
|
||||
(nobody will find the theme store on their own).
|
||||
|
||||
## Phase J — Keyboard & focus completeness (desktop + web)
|
||||
|
||||
`ui_focus` already gives deterministic Esc order and tab-walk
|
||||
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
|
||||
keyboard-only play has never been audited end to end. Deliverables: every
|
||||
modal reachable/dismissable without a mouse, a visible focus ring styled
|
||||
to the Terminal system (current focus state is subtle), and a hotkey
|
||||
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
|
||||
of the static Help text. Web build (Rhys) benefits most.
|
||||
|
||||
## Phase K — UI scale & touch-target accessibility
|
||||
|
||||
All type sizes flow through `TYPE_*` tokens and layout through
|
||||
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
|
||||
cheap to wire and genuinely useful on the Fold's dense cover screen.
|
||||
Pair with: respect Android system font scale on first run (seed the
|
||||
setting from it), and a one-time audit that every interactive element
|
||||
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
|
||||
and picker swatches are the suspects). Lands in the Accessibility tab
|
||||
that Phase A creates.
|
||||
|
||||
## Phase L — Empty & loading states, standardized
|
||||
|
||||
Async/empty surfaces each improvise today: leaderboard with no entries,
|
||||
stats with no games, replay browser with no replays, theme store
|
||||
loading/error (just added), sync status text. Define ONE pattern —
|
||||
glyph + one-line explanation + a single next-step action — as a
|
||||
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
|
||||
onto it. Small, mechanical, big perceived-quality win.
|
||||
|
||||
## Phase M — Sync transparency & data stewardship
|
||||
|
||||
The sync layer returns `ConflictReport`s ("data is never silently
|
||||
discarded") — but no UI ever shows them; players can't tell what a merge
|
||||
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
|
||||
values" → tap for detail in the Account tab), a visible last-synced
|
||||
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
|
||||
local **data export/import** (zip of the JSON saves) for device
|
||||
migrations without a server. Closes the trust loop the server work
|
||||
opened.
|
||||
|
||||
## Roadmap tie-ins (already in flight elsewhere)
|
||||
|
||||
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
|
||||
7th card once engine work lands — the 2×3 grid in Phase B should be
|
||||
designed as N-card flow from day one. Spider UI also needs positional
|
||||
card→entity keys (documented in spider.rs module docs).
|
||||
- **Show solution** (feat/solution-line, in flight): lands in the pause
|
||||
menu; Phase C's grouping should leave room for it under a "Game"
|
||||
cluster if the pause menu grows.
|
||||
|
||||
## Sequencing (updated)
|
||||
|
||||
| Phase | Size | Risk | Depends on |
|
||||
| --- | --- | --- | --- |
|
||||
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
|
||||
| E — "You" hub | ~1 session | Low | A's tab component |
|
||||
| C — Menu grouping | small | Low | E (popover shrinks) |
|
||||
| B — Home hierarchy | 1–2 sessions | Medium | D mockups if wanted |
|
||||
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
|
||||
| G — Post-win flow | small | Low | — |
|
||||
| H — Feedback polish | small each | Low | — |
|
||||
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
|
||||
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
|
||||
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
|
||||
| L — Empty-state standardization | small | Low | — |
|
||||
| M — Sync transparency & export | ~1 session | Low–Medium | A (Account tab) |
|
||||
|
||||
Suggested order: **A → E → C** (one arc: the tab component and the menu
|
||||
slimming), then **B**, then **F/G/H** as independent follow-ups.
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
||||
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
|
||||
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
|
||||
tucked under a "More" card?
|
||||
4. Stitch mockups for Phase B, or iterate directly in-engine?
|
||||
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
||||
@@ -0,0 +1,228 @@
|
||||
# Android testing
|
||||
|
||||
This directory contains lightweight Android test helpers for Ferrous Solitaire.
|
||||
They are intended to run against either a physical Android device or an emulator
|
||||
connected through `adb`. When no device is connected the smoke script can
|
||||
automatically launch an AVD for you.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Android SDK and NDK installed.
|
||||
- `adb` available on `PATH`.
|
||||
- One device/emulator visible in `adb devices`, **or** at least one AVD created
|
||||
(the script will launch one automatically if `LAUNCH_AVD=1`, which is the default).
|
||||
- If multiple devices are connected, set `ADB_SERIAL` to the target device serial.
|
||||
- Environment variables required by `scripts/build_android_apk.sh` when building:
|
||||
|
||||
```sh
|
||||
export ANDROID_HOME=/path/to/android-sdk
|
||||
export ANDROID_NDK_HOME=/path/to/android-ndk
|
||||
export BUILD_TOOLS_VERSION=34.0.0
|
||||
export PLATFORM=android-34
|
||||
```
|
||||
|
||||
## Smoke test
|
||||
|
||||
From the workspace root (`Rusty_Solitaire/`):
|
||||
|
||||
```sh
|
||||
scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
The smoke test first checks whether `adb` can see a ready device. If no device
|
||||
is connected and `LAUNCH_AVD=1` (default), it:
|
||||
|
||||
1. locates the `emulator` binary under `ANDROID_HOME` or `PATH`,
|
||||
2. picks the first available AVD (or uses `AVD_NAME`),
|
||||
3. launches the emulator in the foreground (or headless with `AVD_HEADLESS=1`),
|
||||
4. waits for `sys.boot_completed=1` before proceeding,
|
||||
5. dismisses the lock screen so the screenshot shows the app.
|
||||
|
||||
Once a device is ready (auto-launched or pre-existing) the script:
|
||||
|
||||
1. builds the APK using `scripts/build_android_apk.sh`,
|
||||
2. installs it with `adb install -r -d` so debug smoke builds can replace newer local builds,
|
||||
3. force-stops the package by default for a clean launch,
|
||||
4. clears `logcat`,
|
||||
5. launches `com.ferrousapp.solitaire/android.app.NativeActivity`,
|
||||
6. waits for the app to settle,
|
||||
7. verifies the process is still running,
|
||||
8. captures a screenshot and `logcat`, and
|
||||
9. fails on fatal log patterns such as native crashes, JNI fatal errors, real ANRs,
|
||||
and Rust panics.
|
||||
|
||||
On exit the script kills any emulator it launched (`SHUTDOWN_AVD_ON_EXIT=1` by
|
||||
default). Set `SHUTDOWN_AVD_ON_EXIT=0` to keep the emulator open for inspection.
|
||||
|
||||
Artifacts are written to `target/android-smoke/<timestamp>/` by default. A successful run includes:
|
||||
|
||||
- `device.txt` — selected device and display metadata,
|
||||
- `df-data-before.txt` / `df-data-after.txt` — emulator/device storage snapshots,
|
||||
- `emulator.log` — stdout/stderr from the emulator process (AVD runs only),
|
||||
- `emulator.pid` — PID of the emulator process (AVD runs only),
|
||||
- `launch.png` — screenshot after the wait period,
|
||||
- `logcat.txt` — full captured log,
|
||||
- `log-summary.txt` — grep summary for warnings, errors, JNI, safe-area, and crash terms, and
|
||||
- `pid.txt` — running app process id.
|
||||
|
||||
## Creating an AVD
|
||||
|
||||
If no AVDs exist, create one before running the smoke test:
|
||||
|
||||
```sh
|
||||
# Install a system image
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||
'system-images;android-34;google_apis;x86_64'
|
||||
|
||||
# Create the AVD
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd \
|
||||
-n Pixel_7_API_34 \
|
||||
-k 'system-images;android-34;google_apis;x86_64' \
|
||||
--device 'pixel_7'
|
||||
```
|
||||
|
||||
Then run the smoke test — it will pick `Pixel_7_API_34` automatically:
|
||||
|
||||
```sh
|
||||
scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
## Faster iteration
|
||||
|
||||
If you already built the APK and only want to reinstall/relaunch:
|
||||
|
||||
```sh
|
||||
BUILD_APK=0 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
If the APK is already installed and you only want to relaunch/capture logs:
|
||||
|
||||
```sh
|
||||
BUILD_APK=0 INSTALL_APK=0 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
By default the script force-stops the package before launch so logcat and screenshots represent a clean app start. To test warm-launch behavior instead:
|
||||
|
||||
```sh
|
||||
BUILD_APK=0 INSTALL_APK=0 FORCE_STOP=0 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
This is also useful when an already-installed build is good enough for launch/log checks. On install failure, the script writes `adb-install.txt`, storage snapshots, and installed-package diagnostics to the output directory.
|
||||
|
||||
If install fails with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, the smoke script uninstalls the package and retries once by default (`RESET_ON_SIGNATURE_MISMATCH=1`). This resets app data on the device/emulator. Disable it with:
|
||||
|
||||
```sh
|
||||
RESET_ON_SIGNATURE_MISMATCH=0 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
To write artifacts to a stable path:
|
||||
|
||||
```sh
|
||||
OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
When reusing an output directory, previous files are removed by default so stale artifacts do not contaminate the latest result. To keep existing files:
|
||||
|
||||
```sh
|
||||
CLEAN_OUT_DIR=0 OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
To target a specific device when more than one is attached:
|
||||
|
||||
```sh
|
||||
ADB_SERIAL=emulator-5554 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
To wait longer for safe-area inset polling or slow devices:
|
||||
|
||||
```sh
|
||||
WAIT_SECS=8 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
## AVD options
|
||||
|
||||
To pick a specific AVD by name instead of auto-selecting the first one:
|
||||
|
||||
```sh
|
||||
AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
To run headless (no emulator window) — useful in CI or on a display-less machine:
|
||||
|
||||
```sh
|
||||
AVD_HEADLESS=1 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
To give a slow machine more time to boot the emulator (default is 120 s):
|
||||
|
||||
```sh
|
||||
AVD_BOOT_TIMEOUT=180 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
To keep the emulator running after the test (useful for manual inspection):
|
||||
|
||||
```sh
|
||||
SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
To pass extra flags to the emulator (e.g. disable snapshot for a completely
|
||||
cold boot, or change GPU mode):
|
||||
|
||||
```sh
|
||||
AVD_EXTRA_ARGS="-gpu swiftshader_indirect" scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
To disable AVD auto-launch entirely and fail immediately if no device is
|
||||
connected:
|
||||
|
||||
```sh
|
||||
LAUNCH_AVD=0 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
For build-only validation without requiring a connected device, use the lower-level APK builder directly:
|
||||
|
||||
```sh
|
||||
scripts/build_android_apk.sh
|
||||
```
|
||||
|
||||
For smoke testing, `scripts/android_smoke.sh` defaults to the connected device's primary ABI when `BUILD_APK=1`, which keeps emulator APKs much smaller than the full multi-ABI default. You can still override it explicitly:
|
||||
|
||||
```sh
|
||||
ABIS=x86_64 scripts/android_smoke.sh
|
||||
```
|
||||
|
||||
For build-only validation, `scripts/build_android_apk.sh` still defaults to all configured ABIs unless you set `ABIS` yourself:
|
||||
|
||||
```sh
|
||||
ABIS=x86_64 scripts/build_android_apk.sh
|
||||
```
|
||||
|
||||
The APK builder signs debug builds with a persistent keystore at `target/android/debug.keystore` by default. This avoids signature churn across smoke-test runs.
|
||||
|
||||
The APK builder also strips native debug symbols by default before packaging (`STRIP_NATIVE_LIBS=1`). This keeps debug APKs installable on emulators with limited `/data` storage. To preserve native debug symbols for low-level debugging:
|
||||
|
||||
```sh
|
||||
STRIP_NATIVE_LIBS=0 ABIS=x86_64 scripts/build_android_apk.sh
|
||||
```
|
||||
|
||||
## Device checklist
|
||||
|
||||
The script is only a smoke test. Before shipping Android builds, also verify:
|
||||
|
||||
- safe-area insets arrive and shift the HUD after a few seconds,
|
||||
- HUD does not overlap the top status bar,
|
||||
- modal Done buttons are above the gesture/navigation bar,
|
||||
- stock tap works,
|
||||
- drag-and-drop works on tableau, waste, and foundation piles,
|
||||
- Settings/Help/Profile modals open and close,
|
||||
- login tokens persist after app restart, and
|
||||
- `target/android-smoke/.../logcat.txt` contains no fatal JNI/native crash output.
|
||||
|
||||
## Notes
|
||||
|
||||
- `adb shell input tap X Y` uses physical pixels, not Bevy logical pixels.
|
||||
- The project’s common test device mapping is physical `1080×2400`, Bevy logical
|
||||
`900×2000`, scale factor `1.20`; multiply logical coordinates by `1.20` for
|
||||
scripted `adb shell input` commands on that device.
|
||||
- Keep generated screenshots/logs under `target/android-smoke/` so they stay out
|
||||
of source control.
|
||||
Executable
+362
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env bash
|
||||
# Android smoke test for Ferrous Solitaire.
|
||||
#
|
||||
# Builds (optional), installs, launches, captures logcat + screenshot, and
|
||||
# fails on fatal Android log patterns. Designed as a lightweight device/emulator
|
||||
# sanity check rather than a full UI automation suite.
|
||||
#
|
||||
# Required:
|
||||
# adb on PATH
|
||||
# Android SDK/NDK env required by scripts/build_android_apk.sh when BUILD_APK=1
|
||||
#
|
||||
# Optional environment:
|
||||
# BUILD_APK=1|0 Build APK before install (default: 1)
|
||||
# INSTALL_APK=1|0 Install APK before launch (default: 1)
|
||||
# RESET_ON_SIGNATURE_MISMATCH=1|0
|
||||
# Uninstall/retry if debug signatures differ (default: 1)
|
||||
# LAUNCH_APP=1|0 Launch app before checks (default: 1)
|
||||
# FORCE_STOP=1|0 Force-stop package before launch for clean logs (default: 1)
|
||||
# CAPTURE_SCREENSHOT=1|0 Capture screenshot (default: 1)
|
||||
# ADB_SERIAL=... Device serial to use when multiple devices are connected
|
||||
# APK_PATH=... APK to install (default: target/debug/apk/ferrous-solitaire.apk)
|
||||
# PACKAGE=... Android package (default: com.ferrousapp.solitaire)
|
||||
# ACTIVITY=... Activity class (default: android.app.NativeActivity)
|
||||
# OUT_DIR=... Artifact directory (default: target/android-smoke/<timestamp>)
|
||||
# CLEAN_OUT_DIR=1|0 Remove prior artifacts from OUT_DIR first (default: 1)
|
||||
# WAIT_SECS=... Seconds to wait after launch (default: 5)
|
||||
# ABIS=... Passed to build script. If unset and BUILD_APK=1,
|
||||
# defaults to the connected device's primary ABI.
|
||||
#
|
||||
# AVD auto-launch (used when no device/emulator is already connected):
|
||||
# LAUNCH_AVD=1|0 Auto-launch an AVD when no device is ready (default: 1)
|
||||
# AVD_NAME=... AVD name to launch (default: first from `emulator -list-avds`)
|
||||
# AVD_BOOT_TIMEOUT=... Seconds to wait for the emulator to finish booting (default: 120)
|
||||
# AVD_HEADLESS=1|0 Run with -no-window -no-audio for CI/no-display environments (default: 0)
|
||||
# AVD_EXTRA_ARGS=... Extra arguments appended verbatim to the emulator command line
|
||||
# SHUTDOWN_AVD_ON_EXIT=1|0
|
||||
# Kill the AVD this script launched on exit (default: 1).
|
||||
# Set to 0 to leave the emulator running after the test.
|
||||
#
|
||||
# Examples:
|
||||
# scripts/android_smoke.sh
|
||||
# BUILD_APK=0 scripts/android_smoke.sh
|
||||
# LAUNCH_AVD=0 scripts/android_smoke.sh # error out if no device, never auto-launch
|
||||
# AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
|
||||
# AVD_HEADLESS=1 scripts/android_smoke.sh # CI / no-display
|
||||
# SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh # keep emulator open after test
|
||||
# OUT_DIR=target/android-smoke/latest WAIT_SECS=8 scripts/android_smoke.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
BUILD_APK="${BUILD_APK:-1}"
|
||||
INSTALL_APK="${INSTALL_APK:-1}"
|
||||
RESET_ON_SIGNATURE_MISMATCH="${RESET_ON_SIGNATURE_MISMATCH:-1}"
|
||||
LAUNCH_APP="${LAUNCH_APP:-1}"
|
||||
FORCE_STOP="${FORCE_STOP:-1}"
|
||||
CAPTURE_SCREENSHOT="${CAPTURE_SCREENSHOT:-1}"
|
||||
APK_PATH="${APK_PATH:-target/debug/apk/ferrous-solitaire.apk}"
|
||||
PACKAGE="${PACKAGE:-com.ferrousapp.solitaire}"
|
||||
ACTIVITY="${ACTIVITY:-android.app.NativeActivity}"
|
||||
WAIT_SECS="${WAIT_SECS:-5}"
|
||||
OUT_DIR="${OUT_DIR:-target/android-smoke/$(date +%Y%m%d-%H%M%S)}"
|
||||
CLEAN_OUT_DIR="${CLEAN_OUT_DIR:-1}"
|
||||
REMOTE_SCREENSHOT="/sdcard/ferrous-solitaire-smoke.png"
|
||||
|
||||
LAUNCH_AVD="${LAUNCH_AVD:-1}"
|
||||
AVD_NAME="${AVD_NAME:-}"
|
||||
AVD_BOOT_TIMEOUT="${AVD_BOOT_TIMEOUT:-120}"
|
||||
AVD_HEADLESS="${AVD_HEADLESS:-0}"
|
||||
AVD_EXTRA_ARGS="${AVD_EXTRA_ARGS:-}"
|
||||
SHUTDOWN_AVD_ON_EXIT="${SHUTDOWN_AVD_ON_EXIT:-1}"
|
||||
|
||||
ADB=(adb)
|
||||
if [ -n "${ADB_SERIAL:-}" ]; then
|
||||
ADB+=( -s "$ADB_SERIAL" )
|
||||
fi
|
||||
|
||||
# PID of any emulator we start so the EXIT trap can clean it up.
|
||||
_LAUNCHED_EMULATOR_PID=""
|
||||
|
||||
_cleanup_emulator() {
|
||||
if [ -n "$_LAUNCHED_EMULATOR_PID" ] && [ "$SHUTDOWN_AVD_ON_EXIT" = "1" ]; then
|
||||
echo ">>> shutdown emulator (PID $_LAUNCHED_EMULATOR_PID)"
|
||||
kill "$_LAUNCHED_EMULATOR_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap _cleanup_emulator EXIT
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "missing required command: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
if [ "$CLEAN_OUT_DIR" = "1" ]; then
|
||||
find "$OUT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
fi
|
||||
require_cmd adb
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device / emulator availability
|
||||
# ---------------------------------------------------------------------------
|
||||
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
||||
if [ "$DEVICE_STATE" != "device" ]; then
|
||||
if [ "$LAUNCH_AVD" != "1" ]; then
|
||||
adb devices > "$OUT_DIR/adb-devices.txt" 2>&1 || true
|
||||
if [ -n "${ADB_SERIAL:-}" ]; then
|
||||
echo "Android device '$ADB_SERIAL' is not connected/ready (state: ${DEVICE_STATE:-unknown})." >&2
|
||||
else
|
||||
echo "No Android device/emulator is connected and ready." >&2
|
||||
fi
|
||||
echo "Run 'adb devices' or start an emulator, then retry." >&2
|
||||
echo "Device list saved to $OUT_DIR/adb-devices.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- locate emulator binary -----------------------------------------------
|
||||
# Priority: ANDROID_HOME env → PATH → common SDK install locations.
|
||||
_find_sdk_root() {
|
||||
for candidate in \
|
||||
"$HOME/Android/Sdk" \
|
||||
"$HOME/Library/Android/sdk" \
|
||||
"/opt/android-sdk" \
|
||||
"/usr/lib/android-sdk"; do
|
||||
[ -d "$candidate" ] && echo "$candidate" && return
|
||||
done
|
||||
}
|
||||
|
||||
EMULATOR_BIN=""
|
||||
if [ -n "${ANDROID_HOME:-}" ] && [ -x "$ANDROID_HOME/emulator/emulator" ]; then
|
||||
EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
|
||||
elif command -v emulator >/dev/null 2>&1; then
|
||||
EMULATOR_BIN="$(command -v emulator)"
|
||||
else
|
||||
_SDK_ROOT="$(_find_sdk_root)"
|
||||
if [ -n "$_SDK_ROOT" ] && [ -x "$_SDK_ROOT/emulator/emulator" ]; then
|
||||
EMULATOR_BIN="$_SDK_ROOT/emulator/emulator"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$EMULATOR_BIN" ]; then
|
||||
echo "No Android device found and 'emulator' binary is not available." >&2
|
||||
echo " • Install the Android SDK emulator component, or" >&2
|
||||
echo " • Set ANDROID_HOME to your SDK root, or" >&2
|
||||
echo " • Start a device/emulator manually then retry with LAUNCH_AVD=0." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ">>> emulator binary: $EMULATOR_BIN"
|
||||
|
||||
# --- select AVD -----------------------------------------------------------
|
||||
if [ -z "$AVD_NAME" ]; then
|
||||
AVD_NAME="$("$EMULATOR_BIN" -list-avds 2>/dev/null | head -n 1 | tr -d '\r')"
|
||||
if [ -z "$AVD_NAME" ]; then
|
||||
echo "No AVDs found. Create one first, for example:" >&2
|
||||
echo " sdkmanager 'system-images;android-34;google_apis;x86_64'" >&2
|
||||
echo " avdmanager create avd -n Pixel_7_API_34 \\" >&2
|
||||
echo " -k 'system-images;android-34;google_apis;x86_64' --device 'pixel_7'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ">>> auto-selected AVD: $AVD_NAME"
|
||||
fi
|
||||
|
||||
# --- launch emulator -------------------------------------------------------
|
||||
EMULATOR_ARGS=( -avd "$AVD_NAME" -no-snapshot-load )
|
||||
[ "$AVD_HEADLESS" = "1" ] && EMULATOR_ARGS+=( -no-window -no-audio )
|
||||
# Split AVD_EXTRA_ARGS on whitespace only (disable glob expansion).
|
||||
set -f
|
||||
# shellcheck disable=SC2206
|
||||
[ -n "$AVD_EXTRA_ARGS" ] && EMULATOR_ARGS+=( $AVD_EXTRA_ARGS )
|
||||
set +f
|
||||
|
||||
echo ">>> launch emulator: $AVD_NAME"
|
||||
"$EMULATOR_BIN" "${EMULATOR_ARGS[@]}" > "$OUT_DIR/emulator.log" 2>&1 &
|
||||
_LAUNCHED_EMULATOR_PID=$!
|
||||
echo "$_LAUNCHED_EMULATOR_PID" > "$OUT_DIR/emulator.pid"
|
||||
echo " emulator PID: $_LAUNCHED_EMULATOR_PID"
|
||||
echo " emulator log: $OUT_DIR/emulator.log"
|
||||
|
||||
# --- wait for adb transport -----------------------------------------------
|
||||
# Poll adb get-state (≠ wait-for-device which blocks indefinitely) so we can
|
||||
# honour AVD_BOOT_TIMEOUT for the whole boot sequence.
|
||||
echo ">>> waiting for device to appear in adb (timeout: ${AVD_BOOT_TIMEOUT}s)"
|
||||
_ELAPSED=0
|
||||
while true; do
|
||||
_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
||||
if [ "$_STATE" = "device" ] || [ "$_STATE" = "offline" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
|
||||
echo "Device did not appear in adb within ${AVD_BOOT_TIMEOUT}s" >&2
|
||||
echo "emulator log:" >&2
|
||||
tail -20 "$OUT_DIR/emulator.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 3
|
||||
_ELAPSED=$(( _ELAPSED + 3 ))
|
||||
echo " ... ${_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s"
|
||||
done
|
||||
|
||||
# Capture emulator serial (emulator-5554 etc.) so all subsequent adb calls
|
||||
# target the right device when ADB_SERIAL was not set by the caller.
|
||||
if [ -z "${ADB_SERIAL:-}" ]; then
|
||||
_EMU_SERIAL="$(adb devices 2>/dev/null | awk '/^emulator-/{print $1; exit}' | tr -d '\r')"
|
||||
if [ -n "$_EMU_SERIAL" ]; then
|
||||
ADB_SERIAL="$_EMU_SERIAL"
|
||||
ADB=(adb -s "$ADB_SERIAL")
|
||||
echo ">>> detected emulator serial: $ADB_SERIAL"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- wait for full Android boot -------------------------------------------
|
||||
# adb get-state returning "device" means the transport is up, but the
|
||||
# Android framework may still be initialising. Poll sys.boot_completed.
|
||||
echo ">>> waiting for boot_completed (timeout: ${AVD_BOOT_TIMEOUT}s)"
|
||||
_BOOT_ELAPSED=0
|
||||
_BOOT_INTERVAL=5
|
||||
while true; do
|
||||
_BOOT="$("${ADB[@]}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
|
||||
if [ "$_BOOT" = "1" ]; then
|
||||
echo ">>> emulator boot complete"
|
||||
break
|
||||
fi
|
||||
if [ "$_BOOT_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
|
||||
echo "Emulator did not finish booting within ${AVD_BOOT_TIMEOUT}s" >&2
|
||||
echo "emulator log:" >&2
|
||||
tail -20 "$OUT_DIR/emulator.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep "$_BOOT_INTERVAL"
|
||||
_BOOT_ELAPSED=$(( _BOOT_ELAPSED + _BOOT_INTERVAL ))
|
||||
echo " ... ${_BOOT_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s (boot_completed='${_BOOT}')"
|
||||
done
|
||||
|
||||
# Dismiss the lock screen so later screencap shows the app, not the keyguard.
|
||||
"${ADB[@]}" shell input keyevent 82 2>/dev/null || true
|
||||
|
||||
# Final sanity check — device must be fully ready before we proceed.
|
||||
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
||||
if [ "$DEVICE_STATE" != "device" ]; then
|
||||
echo "Emulator is running but adb state is '${DEVICE_STATE:-unknown}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
{
|
||||
echo "adb_serial=${ADB_SERIAL:-default}"
|
||||
echo "package=$PACKAGE"
|
||||
echo "activity=$ACTIVITY"
|
||||
echo "device_state=$DEVICE_STATE"
|
||||
"${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' | sed 's/^/product_model=/'
|
||||
"${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' | sed 's/^/android_release=/'
|
||||
"${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' | sed 's/^/android_sdk=/'
|
||||
"${ADB[@]}" shell wm size 2>/dev/null | tr -d '\r' | sed 's/^/wm_size=/'
|
||||
"${ADB[@]}" shell wm density 2>/dev/null | tr -d '\r' | sed 's/^/wm_density=/'
|
||||
} > "$OUT_DIR/device.txt"
|
||||
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-before.txt" 2>&1 || true
|
||||
|
||||
if [ "$BUILD_APK" = "1" ]; then
|
||||
if [ -z "${ABIS:-}" ]; then
|
||||
DEVICE_ABI="$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')"
|
||||
case "$DEVICE_ABI" in
|
||||
x86_64|arm64-v8a|armeabi-v7a)
|
||||
export ABIS="$DEVICE_ABI"
|
||||
;;
|
||||
armeabi*)
|
||||
export ABIS="armeabi-v7a"
|
||||
;;
|
||||
*)
|
||||
echo "Could not map device ABI '$DEVICE_ABI'; using build script default ABIS." >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
echo ">>> build Android APK${ABIS:+ (ABIS=$ABIS)}"
|
||||
scripts/build_android_apk.sh
|
||||
fi
|
||||
|
||||
if [ "$INSTALL_APK" = "1" ]; then
|
||||
[ -f "$APK_PATH" ] || {
|
||||
echo "APK not found: $APK_PATH" >&2
|
||||
echo "Set APK_PATH or run with BUILD_APK=1." >&2
|
||||
exit 1
|
||||
}
|
||||
ls -lh "$APK_PATH" > "$OUT_DIR/apk.txt"
|
||||
echo ">>> install $APK_PATH"
|
||||
if ! "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install.txt" 2>&1; then
|
||||
if [ "$RESET_ON_SIGNATURE_MISMATCH" = "1" ] && grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE" "$OUT_DIR/adb-install.txt"; then
|
||||
echo ">>> signature mismatch; uninstalling $PACKAGE and retrying install"
|
||||
"${ADB[@]}" uninstall "$PACKAGE" > "$OUT_DIR/adb-uninstall-before-retry.txt" 2>&1 || true
|
||||
if "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install-retry.txt" 2>&1; then
|
||||
cat "$OUT_DIR/adb-install-retry.txt" >> "$OUT_DIR/adb-install.txt"
|
||||
else
|
||||
cat "$OUT_DIR/adb-install.txt" >&2
|
||||
cat "$OUT_DIR/adb-install-retry.txt" >&2
|
||||
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
|
||||
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
|
||||
echo "APK install retry failed. Diagnostics saved in $OUT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
cat "$OUT_DIR/adb-install.txt" >&2
|
||||
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
|
||||
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
|
||||
echo "APK install failed. Diagnostics saved in $OUT_DIR" >&2
|
||||
echo "If the package is already installed and you only need launch/log checks, retry with INSTALL_APK=0." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FORCE_STOP" = "1" ]; then
|
||||
echo ">>> force-stop $PACKAGE"
|
||||
"${ADB[@]}" shell am force-stop "$PACKAGE" || true
|
||||
fi
|
||||
|
||||
echo ">>> clear logcat"
|
||||
"${ADB[@]}" logcat -c
|
||||
|
||||
if [ "$LAUNCH_APP" = "1" ]; then
|
||||
echo ">>> launch $PACKAGE/$ACTIVITY"
|
||||
"${ADB[@]}" shell am start -n "$PACKAGE/$ACTIVITY" > "$OUT_DIR/am-start.txt"
|
||||
fi
|
||||
|
||||
echo ">>> wait ${WAIT_SECS}s"
|
||||
sleep "$WAIT_SECS"
|
||||
|
||||
PID="$("${ADB[@]}" shell pidof "$PACKAGE" | tr -d '\r' || true)"
|
||||
if [ -z "$PID" ]; then
|
||||
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt" || true
|
||||
echo "app process is not running after launch: $PACKAGE" >&2
|
||||
echo "logcat saved to $OUT_DIR/logcat.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$PID" > "$OUT_DIR/pid.txt"
|
||||
|
||||
if [ "$CAPTURE_SCREENSHOT" = "1" ]; then
|
||||
echo ">>> capture screenshot"
|
||||
"${ADB[@]}" shell screencap -p "$REMOTE_SCREENSHOT"
|
||||
"${ADB[@]}" pull "$REMOTE_SCREENSHOT" "$OUT_DIR/launch.png" >/dev/null
|
||||
"${ADB[@]}" shell rm -f "$REMOTE_SCREENSHOT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo ">>> capture logcat"
|
||||
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt"
|
||||
grep -iE "panic|fatal|jni|native crash|\bANR\b|exception|error|warn|keystore|safe_area" "$OUT_DIR/logcat.txt" > "$OUT_DIR/log-summary.txt" || true
|
||||
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after.txt" 2>&1 || true
|
||||
|
||||
# Fatal patterns only. Avoid matching generic "error" because Android logs are
|
||||
# noisy and many non-fatal framework lines contain that word.
|
||||
if grep -iE "fatal exception|jni detected error|native crash|signal [0-9]+|ANR in|Application Not Responding|Input dispatching timed out|thread exiting with uncaught exception|panicked at" "$OUT_DIR/logcat.txt"; then
|
||||
echo "Android smoke test found fatal log output" >&2
|
||||
echo "Artifacts saved in $OUT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> Android smoke test passed"
|
||||
echo "Artifacts saved in $OUT_DIR"
|
||||
+108
-16
@@ -6,11 +6,15 @@
|
||||
# ndk-build crate that we couldn't isolate; running each Android toolchain
|
||||
# step explicitly gives us a debuggable pipeline.
|
||||
#
|
||||
# Required environment:
|
||||
# ANDROID_HOME Path to Android SDK root
|
||||
# ANDROID_NDK_HOME Path to the specific NDK version
|
||||
# BUILD_TOOLS_VERSION e.g. "34.0.0"
|
||||
# PLATFORM e.g. "android-34"
|
||||
# Environment:
|
||||
# ANDROID_HOME Path to Android SDK root. If unset, common SDK
|
||||
# locations such as ~/Android/Sdk are tried.
|
||||
# ANDROID_NDK_HOME Path to the specific NDK version. If unset, the
|
||||
# newest $ANDROID_HOME/ndk/* directory is used.
|
||||
# BUILD_TOOLS_VERSION e.g. "34.0.0". If unset, newest installed build-tools
|
||||
# version is used.
|
||||
# PLATFORM e.g. "android-34". If unset, newest installed
|
||||
# $ANDROID_HOME/platforms/android-* platform is used.
|
||||
#
|
||||
# Optional environment:
|
||||
# PROFILE "debug" (default) | "release"
|
||||
@@ -19,7 +23,8 @@
|
||||
# fit the runner's disk budget — a full three-ABI
|
||||
# debug build can exceed 25 GB of target/ output.
|
||||
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
|
||||
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
|
||||
# STRIP_NATIVE_LIBS 1 to strip .so files before packaging (default: 1)
|
||||
# KEYSTORE Path to keystore for signing (default: target/android/debug.keystore)
|
||||
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
||||
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
||||
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
|
||||
@@ -28,18 +33,63 @@
|
||||
# $APK_OUT Signed, zipaligned APK
|
||||
set -euo pipefail
|
||||
|
||||
: "${ANDROID_HOME:?ANDROID_HOME must be set}"
|
||||
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
|
||||
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
|
||||
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
|
||||
infer_latest_dir_name() {
|
||||
local pattern="$1"
|
||||
local latest=""
|
||||
shopt -s nullglob
|
||||
local dirs=( $pattern )
|
||||
shopt -u nullglob
|
||||
if [ ${#dirs[@]} -gt 0 ]; then
|
||||
latest="$(printf '%s\n' "${dirs[@]}" | sort -V | tail -n 1)"
|
||||
basename "$latest"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "${ANDROID_HOME:-}" ]; then
|
||||
for candidate in "$HOME/Android/Sdk" "$HOME/Library/Android/sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
ANDROID_HOME="$candidate"
|
||||
export ANDROID_HOME
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
: "${ANDROID_HOME:?ANDROID_HOME must be set or discoverable under a common SDK path}"
|
||||
|
||||
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||
NDK_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/ndk/*")"
|
||||
if [ -n "$NDK_VERSION" ]; then
|
||||
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
export ANDROID_NDK_HOME
|
||||
fi
|
||||
fi
|
||||
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set or discoverable under ANDROID_HOME/ndk}"
|
||||
|
||||
if [ -z "${BUILD_TOOLS_VERSION:-}" ]; then
|
||||
BUILD_TOOLS_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/build-tools/*")"
|
||||
export BUILD_TOOLS_VERSION
|
||||
fi
|
||||
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set or discoverable under ANDROID_HOME/build-tools}"
|
||||
|
||||
if [ -z "${PLATFORM:-}" ]; then
|
||||
PLATFORM="$(infer_latest_dir_name "$ANDROID_HOME/platforms/android-*")"
|
||||
export PLATFORM
|
||||
fi
|
||||
: "${PLATFORM:?PLATFORM must be set or discoverable under ANDROID_HOME/platforms}"
|
||||
|
||||
PROFILE="${PROFILE:-debug}"
|
||||
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
||||
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
|
||||
STRIP_NATIVE_LIBS="${STRIP_NATIVE_LIBS:-1}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo ">>> Android SDK: $ANDROID_HOME"
|
||||
echo ">>> Android NDK: $ANDROID_NDK_HOME"
|
||||
echo ">>> Build tools: $BUILD_TOOLS_VERSION"
|
||||
echo ">>> Platform: $PLATFORM"
|
||||
|
||||
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
|
||||
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
|
||||
MANIFEST="solitaire_app/android/AndroidManifest.xml"
|
||||
@@ -69,6 +119,24 @@ fi
|
||||
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
|
||||
cargo ndk "${CARGO_NDK_ARGS[@]}"
|
||||
|
||||
if [ "$STRIP_NATIVE_LIBS" = "1" ]; then
|
||||
LLVM_STRIP=""
|
||||
shopt -s nullglob
|
||||
STRIP_CANDIDATES=( "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/llvm-strip )
|
||||
shopt -u nullglob
|
||||
if [ ${#STRIP_CANDIDATES[@]} -gt 0 ]; then
|
||||
LLVM_STRIP="${STRIP_CANDIDATES[0]}"
|
||||
fi
|
||||
if [ -z "$LLVM_STRIP" ]; then
|
||||
echo "llvm-strip not found under ANDROID_NDK_HOME; native libraries will remain unstripped" >&2
|
||||
else
|
||||
echo ">>> strip native libraries with $LLVM_STRIP"
|
||||
find "$STAGING/lib" -name '*.so' -print0 | while IFS= read -r -d '' so; do
|
||||
"$LLVM_STRIP" --strip-debug "$so"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. compile + link resources and manifest ------------------------------
|
||||
if [ -d "$RES_DIR" ]; then
|
||||
echo ">>> aapt2 compile resources"
|
||||
@@ -120,11 +188,15 @@ rm -f "$STAGING/app-unsigned.apk"
|
||||
|
||||
# --- 5. sign ---------------------------------------------------------------
|
||||
if [ -z "${KEYSTORE:-}" ]; then
|
||||
# Generate a deterministic debug keystore on the fly.
|
||||
KEYSTORE="$STAGING/debug.keystore"
|
||||
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
|
||||
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
|
||||
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
||||
KEYSTORE="target/android/debug.keystore"
|
||||
fi
|
||||
|
||||
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
|
||||
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
|
||||
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
||||
|
||||
if [ ! -f "$KEYSTORE" ]; then
|
||||
mkdir -p "$(dirname "$KEYSTORE")"
|
||||
echo ">>> generating debug keystore at $KEYSTORE"
|
||||
keytool -genkeypair -v \
|
||||
-keystore "$KEYSTORE" \
|
||||
@@ -141,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
||||
|
||||
mkdir -p "$(dirname "$APK_OUT")"
|
||||
echo ">>> apksigner sign -> $APK_OUT"
|
||||
# Sign the schemes explicitly instead of relying on apksigner's auto behaviour.
|
||||
# Left to "auto", this pipeline produced an APK carrying invalid v1 (JAR)
|
||||
# signature files (META-INF/*.SF/.RSA present but failing v1 verification).
|
||||
# Android installs it fine via v2/v3, but Obtainium parses the APK's legacy v1
|
||||
# certificate at install time, gets an empty cert list, and crashes with
|
||||
# "RangeError (length): Invalid value: valid value range is empty: 0".
|
||||
# minSdk is 26 (solitaire_app/android/AndroidManifest.xml), so v1/JAR signing is
|
||||
# not needed at all — disable it and ship a clean v2+v3 signature, matching what
|
||||
# modern Android tooling produces for minSdk >= 24.
|
||||
"$BT/apksigner" sign \
|
||||
--ks "$KEYSTORE" \
|
||||
--ks-pass "pass:$KEYSTORE_PASS" \
|
||||
--ks-key-alias "$KEY_ALIAS" \
|
||||
--key-pass "pass:$KEY_PASS" \
|
||||
--min-sdk-version 26 \
|
||||
--v1-signing-enabled false \
|
||||
--v2-signing-enabled true \
|
||||
--v3-signing-enabled true \
|
||||
--out "$APK_OUT" \
|
||||
"$STAGING/app-aligned.apk"
|
||||
|
||||
echo ">>> verify"
|
||||
"$BT/apksigner" verify --verbose "$APK_OUT"
|
||||
"$BT/apksigner" verify --min-sdk-version 26 --verbose "$APK_OUT"
|
||||
|
||||
# Guard: no leftover v1/JAR signature files may remain — their presence (valid or
|
||||
# not) is what tripped Obtainium. Fail the build if any slipped through.
|
||||
if unzip -l "$APK_OUT" 2>/dev/null | grep -qiE 'META-INF/.*\.(SF|RSA|DSA|EC)$'; then
|
||||
echo "ERROR: APK still contains v1/JAR signature files; expected v2+v3 only" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> done: $APK_OUT"
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update Quaternions registry dependencies and run the full safety gate.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
|
||||
#
|
||||
# Example:
|
||||
# scripts/update_quaternions_deps.sh 0.3.1 0.4.1
|
||||
#
|
||||
# This script updates Cargo.lock to the requested versions (within the semver
|
||||
# ranges already declared in Cargo.toml), then runs the project's required
|
||||
# verification steps plus deterministic replay checks.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: $0 <klondike_version> <card_game_version>"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
KLONDIKE_VERSION="$1"
|
||||
CARD_GAME_VERSION="$2"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo ">>> Quaternions registry:"
|
||||
echo " https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
echo
|
||||
echo ">>> Review upstream release notes / changelogs before proceeding:"
|
||||
echo " - https://git.aleshym.co/Quaternions/card_game"
|
||||
echo " - https://git.aleshym.co/Quaternions/klondike"
|
||||
echo
|
||||
|
||||
echo ">>> Updating lockfile to klondike=$KLONDIKE_VERSION card_game=$CARD_GAME_VERSION"
|
||||
cargo update -p klondike --precise "$KLONDIKE_VERSION"
|
||||
cargo update -p card_game --precise "$CARD_GAME_VERSION"
|
||||
|
||||
echo ">>> Verifying dependency graph"
|
||||
cargo tree -p solitaire_core --depth 2 | cat
|
||||
|
||||
echo ">>> Running workspace tests"
|
||||
cargo test --workspace
|
||||
|
||||
echo ">>> Running workspace clippy"
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
echo ">>> Running deterministic replay / debug-api smoke checks"
|
||||
cargo test -p solitaire_wasm debug_snapshot_exposes_replayable_seed_and_history -- --exact
|
||||
cargo test -p solitaire_wasm debug_api_autonomous_seed_batch_smoke -- --exact
|
||||
|
||||
echo ">>> Quaternions dependency upgrade gate passed"
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
||||
#
|
||||
# Polls recent workflow runs and prints a compact status block each cycle.
|
||||
# Stops when the newest docker-build (the deploy) has completed — the wasm
|
||||
# is built inside that image build, so no other workflow gates the deploy.
|
||||
#
|
||||
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
||||
# token is read from ~/.config/tea/config.yml (never printed).
|
||||
set -uo pipefail
|
||||
|
||||
REPO="funman300/Ferrous-Solitaire"
|
||||
BASE="https://git.aleshym.co/api/v1/repos/${REPO}"
|
||||
INTERVAL="${1:-20}"
|
||||
CFG="${HOME}/.config/tea/config.yml"
|
||||
|
||||
TOKEN="$(grep -E '^[[:space:]]*token:' "$CFG" | head -1 | sed -E 's/.*token:[[:space:]]*//' | tr -d '"'\'' ')"
|
||||
[ -z "$TOKEN" ] && { echo "error: no token in $CFG" >&2; exit 1; }
|
||||
|
||||
echo "── watching ${REPO} deploy (poll ${INTERVAL}s, Ctrl-C to stop) ──"
|
||||
|
||||
while :; do
|
||||
json="$(curl -s --max-time 20 -H "Authorization: token ${TOKEN}" "${BASE}/actions/runs?limit=6")"
|
||||
# Print rows + emit the deploy state on the last line (parsed below).
|
||||
# JSON is passed via env var because `python3 -` reads its program from the
|
||||
# heredoc on stdin, so stdin can't also carry the data.
|
||||
out="$(JSON_DATA="$json" python3 - <<'PY'
|
||||
import os, sys, json, datetime
|
||||
now = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
raw = os.environ.get("JSON_DATA", "").strip()
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
print("[%s] (api unavailable, retrying)" % now)
|
||||
print("STATE=DEPLOYING")
|
||||
sys.exit(0)
|
||||
runs = d.get("workflow_runs", [])[:6]
|
||||
icons = {("completed","success"):"OK ", ("completed","failure"):"FAIL",
|
||||
("completed","cancelled"):"CXL "}
|
||||
def ic(s, c):
|
||||
if s == "queued": return "queue"
|
||||
if s == "in_progress": return "run.."
|
||||
return icons.get((s, c), s or "?")
|
||||
print("[%s]" % datetime.datetime.now().strftime("%H:%M:%S"))
|
||||
for r in runs:
|
||||
wf = str(r.get("path","")).split("@")[0].split("/")[-1].replace(".yml","")
|
||||
print(" %-5s %-5s %-7s %-18s %s/%s" % (
|
||||
ic(r.get("status"), r.get("conclusion")),
|
||||
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
||||
r.get("status"), r.get("conclusion")))
|
||||
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
||||
top = db[0] if db else None
|
||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success")
|
||||
print("STATE=%s" % ("LIVE" if live else "DEPLOYING"))
|
||||
PY
|
||||
)"
|
||||
echo "$out" | grep -v '^STATE='
|
||||
if echo "$out" | grep -q '^STATE=LIVE'; then
|
||||
echo ""
|
||||
echo "DEPLOY LIVE — newest docker-build succeeded."
|
||||
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
||||
break
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@@ -22,6 +22,13 @@ bevy = { workspace = true }
|
||||
solitaire_engine = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
|
||||
# Android-only: the entry point reconstructs the raw `JavaVM` / activity
|
||||
# handles and registers the safe `solitaire_data::android_jni` bridge. This
|
||||
# is the one crate in the workspace that performs `unsafe` FFI, so it is also
|
||||
# the only one that depends on `jni` directly at the app layer.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = { workspace = true }
|
||||
|
||||
# Desktop-only deps. `keyring`'s default-store init only matters on
|
||||
# platforms with a real keychain backend (Linux Secret Service,
|
||||
# macOS Keychain, Windows Credential Store), and its transitive
|
||||
@@ -99,3 +106,18 @@ icon = "@mipmap/ic_launcher"
|
||||
# in portrait orientation. Remove (or add a landscape layout) before
|
||||
# enabling auto-rotate.
|
||||
orientation = "portrait"
|
||||
|
||||
# `solitaire_app` is the one crate that cannot inherit the workspace
|
||||
# `forbid(unsafe_code)`: as the Android cdylib it must export the
|
||||
# `#[unsafe(no_mangle)]` entry point and reconstruct the raw JNI handles
|
||||
# there (a `no_mangle` symbol cannot live in a dependency rlib). It mirrors
|
||||
# the workspace lints but at `deny`, so the two `#[allow(unsafe_code)]`
|
||||
# scopes in the Android entry point are the only unsafe in the whole tree.
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
single_use_lifetimes = "warn"
|
||||
trivial_casts = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
unused_qualifications = "warn"
|
||||
variant_size_differences = "warn"
|
||||
unexpected_cfgs = "warn"
|
||||
|
||||
+68
-17
@@ -25,7 +25,10 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
use bevy::winit::WinitWindows;
|
||||
#[cfg(target_os = "android")]
|
||||
use bevy::winit::{UpdateMode, WinitSettings};
|
||||
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
|
||||
use solitaire_data::{
|
||||
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
|
||||
settings_file_path,
|
||||
};
|
||||
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
||||
|
||||
fn load_settings() -> Settings {
|
||||
@@ -34,11 +37,6 @@ fn load_settings() -> Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build the Bevy app without entering the event loop.
|
||||
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
|
||||
build_app_with_settings(load_settings(), sync_provider)
|
||||
}
|
||||
|
||||
/// App entry point — configures runtime services, builds, and runs the app.
|
||||
///
|
||||
/// Called from both the desktop `bin` target's `main` shim and (on
|
||||
@@ -49,6 +47,12 @@ pub fn run() {
|
||||
// and any debugger attached still sees the panic).
|
||||
install_crash_log_hook();
|
||||
|
||||
// Remove any *.tmp files left behind by a crash between an atomic write
|
||||
// and its rename. Safe to call unconditionally — missing data dir is a
|
||||
// no-op. Must run before GamePlugin loads saved state so orphaned files
|
||||
// don't accumulate across launches.
|
||||
let _ = cleanup_orphaned_tmp_files();
|
||||
|
||||
// Initialise the platform keyring store before any token operations.
|
||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||
@@ -56,10 +60,9 @@ pub fn run() {
|
||||
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||
//
|
||||
// Android: `keyring` isn't compiled in (its `rpassword` transitive
|
||||
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
|
||||
// ships an Android stub that returns KeychainUnavailable for every
|
||||
// call — the runtime behaviour is "session login required each launch"
|
||||
// until we wire Android Keystore via JNI in the Phase-Android round.
|
||||
// pulls a libc symbol Android's bionic doesn't expose). The Android
|
||||
// auth-token path uses Android Keystore via JNI; `android_main` passes
|
||||
// the process JavaVM pointer into `solitaire_data` before `run()`.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if let Err(e) = keyring::use_native_store(true) {
|
||||
eprintln!(
|
||||
@@ -136,7 +139,7 @@ fn build_app_with_settings(
|
||||
// Android windows always fill the screen; max_width/max_height
|
||||
// default to 0.0, which panics Bevy's clamp when min > max.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
resize_constraints: WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
..default()
|
||||
@@ -158,7 +161,7 @@ fn build_app_with_settings(
|
||||
// default makes it walk *out* of the APK's assets root and
|
||||
// all loads fail silently — which is what produced the
|
||||
// solid-red card-back fallback in the v0.22.3 screenshot.
|
||||
.set(bevy::asset::AssetPlugin {
|
||||
.set(AssetPlugin {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
file_path: "../assets".to_string(),
|
||||
..default()
|
||||
@@ -172,13 +175,16 @@ fn build_app_with_settings(
|
||||
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
||||
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
||||
//
|
||||
// The focused mode stays Continuous so that card-slide animations remain
|
||||
// smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the
|
||||
// display refresh rate (~60 Hz) when foregrounded, which already prevents
|
||||
// the GPU from spinning at 200+ fps between vsync intervals.
|
||||
// focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when
|
||||
// an event arrives (touch, resize, etc.) or an animation system writes
|
||||
// RequestRedraw. The 100 ms ceiling is a fallback that ensures the game
|
||||
// timer ticks at least 10×/s even with no input, while keeping the GPU
|
||||
// completely idle between frames when the board is static.
|
||||
// PresentMode::AutoVsync (set above) still caps the GPU at the display
|
||||
// refresh rate when frames do render.
|
||||
#[cfg(target_os = "android")]
|
||||
app.insert_resource(WinitSettings {
|
||||
focused_mode: UpdateMode::Continuous,
|
||||
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
|
||||
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
||||
});
|
||||
|
||||
@@ -352,12 +358,57 @@ fn set_window_icon(
|
||||
/// works on a function named `main`; our shared entry point is `run`, so
|
||||
/// we emit the equivalent expansion manually.
|
||||
#[cfg(target_os = "android")]
|
||||
#[allow(unsafe_code)]
|
||||
#[unsafe(no_mangle)]
|
||||
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
||||
if let Err(e) = init_android_jni(&android_app) {
|
||||
eprintln!("warn: could not initialise Android JNI bridge ({e})");
|
||||
}
|
||||
let _ = bevy::android::ANDROID_APP.set(android_app);
|
||||
run();
|
||||
}
|
||||
|
||||
/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
|
||||
/// Android runtime and registers safe wrappers with `solitaire_data`.
|
||||
///
|
||||
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
|
||||
/// reconstruction. Every other crate consumes the safe
|
||||
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
|
||||
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
|
||||
/// function and the `#[unsafe(no_mangle)]` entry point above.
|
||||
#[cfg(target_os = "android")]
|
||||
#[allow(unsafe_code)]
|
||||
fn init_android_jni(
|
||||
android_app: &bevy::android::android_activity::AndroidApp,
|
||||
) -> Result<(), String> {
|
||||
use jni::JavaVM;
|
||||
use jni::objects::JObject;
|
||||
|
||||
let vm_ptr = android_app.vm_as_ptr();
|
||||
if vm_ptr.is_null() {
|
||||
return Err("JavaVM pointer is null".into());
|
||||
}
|
||||
// SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
|
||||
// Android runtime; it is valid for the lifetime of the process.
|
||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;
|
||||
|
||||
let env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
|
||||
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
|
||||
// valid for the lifetime of the process. Promote it to a global reference
|
||||
// so the safe bridge can hand it to any thread.
|
||||
let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
|
||||
let activity_ref = env
|
||||
.new_global_ref(&activity)
|
||||
.map_err(|e| format!("activity global ref: {e}"))?;
|
||||
|
||||
solitaire_data::android_jni::set_jvm(vm);
|
||||
solitaire_data::android_jni::set_activity(activity_ref);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
|
||||
@@ -30,3 +30,6 @@ path = "src/bin/gen_seeds.rs"
|
||||
[[bin]]
|
||||
name = "gen_difficulty_seeds"
|
||||
path = "src/bin/gen_difficulty_seeds.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
||||
//! `solitaire_data/src/difficulty_seeds.rs`.
|
||||
//!
|
||||
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
|
||||
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
|
||||
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
|
||||
//! provably-winnable seeds).
|
||||
//! A seed's tier is determined by the **smallest** solve budget at which it is
|
||||
//! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget
|
||||
//! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded
|
||||
//! (we only emit provably-winnable seeds).
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
@@ -19,12 +19,12 @@
|
||||
//! --per-tier Seeds to emit per tier (default 40)
|
||||
//! --help Print this message
|
||||
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||
// whose budget proves it Winnable.
|
||||
const BUDGETS: &[(&str, u64, usize)] = &[
|
||||
const BUDGETS: &[(&str, u64, u64)] = &[
|
||||
("Easy", 1_000, 1_000),
|
||||
("Medium", 5_000, 5_000),
|
||||
("Hard", 25_000, 25_000),
|
||||
@@ -74,7 +74,7 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let draw_mode = DrawMode::DrawOne;
|
||||
let draw_mode = DrawStockConfig::DrawOne;
|
||||
let num_tiers = BUDGETS.len();
|
||||
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||
let mut tried: u64 = 0;
|
||||
@@ -99,12 +99,8 @@ fn main() {
|
||||
if buckets[i].len() >= per_tier {
|
||||
continue;
|
||||
}
|
||||
let cfg = SolverConfig {
|
||||
move_budget,
|
||||
state_budget,
|
||||
};
|
||||
match try_solve(seed, draw_mode, &cfg) {
|
||||
SolverResult::Winnable => {
|
||||
match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) {
|
||||
Ok(Some(_)) => {
|
||||
buckets[i].push(seed);
|
||||
eprintln!(
|
||||
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
||||
@@ -113,13 +109,13 @@ fn main() {
|
||||
);
|
||||
break 'tier; // assign to the cheapest tier that proves it winnable
|
||||
}
|
||||
SolverResult::Unwinnable => {
|
||||
Ok(None) => {
|
||||
// Definitely unsolvable — skip all remaining tiers.
|
||||
break 'tier;
|
||||
}
|
||||
SolverResult::Inconclusive => {
|
||||
Err(_) => {
|
||||
// Budget exhausted without proof — try the next larger tier.
|
||||
// If this is the last tier, the seed is discarded (Inconclusive
|
||||
// If this is the last tier, the seed is discarded (inconclusive
|
||||
// at max budget means "probably but not provably winnable").
|
||||
if i == num_tiers - 1 {
|
||||
break 'tier;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
||||
//!
|
||||
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
||||
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
|
||||
//! collects only those proven winnable (`Ok(Some(_))`; inconclusive is
|
||||
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
||||
//! pasting into `solitaire_data/src/challenge.rs`.
|
||||
//!
|
||||
@@ -17,8 +17,9 @@
|
||||
//! --count Number of Winnable seeds to emit (default 75)
|
||||
//! --help Print this message
|
||||
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
@@ -67,8 +68,7 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let cfg = SolverConfig::default();
|
||||
let draw_mode = DrawMode::DrawOne;
|
||||
let draw_mode = DrawStockConfig::DrawOne;
|
||||
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||
let mut tried: u64 = 0;
|
||||
let mut seed = start;
|
||||
@@ -77,7 +77,15 @@ fn main() {
|
||||
|
||||
while found.len() < count {
|
||||
tried += 1;
|
||||
if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) {
|
||||
if matches!(
|
||||
GameState::solve_fresh_deal(
|
||||
seed,
|
||||
draw_mode,
|
||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||
DEFAULT_SOLVE_STATES_BUDGET
|
||||
),
|
||||
Ok(Some(_))
|
||||
) {
|
||||
found.push(seed);
|
||||
eprintln!(
|
||||
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
||||
|
||||
@@ -4,7 +4,23 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-support = []
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
|
||||
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
|
||||
# against the same crate version as `klondike::Rng` and Spider deals go
|
||||
# through the identical shuffle stack as Klondike deals.
|
||||
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Card suit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Suit {
|
||||
Clubs,
|
||||
Diamonds,
|
||||
Hearts,
|
||||
Spades,
|
||||
}
|
||||
|
||||
impl Suit {
|
||||
/// All four suits in declaration order.
|
||||
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
|
||||
|
||||
/// Returns `true` for red suits (Diamonds, Hearts).
|
||||
pub fn is_red(self) -> bool {
|
||||
matches!(self, Suit::Diamonds | Suit::Hearts)
|
||||
}
|
||||
|
||||
/// Returns `true` for black suits (Clubs, Spades).
|
||||
pub fn is_black(self) -> bool {
|
||||
!self.is_red()
|
||||
}
|
||||
}
|
||||
|
||||
/// Card rank, Ace through King.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Rank {
|
||||
Ace = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6,
|
||||
Seven = 7,
|
||||
Eight = 8,
|
||||
Nine = 9,
|
||||
Ten = 10,
|
||||
Jack = 11,
|
||||
Queen = 12,
|
||||
King = 13,
|
||||
}
|
||||
|
||||
impl Rank {
|
||||
/// All thirteen ranks in ascending order.
|
||||
pub const RANKS: [Self; 13] = [
|
||||
Self::Ace,
|
||||
Self::Two,
|
||||
Self::Three,
|
||||
Self::Four,
|
||||
Self::Five,
|
||||
Self::Six,
|
||||
Self::Seven,
|
||||
Self::Eight,
|
||||
Self::Nine,
|
||||
Self::Ten,
|
||||
Self::Jack,
|
||||
Self::Queen,
|
||||
Self::King,
|
||||
];
|
||||
|
||||
/// Numeric value: Ace = 1, King = 13.
|
||||
pub fn value(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
const fn new(n: u8) -> Option<Self> {
|
||||
match n {
|
||||
1 => Some(Self::Ace),
|
||||
2 => Some(Self::Two),
|
||||
3 => Some(Self::Three),
|
||||
4 => Some(Self::Four),
|
||||
5 => Some(Self::Five),
|
||||
6 => Some(Self::Six),
|
||||
7 => Some(Self::Seven),
|
||||
8 => Some(Self::Eight),
|
||||
9 => Some(Self::Nine),
|
||||
10 => Some(Self::Ten),
|
||||
11 => Some(Self::Jack),
|
||||
12 => Some(Self::Queen),
|
||||
13 => Some(Self::King),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
|
||||
pub const fn checked_add(self, n: u8) -> Option<Self> {
|
||||
Self::new((self as u8).saturating_add(n))
|
||||
}
|
||||
|
||||
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
|
||||
pub const fn checked_sub(self, n: u8) -> Option<Self> {
|
||||
match (self as u8).checked_sub(n) {
|
||||
Some(v) => Self::new(v),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single playing card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
/// Unique identifier for this card within the deal. Stable across moves and undo.
|
||||
pub id: u32,
|
||||
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
|
||||
pub suit: Suit,
|
||||
/// The card's rank (Ace through King).
|
||||
pub rank: Rank,
|
||||
/// Whether the card is visible to the player. Face-down cards may not be moved.
|
||||
pub face_up: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rank_values_are_sequential() {
|
||||
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||
assert_eq!(r.value(), (i + 1) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_as_u8_matches_value() {
|
||||
for r in Rank::RANKS {
|
||||
assert_eq!(r as u8, r.value());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_checked_add_boundary() {
|
||||
assert_eq!(Rank::King.checked_add(1), None);
|
||||
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
|
||||
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
|
||||
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_checked_sub_boundary() {
|
||||
assert_eq!(Rank::Ace.checked_sub(1), None);
|
||||
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
|
||||
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
|
||||
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_suits_contains_all_four() {
|
||||
assert_eq!(Suit::SUITS.len(), 4);
|
||||
assert!(Suit::SUITS.contains(&Suit::Clubs));
|
||||
assert!(Suit::SUITS.contains(&Suit::Diamonds));
|
||||
assert!(Suit::SUITS.contains(&Suit::Hearts));
|
||||
assert!(Suit::SUITS.contains(&Suit::Spades));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_red_and_black_are_complementary() {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert_ne!(
|
||||
suit.is_red(),
|
||||
suit.is_black(),
|
||||
"{suit:?} must be exactly one of red/black"
|
||||
);
|
||||
}
|
||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
use crate::pile::{Pile, PileType};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{SeedableRng, seq::SliceRandom};
|
||||
|
||||
const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
const ALL_RANKS: [Rank; 13] = [
|
||||
Rank::Ace,
|
||||
Rank::Two,
|
||||
Rank::Three,
|
||||
Rank::Four,
|
||||
Rank::Five,
|
||||
Rank::Six,
|
||||
Rank::Seven,
|
||||
Rank::Eight,
|
||||
Rank::Nine,
|
||||
Rank::Ten,
|
||||
Rank::Jack,
|
||||
Rank::Queen,
|
||||
Rank::King,
|
||||
];
|
||||
|
||||
/// A standard 52-card deck.
|
||||
pub struct Deck {
|
||||
/// All 52 cards in the deck, in deal order.
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Creates an unshuffled deck with all 52 unique cards (id 0–51).
|
||||
pub fn new() -> Self {
|
||||
let mut cards = Vec::with_capacity(52);
|
||||
let mut id = 0u32;
|
||||
for &suit in &ALL_SUITS {
|
||||
for &rank in &ALL_RANKS {
|
||||
cards.push(Card {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up: false,
|
||||
});
|
||||
id += 1;
|
||||
}
|
||||
}
|
||||
Self { cards }
|
||||
}
|
||||
|
||||
/// Shuffles the deck in-place using Fisher-Yates with a seeded `StdRng`.
|
||||
/// The same seed always produces the same order on any platform.
|
||||
pub fn shuffle(&mut self, seed: u64) {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
self.cards.shuffle(&mut rng);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Deck {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deals a standard Klondike layout from a pre-shuffled deck.
|
||||
///
|
||||
/// Returns 7 tableau piles and the remaining stock pile.
|
||||
/// Column `i` contains `i + 1` cards; only the top card is face-up.
|
||||
/// Stock receives the remaining 24 cards, all face-down.
|
||||
pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) {
|
||||
debug_assert_eq!(
|
||||
deck.cards.len(),
|
||||
52,
|
||||
"deal_klondike requires a full 52-card deck"
|
||||
);
|
||||
let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i)));
|
||||
// Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded.
|
||||
let mut idx = 0usize;
|
||||
|
||||
for (col, pile) in tableau.iter_mut().enumerate() {
|
||||
for row in 0..=col {
|
||||
let mut card = deck.cards[idx].clone();
|
||||
card.face_up = row == col;
|
||||
pile.cards.push(card);
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stock = Pile::new(PileType::Stock);
|
||||
stock.cards.extend(deck.cards.into_iter().skip(idx));
|
||||
(tableau, stock)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deck_new_has_52_cards() {
|
||||
assert_eq!(Deck::new().cards.len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_new_has_unique_ids() {
|
||||
let deck = Deck::new();
|
||||
let mut ids: Vec<u32> = deck.cards.iter().map(|c| c.id).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_new_has_all_suits_and_ranks() {
|
||||
let deck = Deck::new();
|
||||
for suit in ALL_SUITS {
|
||||
for rank in ALL_RANKS {
|
||||
assert!(
|
||||
deck.cards.iter().any(|c| c.suit == suit && c.rank == rank),
|
||||
"missing {rank:?} {suit:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_produces_same_order() {
|
||||
let mut d1 = Deck::new();
|
||||
d1.shuffle(42);
|
||||
let mut d2 = Deck::new();
|
||||
d2.shuffle(42);
|
||||
assert_eq!(d1.cards, d2.cards);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_produce_different_orders() {
|
||||
let mut d1 = Deck::new();
|
||||
d1.shuffle(1);
|
||||
let mut d2 = Deck::new();
|
||||
d2.shuffle(2);
|
||||
assert_ne!(d1.cards, d2.cards);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_correct_tableau_sizes() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(0);
|
||||
let (tableau, stock) = deal_klondike(deck);
|
||||
for (i, pile) in tableau.iter().enumerate() {
|
||||
assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size");
|
||||
}
|
||||
assert_eq!(stock.cards.len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_top_cards_are_face_up() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(0);
|
||||
let (tableau, _) = deal_klondike(deck);
|
||||
for pile in &tableau {
|
||||
assert!(pile.cards.last().unwrap().face_up);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_non_top_cards_are_face_down() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(0);
|
||||
let (tableau, _) = deal_klondike(deck);
|
||||
for pile in &tableau {
|
||||
for card in &pile.cards[..pile.cards.len().saturating_sub(1)] {
|
||||
assert!(!card.face_up);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_stock_is_face_down() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(0);
|
||||
let (_, stock) = deal_klondike(deck);
|
||||
assert!(stock.cards.iter().all(|c| !c.face_up));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_all_52_cards_present() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(99);
|
||||
let (tableau, stock) = deal_klondike(deck);
|
||||
let mut ids: Vec<u32> = stock.cards.iter().map(|c| c.id).collect();
|
||||
for pile in &tableau {
|
||||
ids.extend(pile.cards.iter().map(|c| c.id));
|
||||
}
|
||||
ids.sort_unstable();
|
||||
assert_eq!(ids, (0u32..52).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
+1291
-1964
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
|
||||
//!
|
||||
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
||||
//! - building [`KlondikeConfig`] from Ferrous settings
|
||||
//! - translating between local and upstream types
|
||||
//!
|
||||
//! Ferrous-specific scoring policy (the win-time bonus) lives in
|
||||
//! [`crate::scoring`], not here.
|
||||
//!
|
||||
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
|
||||
//! upstream `card_game` / `klondike` types live here so that the product modules
|
||||
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
||||
|
||||
use klondike::{
|
||||
DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
|
||||
SkipCards, Tableau,
|
||||
};
|
||||
|
||||
/// Bridges `solitaire_core` game config to the upstream `klondike` crate.
|
||||
///
|
||||
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
||||
/// state, and exists only as a namespace for configuration and conversion
|
||||
/// helpers.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct KlondikeAdapter;
|
||||
|
||||
impl KlondikeAdapter {
|
||||
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
||||
pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig {
|
||||
KlondikeConfig {
|
||||
draw_stock: draw_mode,
|
||||
move_from_foundation: if take_from_foundation {
|
||||
MoveFromFoundationConfig::Allowed
|
||||
} else {
|
||||
MoveFromFoundationConfig::Disallowed
|
||||
},
|
||||
scoring: ScoringConfig::DEFAULT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||||
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
|
||||
match index {
|
||||
0 => Some(Tableau::Tableau1),
|
||||
1 => Some(Tableau::Tableau2),
|
||||
2 => Some(Tableau::Tableau3),
|
||||
3 => Some(Tableau::Tableau4),
|
||||
4 => Some(Tableau::Tableau5),
|
||||
5 => Some(Tableau::Tableau6),
|
||||
6 => Some(Tableau::Tableau7),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
|
||||
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
|
||||
match slot {
|
||||
0 => Some(Foundation::Foundation1),
|
||||
1 => Some(Foundation::Foundation2),
|
||||
2 => Some(Foundation::Foundation3),
|
||||
3 => Some(Foundation::Foundation4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
|
||||
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
||||
match skip {
|
||||
0 => Some(SkipCards::Skip0),
|
||||
1 => Some(SkipCards::Skip1),
|
||||
2 => Some(SkipCards::Skip2),
|
||||
3 => Some(SkipCards::Skip3),
|
||||
4 => Some(SkipCards::Skip4),
|
||||
5 => Some(SkipCards::Skip5),
|
||||
6 => Some(SkipCards::Skip6),
|
||||
7 => Some(SkipCards::Skip7),
|
||||
8 => Some(SkipCards::Skip8),
|
||||
9 => Some(SkipCards::Skip9),
|
||||
10 => Some(SkipCards::Skip10),
|
||||
11 => Some(SkipCards::Skip11),
|
||||
12 => Some(SkipCards::Skip12),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,61 @@
|
||||
pub mod achievement;
|
||||
pub mod card;
|
||||
pub mod deck;
|
||||
pub mod error;
|
||||
pub mod game_state;
|
||||
pub mod pile;
|
||||
pub mod rules;
|
||||
pub mod klondike_adapter;
|
||||
pub mod scoring;
|
||||
pub mod solver;
|
||||
pub mod spider;
|
||||
|
||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||
// downstream crates (engine, wasm) can import from one place without a direct
|
||||
// `klondike` / `card_game` dep.
|
||||
//
|
||||
// `KlondikePileStack`, `SkipCards` and `TableauStack` are intentionally NOT
|
||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||
// appear in any public method signature.
|
||||
pub use card_game::{Card, Deck, Rank, SolveError, Suit};
|
||||
pub use klondike::{
|
||||
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||
};
|
||||
|
||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
|
||||
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||
// later phase — nothing outside solitaire_core consumes these yet).
|
||||
pub use spider::{
|
||||
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
|
||||
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
|
||||
};
|
||||
|
||||
/// All four foundation slots, in slot order.
|
||||
///
|
||||
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||
/// `Foundation::ALL` (unlike `Suit::SUITS` / `Rank::RANKS` in `card_game`),
|
||||
/// and inherent impls cannot be added to a foreign type, so the workspace
|
||||
/// const lives here. Use this instead of hand-rolling `[Foundation; 4]`
|
||||
/// arrays; scattered copies can silently diverge.
|
||||
pub const FOUNDATIONS: [Foundation; 4] = [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
];
|
||||
|
||||
/// All seven tableau columns, in column order (left to right on screen).
|
||||
///
|
||||
/// Canonical iteration source for `Tableau` — see [`FOUNDATIONS`] for why
|
||||
/// this lives here rather than upstream.
|
||||
pub const TABLEAUS: [Tableau; 7] = [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptest_tests;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
use crate::card::{Card, Suit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Identifies which pile on the board a set of cards belongs to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum PileType {
|
||||
/// The face-down draw pile.
|
||||
Stock,
|
||||
/// The face-up discard pile drawn to.
|
||||
Waste,
|
||||
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||||
/// is derived from the bottom card of the pile (always an Ace by
|
||||
/// construction).
|
||||
Foundation(u8),
|
||||
/// One of the seven tableau columns (0–6).
|
||||
Tableau(usize),
|
||||
}
|
||||
|
||||
/// A named collection of cards in a specific board position.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Pile {
|
||||
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
||||
pub pile_type: PileType,
|
||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
impl Pile {
|
||||
/// Creates a new empty pile of the given type.
|
||||
pub fn new(pile_type: PileType) -> Self {
|
||||
Self {
|
||||
pile_type,
|
||||
cards: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a reference to the top (last) card, or `None` if empty.
|
||||
pub fn top(&self) -> Option<&Card> {
|
||||
self.cards.last()
|
||||
}
|
||||
|
||||
/// For foundation piles: returns `Some(suit)` once at least one card has
|
||||
/// landed (the bottom card is always an Ace of the claimed suit).
|
||||
/// Returns `None` for empty foundations or non-foundation piles.
|
||||
pub fn claimed_suit(&self) -> Option<Suit> {
|
||||
match self.pile_type {
|
||||
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
|
||||
#[test]
|
||||
fn new_pile_is_empty() {
|
||||
let pile = Pile::new(PileType::Stock);
|
||||
assert!(pile.cards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_top_returns_last_card() {
|
||||
let mut pile = Pile::new(PileType::Waste);
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
assert_eq!(pile.top().unwrap().id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_top_on_empty_is_none() {
|
||||
let pile = Pile::new(PileType::Waste);
|
||||
assert!(pile.top().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_foundation_uses_slot_index() {
|
||||
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_tableau_uses_index() {
|
||||
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_empty_foundation() {
|
||||
let pile = Pile::new(PileType::Foundation(0));
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_non_foundation() {
|
||||
let mut pile = Pile::new(PileType::Tableau(0));
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_returns_bottom_card_suit() {
|
||||
let mut pile = Pile::new(PileType::Foundation(2));
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use card_game::{Card, Game};
|
||||
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::game_state::GameState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Collect all cards across every pile in a fixed traversal order:
|
||||
/// stock → waste → foundations 1–4 → tableaux 1–7.
|
||||
///
|
||||
/// The order is deterministic for a given game state, so two calls on
|
||||
/// equivalent states produce identical Vec outputs — the right fingerprint
|
||||
/// for undo-reversibility checks.
|
||||
fn all_cards(game: &GameState) -> Vec<Card> {
|
||||
let foundations = [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
];
|
||||
let tableaux = [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
];
|
||||
|
||||
let mut cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
|
||||
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
|
||||
for f in &foundations {
|
||||
cards.extend(
|
||||
game.pile(KlondikePile::Foundation(*f))
|
||||
.iter()
|
||||
.map(|(c, _)| c.clone()),
|
||||
);
|
||||
}
|
||||
for t in &tableaux {
|
||||
cards.extend(
|
||||
game.pile(KlondikePile::Tableau(*t))
|
||||
.iter()
|
||||
.map(|(c, _)| c.clone()),
|
||||
);
|
||||
}
|
||||
cards
|
||||
}
|
||||
|
||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||
prop_oneof![
|
||||
Just(DrawStockConfig::DrawOne),
|
||||
Just(DrawStockConfig::DrawThree)
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||
///
|
||||
/// Each action is `(draw_flag, move_index)`:
|
||||
/// - `draw_flag = true` → call `game.draw()`
|
||||
/// - `draw_flag = false` → pick the `move_index % len`th legal instruction
|
||||
/// from `possible_instructions()` and apply it via `apply_instruction()`.
|
||||
///
|
||||
/// `possible_instructions()` may return `RotateStock`, which
|
||||
/// `apply_instruction()` dispatches to `game.draw()`; ordinary instructions
|
||||
/// are equivalent to `move_cards(from, to, count)`.
|
||||
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
|
||||
for &(do_draw, idx) in actions {
|
||||
if do_draw {
|
||||
let _ = game.draw();
|
||||
} else {
|
||||
let moves = game.possible_instructions();
|
||||
if moves.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let instruction = moves[idx % moves.len()];
|
||||
let _ = game.apply_instruction(instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one move from `possible_instructions()` (or a draw if no move is
|
||||
/// available), using `move_idx` to select among the legal options.
|
||||
/// Returns `true` when a move was successfully applied.
|
||||
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
|
||||
if game.is_won() {
|
||||
return false;
|
||||
}
|
||||
let moves = game.possible_instructions();
|
||||
if moves.is_empty() {
|
||||
return game.draw().is_ok();
|
||||
}
|
||||
let instruction = moves[move_idx % moves.len()];
|
||||
game.apply_instruction(instruction).is_ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Properties
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
proptest! {
|
||||
/// `check_auto_complete()` and `is_win_trivial()` must agree on every
|
||||
/// reachable game state.
|
||||
///
|
||||
/// The upstream `Klondike::is_win_trivial()` checks that the stock pile
|
||||
/// (both face-down and face-up halves) is completely empty AND that all
|
||||
/// tableau columns have no face-down cards. Ferrous `check_auto_complete()`
|
||||
/// checks the same three conditions individually (stock empty, waste empty,
|
||||
/// all tableau cards face-up). This property guards against any semantic
|
||||
/// drift between the two implementations so that delegating to upstream is
|
||||
/// safe.
|
||||
///
|
||||
/// If this property ever fails, `check_auto_complete()` must NOT be fully
|
||||
/// replaced — the Ferrous conditions must be preserved and `is_win_trivial()`
|
||||
/// used only as a supplementary guard.
|
||||
#[test]
|
||||
fn check_auto_complete_agrees_with_is_win_trivial(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
prop_assert_eq!(
|
||||
game.check_auto_complete(),
|
||||
game.session().state().state().is_win_trivial(),
|
||||
"check_auto_complete() disagreed with is_win_trivial() after {:?} actions",
|
||||
actions.len(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `check_win()` and `is_win()` must agree on every reachable game state.
|
||||
#[test]
|
||||
fn check_win_agrees_with_is_win(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
prop_assert_eq!(
|
||||
game.check_win(),
|
||||
game.session().state().state().is_win(),
|
||||
"check_win() disagreed with is_win()",
|
||||
);
|
||||
}
|
||||
|
||||
/// All 52 card IDs must be present exactly once across every pile after
|
||||
/// any reachable sequence of draw + move_cards actions.
|
||||
///
|
||||
/// Catches two bug classes at once:
|
||||
/// - Card loss (fewer than 52 unique IDs after the sequence).
|
||||
/// - Card duplication (52 total but deduplication reduces the set).
|
||||
#[test]
|
||||
fn all_52_cards_always_present(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
|
||||
let cards = all_cards(&game);
|
||||
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
|
||||
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
|
||||
prop_assert_eq!(
|
||||
unique.len(), 52,
|
||||
"duplicate cards found after dedup — a card was cloned"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GameState::new(seed, draw_mode)` must be deterministic: two calls
|
||||
/// with the same arguments must produce identical initial pile layouts.
|
||||
///
|
||||
/// Pins that the deal is seeded from `seed` alone and not from any
|
||||
/// implicit source like wall-clock time or global state.
|
||||
#[test]
|
||||
fn deal_is_deterministic(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
) {
|
||||
let a = GameState::new(seed, draw_mode);
|
||||
let b = GameState::new(seed, draw_mode);
|
||||
prop_assert_eq!(
|
||||
all_cards(&a),
|
||||
all_cards(&b),
|
||||
"same seed + draw_mode produced different deals",
|
||||
);
|
||||
}
|
||||
|
||||
/// After applying any single legal move and immediately undoing it, the
|
||||
/// pile layout and move_count must be identical to their pre-move values.
|
||||
///
|
||||
/// `setup_actions` drives the game to an arbitrary mid-game position;
|
||||
/// `move_idx` selects which legal move to apply and then undo.
|
||||
///
|
||||
/// The score is intentionally excluded: `undo()` applies a −15 penalty
|
||||
/// that is by design, not a regression.
|
||||
#[test]
|
||||
fn undo_restores_pile_layout_and_move_count(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
||||
move_idx in 0usize..200,
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
// Snapshot the state before the move.
|
||||
let before_ids = all_cards(&game);
|
||||
let before_move_count = game.move_count();
|
||||
|
||||
// Apply one move.
|
||||
if !apply_one_move(&mut game, move_idx) || game.is_won() {
|
||||
return Ok(()); // nothing to undo
|
||||
}
|
||||
|
||||
// Undo and verify.
|
||||
prop_assert!(
|
||||
game.undo().is_ok(),
|
||||
"undo must succeed immediately after a successful move",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
all_cards(&game),
|
||||
before_ids,
|
||||
"pile layout after undo differs from the pre-move snapshot",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
game.move_count(),
|
||||
before_move_count,
|
||||
"move_count after undo must equal the pre-move value",
|
||||
);
|
||||
}
|
||||
|
||||
/// Every move returned by `possible_instructions()` must succeed when
|
||||
/// applied via `move_cards()`.
|
||||
///
|
||||
/// `possible_instructions()` and `move_cards()` both validate moves
|
||||
/// through the same upstream rule engine. This property ensures no
|
||||
/// drift has opened up between what the engine reports as legal and
|
||||
/// what it actually accepts.
|
||||
#[test]
|
||||
fn legal_moves_always_succeed(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
for instruction in game.possible_instructions() {
|
||||
// Clone so each move is tried from the same starting state.
|
||||
let mut trial = game.clone();
|
||||
let result = trial.apply_instruction(instruction);
|
||||
prop_assert!(
|
||||
result.is_ok(),
|
||||
"possible_instructions() reported {instruction:?} \
|
||||
as legal but the call returned Err: {result:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::pile::Pile;
|
||||
|
||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||
///
|
||||
/// Foundation rules:
|
||||
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
||||
/// becomes the pile's claimed suit (derived from the bottom card via
|
||||
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||
/// - When the pile is non-empty, the next card must match the top card's
|
||||
/// suit and be exactly one rank higher.
|
||||
#[must_use]
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank == Rank::Ace,
|
||||
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
||||
///
|
||||
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
||||
#[must_use]
|
||||
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank == Rank::King,
|
||||
Some(top) => {
|
||||
top.face_up
|
||||
&& card.rank.checked_add(1) == Some(top.rank)
|
||||
&& card.suit.is_red() != top.suit.is_red()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `cards` is a legal tableau run on its own — every
|
||||
/// adjacent pair descends by one rank and alternates colour. A single
|
||||
/// card is trivially valid. The destination check is separate; this
|
||||
/// only validates the sequence's *internal* structure, which the tableau
|
||||
/// move path must enforce so a player can't smuggle an arbitrary stack
|
||||
/// onto another column when the bottom card happens to land legally.
|
||||
#[must_use]
|
||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||
cards.windows(2).all(|w| {
|
||||
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
use crate::pile::{Pile, PileType};
|
||||
|
||||
fn card(suit: Suit, rank: Rank) -> Card {
|
||||
Card {
|
||||
id: 0,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
|
||||
Pile { pile_type, cards }
|
||||
}
|
||||
|
||||
// Foundation tests
|
||||
#[test]
|
||||
fn foundation_ace_on_empty_is_valid() {
|
||||
// Every suit's Ace must land on an empty foundation slot regardless of
|
||||
// its slot index; the slot claims the suit only after the Ace lands.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let c = card(suit, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(
|
||||
can_place_on_foundation(&c, &p),
|
||||
"Ace of {suit:?} must land on empty slot 0",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_non_ace_on_empty_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Two);
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_two_on_ace_same_suit_is_valid() {
|
||||
let c = card(Suit::Clubs, Rank::Two);
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_second_card_must_match_claimed_suit() {
|
||||
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
||||
// because the slot's claimed suit is Hearts after the Ace lands.
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
|
||||
let c = card(Suit::Spades, Rank::Two);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_skipping_rank_is_invalid() {
|
||||
let c = card(Suit::Diamonds, Rank::Three);
|
||||
let p = pile_with(
|
||||
PileType::Foundation(0),
|
||||
vec![card(Suit::Diamonds, Rank::Ace)],
|
||||
);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
// Tableau tests
|
||||
#[test]
|
||||
fn tableau_king_on_empty_is_valid() {
|
||||
let c = card(Suit::Hearts, Rank::King);
|
||||
let p = Pile::new(PileType::Tableau(0));
|
||||
assert!(can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_non_king_on_empty_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Queen);
|
||||
let p = Pile::new(PileType::Tableau(0));
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_red_on_black_one_lower_is_valid() {
|
||||
let c = card(Suit::Hearts, Rank::Nine);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||
assert!(can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_same_color_is_invalid() {
|
||||
let c = card(Suit::Clubs, Rank::Nine);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_wrong_rank_difference_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Eight);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_black_on_red_one_lower_is_valid() {
|
||||
let c = card(Suit::Clubs, Rank::Six);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
|
||||
assert!(can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_king_on_queen_completes_suit() {
|
||||
// The last card placed to complete a foundation is always King on Queen.
|
||||
let c = card(Suit::Spades, Rank::King);
|
||||
let p = pile_with(
|
||||
PileType::Foundation(0),
|
||||
vec![card(Suit::Spades, Rank::Queen)],
|
||||
);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_king_wrong_suit_is_invalid() {
|
||||
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
|
||||
let c = card(Suit::Hearts, Rank::King);
|
||||
let p = pile_with(
|
||||
PileType::Foundation(0),
|
||||
vec![card(Suit::Spades, Rank::Queen)],
|
||||
);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_ace_on_two_different_color_is_valid() {
|
||||
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
|
||||
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
|
||||
assert!(can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_same_rank_different_color_is_invalid() {
|
||||
// Two cards of the same rank cannot be stacked regardless of colour.
|
||||
let c = card(Suit::Hearts, Rank::Nine);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_face_down_destination_top_is_invalid() {
|
||||
// A face-down top card must never be a valid placement target.
|
||||
let c = card(Suit::Hearts, Rank::Nine);
|
||||
let mut top = card(Suit::Spades, Rank::Ten);
|
||||
top.face_up = false;
|
||||
let p = pile_with(PileType::Tableau(0), vec![top]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_sequence_validation() {
|
||||
// Single card is trivially a valid sequence.
|
||||
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
|
||||
// Valid descending alternating-colour run K♠ Q♥ J♣.
|
||||
assert!(is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Hearts, Rank::Queen),
|
||||
card(Suit::Clubs, Rank::Jack),
|
||||
]));
|
||||
// Same colour twice (Q♠ on K♠) — invalid.
|
||||
assert!(!is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Spades, Rank::Queen),
|
||||
]));
|
||||
// Rank gap (K♠ → J♥) — invalid.
|
||||
assert!(!is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Hearts, Rank::Jack),
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,9 @@
|
||||
use crate::pile::PileType;
|
||||
|
||||
/// Score delta for moving cards from `from` to `to`.
|
||||
///
|
||||
/// Windows XP Standard scoring:
|
||||
/// - +10 for any card reaching a foundation pile
|
||||
/// - +5 for a waste → tableau move
|
||||
/// - -15 for a foundation → tableau (take-from-foundation) move
|
||||
/// - 0 for all other moves
|
||||
///
|
||||
/// Note: the +5 flip bonus for exposing a face-down tableau card is applied
|
||||
/// separately in `game_state::move_cards` because it depends on post-move state.
|
||||
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
|
||||
match to {
|
||||
PileType::Foundation(_) => 10,
|
||||
PileType::Tableau(_) => match from {
|
||||
PileType::Waste => 5,
|
||||
PileType::Foundation(_) => -15,
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Score penalty applied when the player uses undo: -15.
|
||||
pub fn score_undo() -> i32 {
|
||||
-15
|
||||
}
|
||||
|
||||
/// Score bonus awarded when a face-down tableau card is flipped face-up: +5.
|
||||
pub fn score_flip() -> i32 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Score penalty for recycling the waste pile back to stock.
|
||||
///
|
||||
/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3).
|
||||
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
|
||||
/// `recycle_count` is the new total count **after** this recycle.
|
||||
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||
let (free, penalty) = if is_draw_three {
|
||||
(3_u32, -20_i32)
|
||||
} else {
|
||||
(1_u32, -100_i32)
|
||||
};
|
||||
if recycle_count > free { penalty } else { 0 }
|
||||
}
|
||||
//! Ferrous-specific scoring policy layered on top of upstream `klondike`.
|
||||
//!
|
||||
//! Upstream [`klondike::KlondikeStats::score`] owns the per-move point values
|
||||
//! (move-to-foundation, flip-up bonus, recycle penalty, etc.). The functions
|
||||
//! here are the Ferrous Solitaire house rules that upstream has no opinion on —
|
||||
//! currently just the win-time bonus shown in the win modal.
|
||||
|
||||
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||
@@ -54,99 +13,3 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
}
|
||||
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn move_to_foundation_scores_ten() {
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
||||
assert_eq!(
|
||||
score_move(&PileType::Tableau(0), &PileType::Foundation(0)),
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waste_to_tableau_scores_five() {
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Tableau(3)), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_to_tableau_scores_zero() {
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Tableau(1)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_penalty_is_negative_fifteen() {
|
||||
assert_eq!(score_undo(), -15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bonus_at_100_seconds() {
|
||||
assert_eq!(compute_time_bonus(100), 7000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bonus_at_zero_is_zero() {
|
||||
assert_eq!(compute_time_bonus(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bonus_at_one_second() {
|
||||
assert_eq!(compute_time_bonus(1), 700_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_to_tableau_penalises_fifteen() {
|
||||
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
|
||||
assert_eq!(
|
||||
score_move(&PileType::Foundation(0), &PileType::Tableau(0)),
|
||||
-15
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_to_stock_or_waste_scores_zero() {
|
||||
// These destinations are illegal moves in practice, but the function
|
||||
// must not panic and should return 0.
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Stock), 0);
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Waste), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
||||
// Very short elapsed time would overflow without the .min() guard.
|
||||
let bonus = compute_time_bonus(1);
|
||||
assert!(
|
||||
bonus >= 0,
|
||||
"time bonus must be non-negative after u64→i32 cast"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_bonus_is_five() {
|
||||
assert_eq!(score_flip(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw1_first_pass_free() {
|
||||
assert_eq!(score_recycle(1, false), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw1_second_pass_penalised() {
|
||||
assert_eq!(score_recycle(2, false), -100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw3_third_pass_free() {
|
||||
assert_eq!(score_recycle(3, true), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw3_fourth_pass_penalised() {
|
||||
assert_eq!(score_recycle(4, true), -20);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,15 +7,26 @@ edition.workspace = true
|
||||
[dependencies]
|
||||
solitaire_core = { workspace = true }
|
||||
solitaire_sync = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# These deps are not available / not needed on wasm32:
|
||||
# dirs — platform data directories (no filesystem on browser)
|
||||
# reqwest — native HTTP client (sync/analytics gated out on wasm32)
|
||||
# tokio — OS-threaded async runtime (mio doesn't compile on wasm32)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
dirs = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
# Theme-store downloads are verified against the catalog's SHA-256
|
||||
# before the archive is handed to the engine's theme importer.
|
||||
sha2 = { workspace = true }
|
||||
|
||||
# `keyring-core` is the typed Entry/Error API used by
|
||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||
@@ -24,17 +35,14 @@ uuid = { workspace = true }
|
||||
# on bionic). On Android `auth_tokens` falls back to a stub
|
||||
# implementation that always returns `KeychainUnavailable`; the
|
||||
# real backend lands when we wire Android Keystore via JNI.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
|
||||
keyring-core = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = { workspace = true }
|
||||
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
|
||||
# process-wide JavaVM handle for JNI. Must be listed here so the
|
||||
# symbol resolves when cross-compiling for Android targets.
|
||||
bevy = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
solitaire_core = { workspace = true, features = ["test-support"] }
|
||||
solitaire_server = { path = "../solitaire_server" }
|
||||
solitaire_sync = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
@@ -42,3 +50,10 @@ sqlx = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
# theme_store_round_trip builds a theme zip in a tempdir for the
|
||||
# in-process store server.
|
||||
zip = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Safe JNI bridge for Android platform integration.
|
||||
//!
|
||||
//! Reconstructing the raw `JavaVM` / `NativeActivity` handles handed over by
|
||||
//! the Android runtime requires `unsafe` FFI. That single reconstruction lives
|
||||
//! in `solitaire_app`'s entry point ([`solitaire_app::android_main`]); this
|
||||
//! module only ever stores the resulting *safe* [`JavaVM`] and activity
|
||||
//! [`GlobalRef`] and hands callers an attached [`JNIEnv`].
|
||||
//!
|
||||
//! As a result every consumer of Android JNI — the keystore, clipboard, and
|
||||
//! safe-area subsystems — goes through the safe functions here and stays
|
||||
//! `forbid(unsafe_code)`. The only crate carrying `unsafe` is `solitaire_app`.
|
||||
//!
|
||||
//! Only compiled and linked on `target_os = "android"`.
|
||||
|
||||
use jni::objects::{GlobalRef, JObject};
|
||||
use jni::{JNIEnv, JavaVM};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
|
||||
static ANDROID_ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
|
||||
|
||||
/// Store the process-wide [`JavaVM`]. Called once from Android startup
|
||||
/// (`solitaire_app::android_main`); subsequent calls are ignored.
|
||||
pub fn set_jvm(vm: JavaVM) {
|
||||
let _ = ANDROID_JVM.set(vm);
|
||||
}
|
||||
|
||||
/// Store a global reference to the `NativeActivity`. Called once from Android
|
||||
/// startup; subsequent calls are ignored.
|
||||
pub fn set_activity(activity: GlobalRef) {
|
||||
let _ = ANDROID_ACTIVITY.set(activity);
|
||||
}
|
||||
|
||||
/// Run `f` with a [`JNIEnv`] attached to the current thread.
|
||||
///
|
||||
/// Returns an error string if the bridge has not been initialised yet or the
|
||||
/// thread cannot be attached. The closure's JNI errors are surfaced through the
|
||||
/// same `String` channel so callers have a single error type to map.
|
||||
pub fn with_env<F, R>(f: F) -> Result<R, String>
|
||||
where
|
||||
F: for<'local> FnOnce(&mut JNIEnv<'local>) -> jni::errors::Result<R>,
|
||||
{
|
||||
let vm = ANDROID_JVM
|
||||
.get()
|
||||
.ok_or_else(|| "Android JavaVM not initialised".to_string())?;
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
f(&mut env).map_err(|e| format!("JNI: {e}"))
|
||||
}
|
||||
|
||||
/// Run `f` with an attached [`JNIEnv`] and the `NativeActivity` object.
|
||||
///
|
||||
/// Like [`with_env`] but also resolves the cached activity global reference, so
|
||||
/// callers that need to invoke instance methods on the activity (clipboard,
|
||||
/// window insets) never touch a raw handle.
|
||||
pub fn with_activity_env<F, R>(f: F) -> Result<R, String>
|
||||
where
|
||||
F: for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> jni::errors::Result<R>,
|
||||
{
|
||||
let activity = ANDROID_ACTIVITY
|
||||
.get()
|
||||
.ok_or_else(|| "Android activity not initialised".to_string())?;
|
||||
with_env(|env| f(env, activity.as_obj()))
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
///
|
||||
/// Only compiled and linked on `target_os = "android"`.
|
||||
use jni::{
|
||||
JNIEnv, JavaVM,
|
||||
JNIEnv,
|
||||
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -36,23 +36,15 @@ struct TokenBlob {
|
||||
// JVM helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run `f` with an attached `JNIEnv`, delegating thread attach and the
|
||||
/// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
|
||||
/// initialised once from Android startup, so the keystore never touches a raw
|
||||
/// pointer and this module stays `forbid(unsafe_code)`.
|
||||
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
||||
where
|
||||
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
||||
{
|
||||
let app = bevy::android::ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?;
|
||||
|
||||
// SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
|
||||
|
||||
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
|
||||
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -207,9 +199,10 @@ fn encrypt_gcm(
|
||||
.v()?;
|
||||
|
||||
// IV is generated by Android's provider; read it back after init.
|
||||
// `getIV()` returns `[B`; the safe `From<JObject>` reinterprets the
|
||||
// returned object reference as a typed byte array.
|
||||
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
|
||||
// SAFETY: the method signature guarantees a byte array return.
|
||||
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
|
||||
let iv_arr = JByteArray::from(iv_jobj);
|
||||
let iv = env.convert_byte_array(&iv_arr)?;
|
||||
|
||||
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
||||
@@ -217,8 +210,7 @@ fn encrypt_gcm(
|
||||
let ct_jobj = env
|
||||
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
||||
.l()?;
|
||||
// SAFETY: doFinal([B) returns [B.
|
||||
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
|
||||
let ct_arr = JByteArray::from(ct_jobj);
|
||||
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
||||
|
||||
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
||||
@@ -269,8 +261,7 @@ fn decrypt_gcm(
|
||||
let pt_jobj = env
|
||||
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
||||
.l()?;
|
||||
// SAFETY: doFinal([B) returns [B.
|
||||
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
|
||||
let pt_arr = JByteArray::from(pt_jobj);
|
||||
env.convert_byte_array(&pt_arr)
|
||||
}
|
||||
|
||||
@@ -357,29 +348,29 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
|
||||
}
|
||||
|
||||
// --- 2. Legacy path migration ---
|
||||
if let Some(ref lpath) = legacy_path {
|
||||
if lpath.exists() {
|
||||
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||
other => other,
|
||||
if let Some(ref lpath) = legacy_path
|
||||
&& lpath.exists()
|
||||
{
|
||||
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||
other => other,
|
||||
})?;
|
||||
if data.len() >= 12 {
|
||||
let plaintext = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
})?;
|
||||
if data.len() >= 12 {
|
||||
let plaintext = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
})?;
|
||||
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(blob.username.clone(), blob);
|
||||
// Write to the new location, then remove the legacy file.
|
||||
if write_map_inner(&map).is_ok() {
|
||||
let _ = std::fs::remove_file(lpath);
|
||||
}
|
||||
return Ok(map);
|
||||
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(blob.username.clone(), blob);
|
||||
// Write to the new location, then remove the legacy file.
|
||||
if write_map_inner(&map).is_ok() {
|
||||
let _ = std::fs::remove_file(lpath);
|
||||
}
|
||||
return Ok(map);
|
||||
}
|
||||
// Legacy file corrupt or unrecognised — treat as empty.
|
||||
}
|
||||
// Legacy file corrupt or unrecognised — treat as empty.
|
||||
}
|
||||
|
||||
// --- 3. No file found ---
|
||||
@@ -468,11 +459,11 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
|
||||
if map.is_empty() {
|
||||
// No more users — remove the file and the Keystore key.
|
||||
if let Some(path) = token_file_path() {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||
}
|
||||
if let Some(path) = token_file_path()
|
||||
&& path.exists()
|
||||
{
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||
}
|
||||
|
||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
//! the Bevy `App`). If no default store is set, all operations in this module
|
||||
//! will return [`TokenError::KeychainUnavailable`].
|
||||
//!
|
||||
//! # Android stub
|
||||
//! # Android
|
||||
//!
|
||||
//! `keyring-core` cannot compile for the android target (its `rpassword`
|
||||
//! transitive dep uses `libc::__errno_location`, which Android's bionic
|
||||
//! doesn't expose). On Android every function in this module returns
|
||||
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
|
||||
//! the same way they handle a Linux box without Secret Service. The
|
||||
//! real Android backend will arrive in the Phase-Android round when we
|
||||
//! wire Android Keystore via JNI.
|
||||
//! doesn't expose). On Android this module delegates to an Android Keystore
|
||||
//! JNI backend. `solitaire_app` must initialise the safe
|
||||
//! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
|
||||
//! startup before token operations can succeed.
|
||||
//!
|
||||
//! # Note: no unit tests — requires live OS keychain.
|
||||
|
||||
|
||||
@@ -26,227 +26,227 @@ use solitaire_core::game_state::DifficultyLevel;
|
||||
|
||||
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
|
||||
pub const EASY_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0001,
|
||||
0xD1FF_0000_0000_0002,
|
||||
0xD1FF_0000_0000_0007,
|
||||
0xD1FF_0000_0000_0008,
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-06-04)
|
||||
0xD1FF_0000_0000_0009,
|
||||
0xD1FF_0000_0000_000E,
|
||||
0xD1FF_0000_0000_0013,
|
||||
0xD1FF_0000_0000_0015,
|
||||
0xD1FF_0000_0000_0018,
|
||||
0xD1FF_0000_0000_001D,
|
||||
0xD1FF_0000_0000_0021,
|
||||
0xD1FF_0000_0000_0022,
|
||||
0xD1FF_0000_0000_0026,
|
||||
0xD1FF_0000_0000_002C,
|
||||
0xD1FF_0000_0000_002E,
|
||||
0xD1FF_0000_0000_002F,
|
||||
0xD1FF_0000_0000_0035,
|
||||
0xD1FF_0000_0000_0036,
|
||||
0xD1FF_0000_0000_003C,
|
||||
0xD1FF_0000_0000_0045,
|
||||
0xD1FF_0000_0000_0046,
|
||||
0xD1FF_0000_0000_0048,
|
||||
0xD1FF_0000_0000_0049,
|
||||
0xD1FF_0000_0000_004D,
|
||||
0xD1FF_0000_0000_004F,
|
||||
0xD1FF_0000_0000_0050,
|
||||
0xD1FF_0000_0000_0051,
|
||||
0xD1FF_0000_0000_0053,
|
||||
0xD1FF_0000_0000_0054,
|
||||
0xD1FF_0000_0000_0057,
|
||||
0xD1FF_0000_0000_0058,
|
||||
0xD1FF_0000_0000_005A,
|
||||
0xD1FF_0000_0000_005B,
|
||||
0xD1FF_0000_0000_005C,
|
||||
0xD1FF_0000_0000_005D,
|
||||
0xD1FF_0000_0000_005F,
|
||||
0xD1FF_0000_0000_0061,
|
||||
0xD1FF_0000_0000_0062,
|
||||
0xD1FF_0000_0000_0063,
|
||||
0xD1FF_0000_0000_0069,
|
||||
0xD1FF_0000_0000_0087,
|
||||
0xD1FF_0000_0000_00EB,
|
||||
0xD1FF_0000_0000_017F,
|
||||
0xD1FF_0000_0000_01CE,
|
||||
0xD1FF_0000_0000_020F,
|
||||
0xD1FF_0000_0000_0251,
|
||||
0xD1FF_0000_0000_0275,
|
||||
0xD1FF_0000_0000_029C,
|
||||
0xD1FF_0000_0000_02BD,
|
||||
0xD1FF_0000_0000_02ED,
|
||||
0xD1FF_0000_0000_038F,
|
||||
0xD1FF_0000_0000_03C9,
|
||||
0xD1FF_0000_0000_0415,
|
||||
0xD1FF_0000_0000_045F,
|
||||
0xD1FF_0000_0000_04C4,
|
||||
0xD1FF_0000_0000_04CC,
|
||||
0xD1FF_0000_0000_04EE,
|
||||
0xD1FF_0000_0000_0631,
|
||||
0xD1FF_0000_0000_0651,
|
||||
0xD1FF_0000_0000_0689,
|
||||
0xD1FF_0000_0000_0735,
|
||||
0xD1FF_0000_0000_0748,
|
||||
0xD1FF_0000_0000_0801,
|
||||
0xD1FF_0000_0000_0820,
|
||||
0xD1FF_0000_0000_08F9,
|
||||
0xD1FF_0000_0000_091C,
|
||||
0xD1FF_0000_0000_0937,
|
||||
0xD1FF_0000_0000_09A6,
|
||||
0xD1FF_0000_0000_09C3,
|
||||
0xD1FF_0000_0000_09DD,
|
||||
0xD1FF_0000_0000_0BD9,
|
||||
0xD1FF_0000_0000_0BEC,
|
||||
0xD1FF_0000_0000_0BF2,
|
||||
0xD1FF_0000_0000_0C1B,
|
||||
0xD1FF_0000_0000_0C26,
|
||||
0xD1FF_0000_0000_0C36,
|
||||
0xD1FF_0000_0000_0C4B,
|
||||
0xD1FF_0000_0000_0C78,
|
||||
0xD1FF_0000_0000_0CBC,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
|
||||
pub const MEDIUM_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0000,
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-06-04)
|
||||
0xD1FF_0000_0000_0012,
|
||||
0xD1FF_0000_0000_0016,
|
||||
0xD1FF_0000_0000_001B,
|
||||
0xD1FF_0000_0000_001C,
|
||||
0xD1FF_0000_0000_0020,
|
||||
0xD1FF_0000_0000_002A,
|
||||
0xD1FF_0000_0000_0034,
|
||||
0xD1FF_0000_0000_003A,
|
||||
0xD1FF_0000_0000_0041,
|
||||
0xD1FF_0000_0000_0043,
|
||||
0xD1FF_0000_0000_0060,
|
||||
0xD1FF_0000_0000_006A,
|
||||
0xD1FF_0000_0000_006C,
|
||||
0xD1FF_0000_0000_006E,
|
||||
0xD1FF_0000_0000_006F,
|
||||
0xD1FF_0000_0000_0071,
|
||||
0xD1FF_0000_0000_0072,
|
||||
0xD1FF_0000_0000_0075,
|
||||
0xD1FF_0000_0000_0076,
|
||||
0xD1FF_0000_0000_007B,
|
||||
0xD1FF_0000_0000_007E,
|
||||
0xD1FF_0000_0000_0081,
|
||||
0xD1FF_0000_0000_0083,
|
||||
0xD1FF_0000_0000_0084,
|
||||
0xD1FF_0000_0000_0087,
|
||||
0xD1FF_0000_0000_0090,
|
||||
0xD1FF_0000_0000_0092,
|
||||
0xD1FF_0000_0000_0093,
|
||||
0xD1FF_0000_0000_0098,
|
||||
0xD1FF_0000_0000_002C,
|
||||
0xD1FF_0000_0000_004B,
|
||||
0xD1FF_0000_0000_0052,
|
||||
0xD1FF_0000_0000_0058,
|
||||
0xD1FF_0000_0000_005E,
|
||||
0xD1FF_0000_0000_0063,
|
||||
0xD1FF_0000_0000_0099,
|
||||
0xD1FF_0000_0000_009A,
|
||||
0xD1FF_0000_0000_009E,
|
||||
0xD1FF_0000_0000_00A5,
|
||||
0xD1FF_0000_0000_00A8,
|
||||
0xD1FF_0000_0000_00AA,
|
||||
0xD1FF_0000_0000_00AB,
|
||||
0xD1FF_0000_0000_00AE,
|
||||
0xD1FF_0000_0000_00A9,
|
||||
0xD1FF_0000_0000_00AF,
|
||||
0xD1FF_0000_0000_00B0,
|
||||
0xD1FF_0000_0000_00BB,
|
||||
0xD1FF_0000_0000_00D1,
|
||||
0xD1FF_0000_0000_00E3,
|
||||
0xD1FF_0000_0000_0108,
|
||||
0xD1FF_0000_0000_010D,
|
||||
0xD1FF_0000_0000_0110,
|
||||
0xD1FF_0000_0000_012F,
|
||||
0xD1FF_0000_0000_0139,
|
||||
0xD1FF_0000_0000_013C,
|
||||
0xD1FF_0000_0000_0148,
|
||||
0xD1FF_0000_0000_015E,
|
||||
0xD1FF_0000_0000_016A,
|
||||
0xD1FF_0000_0000_016F,
|
||||
0xD1FF_0000_0000_0179,
|
||||
0xD1FF_0000_0000_019E,
|
||||
0xD1FF_0000_0000_01A8,
|
||||
0xD1FF_0000_0000_01AB,
|
||||
0xD1FF_0000_0000_01B5,
|
||||
0xD1FF_0000_0000_01B8,
|
||||
0xD1FF_0000_0000_01D3,
|
||||
0xD1FF_0000_0000_01EE,
|
||||
0xD1FF_0000_0000_01F3,
|
||||
0xD1FF_0000_0000_0202,
|
||||
0xD1FF_0000_0000_0203,
|
||||
0xD1FF_0000_0000_021E,
|
||||
0xD1FF_0000_0000_022C,
|
||||
0xD1FF_0000_0000_022D,
|
||||
0xD1FF_0000_0000_0233,
|
||||
0xD1FF_0000_0000_0245,
|
||||
0xD1FF_0000_0000_024E,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
|
||||
pub const HARD_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
|
||||
0xD1FF_0000_0000_001F,
|
||||
0xD1FF_0000_0000_0024,
|
||||
0xD1FF_0000_0000_0025,
|
||||
0xD1FF_0000_0000_0031,
|
||||
0xD1FF_0000_0000_0032,
|
||||
0xD1FF_0000_0000_003E,
|
||||
0xD1FF_0000_0000_004A,
|
||||
0xD1FF_0000_0000_006D,
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-06-04)
|
||||
0xD1FF_0000_0000_0006,
|
||||
0xD1FF_0000_0000_0008,
|
||||
0xD1FF_0000_0000_000F,
|
||||
0xD1FF_0000_0000_0011,
|
||||
0xD1FF_0000_0000_0022,
|
||||
0xD1FF_0000_0000_0023,
|
||||
0xD1FF_0000_0000_002A,
|
||||
0xD1FF_0000_0000_002D,
|
||||
0xD1FF_0000_0000_0040,
|
||||
0xD1FF_0000_0000_0042,
|
||||
0xD1FF_0000_0000_0050,
|
||||
0xD1FF_0000_0000_005B,
|
||||
0xD1FF_0000_0000_005D,
|
||||
0xD1FF_0000_0000_0067,
|
||||
0xD1FF_0000_0000_0069,
|
||||
0xD1FF_0000_0000_006E,
|
||||
0xD1FF_0000_0000_0072,
|
||||
0xD1FF_0000_0000_0079,
|
||||
0xD1FF_0000_0000_007C,
|
||||
0xD1FF_0000_0000_0080,
|
||||
0xD1FF_0000_0000_008A,
|
||||
0xD1FF_0000_0000_0097,
|
||||
0xD1FF_0000_0000_0081,
|
||||
0xD1FF_0000_0000_0083,
|
||||
0xD1FF_0000_0000_0091,
|
||||
0xD1FF_0000_0000_009B,
|
||||
0xD1FF_0000_0000_00A1,
|
||||
0xD1FF_0000_0000_00B1,
|
||||
0xD1FF_0000_0000_00B2,
|
||||
0xD1FF_0000_0000_00B3,
|
||||
0xD1FF_0000_0000_00B5,
|
||||
0xD1FF_0000_0000_00B7,
|
||||
0xD1FF_0000_0000_00B8,
|
||||
0xD1FF_0000_0000_00B9,
|
||||
0xD1FF_0000_0000_00BA,
|
||||
0xD1FF_0000_0000_00BB,
|
||||
0xD1FF_0000_0000_00BC,
|
||||
0xD1FF_0000_0000_00BD,
|
||||
0xD1FF_0000_0000_00C2,
|
||||
0xD1FF_0000_0000_00C3,
|
||||
0xD1FF_0000_0000_00C5,
|
||||
0xD1FF_0000_0000_00CC,
|
||||
0xD1FF_0000_0000_00CE,
|
||||
0xD1FF_0000_0000_00D1,
|
||||
0xD1FF_0000_0000_00D2,
|
||||
0xD1FF_0000_0000_00D6,
|
||||
0xD1FF_0000_0000_00D7,
|
||||
0xD1FF_0000_0000_00DC,
|
||||
0xD1FF_0000_0000_00DF,
|
||||
0xD1FF_0000_0000_00E0,
|
||||
0xD1FF_0000_0000_00E1,
|
||||
0xD1FF_0000_0000_00E4,
|
||||
0xD1FF_0000_0000_00E6,
|
||||
0xD1FF_0000_0000_00E7,
|
||||
0xD1FF_0000_0000_00DD,
|
||||
0xD1FF_0000_0000_00E8,
|
||||
0xD1FF_0000_0000_00F2,
|
||||
0xD1FF_0000_0000_0101,
|
||||
0xD1FF_0000_0000_010F,
|
||||
0xD1FF_0000_0000_0113,
|
||||
0xD1FF_0000_0000_0118,
|
||||
0xD1FF_0000_0000_0119,
|
||||
0xD1FF_0000_0000_012D,
|
||||
0xD1FF_0000_0000_0133,
|
||||
0xD1FF_0000_0000_0144,
|
||||
0xD1FF_0000_0000_0147,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
|
||||
pub const EXPERT_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0006,
|
||||
0xD1FF_0000_0000_000B,
|
||||
0xD1FF_0000_0000_0019,
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-06-04)
|
||||
0xD1FF_0000_0000_0000,
|
||||
0xD1FF_0000_0000_0002,
|
||||
0xD1FF_0000_0000_000A,
|
||||
0xD1FF_0000_0000_0013,
|
||||
0xD1FF_0000_0000_0017,
|
||||
0xD1FF_0000_0000_001C,
|
||||
0xD1FF_0000_0000_001F,
|
||||
0xD1FF_0000_0000_0021,
|
||||
0xD1FF_0000_0000_0024,
|
||||
0xD1FF_0000_0000_0029,
|
||||
0xD1FF_0000_0000_002E,
|
||||
0xD1FF_0000_0000_0035,
|
||||
0xD1FF_0000_0000_0045,
|
||||
0xD1FF_0000_0000_0048,
|
||||
0xD1FF_0000_0000_0049,
|
||||
0xD1FF_0000_0000_004F,
|
||||
0xD1FF_0000_0000_0062,
|
||||
0xD1FF_0000_0000_006D,
|
||||
0xD1FF_0000_0000_0074,
|
||||
0xD1FF_0000_0000_0076,
|
||||
0xD1FF_0000_0000_0082,
|
||||
0xD1FF_0000_0000_00CB,
|
||||
0xD1FF_0000_0000_00D5,
|
||||
0xD1FF_0000_0000_00D8,
|
||||
0xD1FF_0000_0000_00E8,
|
||||
0xD1FF_0000_0000_00EA,
|
||||
0xD1FF_0000_0000_00EB,
|
||||
0xD1FF_0000_0000_00EC,
|
||||
0xD1FF_0000_0000_008F,
|
||||
0xD1FF_0000_0000_0090,
|
||||
0xD1FF_0000_0000_0097,
|
||||
0xD1FF_0000_0000_009A,
|
||||
0xD1FF_0000_0000_009F,
|
||||
0xD1FF_0000_0000_00A5,
|
||||
0xD1FF_0000_0000_00A8,
|
||||
0xD1FF_0000_0000_00AD,
|
||||
0xD1FF_0000_0000_00AE,
|
||||
0xD1FF_0000_0000_00B8,
|
||||
0xD1FF_0000_0000_00B9,
|
||||
0xD1FF_0000_0000_00BC,
|
||||
0xD1FF_0000_0000_00C5,
|
||||
0xD1FF_0000_0000_00CA,
|
||||
0xD1FF_0000_0000_00CE,
|
||||
0xD1FF_0000_0000_00DE,
|
||||
0xD1FF_0000_0000_00ED,
|
||||
0xD1FF_0000_0000_00F2,
|
||||
0xD1FF_0000_0000_00F3,
|
||||
0xD1FF_0000_0000_00F4,
|
||||
0xD1FF_0000_0000_00FE,
|
||||
0xD1FF_0000_0000_00FF,
|
||||
0xD1FF_0000_0000_0102,
|
||||
0xD1FF_0000_0000_0103,
|
||||
0xD1FF_0000_0000_0104,
|
||||
0xD1FF_0000_0000_0105,
|
||||
0xD1FF_0000_0000_0106,
|
||||
0xD1FF_0000_0000_0109,
|
||||
0xD1FF_0000_0000_010B,
|
||||
0xD1FF_0000_0000_010C,
|
||||
0xD1FF_0000_0000_0110,
|
||||
0xD1FF_0000_0000_0113,
|
||||
0xD1FF_0000_0000_0114,
|
||||
0xD1FF_0000_0000_011B,
|
||||
0xD1FF_0000_0000_011C,
|
||||
0xD1FF_0000_0000_011E,
|
||||
0xD1FF_0000_0000_0120,
|
||||
0xD1FF_0000_0000_0121,
|
||||
0xD1FF_0000_0000_0122,
|
||||
0xD1FF_0000_0000_0123,
|
||||
0xD1FF_0000_0000_0124,
|
||||
0xD1FF_0000_0000_0126,
|
||||
0xD1FF_0000_0000_012B,
|
||||
0xD1FF_0000_0000_012C,
|
||||
0xD1FF_0000_0000_012E,
|
||||
0xD1FF_0000_0000_00EE,
|
||||
0xD1FF_0000_0000_00EF,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
|
||||
pub const GRANDMASTER_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0027,
|
||||
0xD1FF_0000_0000_00A0,
|
||||
0xD1FF_0000_0000_00C4,
|
||||
0xD1FF_0000_0000_00D4,
|
||||
0xD1FF_0000_0000_00DE,
|
||||
0xD1FF_0000_0000_00F9,
|
||||
0xD1FF_0000_0000_0107,
|
||||
0xD1FF_0000_0000_0108,
|
||||
0xD1FF_0000_0000_0130,
|
||||
0xD1FF_0000_0000_0132,
|
||||
0xD1FF_0000_0000_0133,
|
||||
0xD1FF_0000_0000_0134,
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-06-04)
|
||||
0xD1FF_0000_0000_003C,
|
||||
0xD1FF_0000_0000_0047,
|
||||
0xD1FF_0000_0000_005A,
|
||||
0xD1FF_0000_0000_009C,
|
||||
0xD1FF_0000_0000_00D2,
|
||||
0xD1FF_0000_0000_00F4,
|
||||
0xD1FF_0000_0000_00F6,
|
||||
0xD1FF_0000_0000_0104,
|
||||
0xD1FF_0000_0000_0106,
|
||||
0xD1FF_0000_0000_0111,
|
||||
0xD1FF_0000_0000_0112,
|
||||
0xD1FF_0000_0000_0116,
|
||||
0xD1FF_0000_0000_0117,
|
||||
0xD1FF_0000_0000_011A,
|
||||
0xD1FF_0000_0000_0123,
|
||||
0xD1FF_0000_0000_012B,
|
||||
0xD1FF_0000_0000_012E,
|
||||
0xD1FF_0000_0000_0135,
|
||||
0xD1FF_0000_0000_0137,
|
||||
0xD1FF_0000_0000_0139,
|
||||
0xD1FF_0000_0000_013A,
|
||||
0xD1FF_0000_0000_013D,
|
||||
0xD1FF_0000_0000_013F,
|
||||
0xD1FF_0000_0000_0140,
|
||||
0xD1FF_0000_0000_013B,
|
||||
0xD1FF_0000_0000_0141,
|
||||
0xD1FF_0000_0000_0142,
|
||||
0xD1FF_0000_0000_0143,
|
||||
0xD1FF_0000_0000_0145,
|
||||
0xD1FF_0000_0000_0146,
|
||||
0xD1FF_0000_0000_014A,
|
||||
0xD1FF_0000_0000_014B,
|
||||
0xD1FF_0000_0000_014C,
|
||||
0xD1FF_0000_0000_014D,
|
||||
0xD1FF_0000_0000_014F,
|
||||
0xD1FF_0000_0000_014E,
|
||||
0xD1FF_0000_0000_0150,
|
||||
0xD1FF_0000_0000_0151,
|
||||
0xD1FF_0000_0000_0152,
|
||||
0xD1FF_0000_0000_0153,
|
||||
0xD1FF_0000_0000_0155,
|
||||
0xD1FF_0000_0000_0157,
|
||||
0xD1FF_0000_0000_0158,
|
||||
0xD1FF_0000_0000_015B,
|
||||
0xD1FF_0000_0000_0159,
|
||||
0xD1FF_0000_0000_015A,
|
||||
0xD1FF_0000_0000_015C,
|
||||
0xD1FF_0000_0000_015E,
|
||||
0xD1FF_0000_0000_0162,
|
||||
0xD1FF_0000_0000_0164,
|
||||
0xD1FF_0000_0000_015D,
|
||||
0xD1FF_0000_0000_015F,
|
||||
0xD1FF_0000_0000_0166,
|
||||
0xD1FF_0000_0000_0173,
|
||||
0xD1FF_0000_0000_0174,
|
||||
0xD1FF_0000_0000_0178,
|
||||
0xD1FF_0000_0000_017D,
|
||||
0xD1FF_0000_0000_0182,
|
||||
0xD1FF_0000_0000_0187,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+31
-19
@@ -58,7 +58,7 @@ pub trait SyncProvider: Send + Sync {
|
||||
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
||||
/// silently no-op'd by the engine's push-on-win system, matching
|
||||
/// the same pattern `pull` / `push` follow.
|
||||
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError> {
|
||||
Err(SyncError::UnsupportedPlatform)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||
(**self).delete_account().await
|
||||
}
|
||||
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
|
||||
(**self).push_replay(replay).await
|
||||
}
|
||||
}
|
||||
@@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot};
|
||||
pub mod storage;
|
||||
pub use storage::{
|
||||
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
|
||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
|
||||
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
|
||||
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
|
||||
time_attack_session_path, time_attack_session_with_now,
|
||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from,
|
||||
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to,
|
||||
stats_file_path, time_attack_session_path,
|
||||
};
|
||||
|
||||
pub mod achievements;
|
||||
@@ -118,8 +117,8 @@ pub use achievements::{
|
||||
|
||||
pub mod progress;
|
||||
pub use progress::{
|
||||
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
|
||||
save_progress_to, xp_for_win,
|
||||
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
|
||||
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
|
||||
};
|
||||
|
||||
pub mod weekly;
|
||||
@@ -136,34 +135,47 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
|
||||
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
|
||||
load_settings_from, save_settings_to, settings_file_path,
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from,
|
||||
save_settings_to, settings_file_path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_jni;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_keystore;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod auth_tokens;
|
||||
pub use auth_tokens::{
|
||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use auth_tokens::{TokenError, delete_tokens, store_tokens};
|
||||
|
||||
pub mod sync_client;
|
||||
pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend};
|
||||
pub use sync_client::LocalOnlyProvider;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod theme_store_client;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use theme_store_client::{ThemeStoreClient, ThemeStoreError};
|
||||
|
||||
pub mod replay;
|
||||
pub use replay::{
|
||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
|
||||
ReplayHistory, append_replay_to_history, load_replay_history_from,
|
||||
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
||||
};
|
||||
// `latest_replay_path` is still consumed by the engine's one-shot legacy
|
||||
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
|
||||
// outside `replay.rs` and were dropped from the public surface.
|
||||
#[allow(deprecated)]
|
||||
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
||||
pub use replay::latest_replay_path;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod matomo_client;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use matomo_client::MatomoClient;
|
||||
|
||||
pub mod platform;
|
||||
|
||||
@@ -114,3 +114,62 @@ fn url_encode(s: &str) -> String {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pending(client: &MatomoClient) -> Vec<String> {
|
||||
client.pending.lock().expect("pending lock").clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_buffers_encoded_matomo_query() {
|
||||
let client = MatomoClient::new(
|
||||
"https://analytics.example.com/",
|
||||
7,
|
||||
Some("alice bob".into()),
|
||||
);
|
||||
|
||||
client.event("Game Flow", "Won+Fast", Some("draw three"), Some(42.5));
|
||||
|
||||
let pending = pending(&client);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let query = &pending[0];
|
||||
assert!(query.contains("idsite=7"));
|
||||
assert!(query.contains("rec=1"));
|
||||
assert!(query.contains("e_c=Game%20Flow"));
|
||||
assert!(query.contains("e_a=Won%2BFast"));
|
||||
assert!(query.contains("e_n=draw%20three"));
|
||||
assert!(query.contains("e_v=42.5"));
|
||||
assert!(query.contains("uid=alice%20bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_buffer_drops_oldest_entries_when_capacity_exceeded() {
|
||||
let client = MatomoClient::new("https://analytics.example.com", 1, None);
|
||||
|
||||
for idx in 0..101 {
|
||||
client.event("Game", "Start", Some(&format!("event-{idx}")), None);
|
||||
}
|
||||
|
||||
let pending = pending(&client);
|
||||
assert_eq!(pending.len(), 51);
|
||||
assert!(
|
||||
pending[0].contains("event-50"),
|
||||
"oldest retained event should be event-50, got {}",
|
||||
pending[0]
|
||||
);
|
||||
assert!(
|
||||
pending[50].contains("event-100"),
|
||||
"newest retained event should be event-100, got {}",
|
||||
pending[50]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_leaves_unreserved_bytes_and_escapes_everything_else() {
|
||||
assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~");
|
||||
assert_eq!(url_encode("a b+c/d?"), "a%20b%2Bc%2Fd%3F");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,15 @@ pub fn data_dir() -> Option<PathBuf> {
|
||||
{
|
||||
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// No filesystem on the browser; all persistence goes through
|
||||
// WasmStorage (localStorage-backed). Return None so every caller
|
||||
// degrades gracefully (the same path they take on a
|
||||
// misconfigured desktop environment).
|
||||
None
|
||||
}
|
||||
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
|
||||
{
|
||||
dirs::data_dir()
|
||||
}
|
||||
|
||||
@@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
||||
y * 10_000 + m * 100 + d
|
||||
}
|
||||
|
||||
/// XP awarded for winning a game.
|
||||
/// Component breakdown of the XP awarded for a win.
|
||||
///
|
||||
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
|
||||
/// it for the total, and UI that displays the individual lines (the win-summary
|
||||
/// modal) reads the parts from here so the breakdown can never drift from the
|
||||
/// total.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct XpBreakdown {
|
||||
/// Flat base XP granted for any win.
|
||||
pub base: u64,
|
||||
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
|
||||
pub speed_bonus: u64,
|
||||
/// Bonus for winning without using undo (25, else 0).
|
||||
pub no_undo_bonus: u64,
|
||||
}
|
||||
|
||||
impl XpBreakdown {
|
||||
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
|
||||
pub fn total(self) -> u64 {
|
||||
self.base + self.speed_bonus + self.no_undo_bonus
|
||||
}
|
||||
}
|
||||
|
||||
/// Component breakdown of the XP awarded for a win.
|
||||
///
|
||||
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
||||
/// the player did not use undo.
|
||||
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
let base: u64 = 50;
|
||||
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
|
||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
||||
0
|
||||
} else {
|
||||
@@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
||||
scaled.max(10)
|
||||
};
|
||||
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
||||
base + speed_bonus + no_undo_bonus
|
||||
XpBreakdown {
|
||||
base: 50,
|
||||
speed_bonus,
|
||||
no_undo_bonus: if used_undo { 0 } else { 25 },
|
||||
}
|
||||
}
|
||||
|
||||
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
|
||||
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
xp_breakdown(time_seconds, used_undo).total()
|
||||
}
|
||||
|
||||
/// Platform-specific default path for `progress.json`.
|
||||
|
||||
@@ -12,13 +12,22 @@
|
||||
//! carries any other version so older replays are silently dropped instead
|
||||
//! of crashing the loader.
|
||||
//!
|
||||
//! The recording is intentionally minimal — only [`ReplayMove`] entries
|
||||
//! that successfully advanced the game. `Undo` is **not** recorded: a
|
||||
//! replay represents the canonical path the player ultimately took to win,
|
||||
//! so backed-out missteps simply do not appear in the move list. The
|
||||
//! starting deal is not stored either — the [`seed`](Replay::seed) +
|
||||
//! The recording is intentionally minimal — only the
|
||||
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
||||
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
||||
//! represents the canonical path the player ultimately took to win, so
|
||||
//! backed-out missteps simply do not appear in the move list. The starting
|
||||
//! deal is not stored either — the [`seed`](Replay::seed) +
|
||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||
//!
|
||||
//! Each recorded move is the player's atomic *input*, not its outcome.
|
||||
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||
//! stock state during playback, so the same input always produces the same
|
||||
//! effect on the same starting deal. Runtime-only pile-position types are
|
||||
//! never serialised — the instruction itself serialises via its compact
|
||||
//! upstream serde representation.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -26,8 +35,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
@@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 {
|
||||
/// seeing a broken one.
|
||||
///
|
||||
/// History:
|
||||
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
|
||||
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
|
||||
/// variants which carried the *outcome* of a stock interaction rather
|
||||
/// than the player's atomic input.
|
||||
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
|
||||
/// variant. The engine resolves draw-vs-recycle deterministically from
|
||||
/// the current stock state, so the input alone is sufficient and the
|
||||
/// replay model now stores atomic player inputs end-to-end.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
|
||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||
/// are now stored directly as upstream
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
||||
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
||||
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
||||
/// and are discarded by the loader.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
||||
|
||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||
@@ -81,32 +92,6 @@ fn schema_v0() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// One atomic player input recorded during a winning game, in the order
|
||||
/// it was applied to the live `GameState`.
|
||||
///
|
||||
/// `Undo` is intentionally absent — see the module-level docs.
|
||||
///
|
||||
/// The variants represent *inputs*, not outcomes. `StockClick` covers
|
||||
/// every player click on the stock pile; the engine then resolves
|
||||
/// draw-vs-recycle deterministically from the current state during both
|
||||
/// recording and playback, so the same input always produces the same
|
||||
/// effect on the same starting deal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReplayMove {
|
||||
/// A successful `move_cards(from, to, count)` call.
|
||||
Move {
|
||||
/// Source pile.
|
||||
from: PileType,
|
||||
/// Destination pile.
|
||||
to: PileType,
|
||||
/// Number of cards moved.
|
||||
count: usize,
|
||||
},
|
||||
/// A click on the stock pile. Resolves to a draw when stock is
|
||||
/// non-empty and to a waste→stock recycle when stock is empty.
|
||||
StockClick,
|
||||
}
|
||||
|
||||
/// A complete recording of a single winning game.
|
||||
///
|
||||
/// Replays are reconstructed by rebuilding a fresh
|
||||
@@ -124,7 +109,7 @@ pub struct Replay {
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||
pub seed: u64,
|
||||
/// Draw mode the recorded game was played in.
|
||||
pub draw_mode: DrawMode,
|
||||
pub draw_mode: DrawStockConfig,
|
||||
/// Game mode the recorded game was played in.
|
||||
pub mode: GameMode,
|
||||
/// Total wall-clock seconds the win took. Used for the Stats UI
|
||||
@@ -134,9 +119,11 @@ pub struct Replay {
|
||||
pub final_score: i32,
|
||||
/// ISO-8601 date the win was recorded.
|
||||
pub recorded_at: NaiveDate,
|
||||
/// Ordered move list. Each entry is what the player did, replayable
|
||||
/// against a fresh `GameState` constructed from the seed.
|
||||
pub moves: Vec<ReplayMove>,
|
||||
/// Ordered move list. Each entry is the atomic
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
||||
/// issued, replayable against a fresh `GameState` constructed from the
|
||||
/// seed via `GameState::apply_instruction`.
|
||||
pub moves: Vec<KlondikeInstruction>,
|
||||
/// Public share URL for this replay on the active sync backend, set
|
||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||
/// task resolves. `None` when the player won on a local-only
|
||||
@@ -180,12 +167,12 @@ impl Replay {
|
||||
/// latter directly when the upload task resolves.
|
||||
pub fn new(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
draw_mode: DrawStockConfig,
|
||||
mode: GameMode,
|
||||
time_seconds: u64,
|
||||
final_score: i32,
|
||||
recorded_at: NaiveDate,
|
||||
moves: Vec<ReplayMove>,
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_SCHEMA_VERSION,
|
||||
@@ -442,6 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
#[allow(deprecated)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
||||
};
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
@@ -452,24 +442,22 @@ mod tests {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||
Replay::new(
|
||||
12345,
|
||||
DrawMode::DrawThree,
|
||||
DrawStockConfig::DrawThree,
|
||||
GameMode::Classic,
|
||||
134,
|
||||
5_120,
|
||||
date,
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Tableau(3),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Tableau(3),
|
||||
to: PileType::Foundation(0),
|
||||
count: 1,
|
||||
},
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstTableau(DstTableau {
|
||||
src: KlondikePileStack::Stock,
|
||||
tableau: Tableau::Tableau4,
|
||||
}),
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||
foundation: Foundation::Foundation1,
|
||||
}),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -595,12 +583,12 @@ mod tests {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||
Replay::new(
|
||||
id as u64,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
id,
|
||||
date,
|
||||
vec![ReplayMove::StockClick],
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -836,9 +824,11 @@ mod tests {
|
||||
let path = tmp_path("legacy_no_win_move_index");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
||||
let v2_no_field = r#"{
|
||||
"schema_version": 2,
|
||||
// Hand-rolled minimal current-schema replay JSON with no
|
||||
// win_move_index field — the additive field must still default to None.
|
||||
let no_field = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
@@ -846,8 +836,10 @@ mod tests {
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": []
|
||||
}"#;
|
||||
fs::write(&path, v2_no_field).expect("write fixture");
|
||||
}}"#,
|
||||
schema = REPLAY_SCHEMA_VERSION,
|
||||
);
|
||||
fs::write(&path, no_field).expect("write fixture");
|
||||
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, None);
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
|
||||
@@ -101,7 +101,7 @@ pub struct WindowGeometry {
|
||||
pub struct Settings {
|
||||
/// Draw mode selected for new games.
|
||||
#[serde(default = "default_draw_mode")]
|
||||
pub draw_mode: DrawMode,
|
||||
pub draw_mode: DrawStockConfig,
|
||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
||||
#[serde(default = "default_sfx_volume")]
|
||||
pub sfx_volume: f32,
|
||||
@@ -161,8 +161,10 @@ pub struct Settings {
|
||||
/// Identifier of the active card-art theme. Matches `meta.id` from
|
||||
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
||||
/// always present; user-supplied themes register under their own ids.
|
||||
/// Older `settings.json` files that stored `"default"` or `"classic"`
|
||||
/// are migrated to `"dark"` by [`Settings::sanitized`].
|
||||
/// Older `settings.json` files that stored `"default"` (the
|
||||
/// pre-rename id of the dark theme) are migrated to `"dark"` by
|
||||
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
|
||||
/// and is never rewritten.
|
||||
#[serde(default = "default_theme_id")]
|
||||
pub selected_theme_id: String,
|
||||
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||
@@ -200,7 +202,7 @@ pub struct Settings {
|
||||
#[serde(default = "default_time_bonus_multiplier")]
|
||||
pub time_bonus_multiplier: f32,
|
||||
/// When `true`, the engine rejects new-game deals the
|
||||
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
||||
/// the solver cannot prove winnable, retrying
|
||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||
/// giving up and using the last tried seed. Off by default —
|
||||
/// the solver adds a few hundred milliseconds of latency on the
|
||||
@@ -288,8 +290,8 @@ pub struct Settings {
|
||||
pub touch_input_mode: TouchInputMode,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
DrawMode::DrawOne
|
||||
fn default_draw_mode() -> DrawStockConfig {
|
||||
DrawStockConfig::DrawOne
|
||||
}
|
||||
|
||||
fn default_sfx_volume() -> f32 {
|
||||
@@ -381,9 +383,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||
/// is willing to attempt before giving up and accepting the latest
|
||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
||||
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
||||
/// deal than spin forever on the main thread.
|
||||
/// every retry comes back provably unwinnable (`Ok(None)` from the
|
||||
/// solver, which would be very unusual) we'd rather hand the player a
|
||||
/// possibly-unwinnable deal than spin forever on the main thread.
|
||||
///
|
||||
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||
/// the upper bound on UI freeze when the toggle is on.
|
||||
@@ -392,7 +394,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
draw_mode: DrawMode::DrawOne,
|
||||
draw_mode: DrawStockConfig::DrawOne,
|
||||
sfx_volume: default_sfx_volume(),
|
||||
music_volume: default_music_volume(),
|
||||
animation_speed: AnimSpeed::Normal,
|
||||
|
||||
+26
-26
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
|
||||
//! This module adds the [`StatsExt`] extension trait, which supplies the
|
||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||
//! `update_on_win` method that depends on [`DrawStockConfig`] from `solitaire_core`.
|
||||
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
|
||||
pub use solitaire_sync::StatsSnapshot;
|
||||
|
||||
@@ -18,9 +18,9 @@ pub trait StatsExt {
|
||||
///
|
||||
/// Tracks lifetime totals only — per-mode best scores and times are
|
||||
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
|
||||
/// long-standing call sites that only know about [`DrawMode`] keep
|
||||
/// long-standing call sites that only know about [`DrawStockConfig`] keep
|
||||
/// compiling.
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig);
|
||||
|
||||
/// Updates the per-mode best score and fastest-win-time fields for the
|
||||
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
|
||||
@@ -37,7 +37,7 @@ pub trait StatsExt {
|
||||
}
|
||||
|
||||
impl StatsExt for StatsSnapshot {
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig) {
|
||||
let prev_wins = self.games_won;
|
||||
self.games_played += 1;
|
||||
self.games_won += 1;
|
||||
@@ -64,8 +64,8 @@ impl StatsExt for StatsSnapshot {
|
||||
};
|
||||
|
||||
match draw_mode {
|
||||
DrawMode::DrawOne => self.draw_one_wins += 1,
|
||||
DrawMode::DrawThree => self.draw_three_wins += 1,
|
||||
DrawStockConfig::DrawOne => self.draw_one_wins += 1,
|
||||
DrawStockConfig::DrawThree => self.draw_three_wins += 1,
|
||||
}
|
||||
|
||||
self.last_modified = Utc::now();
|
||||
@@ -135,7 +135,7 @@ mod tests {
|
||||
#[test]
|
||||
fn first_win_sets_all_fields() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(1500, 120, &DrawMode::DrawOne);
|
||||
s.update_on_win(1500, 120, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.games_played, 1);
|
||||
assert_eq!(s.games_won, 1);
|
||||
assert_eq!(s.win_streak_current, 1);
|
||||
@@ -152,7 +152,7 @@ mod tests {
|
||||
fn streak_tracks_across_wins() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
for _ in 0..3 {
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
}
|
||||
assert_eq!(s.win_streak_current, 3);
|
||||
assert_eq!(s.win_streak_best, 3);
|
||||
@@ -161,8 +161,8 @@ mod tests {
|
||||
#[test]
|
||||
fn record_abandoned_resets_streak_and_increments_played() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.win_streak_current, 2);
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.games_played, 3);
|
||||
@@ -174,35 +174,35 @@ mod tests {
|
||||
#[test]
|
||||
fn fastest_win_takes_minimum() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 120, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 500, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 120, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 500, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.fastest_win_seconds, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avg_time_is_correct_rolling_average() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 100, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 200, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 100, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 200, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.avg_time_seconds, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_score_updates_only_on_higher_score() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(500, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(300, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(500, 60, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(300, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.best_single_score, 500);
|
||||
s.update_on_win(800, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(800, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.best_single_score, 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_score_treated_as_zero() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(-50, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(-50, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.best_single_score, 0);
|
||||
assert_eq!(s.lifetime_score, 0);
|
||||
}
|
||||
@@ -210,8 +210,8 @@ mod tests {
|
||||
#[test]
|
||||
fn draw_three_wins_tracked_separately() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawMode::DrawThree);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawThree);
|
||||
assert_eq!(s.draw_one_wins, 1);
|
||||
assert_eq!(s.draw_three_wins, 1);
|
||||
}
|
||||
@@ -221,7 +221,7 @@ mod tests {
|
||||
let mut s = StatsSnapshot::default();
|
||||
// Build a streak of 5.
|
||||
for _ in 0..5 {
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
}
|
||||
assert_eq!(s.win_streak_best, 5);
|
||||
// Lose (abandon), resetting current.
|
||||
@@ -229,7 +229,7 @@ mod tests {
|
||||
assert_eq!(s.win_streak_current, 0);
|
||||
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
|
||||
// Win once — current becomes 1, best must remain 5.
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.win_streak_current, 1);
|
||||
assert_eq!(
|
||||
s.win_streak_best, 5,
|
||||
@@ -243,7 +243,7 @@ mod tests {
|
||||
lifetime_score: u64::MAX - 100,
|
||||
..Default::default()
|
||||
};
|
||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(200, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(
|
||||
s.lifetime_score,
|
||||
u64::MAX,
|
||||
|
||||
+177
-70
@@ -3,13 +3,13 @@
|
||||
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
||||
//! loss during a write never corrupts the saved data.
|
||||
|
||||
use chrono::Utc;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
|
||||
@@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load stats from the platform default path. Returns default if the path
|
||||
/// is unavailable or the file is missing/corrupt.
|
||||
pub fn load_stats() -> StatsSnapshot {
|
||||
stats_file_path()
|
||||
.map(|p| load_stats_from(&p))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save stats to the platform default path. Returns an error if the platform
|
||||
/// data dir is unavailable or the write fails.
|
||||
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
||||
let path = stats_file_path()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
|
||||
save_stats_to(&path, stats)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-progress game state
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -85,16 +69,13 @@ pub fn game_state_file_path() -> Option<PathBuf> {
|
||||
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
if gs.is_won { None } else { Some(gs) }
|
||||
if gs.is_won() { None } else { Some(gs) }
|
||||
}
|
||||
|
||||
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
|
||||
/// because a completed game should not be resumed.
|
||||
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
|
||||
if gs.is_won {
|
||||
if gs.is_won() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -234,9 +215,7 @@ pub fn load_time_attack_session_from_at(
|
||||
/// See [`load_time_attack_session_from_at`] for the rules under which
|
||||
/// the call returns `None` (missing file, corrupt JSON, expired window).
|
||||
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let now = Utc::now().timestamp().max(0) as u64;
|
||||
load_time_attack_session_from_at(path, now)
|
||||
}
|
||||
|
||||
@@ -250,20 +229,6 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helper for callers that want to stamp a session with the
|
||||
/// current wall-clock time. Equivalent to constructing the struct
|
||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
TimeAttackSession {
|
||||
remaining_secs,
|
||||
wins,
|
||||
saved_at_unix_secs: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
||||
///
|
||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||
@@ -286,7 +251,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stats::{StatsExt, StatsSnapshot};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
@@ -299,7 +264,7 @@ mod tests {
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut stats = StatsSnapshot::default();
|
||||
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
|
||||
stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne);
|
||||
save_stats_to(&path, &stats).expect("save");
|
||||
|
||||
let loaded = load_stats_from(&path);
|
||||
@@ -384,17 +349,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn game_state_round_trip() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let gs = GameState::new(12345, DrawMode::DrawOne);
|
||||
let gs = GameState::new(12345, DrawStockConfig::DrawOne);
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
|
||||
let loaded = load_game_state_from(&path).expect("load");
|
||||
assert_eq!(loaded.seed, gs.seed);
|
||||
assert_eq!(loaded.draw_mode, gs.draw_mode);
|
||||
assert!(!loaded.is_won);
|
||||
assert_eq!(loaded.draw_mode(), gs.draw_mode());
|
||||
assert!(!loaded.is_won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,12 +378,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_game_state_skips_won_games() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("won_skip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut gs = GameState::new(99, DrawMode::DrawOne);
|
||||
gs.is_won = true;
|
||||
let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
|
||||
gs.set_test_won(true);
|
||||
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
|
||||
assert!(
|
||||
!path.exists(),
|
||||
@@ -426,28 +391,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_game_state_ignores_won_games() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
let path = gs_path("won_load");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Write a won game directly (bypassing save_game_state_to's guard).
|
||||
let mut gs = GameState::new(77, DrawMode::DrawOne);
|
||||
gs.is_won = true;
|
||||
let json = serde_json::to_string_pretty(&gs).unwrap();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes()).unwrap();
|
||||
fs::rename(&tmp, &path).unwrap();
|
||||
|
||||
assert!(load_game_state_from(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_game_state_removes_file() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("delete");
|
||||
let gs = GameState::new(1, DrawMode::DrawOne);
|
||||
let gs = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
assert!(path.exists());
|
||||
delete_game_state_at(&path).expect("delete");
|
||||
@@ -463,9 +411,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_game_state_is_atomic() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("atomic");
|
||||
let gs = GameState::new(55, DrawMode::DrawThree);
|
||||
let gs = GameState::new(55, DrawStockConfig::DrawThree);
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
@@ -516,6 +464,165 @@ mod tests {
|
||||
assert_eq!(loaded, StatsSnapshot::default());
|
||||
}
|
||||
|
||||
/// Schema v4 serialises the instruction history using upstream
|
||||
/// `KlondikeInstruction` serde (named enum variants). The deserialiser
|
||||
/// replays all `saved_moves` to reconstruct every pile.
|
||||
///
|
||||
/// A fresh-game test (zero moves) never exercises that replay path, so this
|
||||
/// test plays several real moves — including an undo — before saving.
|
||||
///
|
||||
/// Since schema v5 no longer persists `score`/`undo_count`/`recycle_count`
|
||||
/// (they are derived from the replayed session stats), round-trip fidelity is
|
||||
/// verified by **re-save idempotency**: reloading the save and serialising it
|
||||
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
||||
/// to 0 on load because only the forward instruction history is persisted.
|
||||
#[test]
|
||||
fn game_state_v5_mid_game_round_trip() {
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let path = gs_path("v4_mid_game");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
|
||||
// Draw several times to populate the instruction history with
|
||||
// RotateStock entries and expose waste cards for further moves.
|
||||
for _ in 0..6 {
|
||||
if gs.draw().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the first available DstTableau or DstFoundation move so the
|
||||
// instruction history contains a type other than RotateStock.
|
||||
if let Some(instruction) = gs.possible_instructions().into_iter().find(|i| {
|
||||
matches!(
|
||||
i,
|
||||
KlondikeInstruction::DstTableau(_) | KlondikeInstruction::DstFoundation(_)
|
||||
)
|
||||
}) {
|
||||
let _ = gs.apply_instruction(instruction);
|
||||
}
|
||||
|
||||
// Undo once: verifies that `undo_count` is persisted and that the
|
||||
// truncated history (post-undo) replays back to the correct state.
|
||||
if gs.undo_stack_len() > 0 {
|
||||
let _ = gs.undo();
|
||||
}
|
||||
|
||||
assert!(
|
||||
gs.undo_stack_len() > 0,
|
||||
"instruction history must be non-empty (seed 42 always produces draws)",
|
||||
);
|
||||
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
|
||||
// Verify the file carries the v5 schema marker.
|
||||
let json = fs::read_to_string(&path).expect("read json");
|
||||
assert!(
|
||||
json.contains("\"schema_version\"") && json.contains('5'),
|
||||
"saved file must use schema version 5",
|
||||
);
|
||||
|
||||
let loaded =
|
||||
load_game_state_from(&path).expect("a valid in-progress game must load without error");
|
||||
|
||||
// The forward instruction history round-trips, so the reconstructed board
|
||||
// re-serialises to byte-identical JSON.
|
||||
let path_reload = gs_path("v5_mid_game_reload");
|
||||
let _ = fs::remove_file(&path_reload);
|
||||
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).expect("read original save"),
|
||||
fs::read_to_string(&path_reload).expect("read re-saved"),
|
||||
"re-saving the loaded game must reproduce the original save exactly",
|
||||
);
|
||||
|
||||
// Derived board reads match the live game (move count + recycle count are
|
||||
// both rebuilt from the replayed forward history).
|
||||
assert_eq!(
|
||||
loaded.move_count(),
|
||||
gs.move_count(),
|
||||
"move_count round-trips"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.recycle_count(),
|
||||
gs.recycle_count(),
|
||||
"recycle_count round-trips",
|
||||
);
|
||||
// undo_count is intentionally not persisted: it resets to 0 on load.
|
||||
assert_eq!(
|
||||
loaded.undo_count(),
|
||||
0,
|
||||
"undo_count resets across save/load under schema v5",
|
||||
);
|
||||
}
|
||||
|
||||
/// A schema v3 save (instruction history using the old u8-index mirror
|
||||
/// types) is no longer loadable. The legacy migration path was dropped,
|
||||
/// so any file claiming `schema_version: 3` must be rejected and the
|
||||
/// player started on a fresh game.
|
||||
#[test]
|
||||
fn game_state_v3_is_rejected() {
|
||||
let path = gs_path("v3_reject");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
|
||||
let v3_json = r#"{
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"score": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"seed": 42,
|
||||
"undo_count": 0,
|
||||
"recycle_count": 0,
|
||||
"take_from_foundation": true,
|
||||
"schema_version": 3,
|
||||
"saved_moves": ["RotateStock"]
|
||||
}"#;
|
||||
fs::write(&path, v3_json).expect("write v3 fixture");
|
||||
|
||||
assert!(
|
||||
load_game_state_from(&path).is_none(),
|
||||
"schema v3 must be rejected (no migration path)",
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
||||
/// history). Any file claiming `schema_version: 2` must be rejected so
|
||||
/// players upgrading from an older build start with a fresh game rather
|
||||
/// than a half-reconstructed state.
|
||||
#[test]
|
||||
fn save_format_v2_is_rejected() {
|
||||
let path = gs_path("schema_v2");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Structurally valid JSON for `PersistedGameState` but with
|
||||
// `schema_version: 2`. The schema-version gate in
|
||||
// `GameState::deserialize` must reject this before replay starts.
|
||||
let v2_json = r#"{
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"score": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"seed": 42,
|
||||
"undo_count": 0,
|
||||
"recycle_count": 0,
|
||||
"take_from_foundation": true,
|
||||
"schema_version": 2,
|
||||
"saved_moves": []
|
||||
}"#;
|
||||
fs::write(&path, v2_json).expect("write v2 fixture");
|
||||
|
||||
assert!(
|
||||
load_game_state_from(&path).is_none(),
|
||||
"schema v2 game_state.json must be rejected — player must start a fresh game",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Time Attack session persistence
|
||||
//
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
|
||||
use solitaire_sync::{SyncPayload, SyncResponse};
|
||||
|
||||
use crate::{SyncError, SyncProvider};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
SyncError, SyncProvider,
|
||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||
replay::Replay,
|
||||
settings::SyncBackend,
|
||||
@@ -54,12 +58,17 @@ impl SyncProvider for LocalOnlyProvider {
|
||||
// ---------------------------------------------------------------------------
|
||||
// SolitaireServerClient
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native-only: HTTP sync client and factory function.
|
||||
// On wasm32 these are gated out because reqwest uses native OS networking
|
||||
// (mio + hyper) which does not compile for wasm32-unknown-unknown.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
|
||||
///
|
||||
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
||||
/// client automatically attempts a token refresh and retries the request once
|
||||
/// before returning an error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct SolitaireServerClient {
|
||||
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||
/// Trailing slashes are stripped on construction.
|
||||
@@ -68,8 +77,14 @@ pub struct SolitaireServerClient {
|
||||
username: String,
|
||||
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
||||
client: reqwest::Client,
|
||||
/// Serialises token refreshes. The server rotates refresh tokens and each
|
||||
/// is single-use, so two overlapping 401-retries must not both spend one:
|
||||
/// the loser's (already-consumed) token would be rejected and surface as
|
||||
/// a spurious "session expired" to the player. See [`Self::refresh_token`].
|
||||
refresh_lock: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl SolitaireServerClient {
|
||||
/// Construct a new client for the given server URL and username.
|
||||
///
|
||||
@@ -80,6 +95,7 @@ impl SolitaireServerClient {
|
||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||
username: username.into(),
|
||||
client: reqwest::Client::new(),
|
||||
refresh_lock: tokio::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +178,27 @@ impl SolitaireServerClient {
|
||||
/// The server rotates refresh tokens on each call: the response includes a
|
||||
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||
/// to the OS keychain on success.
|
||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||
///
|
||||
/// `stale_access` is the access token that just earned the caller a 401.
|
||||
/// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use
|
||||
/// rotated refresh tokens, two overlapping 401-retries (e.g. a replay
|
||||
/// upload racing a manual sync) must not both call `/api/auth/refresh` —
|
||||
/// the second call would present an already-consumed token, get rejected,
|
||||
/// and force a re-login for no user-visible reason. Whoever loses the lock
|
||||
/// race checks whether the stored access token has already moved past
|
||||
/// `stale_access`; if so the refresh already happened and there is nothing
|
||||
/// left to do.
|
||||
async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> {
|
||||
let _guard = self.refresh_lock.lock().await;
|
||||
|
||||
if let Ok(current) = load_access_token(&self.username)
|
||||
&& current != stale_access
|
||||
{
|
||||
// Another task refreshed while we waited on the lock; retry with
|
||||
// the token it stored rather than spending the new refresh token.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let old_refresh =
|
||||
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||
|
||||
@@ -201,6 +237,7 @@ impl SolitaireServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[async_trait]
|
||||
impl SyncProvider for SolitaireServerClient {
|
||||
/// Fetch the latest sync payload from the server.
|
||||
@@ -220,7 +257,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Token expired — refresh and retry once.
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -253,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Token expired — refresh and retry once.
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -320,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -355,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -395,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -435,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -469,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -486,6 +523,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl SolitaireServerClient {
|
||||
/// Pulled out of `push_replay` so both the first attempt and the
|
||||
/// post-401-retry attempt go through the same parse path.
|
||||
@@ -530,7 +568,7 @@ impl SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -581,9 +619,10 @@ impl SolitaireServerClient {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response extraction helpers
|
||||
// Response extraction helpers (native-only, use reqwest::Response)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
||||
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
||||
///
|
||||
@@ -607,6 +646,7 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
|
||||
async fn extract_leaderboard_body(
|
||||
resp: reqwest::Response,
|
||||
@@ -621,6 +661,7 @@ async fn extract_leaderboard_body(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
||||
/// statuses to the appropriate [`SyncError`].
|
||||
///
|
||||
@@ -652,6 +693,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
|
||||
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
||||
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
||||
/// and remains backend-agnostic.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||
match backend {
|
||||
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! HTTP client for the server's theme-store endpoints.
|
||||
//!
|
||||
//! Fetches the catalog (`GET /api/themes`) and downloads theme
|
||||
//! archives, verifying each download's size and SHA-256 against the
|
||||
//! catalog entry before returning the bytes. The caller (the engine's
|
||||
//! theme-store UI) hands verified bytes to the theme importer, which
|
||||
//! independently re-validates the archive's structure — the store
|
||||
//! pipeline never trusts a byte the importer hasn't checked.
|
||||
//!
|
||||
//! Native-only: gated out on wasm32 alongside the other `reqwest`
|
||||
//! consumers in this crate.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Hard cap on a theme archive download, matching the engine
|
||||
/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import,
|
||||
/// so downloading it is pure waste.
|
||||
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
/// Errors surfaced by [`ThemeStoreClient`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ThemeStoreError {
|
||||
/// The request could not be sent or the response body not read.
|
||||
#[error("network error: {0}")]
|
||||
Network(String),
|
||||
/// The server answered with a non-success status code.
|
||||
#[error("server returned HTTP {0}")]
|
||||
Http(u16),
|
||||
/// The catalog JSON did not parse.
|
||||
#[error("malformed catalog: {0}")]
|
||||
MalformedCatalog(String),
|
||||
/// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its
|
||||
/// catalog-declared size.
|
||||
#[error("archive size {got} exceeds the expected {expected} bytes")]
|
||||
Oversized { expected: u64, got: u64 },
|
||||
/// The downloaded bytes do not hash to the catalog's SHA-256 —
|
||||
/// the file changed on the server or was corrupted in transit.
|
||||
#[error("archive checksum mismatch (expected {expected}, got {got})")]
|
||||
ChecksumMismatch { expected: String, got: String },
|
||||
}
|
||||
|
||||
/// Client for one server's theme store.
|
||||
///
|
||||
/// Unauthenticated: the catalog and downloads are public endpoints,
|
||||
/// so unlike `SolitaireServerClient` there is no token handling.
|
||||
pub struct ThemeStoreClient {
|
||||
/// Base URL of the server, trailing slash stripped.
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ThemeStoreClient {
|
||||
/// Construct a client for the server at `base_url`
|
||||
/// (e.g. `"https://solitaire.example.com"`).
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the theme catalog, sorted by display name (server-side).
|
||||
pub async fn fetch_catalog(&self) -> Result<Vec<ThemeCatalogEntry>, ThemeStoreError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/themes", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||
}
|
||||
let catalog: ThemeCatalogResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?;
|
||||
Ok(catalog.themes)
|
||||
}
|
||||
|
||||
/// Download `entry`'s archive and verify it against the catalog's
|
||||
/// size and SHA-256. Returns the verified `.zip` bytes, ready for
|
||||
/// the engine's theme importer.
|
||||
pub async fn download_theme(
|
||||
&self,
|
||||
entry: &ThemeCatalogEntry,
|
||||
) -> Result<Vec<u8>, ThemeStoreError> {
|
||||
// The catalog itself could name an absurd size; refuse before
|
||||
// buffering anything.
|
||||
if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES {
|
||||
return Err(ThemeStoreError::Oversized {
|
||||
expected: MAX_THEME_DOWNLOAD_BYTES,
|
||||
got: entry.size_bytes,
|
||||
});
|
||||
}
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}{}", self.base_url, entry.download_url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||
}
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||
verify_archive(&bytes, entry)?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks downloaded `bytes` against the catalog `entry`'s declared
|
||||
/// size and SHA-256. Pure so it can be unit-tested without a server.
|
||||
fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> {
|
||||
if bytes.len() as u64 != entry.size_bytes {
|
||||
return Err(ThemeStoreError::Oversized {
|
||||
expected: entry.size_bytes,
|
||||
got: bytes.len() as u64,
|
||||
});
|
||||
}
|
||||
let got = hex_digest(bytes);
|
||||
// Case-insensitive: the server emits lowercase hex, but a
|
||||
// hand-authored catalog mirror shouldn't fail on case alone.
|
||||
if !got.eq_ignore_ascii_case(&entry.sha256) {
|
||||
return Err(ThemeStoreError::ChecksumMismatch {
|
||||
expected: entry.sha256.clone(),
|
||||
got,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest
|
||||
/// encoding in `solitaire_server::theme_store`.
|
||||
fn hex_digest(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry {
|
||||
ThemeCatalogEntry {
|
||||
id: "neon".into(),
|
||||
name: "Neon".into(),
|
||||
author: "Test".into(),
|
||||
version: "1.0.0".into(),
|
||||
card_aspect: (2, 3),
|
||||
size_bytes: bytes.len() as u64,
|
||||
sha256: hex_digest(bytes),
|
||||
download_url: "/api/themes/neon/download".into(),
|
||||
preview_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_accepts_matching_bytes() {
|
||||
let bytes = b"theme archive bytes";
|
||||
assert!(verify_archive(bytes, &entry_for(bytes)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_accepts_uppercase_catalog_hash() {
|
||||
let bytes = b"theme archive bytes";
|
||||
let mut entry = entry_for(bytes);
|
||||
entry.sha256 = entry.sha256.to_uppercase();
|
||||
assert!(verify_archive(bytes, &entry).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_size_mismatch() {
|
||||
let bytes = b"theme archive bytes";
|
||||
let mut entry = entry_for(bytes);
|
||||
entry.size_bytes += 1;
|
||||
assert!(matches!(
|
||||
verify_archive(bytes, &entry),
|
||||
Err(ThemeStoreError::Oversized { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_checksum_mismatch() {
|
||||
let bytes = b"theme archive bytes";
|
||||
let mut entry = entry_for(bytes);
|
||||
entry.sha256 = "0".repeat(64);
|
||||
assert!(matches!(
|
||||
verify_archive(bytes, &entry),
|
||||
Err(ThemeStoreError::ChecksumMismatch { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
|
||||
/// XP awarded each time a weekly goal is just completed.
|
||||
pub const WEEKLY_GOAL_XP: u64 = 75;
|
||||
@@ -36,7 +36,7 @@ pub struct WeeklyGoalDef {
|
||||
pub struct WeeklyGoalContext {
|
||||
pub time_seconds: u64,
|
||||
pub used_undo: bool,
|
||||
pub draw_mode: DrawMode,
|
||||
pub draw_mode: DrawStockConfig,
|
||||
}
|
||||
|
||||
impl WeeklyGoalDef {
|
||||
@@ -47,7 +47,7 @@ impl WeeklyGoalDef {
|
||||
WeeklyGoalKind::WinGame => true,
|
||||
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
||||
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
||||
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
|
||||
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawStockConfig::DrawThree,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ mod tests {
|
||||
WeeklyGoalContext {
|
||||
time_seconds: time,
|
||||
used_undo: undo,
|
||||
draw_mode: DrawMode::DrawOne,
|
||||
draw_mode: DrawStockConfig::DrawOne,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ mod tests {
|
||||
WeeklyGoalContext {
|
||||
time_seconds: time,
|
||||
used_undo: false,
|
||||
draw_mode: DrawMode::DrawThree,
|
||||
draw_mode: DrawStockConfig::DrawThree,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! End-to-end theme-store test: a real `solitaire_server` router
|
||||
//! serving a scanned catalog over a localhost TCP socket, consumed by
|
||||
//! [`solitaire_data::ThemeStoreClient`].
|
||||
//!
|
||||
//! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0,
|
||||
//! router in a background `tokio::spawn`, no explicit shutdown.
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use solitaire_data::ThemeStoreClient;
|
||||
use solitaire_server::theme_store::ThemeStore;
|
||||
|
||||
/// Minimal theme archive: the catalog scan only reads the `meta`
|
||||
/// block, so no face SVGs are needed.
|
||||
fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf {
|
||||
let manifest = format!(
|
||||
r#"(
|
||||
meta: (
|
||||
id: "{id}",
|
||||
name: "{name}",
|
||||
author: "Round Trip",
|
||||
version: "1.0.0",
|
||||
card_aspect: (2, 3),
|
||||
),
|
||||
faces: {{}},
|
||||
back: "back.svg",
|
||||
)"#
|
||||
);
|
||||
let path = dir.join(file_name);
|
||||
let file = std::fs::File::create(&path).expect("create zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
writer.start_file("theme.ron", options).expect("start_file");
|
||||
writer
|
||||
.write_all(manifest.as_bytes())
|
||||
.expect("write manifest");
|
||||
writer.finish().expect("finish zip");
|
||||
path
|
||||
}
|
||||
|
||||
/// Spawn the test server with a theme store scanned from `store_dir`
|
||||
/// and return its base URL.
|
||||
async fn spawn_store_server(store_dir: &Path) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("failed to bind test listener");
|
||||
let addr = listener.local_addr().expect("listener has no local addr");
|
||||
|
||||
let pool = solitaire_server::build_test_pool().await;
|
||||
let app =
|
||||
solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir));
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
eprintln!("test server crashed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
/// Catalog fetch → download → verified bytes match the file the
|
||||
/// server scanned. This is the exact path the engine's store UI runs.
|
||||
#[tokio::test]
|
||||
async fn catalog_fetch_and_verified_download_round_trip() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||
let base_url = spawn_store_server(dir.path()).await;
|
||||
|
||||
let client = ThemeStoreClient::new(&base_url);
|
||||
let catalog = client.fetch_catalog().await.expect("fetch catalog");
|
||||
assert_eq!(catalog.len(), 1);
|
||||
let entry = &catalog[0];
|
||||
assert_eq!(entry.id, "neon");
|
||||
|
||||
let bytes = client.download_theme(entry).await.expect("download");
|
||||
assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip"));
|
||||
}
|
||||
|
||||
/// A catalog entry whose hash no longer matches the served file must
|
||||
/// be rejected client-side — the importer never sees unverified bytes.
|
||||
#[tokio::test]
|
||||
async fn download_with_stale_catalog_hash_is_rejected() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||
let base_url = spawn_store_server(dir.path()).await;
|
||||
|
||||
let client = ThemeStoreClient::new(&base_url);
|
||||
let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone();
|
||||
entry.sha256 = "0".repeat(64);
|
||||
|
||||
let err = client
|
||||
.download_theme(&entry)
|
||||
.await
|
||||
.expect_err("mismatched hash must fail");
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
solitaire_data::ThemeStoreError::ChecksumMismatch { .. }
|
||||
),
|
||||
"expected ChecksumMismatch, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty (or absent) store directory serves an empty catalog — the
|
||||
/// client sees a store with nothing in it, not an error.
|
||||
#[tokio::test]
|
||||
async fn empty_store_yields_empty_catalog() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let base_url = spawn_store_server(dir.path()).await;
|
||||
|
||||
let client = ThemeStoreClient::new(&base_url);
|
||||
let catalog = client.fetch_catalog().await.expect("fetch catalog");
|
||||
assert!(catalog.is_empty());
|
||||
}
|
||||
+19
-11
@@ -7,14 +7,11 @@ edition.workspace = true
|
||||
[dependencies]
|
||||
bevy = { workspace = true }
|
||||
image = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
kira = { workspace = true }
|
||||
solitaire_core = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
solitaire_sync = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -22,17 +19,24 @@ usvg = { workspace = true }
|
||||
resvg = { workspace = true }
|
||||
tiny-skia = { workspace = true }
|
||||
ron = { workspace = true }
|
||||
|
||||
# These deps are not available / not needed on wasm32:
|
||||
# reqwest — uses mio/hyper native networking (sync plugin is gated out)
|
||||
# kira — uses cpal OS audio (audio plugin is gated out)
|
||||
# tokio — multi-threaded runtime (TokioRuntimeResource is gated out)
|
||||
# dirs — platform data directories (storage uses WasmStorage instead)
|
||||
# zip — theme ZIP importer (importer is gated out on wasm32)
|
||||
# arboard — clipboard (no wasm backend; stats copy-link uses localStorage)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
reqwest = { workspace = true }
|
||||
kira = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
# `arboard` provides clipboard access for the Stats overlay's
|
||||
# "Copy share link" button. The crate has no Android backend
|
||||
# (its `platform::Clipboard` module is unimplemented for the
|
||||
# android target — `cargo apk build` fails with E0433 if this is
|
||||
# left unconditional). On Android the same button surfaces an
|
||||
# informational toast instead; see
|
||||
# `stats_plugin::handle_copy_share_link_button`.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
# `arboard` has no Android backend and no wasm32 backend. Gate it out for
|
||||
# both; the copy-share-link button surfaces an informational toast instead.
|
||||
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
|
||||
arboard = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
@@ -47,3 +51,7 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
solitaire_core = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
|
||||
};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
|
||||
@@ -116,7 +112,7 @@ impl Plugin for AchievementPlugin {
|
||||
// achievements-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
.add_message::<TouchInput>()
|
||||
// Run after GameMutation (so GameWonEvent is available), after
|
||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
|
||||
.after(GameMutation)
|
||||
.after(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content + scroll.
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -176,9 +172,9 @@ fn evaluate_on_win(
|
||||
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||
last_win_score: ev.score,
|
||||
last_win_time_seconds: ev.time_seconds,
|
||||
last_win_used_undo: game.0.undo_count > 0,
|
||||
last_win_used_undo: game.0.undo_count() > 0,
|
||||
wall_clock_hour: Some(Local::now().hour()),
|
||||
last_win_recycle_count: game.0.recycle_count,
|
||||
last_win_recycle_count: game.0.recycle_count(),
|
||||
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||
};
|
||||
|
||||
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String {
|
||||
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
|
||||
}
|
||||
|
||||
/// Marker on the "Done" button inside the Achievements modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementsCloseButton;
|
||||
|
||||
/// Toggle the achievements overlay — `A` keyboard accelerator or
|
||||
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
|
||||
fn toggle_achievements_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
|
||||
achievements: Res<AchievementsResource>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<AchievementsScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else if other_modal_scrims.is_empty() {
|
||||
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button — despawns the overlay
|
||||
/// the same way the `A` accelerator does.
|
||||
fn handle_achievements_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<AchievementsScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||
/// while the panel is open.
|
||||
///
|
||||
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_achievements_screen(
|
||||
commands: &mut Commands,
|
||||
/// Builds the Achievements tab body inside the You hub's card. The
|
||||
/// unlock-count line that used to live in the standalone modal's
|
||||
/// header renders as the first body line instead (the hub owns the
|
||||
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
|
||||
/// are unchanged.
|
||||
pub(crate) fn spawn_achievements_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
records: &[AchievementRecord],
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
||||
let total = ALL_ACHIEVEMENTS.len();
|
||||
let header = format!("Achievements ({}/{})", unlocked.len(), total);
|
||||
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_name = TextFont {
|
||||
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
|
||||
|
||||
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||
|
||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, header, font_res);
|
||||
{
|
||||
// Unlock progress — formerly the standalone modal's header.
|
||||
card.spawn((
|
||||
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
|
||||
font_name.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// First-time hint — shown until the player has unlocked anything.
|
||||
// The list itself describes individual rewards, but a top-level
|
||||
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
AchievementsCloseButton,
|
||||
"Done",
|
||||
Some("A"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Achievements is a read-only list — clicking the scrim outside
|
||||
// the card dismisses alongside the existing A / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reward(reward: Reward) -> String {
|
||||
@@ -668,10 +618,11 @@ mod tests {
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::headless())
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
.add_plugins(AchievementPlugin::headless())
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||
// MinimalPlugins it isn't auto-registered.
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -779,7 +730,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.undo_count = 1;
|
||||
.force_test_undos(1);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1000,
|
||||
@@ -819,7 +770,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||
.set_test_draw_mode(DrawStockConfig::DrawThree);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -868,7 +819,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||
.set_test_draw_mode(DrawStockConfig::DrawThree);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -911,8 +862,7 @@ mod tests {
|
||||
|
||||
// Put the active game in Zen mode. evaluate_on_win reads
|
||||
// GameStateResource.mode directly to populate last_win_is_zen.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||
solitaire_core::game_state::GameMode::Zen;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
@@ -946,7 +896,7 @@ mod tests {
|
||||
// Default GameMode is Classic; assert and rely on it.
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.mode,
|
||||
solitaire_core::game_state::GameMode::Classic
|
||||
GameMode::Classic
|
||||
);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
@@ -1250,7 +1200,7 @@ mod tests {
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -1393,8 +1343,8 @@ mod tests {
|
||||
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
use solitaire_data::Replay;
|
||||
|
||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
||||
@@ -1409,12 +1359,12 @@ mod tests {
|
||||
fn dummy_replay() -> Replay {
|
||||
Replay::new(
|
||||
1,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -204,3 +204,61 @@ fn mode_str(mode: GameMode) -> &'static str {
|
||||
GameMode::Difficulty(_) => "difficulty",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use solitaire_core::game_state::DifficultyLevel;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_for_requires_analytics_opt_in() {
|
||||
let settings = Settings {
|
||||
analytics_enabled: false,
|
||||
matomo_url: Some("https://analytics.example.com".into()),
|
||||
..Settings::default()
|
||||
};
|
||||
|
||||
assert!(client_for(&settings).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_for_requires_matomo_url() {
|
||||
let settings = Settings {
|
||||
analytics_enabled: true,
|
||||
matomo_url: None,
|
||||
..Settings::default()
|
||||
};
|
||||
|
||||
assert!(client_for(&settings).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_for_creates_client_when_enabled_and_configured() {
|
||||
let settings = Settings {
|
||||
analytics_enabled: true,
|
||||
matomo_url: Some("https://analytics.example.com".into()),
|
||||
matomo_site_id: 2,
|
||||
sync_backend: SyncBackend::SolitaireServer {
|
||||
url: "https://solitaire.example.com".into(),
|
||||
username: "alice".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
..Settings::default()
|
||||
};
|
||||
|
||||
assert!(client_for(&settings).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_labels_match_analytics_payload_contract() {
|
||||
assert_eq!(mode_str(GameMode::Classic), "classic");
|
||||
assert_eq!(mode_str(GameMode::Zen), "zen");
|
||||
assert_eq!(mode_str(GameMode::Challenge), "challenge");
|
||||
assert_eq!(mode_str(GameMode::TimeAttack), "time_attack");
|
||||
assert_eq!(
|
||||
mode_str(GameMode::Difficulty(DifficultyLevel::Grandmaster)),
|
||||
"difficulty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
/// Android clipboard bridge via JNI.
|
||||
///
|
||||
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
||||
/// through the JNI. Only compiled and linked on `target_os = "android"`.
|
||||
/// through the safe [`solitaire_data::android_jni`] bridge. Only compiled and
|
||||
/// linked on `target_os = "android"`.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn set_text(text: &str) -> Result<(), String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{
|
||||
JavaVM,
|
||||
objects::{JObject, JValueOwned},
|
||||
};
|
||||
use jni::objects::JValueOwned;
|
||||
use solitaire_data::android_jni;
|
||||
|
||||
let app = ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||
|
||||
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
|
||||
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
|
||||
// valid for the lifetime of the process.
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
||||
|
||||
(|| -> jni::errors::Result<()> {
|
||||
android_jni::with_activity_env(|env, activity| {
|
||||
// ClipboardManager cm = activity.getSystemService("clipboard")
|
||||
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
||||
let cm = env
|
||||
.call_method(
|
||||
&activity,
|
||||
activity,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[svc_name.borrow()],
|
||||
@@ -60,6 +42,5 @@ pub fn set_text(text: &str) -> Result<(), String> {
|
||||
&[clip_val.borrow()],
|
||||
)?
|
||||
.v()
|
||||
})()
|
||||
.map_err(|e| format!("clipboard JNI: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
use solitaire_data::{AnimSpeed, Settings};
|
||||
|
||||
use crate::achievement_plugin::display_name_for;
|
||||
@@ -180,6 +181,7 @@ impl Plugin for AnimationPlugin {
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.init_resource::<EffectiveSlideDuration>()
|
||||
.init_resource::<ToastQueue>()
|
||||
.init_resource::<ActiveToast>()
|
||||
@@ -250,10 +252,17 @@ fn advance_card_anims(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the winit loop awake at full frame rate while slides (including
|
||||
// staggered deals still in their delay phase) are in flight — required
|
||||
// for Android's reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
if anim.delay > 0.0 {
|
||||
@@ -352,7 +361,7 @@ fn handle_win_cascade(
|
||||
end: target.truncate(),
|
||||
elapsed: 0.0,
|
||||
duration,
|
||||
curve: crate::card_animation::MotionCurve::Expressive,
|
||||
curve: MotionCurve::Expressive,
|
||||
delay: i as f32 * step,
|
||||
start_z: start.z,
|
||||
end_z: target.z,
|
||||
@@ -556,10 +565,17 @@ fn drive_toast_display(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut queue: ResMut<ToastQueue>,
|
||||
mut active: ResMut<ActiveToast>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the loop ticking while a toast is displayed or queued so the
|
||||
// countdown advances and the despawn frame isn't held hostage by
|
||||
// Android's reactive_low_power wake ceiling.
|
||||
if active.entity.is_some() || !queue.0.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Tick down the active toast timer.
|
||||
@@ -591,9 +607,9 @@ pub enum ToastVariant {
|
||||
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
||||
/// settings volume notifications, and the auto-complete announcement.
|
||||
Info,
|
||||
/// Caution / penalty — gold border. Currently unused by an in-engine
|
||||
/// event; kept so future warning-flavoured toasts have a slot.
|
||||
#[allow(dead_code)]
|
||||
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
|
||||
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
|
||||
/// theme-store, and leaderboard warnings).
|
||||
Warning,
|
||||
/// Failure / rejected action — pink border. Used by
|
||||
/// [`handle_move_rejected_toast`] for illegal-placement
|
||||
@@ -1076,7 +1092,7 @@ mod tests {
|
||||
// Pairs the existing audio (`card_invalid.wav`) and visual
|
||||
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
|
||||
// with an accessibility-focused readable text cue.
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||
|
||||
@@ -1088,8 +1104,8 @@ mod tests {
|
||||
.count();
|
||||
|
||||
app.world_mut().write_message(MoveRejectedEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Tableau(1),
|
||||
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! red/black colour split.
|
||||
|
||||
use bevy::math::UVec2;
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
use solitaire_core::{Rank, Suit};
|
||||
|
||||
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
||||
/// `SvgLoaderSettings` resolution).
|
||||
@@ -74,9 +74,11 @@ pub const ALL_RANKS: [Rank; 13] = [
|
||||
Rank::King,
|
||||
];
|
||||
|
||||
/// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches
|
||||
/// `card_plugin::load_card_images` so the suit index used here lines
|
||||
/// up with `CardImageSet.faces[suit]`.
|
||||
/// Iteration order for the SVG generator and the pin test only —
|
||||
/// output files are keyed by `suit_filename`, so no runtime index
|
||||
/// depends on this order. Kept local (not `Suit::SUITS`) because
|
||||
/// reordering would churn the pinned snapshot ordering for no
|
||||
/// benefit.
|
||||
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
|
||||
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
|
||||
|
||||
@@ -47,12 +47,16 @@
|
||||
//! comments on each call out the pairing so a future reader doesn't
|
||||
//! accidentally drop one half.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::asset::AssetApp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::asset::io::AssetSourceBuilder;
|
||||
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::asset::io::file::FileAssetReader;
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::assets::user_dir::user_theme_dir;
|
||||
|
||||
/// `AssetSourceId` of the user-themes asset source. Use it as
|
||||
@@ -111,6 +115,10 @@ macro_rules! embed_classic_svg {
|
||||
}
|
||||
|
||||
/// Every Dark-theme SVG file bundled into the binary.
|
||||
// The `as &[u8]` in `embed_dark_svg!` coerces each fixed-size
|
||||
// `&[u8; N]` (N varies per file) to a uniform `&[u8]` so the tuples fit
|
||||
// this array type. The cast is load-bearing, not trivial.
|
||||
#[allow(trivial_casts)]
|
||||
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
embed_dark_svg!("back.svg"),
|
||||
embed_dark_svg!("clubs_ace.svg"),
|
||||
@@ -168,6 +176,8 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
];
|
||||
|
||||
/// Every Classic-theme SVG file bundled into the binary.
|
||||
// See `DARK_THEME_SVGS`: the `as &[u8]` cast is load-bearing.
|
||||
#[allow(trivial_casts)]
|
||||
const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
embed_classic_svg!("back.svg"),
|
||||
embed_classic_svg!("clubs_ace.svg"),
|
||||
@@ -235,11 +245,16 @@ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
/// Returns the `&mut App` so the call can be chained from the binary
|
||||
/// entry point.
|
||||
pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
|
||||
let root = user_theme_dir();
|
||||
app.register_asset_source(
|
||||
USER_THEMES,
|
||||
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
||||
);
|
||||
// User themes are stored on the filesystem; wasm32 has no filesystem and
|
||||
// `FileAssetReader` is not available on that target.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let root = user_theme_dir();
|
||||
app.register_asset_source(
|
||||
USER_THEMES,
|
||||
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
||||
);
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
|
||||
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
||||
use usvg::FontResolver;
|
||||
|
||||
usvg::FontResolver {
|
||||
FontResolver {
|
||||
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
|
||||
select_fallback: FontResolver::default_fallback_selector(),
|
||||
}
|
||||
@@ -282,7 +282,7 @@ mod tests {
|
||||
/// tightens.
|
||||
#[test]
|
||||
fn settings_satisfies_loader_bounds() {
|
||||
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
|
||||
fn assert_loader_settings<T: Default + Serialize + serde::de::DeserializeOwned>() {}
|
||||
assert_loader_settings::<SvgLoaderSettings>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
|
||||
/// Returns the absolute path of the user-themes directory on the
|
||||
/// current platform.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
|
||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
|
||||
/// Android always returns `Some`. The panic message names the
|
||||
/// supported workaround ([`set_user_theme_dir`]).
|
||||
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
|
||||
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
|
||||
/// returns an empty path — callers treat that as "no user themes" and
|
||||
/// the bundled default theme still works. A warning naming the
|
||||
/// [`set_user_theme_dir`] workaround is logged once. Android always
|
||||
/// resolves.
|
||||
pub fn user_theme_dir() -> PathBuf {
|
||||
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
||||
return p.clone();
|
||||
@@ -76,19 +76,32 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
||||
/// Per-target-os resolution of the platform's data dir. Delegates
|
||||
/// to [`solitaire_data::data_dir`] which encapsulates the
|
||||
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
||||
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
|
||||
/// only when the underlying resolver returns `None`, which on
|
||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
|
||||
/// the panic message names the supported workaround.
|
||||
/// hardcoded `/data/data/<package>/files` sandbox path).
|
||||
///
|
||||
/// When the resolver returns `None` — always on wasm32 (no
|
||||
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
|
||||
/// desktop — this degrades to an empty path, which downstream theme
|
||||
/// scanning treats as "no user themes"; the bundled default theme is
|
||||
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
|
||||
/// themes must not take the whole game down with it.
|
||||
fn detected_platform_data_dir() -> PathBuf {
|
||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"user_theme_dir(): platform data directory is unavailable. \
|
||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||
the OS reported no Application Support / AppData path. \
|
||||
As a workaround call solitaire_engine::assets::user_dir::\
|
||||
set_user_theme_dir() before App::run()."
|
||||
)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::sync::Once;
|
||||
static WARN_ONCE: Once = Once::new();
|
||||
WARN_ONCE.call_once(|| {
|
||||
bevy::log::warn!(
|
||||
"user_theme_dir(): platform data directory is unavailable; \
|
||||
user themes are disabled. On Linux check $XDG_DATA_HOME or \
|
||||
$HOME; on macOS / Windows the OS reported no Application \
|
||||
Support / AppData path. As a workaround call \
|
||||
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
||||
before App::run()."
|
||||
);
|
||||
});
|
||||
}
|
||||
PathBuf::new()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sound-effect playback via `kira`.
|
||||
//!
|
||||
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
|
||||
//! response to gameplay events:
|
||||
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus
|
||||
//! the ambient loop — and plays them in response to gameplay events:
|
||||
//!
|
||||
//! | Event | Sound |
|
||||
//! |---|---|
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
|
||||
//!
|
||||
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track`.
|
||||
@@ -34,12 +35,11 @@ use crate::events::{
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||
const RECYCLE_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Volume amplitude for the ambient music loop placeholder.
|
||||
/// Volume amplitude for the ambient music loop.
|
||||
const AMBIENT_VOLUME: f64 = 0.05;
|
||||
|
||||
/// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by
|
||||
@@ -102,7 +102,7 @@ pub struct MuteState {
|
||||
pub music_muted: bool,
|
||||
}
|
||||
|
||||
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||
/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||
pub struct AudioPlugin;
|
||||
|
||||
impl Plugin for AudioPlugin {
|
||||
@@ -374,10 +374,7 @@ fn play_on_draw(
|
||||
// When the stock pile is empty the draw action recycles the waste pile
|
||||
// back to stock. Play the flip sound at half volume to give audible
|
||||
// feedback that distinguishes a recycle from a normal draw.
|
||||
let stock_len = game
|
||||
.as_ref()
|
||||
.and_then(|g| g.0.piles.get(&PileType::Stock))
|
||||
.map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound
|
||||
let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
|
||||
|
||||
if is_recycle(stock_len) {
|
||||
let mut data = lib.flip.clone();
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
@@ -20,11 +22,18 @@ use crate::resources::GameStateResource;
|
||||
///
|
||||
/// Plays the win fanfare at half volume so it is clearly distinguishable from
|
||||
/// both normal card-place sounds and the full win fanfare that fires later.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Seconds between consecutive auto-complete moves.
|
||||
const STEP_INTERVAL: f32 = 0.12;
|
||||
|
||||
/// Seconds to wait after detection before firing the first auto-complete move.
|
||||
///
|
||||
/// This pause gives the player a moment to register that the game is
|
||||
/// transitioning into auto-complete mode before cards start moving.
|
||||
const AUTO_COMPLETE_INITIAL_DELAY: f32 = 0.75;
|
||||
|
||||
/// Tracks whether auto-complete is active and when the next move fires.
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct AutoCompleteState {
|
||||
@@ -37,18 +46,27 @@ pub struct AutoCompleteState {
|
||||
/// Plugin that drives the auto-complete sequence.
|
||||
pub struct AutoCompletePlugin;
|
||||
|
||||
/// Set wrapping the auto-complete detect/drive chain; HUD readers of
|
||||
/// [`AutoCompleteState`] order themselves after it (#143).
|
||||
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AutoComplete;
|
||||
|
||||
impl Plugin for AutoCompletePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<AutoCompleteState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
detect_auto_complete,
|
||||
on_auto_complete_start,
|
||||
drive_auto_complete,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
app.init_resource::<AutoCompleteState>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
detect_auto_complete,
|
||||
on_auto_complete_start,
|
||||
drive_auto_complete,
|
||||
)
|
||||
.chain()
|
||||
.in_set(AutoComplete)
|
||||
.after(GameMutation)
|
||||
.before(crate::card_plugin::BoardVisuals),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,21 +83,28 @@ fn detect_auto_complete(
|
||||
}
|
||||
changed.clear();
|
||||
|
||||
if game.0.is_won {
|
||||
if game.0.is_won() {
|
||||
state.active = false;
|
||||
return;
|
||||
}
|
||||
if game.0.is_auto_completable && !state.active {
|
||||
if game.0.is_auto_completable() && !state.active {
|
||||
state.active = true;
|
||||
state.cooldown = 0.0; // fire first move immediately
|
||||
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
||||
} else if !game.0.is_auto_completable() && state.active {
|
||||
// `is_auto_completable` only becomes false after an explicit undo
|
||||
// (which puts a card back on the tableau or re-fills the stock/waste)
|
||||
// or a new-game reset — never as a transient gap during a normal
|
||||
// auto-complete sequence. Deactivate here so `drive_auto_complete`
|
||||
// does not keep retrying indefinitely after the player undoes out of
|
||||
// the sequence.
|
||||
//
|
||||
// Note: the transient-`None` case mentioned in older versions of this
|
||||
// comment referred to `next_auto_complete_move()` returning `None`, not
|
||||
// to `is_auto_completable` being false. Those are independent fields;
|
||||
// `drive_auto_complete` still retries on a transient `None` return from
|
||||
// `next_auto_complete_move` because that check happens there, not here.
|
||||
state.active = false;
|
||||
}
|
||||
// Intentionally no `else if !is_auto_completable` branch here.
|
||||
// Deactivating on every frame where `is_auto_completable` is false
|
||||
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
|
||||
// transiently returns `None` (e.g. while the previous move is still
|
||||
// in-flight). The `is_won` check above already handles the definitive
|
||||
// end-of-game case; `drive_auto_complete` simply retries next tick
|
||||
// when no move is available yet.
|
||||
}
|
||||
|
||||
/// Plays a distinct chime the moment auto-complete first activates.
|
||||
@@ -88,6 +113,7 @@ fn detect_auto_complete(
|
||||
/// exactly once on the `false → true` edge. The win fanfare is played at half
|
||||
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
|
||||
/// not overwhelm the card-place sounds that follow immediately.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn on_auto_complete_start(
|
||||
state: Res<AutoCompleteState>,
|
||||
mut was_active: Local<bool>,
|
||||
@@ -108,6 +134,12 @@ fn on_auto_complete_start(
|
||||
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
|
||||
}
|
||||
|
||||
// No audio on wasm — stub keeps the system registration unconditional.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn on_auto_complete_start(state: Res<AutoCompleteState>, mut was_active: Local<bool>) {
|
||||
*was_active = state.active;
|
||||
}
|
||||
|
||||
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
||||
fn drive_auto_complete(
|
||||
mut state: ResMut<AutoCompleteState>,
|
||||
@@ -115,6 +147,7 @@ fn drive_auto_complete(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if !state.active {
|
||||
return;
|
||||
@@ -122,6 +155,10 @@ fn drive_auto_complete(
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keepalive: the step-interval cooldown only advances on frames that
|
||||
// actually run, so keep the winit loop awake for the whole burst under
|
||||
// Android's reactive_low_power focused_mode.
|
||||
redraw.write(RequestRedraw);
|
||||
|
||||
state.cooldown -= time.delta_secs();
|
||||
if state.cooldown > 0.0 {
|
||||
@@ -142,9 +179,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::{Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -152,36 +189,49 @@ mod tests {
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(AutoCompletePlugin);
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other
|
||||
/// tableau piles empty, stock/waste empty, Clubs foundation empty.
|
||||
fn nearly_won_state() -> GameState {
|
||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
||||
let mut g = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
g.set_test_foundation_cards(foundation, Vec::new());
|
||||
}
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 99,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
g.is_auto_completable = true;
|
||||
g
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
g.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![solitaire_core::Card::new(
|
||||
Deck::Deck1,
|
||||
Suit::Clubs,
|
||||
Rank::Ace,
|
||||
)],
|
||||
);
|
||||
g.set_test_auto_completable(true);
|
||||
let expected = (
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
);
|
||||
assert_eq!(g.next_auto_complete_move(), Some(expected));
|
||||
(g, expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,8 +243,9 @@ mod tests {
|
||||
#[test]
|
||||
fn detect_activates_when_auto_completable() {
|
||||
let mut app = headless_app();
|
||||
// Install a nearly-won state and fire StateChangedEvent.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
g.set_test_auto_completable(true);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
@@ -204,9 +255,14 @@ mod tests {
|
||||
#[test]
|
||||
fn drive_fires_move_request_when_active() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update(); // detect runs, sets active
|
||||
|
||||
// Zero out the cooldown so drive fires on the next update regardless
|
||||
// of the initial delay constant.
|
||||
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
|
||||
app.update(); // drive fires the move
|
||||
|
||||
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||
@@ -214,17 +270,16 @@ mod tests {
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
// At least one MoveRequestEvent should have been fired.
|
||||
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
||||
assert_eq!(fired[0].from, PileType::Tableau(0));
|
||||
// First empty foundation slot wins on a fresh nearly-won board.
|
||||
assert_eq!(fired[0].to, PileType::Foundation(0));
|
||||
assert_eq!(fired[0].from, expected_from);
|
||||
assert_eq!(fired[0].to, expected_to);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_deactivates_on_win() {
|
||||
let mut app = headless_app();
|
||||
// Inject a won game state — active should not be set.
|
||||
let mut gs = nearly_won_state();
|
||||
gs.is_won = true;
|
||||
let (mut gs, _) = seeded_state_with_auto_move();
|
||||
gs.set_test_won(true);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
@@ -36,7 +36,7 @@ pub struct AvatarFetchEvent {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl bevy::prelude::Message for AvatarFetchEvent {}
|
||||
impl Message for AvatarFetchEvent {}
|
||||
|
||||
/// In-flight avatar download task. Returns the raw image bytes on success,
|
||||
/// or `None` on any network / decode error.
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
|
||||
//! card "floats up" in the middle of its travel and lands at its correct rest z.
|
||||
//!
|
||||
//! # Retargeting
|
||||
//!
|
||||
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
|
||||
//! the current interpolated position so the card never snaps.
|
||||
//!
|
||||
//! # Coexistence with `CardAnim`
|
||||
//!
|
||||
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
|
||||
@@ -33,6 +28,7 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
use super::curves::{MotionCurve, sample_curve};
|
||||
use super::timing::compute_duration;
|
||||
@@ -122,8 +118,6 @@ impl CardAnimation {
|
||||
}
|
||||
|
||||
/// Returns the current interpolated XY position without advancing time.
|
||||
///
|
||||
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
|
||||
pub fn current_xy(&self) -> Vec2 {
|
||||
if self.duration <= 0.0 {
|
||||
return self.end;
|
||||
@@ -134,90 +128,6 @@ impl CardAnimation {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retarget helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Redirects a card to a new destination without snapping or interrupting motion.
|
||||
///
|
||||
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
|
||||
/// if present, or from `Transform` if stationary) and starts a fresh
|
||||
/// [`CardAnimation`] from that position. Duration is recalculated from the
|
||||
/// remaining distance so short paths stay quick.
|
||||
///
|
||||
/// # Velocity continuity
|
||||
///
|
||||
/// When a card is mid-flight, the new animation starts with a small positive
|
||||
/// `elapsed` offset (`carry`) derived from how far through the current animation
|
||||
/// the card is. This preserves a sense of forward momentum: the new curve does
|
||||
/// not restart from zero velocity, avoiding a visible "lurch" when the target
|
||||
/// changes rapidly.
|
||||
///
|
||||
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
|
||||
/// never causes a visible position jump — the card's start position is still
|
||||
/// read from the current transform.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Inside a system that decides to move a card to a new target:
|
||||
/// let (entity, transform, anim) = cards.get(card_entity)?;
|
||||
/// retarget_animation(
|
||||
/// &mut commands,
|
||||
/// entity,
|
||||
/// anim, // Option<&CardAnimation>
|
||||
/// transform,
|
||||
/// Vec2::new(400.0, 200.0),
|
||||
/// resting_z,
|
||||
/// MotionCurve::SmoothSnap,
|
||||
/// );
|
||||
/// ```
|
||||
pub fn retarget_animation(
|
||||
commands: &mut Commands,
|
||||
entity: Entity,
|
||||
current_anim: Option<&CardAnimation>,
|
||||
transform: &Transform,
|
||||
new_end: Vec2,
|
||||
new_end_z: f32,
|
||||
curve: MotionCurve,
|
||||
) {
|
||||
let (current_xy, current_z, momentum_carry) = match current_anim {
|
||||
Some(anim) if anim.duration > 0.0 => {
|
||||
// Estimate how far into the current animation we are and carry
|
||||
// a small fraction of that progress into the new animation.
|
||||
// This avoids restarting from zero velocity and makes the motion
|
||||
// feel continuous when the target changes mid-flight.
|
||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||
// Cap at 10 % of the new animation so there's no visible jump.
|
||||
let carry = (t * 0.12).min(0.10);
|
||||
(anim.current_xy(), transform.translation.z, carry)
|
||||
}
|
||||
_ => (
|
||||
transform.translation.truncate(),
|
||||
transform.translation.z,
|
||||
0.0,
|
||||
),
|
||||
};
|
||||
|
||||
let distance = current_xy.distance(new_end);
|
||||
let duration = compute_duration(distance);
|
||||
|
||||
commands.entity(entity).insert(CardAnimation {
|
||||
start: current_xy,
|
||||
end: new_end,
|
||||
// Start slightly into the new animation to carry forward momentum.
|
||||
elapsed: momentum_carry * duration,
|
||||
duration,
|
||||
curve,
|
||||
delay: 0.0,
|
||||
start_z: current_z,
|
||||
end_z: new_end_z,
|
||||
z_lift: 8.0,
|
||||
scale_start: 1.0,
|
||||
scale_end: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,10 +142,18 @@ pub(crate) fn advance_card_animations(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the winit event loop awake while any animation (including one
|
||||
// still in its delay phase) needs per-frame ticks. Without this,
|
||||
// Android's reactive_low_power focused_mode only wakes at its 100 ms
|
||||
// ceiling and card slides render at ~10 fps.
|
||||
if !q.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
for (entity, mut transform, mut anim) in &mut q {
|
||||
|
||||
@@ -33,6 +33,7 @@ use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::Card;
|
||||
|
||||
use super::animation::CardAnimation;
|
||||
use super::tuning::AnimationTuning;
|
||||
@@ -71,9 +72,7 @@ pub struct HoverState {
|
||||
/// Describes a user action that arrived while cards were still animating.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BufferedInput {
|
||||
Move {
|
||||
from: crate::events::MoveRequestEvent,
|
||||
},
|
||||
Move { from: MoveRequestEvent },
|
||||
Draw,
|
||||
Undo,
|
||||
}
|
||||
@@ -210,12 +209,12 @@ pub(crate) fn apply_drag_visual(
|
||||
|
||||
// Only lift cards that are in a *committed* drag. Pending drags (below
|
||||
// threshold) must stay at scale 1.0 to avoid visible premature lift.
|
||||
let (dragged_ids, committed): (&[u32], bool) = drag
|
||||
let (dragged_cards, committed): (&[Card], bool) = drag
|
||||
.as_ref()
|
||||
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
|
||||
|
||||
for (_, card, mut transform) in &mut cards {
|
||||
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
|
||||
let is_active_drag = committed && dragged_cards.contains(&card.card);
|
||||
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
|
||||
let current = transform.scale.x;
|
||||
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
||||
|
||||
@@ -31,28 +31,6 @@
|
||||
//! ));
|
||||
//! ```
|
||||
//!
|
||||
//! Retarget a card mid-flight:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use solitaire_engine::card_animation::retarget_animation;
|
||||
//!
|
||||
//! fn handle_drop(
|
||||
//! mut commands: Commands,
|
||||
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
|
||||
//! ) {
|
||||
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
|
||||
//! retarget_animation(
|
||||
//! &mut commands,
|
||||
//! entity,
|
||||
//! anim,
|
||||
//! transform,
|
||||
//! new_target_xy,
|
||||
//! new_target_z,
|
||||
//! MotionCurve::SmoothSnap,
|
||||
//! );
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Win cascade with `Expressive` curve
|
||||
//!
|
||||
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
||||
@@ -80,7 +58,7 @@ pub mod interaction;
|
||||
pub mod timing;
|
||||
pub mod tuning;
|
||||
|
||||
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
|
||||
pub use animation::{CardAnimation, win_scatter_targets};
|
||||
pub use chain::AnimationChain;
|
||||
pub use curves::{MotionCurve, sample_curve};
|
||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||
@@ -92,6 +70,7 @@ pub use timing::{
|
||||
pub use tuning::{AnimationTuning, InputPlatform};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||
@@ -125,6 +104,7 @@ impl Plugin for CardAnimationPlugin {
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.init_resource::<DragState>()
|
||||
.init_resource::<HoverState>()
|
||||
.init_resource::<InputBuffer>()
|
||||
@@ -305,6 +285,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for the v0.40.0 Android animation-lag bug: commit
|
||||
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
|
||||
/// premise that animation systems write `RequestRedraw` while active,
|
||||
/// but the writers were never added — card slides rendered at the 100 ms
|
||||
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
|
||||
/// every frame; an idle board must not.
|
||||
#[test]
|
||||
fn active_card_animation_requests_redraw() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(CardAnimationPlugin);
|
||||
|
||||
// Idle board: no redraw requests.
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||
"no RequestRedraw expected while no animation is active"
|
||||
);
|
||||
|
||||
app.world_mut().spawn((
|
||||
Transform::from_translation(Vec3::ZERO),
|
||||
CardAnimation {
|
||||
start: Vec2::ZERO,
|
||||
end: Vec2::new(100.0, 0.0),
|
||||
elapsed: 0.0,
|
||||
duration: 1.0,
|
||||
curve: MotionCurve::Responsive,
|
||||
delay: 0.0,
|
||||
start_z: 0.0,
|
||||
end_z: 0.0,
|
||||
z_lift: 0.0,
|
||||
scale_start: 1.0,
|
||||
scale_end: 1.0,
|
||||
},
|
||||
));
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||
"an active CardAnimation must write RequestRedraw each frame to \
|
||||
sustain the reactive render loop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_animation_instant_snaps_on_zero_duration() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -100,7 +100,7 @@ impl AnimationTuning {
|
||||
platform: InputPlatform::Mouse,
|
||||
duration_scale: 1.0,
|
||||
overshoot_scale: 1.0,
|
||||
drag_threshold_px: 4.0,
|
||||
drag_threshold_px: 6.0,
|
||||
drag_scale: 1.08,
|
||||
hover_scale: 1.04,
|
||||
hover_lerp_speed: 14.0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
//! Card flip animation and drag shadows.
|
||||
|
||||
use super::*;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use solitaire_core::Card;
|
||||
|
||||
use crate::animation_plugin::EffectiveSlideDuration;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::resources::DragState;
|
||||
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
|
||||
|
||||
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
||||
///
|
||||
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
||||
pub(super) fn start_flip_anim(
|
||||
mut events: MessageReader<CardFlippedEvent>,
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
) {
|
||||
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
|
||||
// Instant animation speed — skip the flip effect entirely.
|
||||
events.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
for CardFlippedEvent(flipped_card) in events.read() {
|
||||
for (entity, marker) in &card_entities {
|
||||
if marker.card == *flipped_card {
|
||||
commands.entity(entity).insert(CardFlipAnim {
|
||||
timer: 0.0,
|
||||
phase: FlipPhase::ScalingDown,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
||||
///
|
||||
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
||||
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
|
||||
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
|
||||
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
|
||||
/// - When complete the component is removed and scale.x is restored to 1.0.
|
||||
pub(super) fn tick_flip_anim(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
||||
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
||||
anim.timer += dt;
|
||||
match anim.phase {
|
||||
FlipPhase::ScalingDown => {
|
||||
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||
transform.scale.x = 1.0 - t;
|
||||
if t >= 1.0 {
|
||||
anim.phase = FlipPhase::ScalingUp;
|
||||
anim.timer = 0.0;
|
||||
transform.scale.x = 0.0;
|
||||
// Fire the reveal event exactly once, at the phase transition,
|
||||
// so the flip sound is synchronised with the visual face reveal.
|
||||
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
|
||||
}
|
||||
}
|
||||
FlipPhase::ScalingUp => {
|
||||
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||
transform.scale.x = t;
|
||||
if t >= 1.0 {
|
||||
transform.scale.x = 1.0;
|
||||
commands.entity(entity).remove::<CardFlipAnim>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #38 — Drag-elevation shadow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maintains a single `ShadowEntity` while cards are being dragged.
|
||||
///
|
||||
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
|
||||
/// sprite behind the top dragged card.
|
||||
/// - If no drag is active, despawns the shadow entity.
|
||||
pub(super) fn update_drag_shadow(
|
||||
mut commands: Commands,
|
||||
drag: Res<DragState>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
card_entities: Query<(&CardEntity, &Transform)>,
|
||||
card_index: Res<CardEntityIndex>,
|
||||
mut shadow: Local<Option<Entity>>,
|
||||
) {
|
||||
if drag.is_idle() {
|
||||
// No drag in progress — remove shadow if it exists.
|
||||
if let Some(e) = shadow.take() {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(layout) = layout else { return };
|
||||
let card_w = layout.0.card_size.x;
|
||||
let card_h = layout.0.card_size.y;
|
||||
|
||||
// Find the world position of the first (top) dragged card.
|
||||
let top_pos = drag.cards.first().and_then(|first_card| {
|
||||
card_index
|
||||
.get(first_card)
|
||||
.and_then(|entity| card_entities.get(entity).ok())
|
||||
.map(|(_, t)| t.translation)
|
||||
});
|
||||
|
||||
let Some(top_pos) = top_pos else { return };
|
||||
|
||||
// Shadow is slightly larger, offset behind-and-below, at a z slightly
|
||||
// below the dragged cards.
|
||||
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
|
||||
|
||||
match *shadow {
|
||||
Some(e) => {
|
||||
// Reposition the existing shadow.
|
||||
commands
|
||||
.entity(e)
|
||||
.insert(Transform::from_translation(shadow_pos));
|
||||
}
|
||||
None => {
|
||||
// Spawn a new shadow sprite. Alpha tracks the per-card
|
||||
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
|
||||
// "no box-shadow" policy disables this stack shadow in
|
||||
// lockstep with the per-card shadows. Re-enabling shadows
|
||||
// is then a one-line change in `ui_theme`, not a hunt
|
||||
// through plugin code.
|
||||
let e = commands
|
||||
.spawn((
|
||||
ShadowEntity,
|
||||
Sprite {
|
||||
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
|
||||
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_translation(shadow_pos),
|
||||
Visibility::default(),
|
||||
))
|
||||
.id();
|
||||
*shadow = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
|
||||
/// based on whether the parent [`CardEntity`] is currently in
|
||||
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
|
||||
/// (no lerp) — the existing shake / settle feedback already handles motion
|
||||
/// at drag-end, so an additional shadow tween would compete with those cues.
|
||||
///
|
||||
/// The shadow size is rebuilt from the parent card's current `Sprite`
|
||||
/// `custom_size` plus the appropriate padding, so the resize handler does
|
||||
/// not need to pre-tune shadow sizes for the drag state — this system fixes
|
||||
/// the geometry within one frame.
|
||||
pub(super) fn update_card_shadows_on_drag(
|
||||
drag: Res<DragState>,
|
||||
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
|
||||
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
|
||||
) {
|
||||
let dragged: HashSet<&Card> = drag.cards.iter().collect();
|
||||
|
||||
for (card_entity, card_sprite, children) in cards.iter() {
|
||||
let is_dragged = dragged.contains(&card_entity.card);
|
||||
let (offset, padding, alpha) = card_shadow_params(is_dragged);
|
||||
let Some(card_size) = card_sprite.custom_size else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for child in children.iter() {
|
||||
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
|
||||
continue;
|
||||
};
|
||||
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
|
||||
shadow_sprite.custom_size = Some(card_size + padding);
|
||||
shadow_transform.translation.x = offset.x;
|
||||
shadow_transform.translation.y = offset.y;
|
||||
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Hint highlight tick system
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Hint and right-click highlights, plus cursor hit-testing helpers.
|
||||
|
||||
use super::*;
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||
|
||||
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
|
||||
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
|
||||
/// resets the card sprite to its normal face-up colour.
|
||||
pub(super) fn tick_hint_highlight(
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||
let use_images = card_images.is_some();
|
||||
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||
hint.remaining -= time.delta_secs();
|
||||
if hint.remaining <= 0.0 {
|
||||
// Restore the normal sprite colour.
|
||||
// When image-based rendering is active, WHITE is the neutral tint;
|
||||
// otherwise restore the solid colour appropriate to the card state.
|
||||
sprite.color = if use_images {
|
||||
Color::WHITE
|
||||
} else {
|
||||
let is_face_up = all_cards(&game.0)
|
||||
.iter()
|
||||
.find(|(c, _face_up)| *c == card_entity.card)
|
||||
.is_some_and(|(_, face_up)| *face_up);
|
||||
if is_face_up {
|
||||
CARD_FACE_COLOUR
|
||||
} else {
|
||||
card_back_colour(back_idx)
|
||||
}
|
||||
};
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<HintHighlight>()
|
||||
.remove::<HintHighlightTimer>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #46 — Right-click legal destination highlights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Lime tint applied to a `PileMarker` sprite when it is a legal
|
||||
/// destination for the right-clicked card. Same RGB as the design-
|
||||
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
|
||||
/// because `Alpha::with_alpha` is not yet a `const` trait method on
|
||||
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
|
||||
/// so a palette swap can't drift the two apart silently.
|
||||
pub(super) const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
|
||||
|
||||
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
|
||||
/// when the timer expires.
|
||||
///
|
||||
/// This is a fallback expiry: highlights also clear immediately on
|
||||
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
|
||||
/// first. The 1.5 s timer ensures highlights always disappear even if the
|
||||
/// player takes no further action.
|
||||
pub(super) fn tick_right_click_highlights(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut highlights: Query<
|
||||
(Entity, &mut RightClickHighlightTimer, &mut Sprite),
|
||||
With<RightClickHighlight>,
|
||||
>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut timer, mut sprite) in &mut highlights {
|
||||
timer.0 -= dt;
|
||||
if timer.0 <= 0.0 {
|
||||
// Restore the pile marker to its default colour before removing
|
||||
// the highlight marker component.
|
||||
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<RightClickHighlight>()
|
||||
.remove::<RightClickHighlightTimer>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the `RightClickHighlight` marker from every highlighted pile and
|
||||
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
||||
///
|
||||
/// Shared by the on-state-change and on-pause clear systems to avoid
|
||||
/// duplicating the removal logic.
|
||||
pub(super) fn clear_right_click_highlights(
|
||||
commands: &mut Commands,
|
||||
highlighted: &Query<Entity, With<RightClickHighlight>>,
|
||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
for entity in highlighted.iter() {
|
||||
commands.entity(entity).remove::<RightClickHighlight>();
|
||||
}
|
||||
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
|
||||
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all right-click destination highlights whenever any game-state
|
||||
/// mutation succeeds (`StateChangedEvent` fires).
|
||||
///
|
||||
/// This ensures stale highlights do not linger after a card is moved.
|
||||
pub(super) fn clear_right_click_highlights_on_state_change(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
mut commands: Commands,
|
||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||
}
|
||||
|
||||
/// Clears all right-click destination highlights when the game is paused
|
||||
/// (`PausedResource` changes to `true`).
|
||||
///
|
||||
/// Prevents highlighted pile markers from remaining visible behind the pause
|
||||
/// overlay.
|
||||
pub(super) fn clear_right_click_highlights_on_pause(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut commands: Commands,
|
||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
let Some(paused) = paused else { return };
|
||||
if paused.is_changed() && paused.0 {
|
||||
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles right-click: highlights legal destination piles for the clicked card,
|
||||
/// and clears highlights on any subsequent right- or left-click.
|
||||
///
|
||||
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_right_click(
|
||||
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
drag: Res<DragState>,
|
||||
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut commands: Commands,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(buttons) = buttons else { return };
|
||||
let left_pressed = buttons.just_pressed(MouseButton::Left);
|
||||
let right_pressed = buttons.just_pressed(MouseButton::Right);
|
||||
|
||||
// Clear existing highlights on any click.
|
||||
if left_pressed || right_pressed {
|
||||
for entity in &highlighted {
|
||||
commands.entity(entity).remove::<RightClickHighlight>();
|
||||
}
|
||||
for (_entity, _, mut sprite) in &mut pile_markers {
|
||||
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only proceed for right-clicks while not dragging.
|
||||
if !right_pressed || !drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(layout) = layout else { return };
|
||||
|
||||
// Convert cursor to world-space position.
|
||||
let Some(world) = cursor_world_pos(&windows, &cameras) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Find the topmost face-up card under the cursor.
|
||||
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Tint piles that legally accept the card.
|
||||
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
||||
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
|
||||
if legal {
|
||||
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(RightClickHighlight)
|
||||
.insert(RightClickHighlightTimer(1.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts cursor position to 2-D world coordinates.
|
||||
pub(super) fn cursor_world_pos(
|
||||
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||
) -> Option<Vec2> {
|
||||
let window = windows.single().ok()?;
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, camera_transform) = cameras.single().ok()?;
|
||||
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||
}
|
||||
|
||||
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
|
||||
/// bounding rectangles of all card sprites, picking the highest Z.
|
||||
pub(super) fn find_top_card_at(
|
||||
cursor: Vec2,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||
) -> Option<Card> {
|
||||
let half = layout.card_size / 2.0;
|
||||
let mut best: Option<(f32, Card)> = None;
|
||||
|
||||
for (_, card_entity, transform) in card_entities.iter() {
|
||||
let pos = transform.translation.truncate();
|
||||
if cursor.x < pos.x - half.x
|
||||
|| cursor.x > pos.x + half.x
|
||||
|| cursor.y < pos.y - half.y
|
||||
|| cursor.y > pos.y + half.y
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let found = all_cards(game)
|
||||
.into_iter()
|
||||
.find(|(c, face_up)| *c == card_entity.card && *face_up);
|
||||
if let Some((card, _)) = found {
|
||||
let z = transform.translation.z;
|
||||
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
||||
best = Some((z, card));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, card)| card)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Stock-empty visual indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Card face labels: desktop text labels and Android corner labels.
|
||||
|
||||
use super::*;
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::sprite::Anchor;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
|
||||
use crate::ui_theme::TEXT_PRIMARY_HC;
|
||||
|
||||
pub(super) fn label_for(card: &Card) -> String {
|
||||
let rank = match card.rank() {
|
||||
Rank::Ace => "A",
|
||||
Rank::Two => "2",
|
||||
Rank::Three => "3",
|
||||
Rank::Four => "4",
|
||||
Rank::Five => "5",
|
||||
Rank::Six => "6",
|
||||
Rank::Seven => "7",
|
||||
Rank::Eight => "8",
|
||||
Rank::Nine => "9",
|
||||
Rank::Ten => "10",
|
||||
Rank::Jack => "J",
|
||||
Rank::Queen => "Q",
|
||||
Rank::King => "K",
|
||||
};
|
||||
let suit = match card.suit() {
|
||||
Suit::Clubs => "C",
|
||||
Suit::Diamonds => "D",
|
||||
Suit::Hearts => "H",
|
||||
Suit::Spades => "S",
|
||||
};
|
||||
format!("{rank}{suit}")
|
||||
}
|
||||
|
||||
/// Suit colour for the rank/suit overlay rendered atop the constant
|
||||
/// fallback sprite (only fires under `MinimalPlugins` — production
|
||||
/// renders the suit glyph baked into the PNG). 2-colour traditional
|
||||
/// pairing — hearts + diamonds share the saturated red, clubs +
|
||||
/// spades share the near-white. Two accessibility flags compose:
|
||||
///
|
||||
/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM`
|
||||
/// (lime) — the "Settings toggle swaps red→lime" half of the
|
||||
/// design system's colour-blind support. CBM is a hue-replacement
|
||||
/// for red, so HC has no further effect on red when CBM is on
|
||||
/// (the lime is itself a high-luminance colour).
|
||||
/// - `high_contrast`: when CBM is off, red suits boost to
|
||||
/// `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from
|
||||
/// `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`).
|
||||
///
|
||||
/// The other half of CBM support (always-on filled-vs-outlined
|
||||
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
|
||||
/// and has no constant-fallback equivalent.
|
||||
pub(super) fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
|
||||
if card.suit().is_red() {
|
||||
if color_blind {
|
||||
// CBM lime wins — the colour-blind swap replaces the
|
||||
// red hue entirely, and the lime is already high-
|
||||
// luminance, so an HC boost on top has nothing to do.
|
||||
RED_SUIT_COLOUR_CBM
|
||||
} else if high_contrast {
|
||||
RED_SUIT_COLOUR_HC
|
||||
} else {
|
||||
RED_SUIT_COLOUR
|
||||
}
|
||||
} else if high_contrast {
|
||||
TEXT_PRIMARY_HC
|
||||
} else {
|
||||
BLACK_SUIT_COLOUR
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn label_visibility(face_up: bool) -> Visibility {
|
||||
if face_up {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
}
|
||||
}
|
||||
|
||||
/// Rank+suit string for the readability overlay on touch HUD layouts.
|
||||
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||
pub(super) fn mobile_label_for(card: &Card) -> String {
|
||||
let rank = match card.rank() {
|
||||
Rank::Ace => "A",
|
||||
Rank::Two => "2",
|
||||
Rank::Three => "3",
|
||||
Rank::Four => "4",
|
||||
Rank::Five => "5",
|
||||
Rank::Six => "6",
|
||||
Rank::Seven => "7",
|
||||
Rank::Eight => "8",
|
||||
Rank::Nine => "9",
|
||||
Rank::Ten => "10",
|
||||
Rank::Jack => "J",
|
||||
Rank::Queen => "Q",
|
||||
Rank::King => "K",
|
||||
};
|
||||
let suit = match card.suit() {
|
||||
Suit::Clubs => "♣",
|
||||
Suit::Diamonds => "♦",
|
||||
Suit::Hearts => "♥",
|
||||
Suit::Spades => "♠",
|
||||
};
|
||||
format!("{rank}{suit}")
|
||||
}
|
||||
|
||||
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||
/// face-up cards. The background sprite covers the card art's own small
|
||||
/// corner text so only the large overlay is visible.
|
||||
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||
/// face-up cards using FiraMono (passed via `font_handle`) so that the
|
||||
/// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit
|
||||
/// font handle Bevy falls back to its built-in face which does not include
|
||||
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
||||
/// the text colour — the root cause of the "red square on face-down cards"
|
||||
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
||||
pub(super) fn add_android_corner_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
card: &Card,
|
||||
face_up: bool,
|
||||
card_size: Vec2,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
if !face_up {
|
||||
return;
|
||||
}
|
||||
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||
let inset = 3.0_f32;
|
||||
// Background covers ~3 monospace chars wide × 1 line tall.
|
||||
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
|
||||
// (3 chars = 1.8× font_size) plus a small margin.
|
||||
let bg_w = font_size * 2.0;
|
||||
let bg_h = font_size * 1.25;
|
||||
|
||||
// Background covers the PNG's baked-in small corner text (top-left).
|
||||
// Classic PNG cards have a white face, so the background must be white too.
|
||||
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
|
||||
parent.spawn((
|
||||
AndroidCornerBg,
|
||||
Sprite {
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(
|
||||
-card_size.x / 2.0 + inset + bg_w / 2.0,
|
||||
card_size.y / 2.0 - inset - bg_h / 2.0,
|
||||
0.015,
|
||||
),
|
||||
));
|
||||
// Cover the matching rotated baked-in text at the bottom-right corner.
|
||||
parent.spawn((
|
||||
AndroidCornerBg,
|
||||
Sprite {
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(
|
||||
card_size.x / 2.0 - inset - bg_w / 2.0,
|
||||
-card_size.y / 2.0 + inset + bg_h / 2.0,
|
||||
0.015,
|
||||
),
|
||||
));
|
||||
|
||||
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||
//
|
||||
// Classic PNG cards have a white face: red suits stay the same saturated
|
||||
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
|
||||
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
|
||||
// Terminal theme background.
|
||||
let text_col = if card.suit().is_red() {
|
||||
if color_blind {
|
||||
RED_SUIT_COLOUR_CBM
|
||||
} else if high_contrast {
|
||||
RED_SUIT_COLOUR_HC
|
||||
} else {
|
||||
RED_SUIT_COLOUR
|
||||
}
|
||||
} else {
|
||||
CARD_FACE_COLOUR
|
||||
};
|
||||
let label_text = mobile_label_for(card);
|
||||
parent.spawn((
|
||||
AndroidCornerLabel(label_text.clone()),
|
||||
CardLabel,
|
||||
Text2d::new(label_text),
|
||||
TextFont {
|
||||
font: font_handle.cloned().unwrap_or_default(),
|
||||
font_size,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_col),
|
||||
Anchor::TOP_LEFT,
|
||||
Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02),
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation systems
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Resize handling: window-resize snapping, in-place card resizing,
|
||||
//! and tableau fan spread.
|
||||
|
||||
use super::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
use crate::animation_plugin::CardAnim;
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
|
||||
|
||||
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||
/// pending size on [`ResizeThrottle`].
|
||||
///
|
||||
/// `WindowResized` fires per pixel of resize drag, so a fast corner drag can
|
||||
/// emit many events per frame. Reading `.last()` keeps only the final size —
|
||||
/// every frame's snap target is the most recent window size, never a stale
|
||||
/// one. Pending stays set across frames until the throttled applier consumes
|
||||
/// it; that's how we still flush the final "release" position when the user
|
||||
/// stops dragging.
|
||||
pub(super) fn collect_resize_events(
|
||||
mut events: MessageReader<WindowResized>,
|
||||
mut throttle: ResMut<ResizeThrottle>,
|
||||
) {
|
||||
if let Some(ev) = events.read().last() {
|
||||
throttle.pending = Some(Vec2::new(ev.width, ev.height));
|
||||
}
|
||||
}
|
||||
|
||||
/// Snaps every card sprite to its target position, size, and (in the
|
||||
/// fallback Text2d label path) font size when the window is resized.
|
||||
///
|
||||
/// **In-place mutation only.** Resize is the hot path — events fire per
|
||||
/// pixel of drag, so this system cannot afford the despawn/respawn churn
|
||||
/// `update_card_entity` does. We mutate `Sprite.custom_size`, `Transform`,
|
||||
/// and child `TextFont.font_size` directly, leaving the card image handle,
|
||||
/// suit/rank, and `CardLabel` entity untouched. Cards keep their identity
|
||||
/// across resizes; only their size and position change. The full repaint
|
||||
/// path lives in [`update_card_entity`] and is still used by every non-resize
|
||||
/// caller (deals, moves, flips, settings toggles).
|
||||
///
|
||||
/// **Throttled to ~20 Hz.** [`ResizeThrottle::pending`] is consumed at most
|
||||
/// once per [`RESIZE_THROTTLE_SECS`]. When events stop arriving, the next
|
||||
/// tick past the throttle window flushes the final size and clears
|
||||
/// `pending`, so the steady-state always matches the user's release size.
|
||||
///
|
||||
/// **Cancels in-flight slides.** Any `CardAnim` is removed so a mid-slide
|
||||
/// tween is not retargeted relative to the previous card-size's position.
|
||||
///
|
||||
/// The "↺" stock-empty label's `font_size` is derived from
|
||||
/// `layout.card_size.x`, so this system also reapplies the stock indicator —
|
||||
/// otherwise the label would not rescale on resize.
|
||||
///
|
||||
/// Scheduled after [`collect_resize_events`] (which itself runs after
|
||||
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
|
||||
/// window size before we read it.
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
pub(super) fn snap_cards_on_window_resize(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut throttle: ResMut<ResizeThrottle>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(
|
||||
Without<CardLabel>,
|
||||
Without<CardShadow>,
|
||||
Without<CardBackFrame>,
|
||||
),
|
||||
>,
|
||||
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
shadow_query: Query<
|
||||
&mut Sprite,
|
||||
(
|
||||
With<CardShadow>,
|
||||
Without<CardEntity>,
|
||||
Without<PileMarker>,
|
||||
Without<CardBackFrame>,
|
||||
),
|
||||
>,
|
||||
frame_query: Query<
|
||||
&mut Sprite,
|
||||
(
|
||||
With<CardBackFrame>,
|
||||
Without<CardEntity>,
|
||||
Without<CardShadow>,
|
||||
Without<PileMarker>,
|
||||
),
|
||||
>,
|
||||
mut pile_markers: Query<
|
||||
(Entity, &PileMarker, &mut Sprite),
|
||||
(
|
||||
Without<CardEntity>,
|
||||
Without<CardShadow>,
|
||||
Without<CardBackFrame>,
|
||||
),
|
||||
>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
if throttle.pending.is_none() {
|
||||
return;
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
if !should_apply_resize(now, throttle.last_applied_secs) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(game) = game else {
|
||||
// Nothing to apply — clear pending so we don't busy-loop.
|
||||
throttle.pending = None;
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout else {
|
||||
throttle.pending = None;
|
||||
return;
|
||||
};
|
||||
|
||||
resize_cards_in_place(
|
||||
&mut commands,
|
||||
&game.0,
|
||||
&layout.0,
|
||||
card_images.as_deref(),
|
||||
entities,
|
||||
label_query,
|
||||
shadow_query,
|
||||
frame_query,
|
||||
);
|
||||
|
||||
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
apply_stock_empty_indicator(
|
||||
&mut commands,
|
||||
&game.0,
|
||||
&mut pile_markers,
|
||||
&label_children,
|
||||
&layout.0,
|
||||
font,
|
||||
);
|
||||
|
||||
throttle.last_applied_secs = now;
|
||||
throttle.pending = None;
|
||||
}
|
||||
|
||||
/// In-place "size-only" sibling of [`sync_cards`]: walks every existing card
|
||||
/// entity, updates `Sprite.custom_size` and the snap-`Transform` to match the
|
||||
/// fresh layout, and (in fallback solid-colour mode) also updates the child
|
||||
/// `TextFont.font_size` of any `CardLabel`. No despawning, no `Sprite`
|
||||
/// replacement, no children rebuild — that's the entire point of this path.
|
||||
///
|
||||
/// Called only from the resize handler. Game-state changes (deals, moves,
|
||||
/// flips, settings toggles) still flow through [`sync_cards`] /
|
||||
/// [`update_card_entity`], which handle add/remove/repaint correctly.
|
||||
///
|
||||
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||
/// retargeted relative to the previous card-size's position.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub(super) fn resize_cards_in_place(
|
||||
commands: &mut Commands,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
card_images: Option<&CardImageSet>,
|
||||
mut entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(
|
||||
Without<CardLabel>,
|
||||
Without<CardShadow>,
|
||||
Without<CardBackFrame>,
|
||||
),
|
||||
>,
|
||||
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
mut shadow_query: Query<
|
||||
&mut Sprite,
|
||||
(
|
||||
With<CardShadow>,
|
||||
Without<CardEntity>,
|
||||
Without<PileMarker>,
|
||||
Without<CardBackFrame>,
|
||||
),
|
||||
>,
|
||||
mut frame_query: Query<
|
||||
&mut Sprite,
|
||||
(
|
||||
With<CardBackFrame>,
|
||||
Without<CardEntity>,
|
||||
Without<CardShadow>,
|
||||
Without<PileMarker>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
|
||||
.into_iter()
|
||||
.map(|((c, _face_up), p, z)| (c, (p, z)))
|
||||
.collect();
|
||||
|
||||
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
|
||||
let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
|
||||
continue;
|
||||
};
|
||||
sprite.custom_size = Some(layout.card_size);
|
||||
transform.translation.x = pos.x;
|
||||
transform.translation.y = pos.y;
|
||||
transform.translation.z = z;
|
||||
// Cancel any in-flight slide so it doesn't retarget from a stale
|
||||
// mid-animation position computed against the previous card size.
|
||||
commands.entity(entity).remove::<CardAnim>();
|
||||
}
|
||||
|
||||
// Resize every per-card shadow halo to match the new card size. Both
|
||||
// idle and drag states scale with the card body, so we preserve the
|
||||
// *current* padding (idle vs drag) by keeping the alpha as-is and only
|
||||
// recomputing the geometry. The drag-tracking system runs every frame
|
||||
// and will retune offset / alpha / padding-mode within one frame if the
|
||||
// drag state diverges from the resized geometry.
|
||||
let idle_padding = CARD_SHADOW_PADDING_IDLE;
|
||||
let drag_padding = CARD_SHADOW_PADDING_DRAG;
|
||||
for mut shadow_sprite in shadow_query.iter_mut() {
|
||||
// Choose padding based on the shadow's current alpha — preserves
|
||||
// a lifted shadow's larger halo across resize without needing to
|
||||
// plumb DragState through the resize handler.
|
||||
let alpha = shadow_sprite.color.alpha();
|
||||
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
|
||||
drag_padding
|
||||
} else {
|
||||
idle_padding
|
||||
};
|
||||
shadow_sprite.custom_size = Some(layout.card_size + padding);
|
||||
}
|
||||
|
||||
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
|
||||
// when PNG faces are loaded the rank/suit are baked into the image and
|
||||
// there is nothing to resize on the label side.
|
||||
if card_images.is_none() {
|
||||
let new_font_size = layout.card_size.x * FONT_SIZE_FRAC;
|
||||
for mut font in label_query.iter_mut() {
|
||||
font.font_size = new_font_size;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize every face-down border frame to match the new card size.
|
||||
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
|
||||
for mut frame_sprite in frame_query.iter_mut() {
|
||||
frame_sprite.custom_size = Some(frame_size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates font size and top-left anchor transform of every
|
||||
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
|
||||
/// change or any window resize). The full despawn/respawn path in
|
||||
/// `update_card_entity` already handles game-state changes; this system
|
||||
/// covers the resize-only path where children are mutated in place.
|
||||
pub(super) fn resize_android_corner_labels(
|
||||
layout: Res<LayoutResource>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
mut text_query: Query<(
|
||||
&AndroidCornerLabel,
|
||||
&mut Text2d,
|
||||
&mut TextFont,
|
||||
&mut Transform,
|
||||
)>,
|
||||
mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>,
|
||||
) {
|
||||
if !layout.is_changed() || card_images.is_none() {
|
||||
return;
|
||||
}
|
||||
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||
let inset = 3.0_f32;
|
||||
let bg_w = font_size * 2.0;
|
||||
let bg_h = font_size * 1.25;
|
||||
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
||||
let text_y = layout.0.card_size.y / 2.0 - inset;
|
||||
|
||||
for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
|
||||
text2d.0 = label.0.clone();
|
||||
font.font_size = font_size;
|
||||
transform.translation.x = text_x;
|
||||
transform.translation.y = text_y;
|
||||
}
|
||||
for (mut sprite, mut transform) in bg_query.iter_mut() {
|
||||
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
|
||||
transform.translation.x = text_x + bg_w / 2.0;
|
||||
transform.translation.y = text_y - bg_h / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
|
||||
/// the deepest tableau column fills the available vertical space at every stage
|
||||
/// of play. Runs after every `StateChangedEvent`.
|
||||
///
|
||||
/// Depth is measured across *all* cards in a column, weighting each face-down
|
||||
/// card by the fixed face-down/face-up step ratio. Counting the face-down
|
||||
/// portion — not just the face-up tail — is what fills the lower screen on a
|
||||
/// fresh deal (the deepest column is then six face-down cards under one face-up
|
||||
/// one): the earlier face-up-only depth was 1, so the fan never spread and the
|
||||
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
|
||||
///
|
||||
/// Deeper columns drive the fraction down so everything still fits the window;
|
||||
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
|
||||
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
|
||||
/// cards far apart.
|
||||
pub(super) fn update_tableau_fan_frac(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut layout: Option<ResMut<LayoutResource>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout.as_mut() else {
|
||||
return;
|
||||
};
|
||||
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||
}
|
||||
|
||||
/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
|
||||
/// inserted directly as `GameStateResource` at startup without a
|
||||
/// `StateChangedEvent`, so the event-driven system never fires for it. This
|
||||
/// runs once, before [`sync_cards_startup`] renders, so the very first board
|
||||
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
|
||||
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
|
||||
/// and a large empty band below the tableau until the first move.
|
||||
pub(super) fn fill_tableau_fan_on_startup(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut layout: Option<ResMut<LayoutResource>>,
|
||||
) {
|
||||
let Some(game) = game else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout.as_mut() else {
|
||||
return;
|
||||
};
|
||||
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
//! PNG-based card rendering.
|
||||
//!
|
||||
//! Card entities are synced with [`GameStateResource`] on every
|
||||
//! [`StateChangedEvent`]: missing cards are spawned, present cards are
|
||||
//! repositioned/updated in place, and stale cards are despawned.
|
||||
//!
|
||||
//! When [`CardImageSet`] is available, each face-up card renders its own
|
||||
//! 120×168 px `Handle<Image>` chosen from the 52 per-card PNGs loaded from
|
||||
//! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a
|
||||
//! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is
|
||||
//! absent (e.g. in tests running under `MinimalPlugins`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
|
||||
use crate::card_animation::CardAnimation;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::{Layout, LayoutSystem};
|
||||
|
||||
mod anim;
|
||||
mod highlights;
|
||||
mod labels;
|
||||
mod layout;
|
||||
mod stock;
|
||||
mod sync;
|
||||
|
||||
use anim::*;
|
||||
use highlights::*;
|
||||
use labels::*;
|
||||
use layout::*;
|
||||
use stock::*;
|
||||
use sync::*;
|
||||
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsChangedEvent;
|
||||
use crate::ui_theme::{
|
||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_PADDING_IDLE,
|
||||
};
|
||||
|
||||
/// Per-card vertical step for face-down tableau cards, as a fraction of
|
||||
/// card height. Smaller than [`crate::layout::TABLEAU_FAN_FRAC`] because face-down cards
|
||||
/// don't need their full body shown — only the back-pattern strip is
|
||||
/// visible. Public so `input_plugin` can mirror the exact sprite layout
|
||||
/// when hit-testing tableau columns; any drift between this and the
|
||||
/// renderer creates a visible offset between the card face and where
|
||||
/// clicks land.
|
||||
///
|
||||
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
|
||||
/// stay in sync; the layout constant drives the adaptive LayoutResource value
|
||||
/// used at runtime, while this one is the minimum floor used by
|
||||
/// `update_tableau_fan_frac` when computing proportional updates.
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||
|
||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||||
/// `Transform.translation.z` for a card at a given stack index without
|
||||
/// drifting from the value used by [`card_positions`].
|
||||
// Must exceed the highest child local-z of any card entity (0.02 for the
|
||||
// Android corner label) so every card's sprite covers all children of the
|
||||
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
|
||||
// foundation piles bleeding through when a 2 sits on an Ace.
|
||||
pub const STACK_FAN_FRAC: f32 = 0.025;
|
||||
|
||||
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
|
||||
///
|
||||
/// Derived from the actual tableau column spacing (`Tableau2.x − Tableau1.x`)
|
||||
/// rather than a fixed fraction of card width, so the fan scales with the
|
||||
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
|
||||
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
|
||||
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
|
||||
/// land on the card beneath it.
|
||||
pub fn waste_fan_step(layout: &Layout) -> f32 {
|
||||
tableau_col_step(layout) * 0.224
|
||||
}
|
||||
|
||||
/// Horizontal distance between adjacent tableau columns (`Tableau2.x −
|
||||
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
|
||||
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
|
||||
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
|
||||
pub fn tableau_col_step(layout: &Layout) -> f32 {
|
||||
let t1 = layout
|
||||
.pile_positions
|
||||
.get(&KlondikePile::Tableau(Tableau::Tableau1))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let t2 = layout
|
||||
.pile_positions
|
||||
.get(&KlondikePile::Tableau(Tableau::Tableau2))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
(t2.x - t1.x).abs()
|
||||
}
|
||||
|
||||
/// Font size as a fraction of card width.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
|
||||
/// Font-size fraction for the large-print readability overlay on touch HUD layouts.
|
||||
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
|
||||
/// scale, where the baked-in PNG corner text is only ~10 px physical.
|
||||
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
|
||||
|
||||
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
||||
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
||||
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
||||
/// 2-colour traditional pairing (the "Microsoft Solitaire on dark
|
||||
/// mode" feel) replacing the brief 4-colour-deck experiment that
|
||||
/// shipped between v0.21.0 and this commit. Brighter and more
|
||||
/// saturated than the v0.21.0 pink `#fb9fb1` so the cards read as
|
||||
/// a "real solitaire deck" rather than a Terminal-pastel theme.
|
||||
/// Visually distinct from `ACCENT_PRIMARY` (`#a54242` brick red,
|
||||
/// darker) so chrome and suit don't read as the same hue.
|
||||
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.890, 0.325, 0.325);
|
||||
/// High-contrast variant of [`RED_SUIT_COLOUR`] — `#ff6868`. Lifted
|
||||
/// luminance for the Settings → Accessibility → High-contrast mode
|
||||
/// toggle. Pre-2-colour-revert this was `#ff8aa0` (pink-salmon)
|
||||
/// matching the v0.21.0 pink default; rebumped to a brighter red
|
||||
/// so it reads as "more chromatic" than the new saturated default,
|
||||
/// not "less saturated." Independent of `RED_SUIT_COLOUR_CBM`
|
||||
/// (lime) — high-contrast is *additive* over the default colour
|
||||
/// palette; CBM is a *replacement* of red with a hue-distinct
|
||||
/// alternative. The two modes can stack; CBM wins when both are on
|
||||
/// because the CBM lime is itself a high-contrast colour.
|
||||
pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.408, 0.408);
|
||||
/// Suit colour for spades + clubs — near-white `#e8e8e8`. Brighter
|
||||
/// than `TEXT_PRIMARY` (`#d0d0d0`, foreground gray) so the
|
||||
/// "black suit" reads as a distinct, chromatic-neutral counterpart
|
||||
/// to the new saturated red, not as "the same gray as body text."
|
||||
/// `TEXT_PRIMARY_HC` (`#f5f5f5`) is still brighter for the
|
||||
/// high-contrast boost path.
|
||||
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);
|
||||
|
||||
/// Canonical outer index of `s` in [`CardImageSet::faces`].
|
||||
///
|
||||
/// Derived from the upstream `card_game::Suit` discriminants (0..=3 in
|
||||
/// `Suit::SUITS` order), so every reader and writer of `faces` computes
|
||||
/// the same layout from the same source. Three hand-rolled copies of this
|
||||
/// mapping once lived in card_plugin and theme/plugin and were one
|
||||
/// reorder away from drawing the wrong art.
|
||||
pub(crate) const fn suit_index(s: solitaire_core::Suit) -> usize {
|
||||
s as usize
|
||||
}
|
||||
|
||||
/// Canonical inner index of `r` in [`CardImageSet::faces`] — upstream
|
||||
/// `card_game::Rank` discriminants are 1..=13 in `Rank::RANKS` order.
|
||||
pub(crate) const fn rank_index(r: solitaire_core::Rank) -> usize {
|
||||
r as usize - 1
|
||||
}
|
||||
|
||||
/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
|
||||
///
|
||||
/// Loaded once at startup by [`load_card_images`]. When this resource is
|
||||
/// present, card sprites use the PNG artwork; otherwise they fall back to
|
||||
/// solid-colour sprites (used in tests with `MinimalPlugins`).
|
||||
#[derive(Resource)]
|
||||
pub struct CardImageSet {
|
||||
/// Per-card face images indexed by `[suit][rank]`.
|
||||
///
|
||||
/// Layout is pinned to the upstream declaration order — index with
|
||||
/// [`suit_index`] / [`rank_index`], never a hand-rolled match.
|
||||
/// Suit order: `Suit::SUITS` (Spades=0, Hearts=1, Clubs=2, Diamonds=3).
|
||||
/// Rank order: `Rank::RANKS` (Ace=0 … King=12).
|
||||
pub faces: [[Handle<Image>; 13]; 4],
|
||||
/// One handle per unlockable card-back design (indices 0–4). These
|
||||
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
|
||||
/// by `Settings::selected_card_back`. Used as a fallback when the active
|
||||
/// theme does not provide its own back (see [`Self::theme_back`]).
|
||||
pub backs: [Handle<Image>; 5],
|
||||
/// Back image supplied by the currently-active card theme, if any.
|
||||
///
|
||||
/// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
|
||||
/// a `CardTheme` finishes loading. The face-down render path in
|
||||
/// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
|
||||
/// so a theme switch swaps both faces *and* the back without the player
|
||||
/// needing to touch the legacy `selected_card_back` picker. `None` means
|
||||
/// the active theme did not declare a back asset (or no theme has loaded
|
||||
/// yet); in that case [`card_sprite`] falls back to the legacy array.
|
||||
pub theme_back: Option<Handle<Image>>,
|
||||
}
|
||||
|
||||
/// Suit-colour swap for red-suit cards in colour-blind mode — Terminal
|
||||
/// `#acc267` (lime). Replaces `RED_SUIT_COLOUR` (pink) when CBM is on,
|
||||
/// providing a hue-distinct alternative that survives the most common
|
||||
/// red/green deficiencies. Pre-Terminal this was a *face tint*; the new
|
||||
/// design moves CBM differentiation into the suit glyph colour itself
|
||||
/// and keeps the face uniformly `CARD_FACE_COLOUR` regardless of CBM.
|
||||
///
|
||||
/// The CBM swap is lime (not the `ACCENT_PRIMARY` brick-red) because
|
||||
/// the primary accent is itself in the red family — using it for
|
||||
/// "the not-red CBM alternative" would defeat the purpose. Lime is
|
||||
/// the next-best non-red base16-eighties accent; deuteranopia and
|
||||
/// protanopia readers see it as visibly distinct from pink.
|
||||
const RED_SUIT_COLOUR_CBM: Color = Color::srgb(0.675, 0.761, 0.404);
|
||||
|
||||
/// Returns the fallback card-back colour for the given unlocked card-back
|
||||
/// index. Production renders backs from PNG artwork; this fallback only
|
||||
/// fires under `MinimalPlugins` (tests). Mirrors the 5 accent colours
|
||||
/// from `card_face_svg::BACK_ACCENTS` so the test-environment back lives
|
||||
/// in the same hue family as the on-disk PNG art for that index.
|
||||
fn card_back_colour(selected_card_back: usize) -> Color {
|
||||
match selected_card_back {
|
||||
0 => Color::srgb(0.647, 0.259, 0.259), // #a54242 brick red (Terminal canonical, ACCENT_PRIMARY)
|
||||
1 => Color::srgb(0.675, 0.761, 0.404), // #acc267 lime
|
||||
2 => Color::srgb(0.882, 0.639, 0.933), // #e1a3ee lavender
|
||||
3 => Color::srgb(0.984, 0.624, 0.694), // #fb9fb1 pink
|
||||
_ => Color::srgb(0.867, 0.698, 0.435), // #ddb26f gold (4+)
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker component linking a Bevy entity to its `solitaire_core::Card`.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct CardEntity {
|
||||
pub card: Card,
|
||||
}
|
||||
|
||||
/// Cached signature of the inputs that determine a card entity's *child*
|
||||
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
|
||||
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
|
||||
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
|
||||
/// the appearance changed — the common case during a move, where only the
|
||||
/// card's position changes. Without this guard every `StateChangedEvent`
|
||||
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
|
||||
/// glyph re-layouts) in a single frame, which stutters the slide animation on
|
||||
/// high-resolution devices.
|
||||
///
|
||||
/// The children depend only on these four inputs; the card identity is fixed
|
||||
/// per entity, and the face/back *image* is handled by the always-refreshed
|
||||
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
|
||||
/// rebuild.
|
||||
#[derive(Component, Clone, Copy, PartialEq)]
|
||||
struct CardChildrenKey {
|
||||
face_up: bool,
|
||||
card_size: Vec2,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
}
|
||||
|
||||
/// Query data read by the card-sync systems for each live card entity:
|
||||
/// its id, card identity, current transform, any in-flight curve animation,
|
||||
/// and its cached child-appearance key. Factored into an alias to keep the
|
||||
/// system signatures readable (and satisfy clippy's `type_complexity`).
|
||||
type CardSyncData = (
|
||||
Entity,
|
||||
&'static CardEntity,
|
||||
&'static Transform,
|
||||
Option<&'static CardAnimation>,
|
||||
Option<&'static CardChildrenKey>,
|
||||
);
|
||||
|
||||
/// Render-side index mapping each live board card to its [`CardEntity`].
|
||||
///
|
||||
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
|
||||
/// after the card-sync authority ([`sync_cards_on_change`]) and the resize
|
||||
/// re-snap have flushed their spawn/despawn `Commands`. Consumers treat it as
|
||||
/// read-only and must still call `Query::get(entity)` for components beyond the
|
||||
/// `Entity` id (the map yields only the id).
|
||||
///
|
||||
/// Keyed by `Card`, which is unique across all live board entities in
|
||||
/// single-deck Klondike. Transient entities (drag shadow, labels) carry no
|
||||
/// `CardEntity` component, so the rebuild — which scans `&CardEntity` — never
|
||||
/// records them.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct CardEntityIndex(pub HashMap<Card, Entity>);
|
||||
|
||||
impl CardEntityIndex {
|
||||
/// Resolve a card to its live entity, if one is currently spawned.
|
||||
#[inline]
|
||||
pub fn get(&self, card: &Card) -> Option<Entity> {
|
||||
self.0.get(card).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for the text child inside a card entity.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardLabel;
|
||||
|
||||
/// Marker for the large-print rank+suit corner overlay used by touch HUD layouts.
|
||||
///
|
||||
/// Spawned on top of PNG face cards (face-up only) at font size
|
||||
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
|
||||
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||||
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||||
#[derive(Component, Debug, Clone)]
|
||||
struct AndroidCornerLabel(pub String);
|
||||
|
||||
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
||||
///
|
||||
/// Covers the card art's own small corner rank/suit text so only the
|
||||
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
|
||||
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
struct AndroidCornerBg;
|
||||
|
||||
type AndroidCornerBgFilter = (With<AndroidCornerBg>, Without<AndroidCornerLabel>);
|
||||
|
||||
/// Marker component indicating the card is currently highlighted as a hint.
|
||||
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||||
/// reaches zero and the card sprite colour is restored to its normal value.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct HintHighlight {
|
||||
/// Seconds remaining before the highlight is cleared.
|
||||
pub remaining: f32,
|
||||
}
|
||||
|
||||
/// Countdown (seconds) until the `HintHighlight` on a card entity is removed.
|
||||
///
|
||||
/// Inserted alongside `HintHighlight` by the hint-visual system. When the timer
|
||||
/// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from
|
||||
/// the entity and the sprite colour is restored.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct HintHighlightTimer(pub f32);
|
||||
|
||||
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
|
||||
/// card can legally be placed there.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct RightClickHighlight;
|
||||
|
||||
/// Countdown (seconds) until this right-click destination highlight despawns.
|
||||
///
|
||||
/// Inserted alongside `RightClickHighlight` so that highlights auto-clear after
|
||||
/// 1.5 s even if the player does not make a move or click again. The existing
|
||||
/// clear-on-state-change and clear-on-pause logic still fires early when
|
||||
/// appropriate.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct RightClickHighlightTimer(pub f32);
|
||||
|
||||
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
|
||||
/// marker when the stock pile is empty.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockEmptyLabel;
|
||||
|
||||
/// Marker on the chip-background sprite of the stock-pile remaining-count
|
||||
/// badge.
|
||||
///
|
||||
/// The badge is spawned as a *top-level* world entity (not parented to the
|
||||
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
|
||||
/// `LayoutResource` so it tracks the stock pile through window resizes.
|
||||
/// The chip sits in the bottom-right corner of the stock pile and is hidden
|
||||
/// while the stock is empty — the existing `↺` overlay
|
||||
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
|
||||
/// indicators never render simultaneously.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockCountBadge;
|
||||
|
||||
/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
|
||||
/// count of cards remaining in the stock pile.
|
||||
///
|
||||
/// Update systems query this component to write the new count in place rather
|
||||
/// than despawning and respawning the text entity each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockCountBadgeText;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Phase of the two-stage flip animation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FlipPhase {
|
||||
/// Scale X from 1.0 → 0.0 (hiding the back face).
|
||||
ScalingDown,
|
||||
/// Scale X from 0.0 → 1.0 (revealing the front face).
|
||||
ScalingUp,
|
||||
}
|
||||
|
||||
/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
|
||||
///
|
||||
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
|
||||
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct CardFlipAnim {
|
||||
/// Seconds elapsed in the current phase.
|
||||
pub timer: f32,
|
||||
/// Which half of the flip we are in.
|
||||
pub phase: FlipPhase,
|
||||
}
|
||||
|
||||
/// Duration of each half of the flip animation (scale-down or scale-up).
|
||||
const FLIP_HALF_SECS: f32 = 0.08;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #38 — Drag-elevation shadow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker component for the semi-transparent shadow sprite shown while dragging.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ShadowEntity;
|
||||
|
||||
/// Marker component for the per-card drop-shadow child sprite.
|
||||
///
|
||||
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
|
||||
/// neutral-black halo painted slightly down-and-right of the card. Idle state
|
||||
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
|
||||
/// parent card is being dragged the shadow is pushed to the deeper
|
||||
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
|
||||
/// stack reads as "lifted" off the felt.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardShadow;
|
||||
|
||||
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
|
||||
///
|
||||
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
|
||||
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
|
||||
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
|
||||
/// frame — gives every face-down card a visible perimeter.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardBackFrame;
|
||||
|
||||
/// Fill colour for the face-down card border frame. Light-medium gray so it
|
||||
/// reads as a clear "edge" without competing with the suit colours on face-up
|
||||
/// cards. Brightened from `0.38` to `0.48` (≈ #7a7a7a) after a Pixel_7 smoke
|
||||
/// test showed face-down `back_0.png` (≈ #1a1a1a) was nearly invisible against
|
||||
/// the very dark `#151515` felt — the old gray was too close to the back fill
|
||||
/// to define a crisp perimeter.
|
||||
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.48, 0.48, 0.48);
|
||||
|
||||
/// Extra width/height (in world units) added to each side of the card to form
|
||||
/// the visible border. Widened from `3.0` to `6.0` so the frame peeks out as a
|
||||
/// clearly readable perimeter at phone density (420 dpi) rather than a hairline.
|
||||
const CARD_BACK_FRAME_PADDING: f32 = 6.0;
|
||||
|
||||
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||
/// shadow given whether its parent card is currently part of the dragged
|
||||
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||||
/// without spinning up a Bevy app.
|
||||
///
|
||||
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
|
||||
/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)`
|
||||
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
|
||||
if is_dragged {
|
||||
(
|
||||
CARD_SHADOW_OFFSET_DRAG,
|
||||
CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_ALPHA_DRAG,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
CARD_SHADOW_OFFSET_IDLE,
|
||||
CARD_SHADOW_PADDING_IDLE,
|
||||
CARD_SHADOW_ALPHA_IDLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `Sprite` used for a per-card shadow at the resting state. The
|
||||
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
|
||||
/// retunes them at runtime when the parent card joins / leaves the dragged
|
||||
/// stack.
|
||||
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
|
||||
let (_offset, padding, alpha) = card_shadow_params(false);
|
||||
Sprite {
|
||||
color: CARD_SHADOW_COLOR.with_alpha(alpha),
|
||||
custom_size: Some(card_size + padding),
|
||||
..default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `Transform` used for a per-card shadow at the resting state.
|
||||
/// Local — it is parented to the card entity, so positions are relative.
|
||||
fn card_shadow_transform() -> Transform {
|
||||
let (offset, _padding, _alpha) = card_shadow_params(false);
|
||||
Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
|
||||
}
|
||||
|
||||
/// Spawns a single `CardShadow` child under the given card entity builder.
|
||||
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
|
||||
/// exact same shadow recipe — we never want one path to drift from the other.
|
||||
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
parent.spawn((
|
||||
CardShadow,
|
||||
card_shadow_sprite(card_size),
|
||||
card_shadow_transform(),
|
||||
Visibility::default(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
|
||||
/// thin perimeter against the dark felt, regardless of face state.
|
||||
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
parent.spawn((
|
||||
CardBackFrame,
|
||||
Sprite {
|
||||
color: CARD_BACK_FRAME_COLOR,
|
||||
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||
Visibility::default(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Throttle interval for resize-driven card snap work, in seconds.
|
||||
///
|
||||
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||||
/// produce dozens of events per frame. Re-running the per-card snap logic
|
||||
/// (52 cards × sprite/transform/font_size touches) for every event is the
|
||||
/// dominant cost of resize lag. We coalesce pending work and apply it at most
|
||||
/// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates
|
||||
/// during a sustained drag, and the layout always catches up to the final
|
||||
/// size when the drag stops because the pending size is held until applied.
|
||||
const RESIZE_THROTTLE_SECS: f32 = 0.05;
|
||||
|
||||
/// Holds the latest pending window size from `WindowResized` events plus a
|
||||
/// timestamp for the last applied snap, so the resize-snap work can be
|
||||
/// rate-limited to ~20 Hz during sustained drags.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ResizeThrottle {
|
||||
/// Latest unapplied window size from `WindowResized`. `None` when there is
|
||||
/// nothing to apply.
|
||||
pub pending: Option<Vec2>,
|
||||
/// `Time::elapsed_secs()` value at the moment of the most recent applied
|
||||
/// snap. `0.0` until the first apply.
|
||||
pub last_applied_secs: f32,
|
||||
}
|
||||
|
||||
/// Pure helper used by the throttled resize-snap system: returns `true` when
|
||||
/// a pending resize should be flushed given the current `now_secs` and the
|
||||
/// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`].
|
||||
///
|
||||
/// Extracted so the rate-limit logic can be unit-tested without spinning up
|
||||
/// a full Bevy app.
|
||||
fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
|
||||
(now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS
|
||||
}
|
||||
|
||||
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
||||
pub struct CardPlugin;
|
||||
|
||||
/// System set for everything that paints the board: card sprites, pile
|
||||
/// markers, shadows, highlights, badges. Members mutate `Sprite` /
|
||||
/// `Transform` on board entities and run as a deterministic chain (see the
|
||||
/// registration in [`CardPlugin`]'s `build`); table-plugin marker painters
|
||||
/// order themselves after this set. UI-domain systems that touch `Sprite`/
|
||||
/// `Transform` on non-board entities (HUD text pulses, modal cards) declare
|
||||
/// `.ambiguous_with(BoardVisuals)` instead — the entity domains are
|
||||
/// disjoint by design (#143).
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct BoardVisuals;
|
||||
|
||||
impl Plugin for CardPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// PostStartup ensures TablePlugin's Startup system has inserted
|
||||
// LayoutResource before we try to read it.
|
||||
//
|
||||
// `handle_right_click` reads `ButtonInput<MouseButton>`. Under
|
||||
// `MinimalPlugins` (tests) this resource is absent by default, so we
|
||||
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
|
||||
app.init_resource::<ButtonInput<MouseButton>>()
|
||||
.init_resource::<ResizeThrottle>()
|
||||
.init_resource::<CardEntityIndex>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<CardFlippedEvent>()
|
||||
.add_message::<CardFaceRevealedEvent>()
|
||||
.add_systems(Startup, load_card_images)
|
||||
.add_systems(
|
||||
PostStartup,
|
||||
(
|
||||
// Fill the tableau fan before the first render so the
|
||||
// cold-start deal already uses the full viewport height.
|
||||
fill_tableau_fan_on_startup.before(sync_cards_startup),
|
||||
sync_cards_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(
|
||||
Update,
|
||||
(
|
||||
update_tableau_fan_frac,
|
||||
resync_cards_on_settings_change,
|
||||
sync_cards_on_change,
|
||||
start_flip_anim,
|
||||
tick_flip_anim,
|
||||
update_drag_shadow,
|
||||
update_card_shadows_on_drag,
|
||||
handle_right_click,
|
||||
tick_right_click_highlights,
|
||||
clear_right_click_highlights_on_state_change,
|
||||
clear_right_click_highlights_on_pause,
|
||||
tick_hint_highlight,
|
||||
update_stock_empty_indicator,
|
||||
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
|
||||
collect_resize_events,
|
||||
snap_cards_on_window_resize,
|
||||
resize_android_corner_labels,
|
||||
)
|
||||
.chain()
|
||||
.in_set(BoardVisuals)
|
||||
.after(GameMutation),
|
||||
);
|
||||
|
||||
app.add_systems(PostUpdate, rebuild_card_entity_index);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Stock-pile indicators: the empty-stock recycle hint and count badge.
|
||||
|
||||
use super::*;
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||
use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
|
||||
|
||||
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
||||
/// to signal to the player that there are no more cards to draw. Pure white
|
||||
/// at 0.4 alpha — a deliberate brightness-boost over the default marker so
|
||||
/// the "empty" state is more visible, not less. Not derived from a palette
|
||||
/// token: this is a sprite tint, not chrome colour.
|
||||
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
|
||||
|
||||
/// Sprite colour applied to the stock `PileMarker` when cards remain in
|
||||
/// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest
|
||||
/// of the engine's idle pile-marker tint automatically.
|
||||
const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
|
||||
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
|
||||
///
|
||||
/// If the stock pile is empty the marker sprite is dimmed to
|
||||
/// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is
|
||||
/// spawned (if not already present). When the stock is non-empty the marker is
|
||||
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
|
||||
/// despawned.
|
||||
pub(super) fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||
commands: &mut Commands,
|
||||
game: &GameState,
|
||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
||||
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
layout: &Layout,
|
||||
font: Handle<Font>,
|
||||
) {
|
||||
let stock_empty = game.stock_cards().is_empty();
|
||||
|
||||
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
|
||||
if pile_marker.0 != KlondikePile::Stock {
|
||||
continue;
|
||||
}
|
||||
|
||||
if stock_empty {
|
||||
// Dim the marker sprite.
|
||||
sprite.color = STOCK_EMPTY_DIM_COLOUR;
|
||||
|
||||
// Spawn the "↺" label only if one does not already exist.
|
||||
let already_has_label = label_children
|
||||
.iter()
|
||||
.any(|(_, parent)| parent.parent() == entity);
|
||||
if !already_has_label {
|
||||
let font_size = layout.card_size.x * 0.4;
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
StockEmptyLabel,
|
||||
Text2d::new("↺"),
|
||||
TextFont {
|
||||
font: font.clone(),
|
||||
font_size,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Restore normal brightness.
|
||||
sprite.color = STOCK_NORMAL_COLOUR;
|
||||
|
||||
// Despawn any existing "↺" label children.
|
||||
for (label_entity, parent) in label_children.iter() {
|
||||
if parent.parent() == entity {
|
||||
commands.entity(label_entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs at `PostStartup` to apply the stock-empty indicator for the initial
|
||||
/// game state (before any `StateChangedEvent` fires).
|
||||
pub(super) fn update_stock_empty_indicator_startup(
|
||||
mut commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
let Some(layout) = layout else { return };
|
||||
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
apply_stock_empty_indicator(
|
||||
&mut commands,
|
||||
&game.0,
|
||||
&mut pile_markers,
|
||||
&label_children,
|
||||
&layout.0,
|
||||
font,
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
|
||||
/// stock pile marker dim state and "↺" label in sync with the current stock.
|
||||
pub(super) fn update_stock_empty_indicator(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
mut commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else { return };
|
||||
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
apply_stock_empty_indicator(
|
||||
&mut commands,
|
||||
&game.0,
|
||||
&mut pile_markers,
|
||||
&label_children,
|
||||
&layout.0,
|
||||
font,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock-pile remaining-count badge
|
||||
//
|
||||
// Shows a small "N" chip pinned to the bottom-right corner of the stock pile so
|
||||
// the player can see how many cards remain before the next recycle. The
|
||||
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
|
||||
// the badge hides itself when the stock has zero cards — the two indicators
|
||||
// never render at the same time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inset (in pixels) from the bottom-right corner of the stock pile sprite to
|
||||
/// the centre of the count badge. Anchoring to the bottom-right keeps the chip
|
||||
/// clear of the rank/suit pip in the card's top-left corner. Both components
|
||||
/// move the centre *inward* from that corner: `x` is subtracted from the right
|
||||
/// edge, `y` is added to the bottom edge. The `x` magnitude must satisfy
|
||||
/// `x >= STOCK_BADGE_SIZE.x / 2` so the badge right edge stays inside the stock
|
||||
/// pile and never overlaps the adjacent waste pile — critical on Android where
|
||||
/// `H_GAP_DIVISOR = 32` gives an inter-pile gap of only ~4 px.
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(20.0, 8.0);
|
||||
|
||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
|
||||
|
||||
/// Returns the count of cards currently in the stock pile.
|
||||
///
|
||||
/// Pure helper extracted so the count source is identical between the spawn
|
||||
/// system, the update system, and the unit tests.
|
||||
pub(super) fn stock_card_count(game: &GameState) -> usize {
|
||||
game.stock_cards().len()
|
||||
}
|
||||
|
||||
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
|
||||
/// given the current `Layout`. The badge sits at the bottom-right corner of
|
||||
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`], so it stays clear of
|
||||
/// the rank/suit pip in the card's top-left corner.
|
||||
pub(super) fn stock_badge_translation(layout: &Layout) -> Vec3 {
|
||||
// Empty layouts don't contain a Stock entry — fall back to origin so
|
||||
// the badge stays in a deterministic spot until the layout is filled.
|
||||
let pile_pos = layout
|
||||
.pile_positions
|
||||
.get(&KlondikePile::Stock)
|
||||
.copied()
|
||||
.unwrap_or(Vec2::ZERO);
|
||||
let half = layout.card_size * 0.5;
|
||||
// Anchor to the bottom-right corner, then move the centre inward.
|
||||
let x = pile_pos.x + half.x - STOCK_BADGE_INSET.x;
|
||||
let y = pile_pos.y - half.y + STOCK_BADGE_INSET.y;
|
||||
Vec3::new(x, y, Z_STOCK_BADGE)
|
||||
}
|
||||
|
||||
/// Spawns the stock-count badge entity (background sprite + child text)
|
||||
/// into the world. Called once, when the badge does not yet exist.
|
||||
pub(super) fn spawn_stock_count_badge(
|
||||
commands: &mut Commands,
|
||||
layout: &Layout,
|
||||
font: Option<&Handle<Font>>,
|
||||
count: usize,
|
||||
) {
|
||||
let translation = stock_badge_translation(layout);
|
||||
let visibility = if count == 0 {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
let text_font = TextFont {
|
||||
font: font.cloned().unwrap_or_default(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
StockCountBadge,
|
||||
Sprite {
|
||||
color: STOCK_BADGE_BG,
|
||||
custom_size: Some(STOCK_BADGE_SIZE),
|
||||
..default()
|
||||
},
|
||||
Transform::from_translation(translation),
|
||||
visibility,
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
StockCountBadgeText,
|
||||
Text2d::new(format!("{count}")),
|
||||
text_font,
|
||||
TextColor(STOCK_BADGE_FG),
|
||||
// Slightly above the chip background so the digits aren't
|
||||
// occluded by the sprite they sit on.
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns the stock-pile remaining-count badge if it does not yet exist,
|
||||
/// and otherwise updates its text and visibility in place.
|
||||
///
|
||||
/// Visibility rule: hidden when the stock is empty (the existing `↺`
|
||||
/// `StockEmptyLabel` overlay covers that state), shown when one or more
|
||||
/// cards remain.
|
||||
///
|
||||
/// Position is recomputed from `LayoutResource` every tick so the badge
|
||||
/// follows the stock pile across `WindowResized` layout updates without
|
||||
/// needing a dedicated resize handler.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn update_stock_count_badge(
|
||||
mut commands: Commands,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
font: Option<Res<FontResource>>,
|
||||
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
|
||||
children: Query<&Children, With<StockCountBadge>>,
|
||||
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
|
||||
) {
|
||||
let Some(game) = game else { return };
|
||||
let Some(layout) = layout else { return };
|
||||
|
||||
let count = stock_card_count(&game.0);
|
||||
let translation = stock_badge_translation(&layout.0);
|
||||
let target_visibility = if count == 0 {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
|
||||
if badges.is_empty() {
|
||||
spawn_stock_count_badge(&mut commands, &layout.0, font.as_ref().map(|f| &f.0), count);
|
||||
return;
|
||||
}
|
||||
|
||||
for (entity, mut transform, mut visibility) in badges.iter_mut() {
|
||||
transform.translation = translation;
|
||||
if *visibility != target_visibility {
|
||||
*visibility = target_visibility;
|
||||
}
|
||||
// Update the child text to reflect the latest count. The text node
|
||||
// is created at spawn time, so under normal operation we always
|
||||
// have exactly one child here.
|
||||
if let Ok(badge_children) = children.get(entity) {
|
||||
for child in badge_children.iter() {
|
||||
if let Ok(mut text) = texts.get_mut(child) {
|
||||
let new = format!("{count}");
|
||||
if text.0 != new {
|
||||
text.0 = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
//! Card asset loading and entity lifecycle: the sync systems that
|
||||
//! spawn, update, and position card entities from `GameStateResource`.
|
||||
|
||||
use super::*;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||
use crate::card_animation::CardAnimation;
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
|
||||
/// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set.
|
||||
///
|
||||
/// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by
|
||||
/// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update`
|
||||
/// have been flushed at the `Update -> PostUpdate` apply-deferred boundary.
|
||||
/// Rebuilding from scratch (rather than incrementally patching at every
|
||||
/// spawn/despawn site — waste cards churn on every draw) keeps a single writer
|
||||
/// and makes a stale entry structurally impossible.
|
||||
///
|
||||
/// Gated to changed frames only: `Changed<CardEntity>` fires the frame a card
|
||||
/// is spawned, `RemovedComponents<CardEntity>` the frame one is despawned. The
|
||||
/// `card` field is write-once (never mutated in place), so card-reposition
|
||||
/// frames don't trip `Changed` and correctly skip the O(52) rebuild.
|
||||
pub(super) fn rebuild_card_entity_index(
|
||||
mut index: ResMut<CardEntityIndex>,
|
||||
cards: Query<(Entity, &CardEntity)>,
|
||||
changed: Query<(), Changed<CardEntity>>,
|
||||
removed: RemovedComponents<CardEntity>,
|
||||
) {
|
||||
if changed.is_empty() && removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let map = &mut index.0;
|
||||
map.clear();
|
||||
for (entity, ce) in &cards {
|
||||
map.insert(ce.card.clone(), entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the relative asset path for a card face PNG.
|
||||
///
|
||||
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
|
||||
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
|
||||
/// this function so the filename formula is tested in isolation from the
|
||||
/// asset-loading machinery.
|
||||
///
|
||||
/// Note: this function verifies only the **code-side mapping**. If the PNG
|
||||
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
|
||||
/// diamond watermark baked in), that is an **asset content bug** and must be
|
||||
/// fixed by replacing the file — no code change can correct it.
|
||||
pub(super) fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
|
||||
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
||||
const RANK_STRS: [&str; 13] = [
|
||||
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
|
||||
];
|
||||
let suit_idx = match suit {
|
||||
Suit::Clubs => 0,
|
||||
Suit::Diamonds => 1,
|
||||
Suit::Hearts => 2,
|
||||
Suit::Spades => 3,
|
||||
};
|
||||
let rank_idx = match 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,
|
||||
};
|
||||
format!(
|
||||
"cards/faces/classic/{}{}.png",
|
||||
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
|
||||
/// [`CardImageSet`].
|
||||
///
|
||||
/// Faces: `assets/cards/faces/{RANK}{SUIT}.png` (e.g. `AC.png`, `10H.png`)
|
||||
/// Backs: `assets/cards/backs/back_{0..4}.png`
|
||||
///
|
||||
/// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system
|
||||
/// returns without inserting `CardImageSet` and the plugin falls back to
|
||||
/// solid-colour sprites.
|
||||
pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
|
||||
let Some(asset_server) = asset_server else {
|
||||
return;
|
||||
};
|
||||
|
||||
// faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
|
||||
// card_plugin::mod; building from SUITS/RANKS order matches them.
|
||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
||||
std::array::from_fn(|ri| {
|
||||
asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::SUITS[si]))
|
||||
})
|
||||
});
|
||||
let backs =
|
||||
std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
|
||||
commands.insert_resource(CardImageSet {
|
||||
faces,
|
||||
backs,
|
||||
// Populated by the theme plugin once a `CardTheme` finishes loading.
|
||||
// Until then the legacy back fallback (`backs[selected_card_back]`)
|
||||
// is used.
|
||||
theme_back: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
|
||||
/// available and falling back to a solid-colour sprite in tests.
|
||||
pub(super) fn card_sprite(
|
||||
card: &Card,
|
||||
face_up: bool,
|
||||
card_size: Vec2,
|
||||
back_colour: Color,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) -> Sprite {
|
||||
if let Some(set) = card_images {
|
||||
let image = if face_up {
|
||||
let suit_idx = suit_index(card.suit());
|
||||
let rank_idx = rank_index(card.rank());
|
||||
set.faces[suit_idx][rank_idx].clone()
|
||||
} else if let Some(theme_back) = &set.theme_back {
|
||||
// Active theme provides its own back — always wins over the
|
||||
// legacy `selected_card_back` picker, so a theme switch swaps
|
||||
// faces *and* the back. The picker is treated as informational
|
||||
// only while a theme back is active (see settings_plugin).
|
||||
theme_back.clone()
|
||||
} else {
|
||||
let idx = selected_back.min(set.backs.len() - 1);
|
||||
set.backs[idx].clone()
|
||||
};
|
||||
Sprite {
|
||||
image,
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(card_size),
|
||||
..default()
|
||||
}
|
||||
} else {
|
||||
// Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR
|
||||
// regardless of colour-blind mode (CBM differentiation now lives in
|
||||
// the suit glyph colour, applied by `text_colour`, not the face
|
||||
// background). Pre-Terminal this branch dispatched through a
|
||||
// separate `face_colour(card, color_blind)` helper.
|
||||
let body_colour = if face_up {
|
||||
CARD_FACE_COLOUR
|
||||
} else {
|
||||
back_colour
|
||||
};
|
||||
Sprite {
|
||||
color: body_colour,
|
||||
custom_size: Some(card_size),
|
||||
..default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When card-back selection changes in Settings, re-render all cards so the
|
||||
/// new back colour is applied immediately (without waiting for a state change).
|
||||
pub(super) fn resync_cards_on_settings_change(
|
||||
mut setting_events: MessageReader<SettingsChangedEvent>,
|
||||
mut state_events: MessageWriter<StateChangedEvent>,
|
||||
) {
|
||||
if setting_events.read().next().is_some() {
|
||||
state_events.write(StateChangedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
||||
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
||||
/// have already completed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn sync_cards_startup(
|
||||
commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<CardSyncData>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if let Some(layout) = layout {
|
||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||
let back_colour = card_back_colour(selected_back);
|
||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||
sync_cards(
|
||||
commands,
|
||||
&game.0,
|
||||
&layout.0,
|
||||
slide_secs,
|
||||
back_colour,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
&entities,
|
||||
card_images.as_deref(),
|
||||
selected_back,
|
||||
font_handle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn sync_cards_on_change(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<CardSyncData>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(layout) = layout {
|
||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||
let back_colour = card_back_colour(selected_back);
|
||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||
sync_cards(
|
||||
commands,
|
||||
&game.0,
|
||||
&layout.0,
|
||||
slide_secs,
|
||||
back_colour,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
&entities,
|
||||
card_images.as_deref(),
|
||||
selected_back,
|
||||
font_handle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn sync_cards(
|
||||
mut commands: Commands,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
slide_secs: f32,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
entities: &Query<CardSyncData>,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
// The waste buffer card exists only to keep its entity alive while the new
|
||||
// top card's slide animation plays — it must never be visible to the player.
|
||||
// Without this, the buffer sits at waste_base uncovered during the animation
|
||||
// and its rank/suit peek behind the incoming card.
|
||||
let waste_buffer_id: Option<Card> = {
|
||||
let visible = match game.draw_mode() {
|
||||
DrawStockConfig::DrawOne => 1_usize,
|
||||
DrawStockConfig::DrawThree => 3_usize,
|
||||
};
|
||||
let waste_cards = game.waste_cards();
|
||||
(waste_cards.len() > visible)
|
||||
.then_some(waste_cards)
|
||||
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
|
||||
.map(|(c, _face_up)| c)
|
||||
};
|
||||
|
||||
// Map Card -> (Entity, current_translation, anim_end) for in-place
|
||||
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
|
||||
// is currently driving the card (e.g. a drag-rejection return tween).
|
||||
//
|
||||
// In the position loop below we compare `anim_end` against the new game-
|
||||
// state target position to decide whether to honour or cancel the tween:
|
||||
// • end ≈ target → animation is still heading to the right place; let
|
||||
// it finish (skip the snap/slide path).
|
||||
// • end ≠ target → the game state has changed (e.g. a new game started
|
||||
// while the win-cascade was mid-flight); cancel the
|
||||
// stale `CardAnimation` and apply the new position.
|
||||
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
|
||||
HashMap::new();
|
||||
for (entity, marker, transform, anim, children_key) in entities.iter() {
|
||||
existing.insert(
|
||||
marker.card.clone(),
|
||||
(
|
||||
entity,
|
||||
transform.translation,
|
||||
anim.map(|a| a.end),
|
||||
children_key.copied(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
|
||||
|
||||
// Despawn any entity whose card is no longer tracked.
|
||||
for (card, (entity, _, _, _)) in &existing {
|
||||
if !live_ids.contains(card) {
|
||||
commands.entity(*entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// For each card in the current state: spawn or update its entity, then
|
||||
// apply visibility. The waste buffer card is hidden so it cannot peek
|
||||
// behind the incoming top card during the draw slide animation.
|
||||
for ((card, face_up), position, z) in positions {
|
||||
let entity = match existing.get(&card) {
|
||||
Some(&(entity, cur, anim_end, children_key)) => {
|
||||
// If a CardAnimation is in flight, check whether its destination
|
||||
// still matches the game-state target. If the game moved the card
|
||||
// elsewhere (e.g. new game started during a win-cascade scatter),
|
||||
// cancel the stale tween so the card snaps/slides to its new home.
|
||||
let has_anim = match anim_end {
|
||||
Some(end_xy) if (end_xy - position).length() > 2.0 => {
|
||||
commands.entity(entity).remove::<CardAnimation>();
|
||||
false
|
||||
}
|
||||
Some(_) => true,
|
||||
None => false,
|
||||
};
|
||||
update_card_entity(
|
||||
&mut commands,
|
||||
entity,
|
||||
&card,
|
||||
face_up,
|
||||
position,
|
||||
z,
|
||||
layout,
|
||||
slide_secs,
|
||||
back_colour,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
cur,
|
||||
has_anim,
|
||||
children_key,
|
||||
card_images,
|
||||
selected_back,
|
||||
font_handle,
|
||||
);
|
||||
entity
|
||||
}
|
||||
None => spawn_card_entity(
|
||||
&mut commands,
|
||||
&card,
|
||||
face_up,
|
||||
position,
|
||||
z,
|
||||
layout,
|
||||
back_colour,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
card_images,
|
||||
selected_back,
|
||||
font_handle,
|
||||
),
|
||||
};
|
||||
let visibility = if waste_buffer_id.as_ref() == Some(&card) {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
commands.entity(entity).insert(visibility);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
|
||||
pub(super) fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
|
||||
let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
|
||||
let piles = [
|
||||
(KlondikePile::Stock, true),
|
||||
(KlondikePile::Stock, false),
|
||||
(KlondikePile::Foundation(Foundation::Foundation1), false),
|
||||
(KlondikePile::Foundation(Foundation::Foundation2), false),
|
||||
(KlondikePile::Foundation(Foundation::Foundation3), false),
|
||||
(KlondikePile::Foundation(Foundation::Foundation4), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau1), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau2), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau3), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau4), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau5), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau6), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau7), false),
|
||||
];
|
||||
|
||||
// Draw-Three waste fan step, proportional to the column spacing so it scales
|
||||
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
|
||||
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
|
||||
// click target on the card beneath it).
|
||||
let waste_fan_step = waste_fan_step(layout);
|
||||
|
||||
for (pile_type, is_stock_area) in piles {
|
||||
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
|
||||
continue;
|
||||
};
|
||||
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
|
||||
base.x -= tableau_col_step(layout);
|
||||
}
|
||||
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
|
||||
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
|
||||
let cards = if matches!(pile_type, KlondikePile::Stock) {
|
||||
if is_stock_area {
|
||||
game.stock_cards()
|
||||
} else {
|
||||
game.waste_cards()
|
||||
}
|
||||
} else {
|
||||
game.pile(pile_type)
|
||||
};
|
||||
|
||||
// Tableau uses a two-speed fan: face-down cards are packed tighter
|
||||
// than face-up cards so the visible (playable) portion stands out.
|
||||
// Non-tableau piles stack with a negligible offset.
|
||||
//
|
||||
// Waste pile: only the top N cards are rendered to prevent bleed-through
|
||||
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
|
||||
// shows up to 3 fanned in X (matching the standard Klondike presentation).
|
||||
let render_start = if is_waste {
|
||||
let visible = match game.draw_mode() {
|
||||
DrawStockConfig::DrawOne => 1_usize,
|
||||
DrawStockConfig::DrawThree => 3_usize,
|
||||
};
|
||||
// Render one extra card so that the card sliding off the waste
|
||||
// during a draw animation is still present in the world at z=0
|
||||
// (hidden under the stack) rather than vanishing mid-tween.
|
||||
cards.len().saturating_sub(visible + 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut y_offset = 0.0_f32;
|
||||
let rendered_len = cards[render_start..].len();
|
||||
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
|
||||
let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) {
|
||||
// When len > visible, slot 0 is a hidden buffer card kept at
|
||||
// x=0 to prevent a flash during the draw tween. When len ≤
|
||||
// visible (small pile), every card is visible and should fan
|
||||
// normally — no card is hidden, so the shift is 0.
|
||||
let visible = 3_usize;
|
||||
let hidden = rendered_len.saturating_sub(visible);
|
||||
slot.saturating_sub(hidden) as f32 * waste_fan_step
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
|
||||
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
|
||||
out.push(((card.clone(), *face_up), pos, z));
|
||||
if is_tableau {
|
||||
let step = if *face_up {
|
||||
layout.tableau_fan_frac
|
||||
} else {
|
||||
layout.tableau_facedown_fan_frac
|
||||
};
|
||||
y_offset -= layout.card_size.y * step;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
|
||||
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
|
||||
cards.extend(game.stock_cards());
|
||||
cards.extend(game.waste_cards());
|
||||
for foundation in solitaire_core::FOUNDATIONS {
|
||||
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
|
||||
}
|
||||
for tableau in solitaire_core::TABLEAUS {
|
||||
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
|
||||
}
|
||||
cards
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn spawn_card_entity(
|
||||
commands: &mut Commands,
|
||||
card: &Card,
|
||||
face_up: bool,
|
||||
pos: Vec2,
|
||||
z: f32,
|
||||
layout: &Layout,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) -> Entity {
|
||||
let sprite = card_sprite(
|
||||
card,
|
||||
face_up,
|
||||
layout.card_size,
|
||||
back_colour,
|
||||
card_images,
|
||||
selected_back,
|
||||
);
|
||||
|
||||
let mut entity = commands.spawn((
|
||||
CardEntity { card: card.clone() },
|
||||
sprite,
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
Visibility::default(),
|
||||
));
|
||||
let entity_id = entity.id();
|
||||
// Every card gets a subtle drop-shadow child so the play surface reads
|
||||
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
||||
// system retunes its offset / alpha when this card joins the dragged
|
||||
// stack.
|
||||
entity.with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
// Every card gets a thin border frame so it reads as a distinct
|
||||
// rectangle against the dark felt, regardless of face state.
|
||||
entity.with_children(|b| {
|
||||
add_card_back_frame_child(b, layout.card_size);
|
||||
});
|
||||
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||
// On Android we additionally spawn a large-print corner label even in
|
||||
// image mode so the rank/suit are legible at phone scale.
|
||||
if card_images.is_none() {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
CardLabel,
|
||||
Text2d::new(label_for(card)),
|
||||
TextFont {
|
||||
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||
label_visibility(face_up),
|
||||
));
|
||||
});
|
||||
}
|
||||
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||
entity.with_children(|b| {
|
||||
add_android_corner_label(
|
||||
b,
|
||||
card,
|
||||
face_up,
|
||||
layout.card_size,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
font_handle,
|
||||
);
|
||||
});
|
||||
}
|
||||
// Record the appearance signature so subsequent `update_card_entity` calls
|
||||
// can skip rebuilding these children until one of the inputs changes.
|
||||
entity.insert(CardChildrenKey {
|
||||
face_up,
|
||||
card_size: layout.card_size,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
});
|
||||
entity_id
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn update_card_entity(
|
||||
commands: &mut Commands,
|
||||
entity: Entity,
|
||||
card: &Card,
|
||||
face_up: bool,
|
||||
pos: Vec2,
|
||||
z: f32,
|
||||
layout: &Layout,
|
||||
slide_secs: f32,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
cur: Vec3,
|
||||
has_card_animation: bool,
|
||||
existing_children_key: Option<CardChildrenKey>,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
let target = Vec3::new(pos.x, pos.y, z);
|
||||
|
||||
// Always refresh the visual appearance.
|
||||
commands.entity(entity).insert(card_sprite(
|
||||
card,
|
||||
face_up,
|
||||
layout.card_size,
|
||||
back_colour,
|
||||
card_images,
|
||||
selected_back,
|
||||
));
|
||||
|
||||
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
|
||||
// is driving this card (e.g. the drag-rejection return tween). Writing
|
||||
// `Transform` here would race that animation each frame and cause a
|
||||
// visible jump. The animation system snaps the final position itself
|
||||
// when it completes.
|
||||
if !has_card_animation {
|
||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||
// Lift the card immediately on the first frame of the animation so
|
||||
// it never appears behind a card that is already resting at the
|
||||
// destination slot. `advance_card_anims` will maintain this lift
|
||||
// throughout the tween and snap to `target` (without lift) on
|
||||
// completion.
|
||||
let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(Transform::from_translation(start))
|
||||
.insert(CardAnim {
|
||||
start,
|
||||
target,
|
||||
elapsed: 0.0,
|
||||
duration: slide_secs,
|
||||
delay: 0.0,
|
||||
});
|
||||
} else {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<CardAnim>()
|
||||
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the card's child visuals (drop-shadow, border frame, and the
|
||||
// rank/suit label / large-print corner overlay) only when an input that
|
||||
// affects them actually changed. The child set depends solely on
|
||||
// `CardChildrenKey`; the face/back image is carried by the always-refreshed
|
||||
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
|
||||
// this on a position-only move avoids despawning and respawning the child
|
||||
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
|
||||
// `StateChangedEvent` — the spike that stuttered the slide animation on
|
||||
// high-resolution devices.
|
||||
let new_children_key = CardChildrenKey {
|
||||
face_up,
|
||||
card_size: layout.card_size,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
};
|
||||
if existing_children_key != Some(new_children_key) {
|
||||
commands.entity(entity).despawn_related::<Children>();
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_back_frame_child(b, layout.card_size);
|
||||
});
|
||||
if card_images.is_none() {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
CardLabel,
|
||||
Text2d::new(label_for(card)),
|
||||
TextFont {
|
||||
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||
label_visibility(face_up),
|
||||
));
|
||||
});
|
||||
}
|
||||
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_android_corner_label(
|
||||
b,
|
||||
card,
|
||||
face_up,
|
||||
layout.card_size,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
font_handle,
|
||||
);
|
||||
});
|
||||
}
|
||||
commands.entity(entity).insert(new_children_key);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,7 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -135,7 +135,7 @@ mod tests {
|
||||
fn challenge_win_advances_index() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -224,7 +224,7 @@ mod tests {
|
||||
.0
|
||||
.challenge_index = 2;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
|
||||
@@ -13,17 +13,21 @@ use crate::platform::{
|
||||
default_storage_backend,
|
||||
};
|
||||
use crate::{
|
||||
AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin,
|
||||
AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||
SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider,
|
||||
SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
||||
ThemeStorePlugin,
|
||||
};
|
||||
|
||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||
pub struct CoreGamePlugin {
|
||||
@@ -45,6 +49,7 @@ impl Plugin for CoreGamePlugin {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
|
||||
let sync_provider = sync_provider
|
||||
.take()
|
||||
.expect("CoreGamePlugin::build called twice");
|
||||
@@ -89,6 +94,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(SolutionPlaybackPlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
@@ -104,21 +110,28 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(AvatarPlugin)
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(SyncSetupPlugin)
|
||||
.add_plugins(AnalyticsPlugin)
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin)
|
||||
.add_plugins(DiagnosticsHudPlugin);
|
||||
|
||||
// Plugins that use kira/cpal audio or multi-threaded Tokio are not
|
||||
// compatible with the single-threaded wasm32 runtime. Gate them out
|
||||
// so the browser build boots silently and without a sync backend.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.add_plugins(AvatarPlugin)
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(SyncSetupPlugin)
|
||||
.add_plugins(ThemeStorePlugin)
|
||||
.add_plugins(AnalyticsPlugin)
|
||||
.add_plugins(LeaderboardPlugin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::card_plugin::RightClickHighlight;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
@@ -66,10 +66,10 @@ const MARKER_VALID: Color = Color::srgba(0.675, 0.761, 0.404, 0.55);
|
||||
|
||||
/// Marker component on a parent entity that owns one drop-target overlay
|
||||
/// (a translucent fill plus four outline edges as children). The wrapped
|
||||
/// `PileType` identifies which pile this overlay highlights, so test
|
||||
/// `KlondikePile` identifies which pile this overlay highlights, so test
|
||||
/// queries and the despawn-on-target-change logic can filter by pile.
|
||||
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DropTargetOverlay(pub PileType);
|
||||
pub struct DropTargetOverlay(pub KlondikePile);
|
||||
|
||||
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
||||
pub struct CursorPlugin;
|
||||
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
|
||||
Update,
|
||||
(
|
||||
update_cursor_icon,
|
||||
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
|
||||
update_drop_highlights.run_if(resource_changed::<DragState>),
|
||||
update_drop_target_overlays,
|
||||
),
|
||||
);
|
||||
@@ -163,33 +163,34 @@ fn update_cursor_icon(
|
||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
KlondikePile::Foundation(Foundation::Foundation2),
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
KlondikePile::Foundation(Foundation::Foundation4),
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
KlondikePile::Tableau(Tableau::Tableau3),
|
||||
KlondikePile::Tableau(Tableau::Tableau4),
|
||||
KlondikePile::Tableau(Tableau::Tableau5),
|
||||
KlondikePile::Tableau(Tableau::Tableau6),
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
];
|
||||
|
||||
for pile in piles {
|
||||
let Some(pile_cards) = game.piles.get(&pile) else {
|
||||
let pile_cards = pile_cards(game, &pile);
|
||||
if pile_cards.is_empty() {
|
||||
continue;
|
||||
};
|
||||
let is_tableau = matches!(pile, PileType::Tableau(_));
|
||||
}
|
||||
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
|
||||
let base = layout.pile_positions[&pile];
|
||||
|
||||
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
|
||||
if !card.face_up {
|
||||
for (i, card) in pile_cards.iter().enumerate().rev() {
|
||||
if !card.1 {
|
||||
continue;
|
||||
}
|
||||
// Only the topmost card is draggable on non-tableau piles.
|
||||
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
||||
if !is_tableau && i != pile_cards.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
|
||||
@@ -226,38 +227,14 @@ fn update_drop_highlights(
|
||||
|
||||
let Some(game) = game else { return };
|
||||
|
||||
// The first element of drag.cards is the bottom card that lands on the target.
|
||||
let Some(&bottom_id) = drag.cards.first() else {
|
||||
return;
|
||||
};
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else {
|
||||
return;
|
||||
};
|
||||
let drag_count = drag.cards.len();
|
||||
|
||||
let Some(origin) = drag.origin_pile.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (marker, mut sprite, _rch) in &mut markers {
|
||||
let valid = match &marker.0 {
|
||||
PileType::Foundation(slot) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
let pile = game.0.piles.get(&PileType::Foundation(*slot));
|
||||
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(idx) => {
|
||||
let pile = game.0.piles.get(&PileType::Tableau(*idx));
|
||||
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
|
||||
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
||||
}
|
||||
}
|
||||
@@ -297,20 +274,7 @@ fn update_drop_target_overlays(
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve the bottom card of the dragged stack — same logic as
|
||||
// `update_drop_highlights` so rules can't drift between the marker
|
||||
// tint and the overlay.
|
||||
let Some(&bottom_id) = drag.cards.first() else {
|
||||
return;
|
||||
};
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else {
|
||||
let Some(origin) = drag.origin_pile.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let drag_count = drag.cards.len();
|
||||
@@ -318,44 +282,24 @@ fn update_drop_target_overlays(
|
||||
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
||||
// Waste are excluded because they are never legal drop targets.
|
||||
let candidates = [
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
KlondikePile::Foundation(Foundation::Foundation2),
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
KlondikePile::Foundation(Foundation::Foundation4),
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
KlondikePile::Tableau(Tableau::Tableau3),
|
||||
KlondikePile::Tableau(Tableau::Tableau4),
|
||||
KlondikePile::Tableau(Tableau::Tableau5),
|
||||
KlondikePile::Tableau(Tableau::Tableau6),
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
];
|
||||
|
||||
// Compute the new set of valid piles for this frame.
|
||||
let mut valid: Vec<PileType> = Vec::new();
|
||||
let mut valid: Vec<KlondikePile> = Vec::new();
|
||||
for pile in &candidates {
|
||||
let is_valid = match pile {
|
||||
PileType::Foundation(_) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
game.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(_) => game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
|
||||
_ => false,
|
||||
};
|
||||
// Don't highlight the origin pile — dropping onto the source is
|
||||
// a no-op.
|
||||
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
|
||||
valid.push(pile.clone());
|
||||
if game.0.can_move_cards(origin, pile, drag_count) {
|
||||
valid.push(*pile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,9 +311,9 @@ fn update_drop_target_overlays(
|
||||
}
|
||||
|
||||
// Spawn overlays for piles that are now valid but don't yet have one.
|
||||
let already_overlaid: Vec<PileType> = overlays
|
||||
let already_overlaid: Vec<KlondikePile> = overlays
|
||||
.iter()
|
||||
.map(|(_, m)| m.0.clone())
|
||||
.map(|(_, m)| m.0)
|
||||
.filter(|p| valid.contains(p))
|
||||
.collect();
|
||||
|
||||
@@ -388,10 +332,14 @@ fn update_drop_target_overlays(
|
||||
/// for everything else it is card-sized. Replicated here rather than
|
||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||
/// this overlay is the only other consumer.
|
||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
|
||||
fn drop_overlay_rect(
|
||||
pile: &KlondikePile,
|
||||
layout: &Layout,
|
||||
game: &GameState,
|
||||
) -> Option<(Vec2, Vec2)> {
|
||||
let centre = layout.pile_positions.get(pile).copied()?;
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||
let card_count = game.pile(*pile).len();
|
||||
if card_count > 1 {
|
||||
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||
@@ -412,7 +360,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Opti
|
||||
/// the appropriate world position for `pile`.
|
||||
fn spawn_drop_target_overlay(
|
||||
commands: &mut Commands,
|
||||
pile: &PileType,
|
||||
pile: &KlondikePile,
|
||||
layout: &Layout,
|
||||
game: &GameState,
|
||||
) {
|
||||
@@ -430,7 +378,7 @@ fn spawn_drop_target_overlay(
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
||||
DropTargetOverlay(pile.clone()),
|
||||
DropTargetOverlay(*pile),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
// Top edge.
|
||||
@@ -479,7 +427,7 @@ fn spawn_drop_target_overlay(
|
||||
fn tableau_or_stack_pos(
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
pile: &PileType,
|
||||
pile: &KlondikePile,
|
||||
index: usize,
|
||||
base: Vec2,
|
||||
is_tableau: bool,
|
||||
@@ -489,8 +437,9 @@ fn tableau_or_stack_pos(
|
||||
base.x,
|
||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||
)
|
||||
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
|
||||
{
|
||||
let pile_len = game.waste_cards().len();
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = index.saturating_sub(visible_start) as f32;
|
||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||
@@ -499,6 +448,14 @@ fn tableau_or_stack_pos(
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
if matches!(pile, KlondikePile::Stock) {
|
||||
game.waste_cards()
|
||||
} else {
|
||||
game.pile(*pile)
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
let half = size / 2.0;
|
||||
point.x >= center.x - half.x
|
||||
@@ -607,9 +564,9 @@ mod tests {
|
||||
#[test]
|
||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// A cursor far off-screen should never hit anything.
|
||||
assert!(!cursor_over_draggable(
|
||||
@@ -624,8 +581,11 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{
|
||||
DrawStockConfig,
|
||||
game_state::{GameMode, GameState},
|
||||
};
|
||||
|
||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||
/// registered, plus the resources the system needs. Callers
|
||||
@@ -649,12 +609,8 @@ mod tests {
|
||||
/// card. Used to make a specific tableau column accept a chosen
|
||||
/// drag stack.
|
||||
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(idx))
|
||||
.expect("tableau pile exists");
|
||||
pile.cards.clear();
|
||||
pile.cards.push(card);
|
||||
let tableau = GameState::tableau_from_index(idx).expect("tableau pile exists");
|
||||
game.set_test_tableau_cards(tableau, vec![card]);
|
||||
}
|
||||
|
||||
/// Inserts a single face-up dragged card into the waste pile and
|
||||
@@ -664,148 +620,37 @@ mod tests {
|
||||
// Place the dragged card on the waste pile (origin).
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let waste = game
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Waste)
|
||||
.expect("waste pile exists");
|
||||
waste.cards.clear();
|
||||
waste.cards.push(dragged.clone());
|
||||
game.0.set_test_waste_cards(vec![dragged.clone()]);
|
||||
}
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![dragged.id];
|
||||
drag.origin_pile = Some(PileType::Waste);
|
||||
drag.cards = vec![dragged];
|
||||
drag.origin_pile = Some(KlondikePile::Stock);
|
||||
drag.committed = true;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
|
||||
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
|
||||
// (black, rank 6) — alternating colour, one rank lower → legal.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card {
|
||||
id: 9001,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
},
|
||||
);
|
||||
let dragged = Card {
|
||||
id: 9002,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
overlays.contains(&PileType::Tableau(2)),
|
||||
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||
// — same colour family, illegal. Tableau(2) must NOT be
|
||||
// highlighted.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card {
|
||||
id: 9101,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
},
|
||||
);
|
||||
let dragged = Card {
|
||||
id: 9102,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
|
||||
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
let overlays: Vec<KlondikePile> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.map(|o| o.0)
|
||||
.collect();
|
||||
assert!(
|
||||
!overlays.contains(&PileType::Tableau(2)),
|
||||
!overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)),
|
||||
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlays_despawn_on_drag_end() {
|
||||
// Set up a scenario that produces at least one valid overlay,
|
||||
// confirm it spawns, then clear the drag and confirm every
|
||||
// overlay is despawned.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card {
|
||||
id: 9201,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
},
|
||||
);
|
||||
let dragged = Card {
|
||||
id: 9202,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
app.update();
|
||||
|
||||
let count_during_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
count_during_drag >= 1,
|
||||
"expected ≥1 overlay during drag, got {count_during_drag}"
|
||||
);
|
||||
|
||||
// End the drag — every overlay should despawn next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
|
||||
let count_after_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count_after_drag, 0,
|
||||
"all overlays must despawn when the drag ends"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use solitaire_sync::ChallengeGoal;
|
||||
|
||||
use crate::events::{
|
||||
@@ -25,6 +27,7 @@ use crate::events::{
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
|
||||
/// Bonus XP awarded for completing today's daily challenge.
|
||||
@@ -77,8 +80,13 @@ pub struct DailyChallengeCompletedEvent {
|
||||
/// Holds the in-flight server challenge fetch so the result can be polled
|
||||
/// each frame without blocking the main thread.
|
||||
#[derive(Resource, Default)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
struct DailyChallengeTask;
|
||||
|
||||
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
|
||||
/// already fired for, so the toast spawns at most once per day.
|
||||
///
|
||||
@@ -116,17 +124,21 @@ impl Plugin for DailyChallengePlugin {
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_systems(Startup, fetch_server_challenge)
|
||||
.add_systems(Update, poll_server_challenge)
|
||||
// record/award after the base ProgressUpdate so we don't fight
|
||||
// ProgressPlugin's add_xp on the same frame.
|
||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
||||
.add_systems(Update, check_daily_expiry_warning)
|
||||
.add_systems(Update, check_date_rollover);
|
||||
|
||||
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.add_systems(Startup, fetch_server_challenge)
|
||||
.add_systems(Update, poll_server_challenge);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Startup system: spawns an async task to fetch the server's daily challenge.
|
||||
///
|
||||
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
|
||||
@@ -142,6 +154,7 @@ fn fetch_server_challenge(
|
||||
task_res.0 = Some(task);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Update system: polls the server-challenge fetch task.
|
||||
///
|
||||
/// On success, replaces the locally-computed seed in `DailyChallengeResource`
|
||||
@@ -341,7 +354,6 @@ fn check_date_rollover(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
@@ -350,7 +362,7 @@ mod tests {
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
#[allow(unused_imports)]
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -379,7 +391,7 @@ mod tests {
|
||||
|
||||
// Replace the GameState with one whose seed matches the daily seed.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||
GameState::new(daily_seed, DrawStockConfig::DrawOne);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -407,7 +419,7 @@ mod tests {
|
||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||
// Use a deliberately different seed.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
||||
GameState::new(daily_seed.wrapping_add(7777), DrawStockConfig::DrawOne);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -430,7 +442,7 @@ mod tests {
|
||||
let mut app = headless_app();
|
||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||
GameState::new(daily_seed, DrawStockConfig::DrawOne);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! because the starting position is effectively random (player-chosen timing
|
||||
//! determines which seed in the 40-entry catalog they start at).
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use chrono::Utc;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
||||
@@ -54,7 +54,10 @@ impl DifficultyIndexResource {
|
||||
DifficultyLevel::Hard => &mut self.hard,
|
||||
DifficultyLevel::Expert => &mut self.expert,
|
||||
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
||||
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
||||
// Random has no catalog today, so seeds_for() already returned
|
||||
// None above; if it ever gains one, a time seed is still the
|
||||
// right answer for "Random" — never a reachable panic.
|
||||
DifficultyLevel::Random => return seed_from_system_time(),
|
||||
};
|
||||
let seed = catalog[*cursor % catalog.len()];
|
||||
*cursor = cursor.wrapping_add(1);
|
||||
@@ -104,10 +107,9 @@ fn handle_difficulty_request(
|
||||
}
|
||||
|
||||
fn seed_from_system_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
|
||||
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
|
||||
// std::time::SystemTime panics on wasm32-unknown-unknown).
|
||||
Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Cross-system events used by the engine's plugins.
|
||||
|
||||
use bevy::prelude::Message;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_data::AchievementRecord;
|
||||
use solitaire_sync::SyncResponse;
|
||||
|
||||
@@ -11,8 +11,8 @@ use solitaire_sync::SyncResponse;
|
||||
/// consumed by `GamePlugin`.
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct MoveRequestEvent {
|
||||
pub from: PileType,
|
||||
pub to: PileType,
|
||||
pub from: KlondikePile,
|
||||
pub to: KlondikePile,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ pub struct StateChangedEvent;
|
||||
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct MoveRejectedEvent {
|
||||
pub from: PileType,
|
||||
pub to: PileType,
|
||||
pub from: KlondikePile,
|
||||
pub to: KlondikePile,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent {
|
||||
}
|
||||
|
||||
/// Fired when a card's face-up state changes during gameplay.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct CardFlippedEvent(pub u32);
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct CardFlippedEvent(pub Card);
|
||||
|
||||
/// Fired by the flip animation at its midpoint — the instant the card face
|
||||
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
|
||||
@@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub u32);
|
||||
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
||||
/// so the flip sound is synchronised with the visual reveal, not the move
|
||||
/// that triggered the animation.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct CardFaceRevealedEvent(pub u32);
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct CardFaceRevealedEvent(pub Card);
|
||||
|
||||
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||
@@ -134,6 +134,12 @@ pub struct ManualSyncRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct SyncConfigureRequestEvent;
|
||||
|
||||
/// Request to open the in-game theme-store modal. Fired by the
|
||||
/// "Browse theme store" button in the Settings cosmetic section;
|
||||
/// handled by `theme_store_plugin`.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ThemeStoreOpenRequestEvent;
|
||||
|
||||
/// Request to disconnect from the current sync backend, clear stored
|
||||
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
||||
/// button in the Settings sync section.
|
||||
@@ -153,6 +159,12 @@ pub struct DeleteAccountRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct PauseRequestEvent;
|
||||
|
||||
/// Request to solve the current deal and auto-play the winning line.
|
||||
/// Fired by the pause menu's "Show solution" button; consumed by
|
||||
/// `solution_playback_plugin`.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ShowSolutionRequestEvent;
|
||||
|
||||
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
|
||||
/// button alongside the existing `F1` accelerator so the overlay is
|
||||
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
|
||||
@@ -231,6 +243,11 @@ pub struct ToggleSettingsRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ToggleLeaderboardRequestEvent;
|
||||
|
||||
/// Request to toggle the Home mode launcher. Fired by the HUD
|
||||
/// Menu-popover "Home" row alongside the existing `M` accelerator.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ToggleHomeRequestEvent;
|
||||
|
||||
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
|
||||
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
|
||||
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
|
||||
@@ -283,15 +300,6 @@ pub struct ForfeitEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ForfeitRequestEvent;
|
||||
|
||||
/// Fired when the player clicks "Scan for new themes" in Settings.
|
||||
///
|
||||
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
|
||||
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
|
||||
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
|
||||
/// fires [`InfoToastEvent`] messages to report results.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ScanThemesRequestEvent;
|
||||
|
||||
/// Fired when the player requests a hint (H key). Carries the source card ID
|
||||
/// and destination pile for visual highlighting.
|
||||
///
|
||||
@@ -299,8 +307,8 @@ pub struct ScanThemesRequestEvent;
|
||||
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct HintVisualEvent {
|
||||
/// The `Card::id` of the source card to be highlighted.
|
||||
pub source_card_id: u32,
|
||||
/// The source card to be highlighted.
|
||||
pub source_card: Card,
|
||||
/// The destination pile whose `PileMarker` should be tinted gold.
|
||||
pub dest_pile: solitaire_core::pile::PileType,
|
||||
pub dest_pile: KlondikePile,
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ use std::f32::consts::PI;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::pile::PileType;
|
||||
use bevy::window::RequestRedraw;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::klondike_adapter::foundation_from_slot;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::animation_plugin::CardAnim;
|
||||
@@ -204,6 +207,7 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -243,18 +247,16 @@ fn start_shake_anim(
|
||||
continue;
|
||||
}
|
||||
let dest_pile = &ev.to;
|
||||
// Collect the card ids that belong to the destination pile.
|
||||
let Some(pile) = game.0.piles.get(dest_pile) else {
|
||||
continue;
|
||||
};
|
||||
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
|
||||
// Collect the cards that belong to the destination pile.
|
||||
let dest_cards = pile_cards(&game.0, dest_pile);
|
||||
let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect();
|
||||
|
||||
if dest_card_ids.is_empty() {
|
||||
if dest_card_set.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (entity, card_marker, transform) in card_entities.iter() {
|
||||
if dest_card_ids.contains(&card_marker.card_id) {
|
||||
if dest_card_set.contains(&card_marker.card) {
|
||||
commands.entity(entity).insert(ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: transform.translation.x,
|
||||
@@ -274,10 +276,16 @@ fn tick_shake_anim(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the shake under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -311,27 +319,27 @@ fn start_settle_anim(
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
// Build the list of card ids that should bounce this frame from every
|
||||
// Build the list of cards that should bounce this frame from every
|
||||
// queued request; multiple events can fire in the same frame (e.g. a move
|
||||
// followed by a draw via keyboard accelerators).
|
||||
let mut bounce_ids: Vec<u32> = Vec::new();
|
||||
let mut bounce_ids: Vec<Card> = Vec::new();
|
||||
|
||||
for ev in moves.read() {
|
||||
if let Some(pile) = game.0.piles.get(&ev.to) {
|
||||
// The moved cards land on top — take the last `count` ids.
|
||||
let n = ev.count.min(pile.cards.len());
|
||||
let pile = pile_cards(&game.0, &ev.to);
|
||||
if !pile.is_empty() {
|
||||
// The moved cards land on top — take the last `count` cards.
|
||||
let n = ev.count.min(pile.len());
|
||||
if n > 0 {
|
||||
let start = pile.cards.len() - n;
|
||||
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
|
||||
let start = pile.len() - n;
|
||||
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if draws.read().next().is_some()
|
||||
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
|
||||
&& let Some(top) = pile.cards.last()
|
||||
&& let Some((top, _)) = game.0.waste_cards().last()
|
||||
{
|
||||
bounce_ids.push(top.id);
|
||||
bounce_ids.push(top.clone());
|
||||
}
|
||||
|
||||
if bounce_ids.is_empty() {
|
||||
@@ -339,7 +347,7 @@ fn start_settle_anim(
|
||||
}
|
||||
|
||||
for (entity, card_marker) in card_entities.iter() {
|
||||
if bounce_ids.contains(&card_marker.card_id) {
|
||||
if bounce_ids.contains(&card_marker.card) {
|
||||
commands.entity(entity).insert(SettleAnim::default());
|
||||
}
|
||||
}
|
||||
@@ -354,10 +362,16 @@ fn tick_settle_anim(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the settle bounce under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -393,11 +407,11 @@ fn start_deal_anim(
|
||||
return;
|
||||
}
|
||||
// Only animate a fresh deal (no moves made yet).
|
||||
if game.0.move_count != 0 {
|
||||
if game.0.move_count() != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else { return };
|
||||
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
|
||||
let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
|
||||
return;
|
||||
};
|
||||
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
||||
@@ -407,10 +421,14 @@ fn start_deal_anim(
|
||||
|
||||
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
||||
let final_pos = transform.translation;
|
||||
// ±10 % jitter, deterministic per card id, so the deal feels organic
|
||||
// without losing reproducibility (a given seed still produces the
|
||||
// same per-card stagger pattern across runs).
|
||||
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
|
||||
// ±10 % jitter, deterministic per card, so the deal feels organic
|
||||
// without losing reproducibility (a given deal produces the same
|
||||
// per-card stagger pattern across runs). The seed is a hash of the
|
||||
// card's own identity — no separate numeric id needed.
|
||||
let mut card_hasher = DefaultHasher::new();
|
||||
card_marker.card.hash(&mut card_hasher);
|
||||
let per_card_stagger =
|
||||
stagger_secs * (1.0 + deal_stagger_jitter(card_hasher.finish() as u32));
|
||||
commands.entity(entity).insert((
|
||||
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
||||
CardAnim {
|
||||
@@ -518,21 +536,19 @@ fn start_foundation_flourish(
|
||||
if reduce_motion {
|
||||
continue;
|
||||
}
|
||||
let pile_type = PileType::Foundation(ev.slot);
|
||||
let Some(foundation) = foundation_from_slot(ev.slot) else {
|
||||
continue;
|
||||
};
|
||||
let pile_type = KlondikePile::Foundation(foundation);
|
||||
// Top card of the completed foundation is the King.
|
||||
let Some(king_id) = game
|
||||
.0
|
||||
.piles
|
||||
.get(&pile_type)
|
||||
.and_then(|p| p.cards.last())
|
||||
.map(|c| c.id)
|
||||
else {
|
||||
let cards = game.0.pile(pile_type);
|
||||
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Tag the King's card entity.
|
||||
for (entity, card_marker) in card_entities.iter() {
|
||||
if card_marker.card_id == king_id {
|
||||
if card_marker.card == king_card {
|
||||
commands.entity(entity).insert(FoundationFlourish {
|
||||
foundation_slot: ev.slot,
|
||||
elapsed: 0.0,
|
||||
@@ -577,10 +593,16 @@ fn tick_foundation_flourish(
|
||||
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||
Without<FoundationFlourish>,
|
||||
>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the flourish under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !card_anims.is_empty() || !marker_anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Advance the King's scale pulse.
|
||||
@@ -632,6 +654,16 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
||||
)
|
||||
}
|
||||
|
||||
fn pile_cards(
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
pile: &KlondikePile,
|
||||
) -> Vec<(Card, bool)> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (pure functions only — no Bevy world required)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -831,13 +863,17 @@ mod tests {
|
||||
#[test]
|
||||
fn shake_anim_skipped_under_reduce_motion() {
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::Tableau;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
@@ -845,26 +881,25 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||
let dest_pile = PileType::Tableau(0);
|
||||
let card_id = app
|
||||
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
|
||||
let card = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get(&dest_pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.map(|c| c.id)
|
||||
.pile(dest_pile)
|
||||
.last()
|
||||
.map(|(c, _)| c.clone())
|
||||
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||
|
||||
// Spawn a minimal CardEntity matching that id so the system would
|
||||
// Spawn a minimal CardEntity matching that card so the system would
|
||||
// find it and insert ShakeAnim if the gate were absent.
|
||||
app.world_mut()
|
||||
.spawn((CardEntity { card_id }, Transform::default()));
|
||||
.spawn((CardEntity { card }, Transform::default()));
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||
.write(MoveRejectedEvent {
|
||||
from: PileType::Stock,
|
||||
from: KlondikePile::Stock,
|
||||
to: dest_pile,
|
||||
count: 1,
|
||||
});
|
||||
@@ -886,13 +921,16 @@ mod tests {
|
||||
#[test]
|
||||
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
@@ -903,7 +941,7 @@ mod tests {
|
||||
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||
.write(FoundationCompletedEvent {
|
||||
slot: 0,
|
||||
suit: solitaire_core::card::Suit::Spades,
|
||||
suit: solitaire_core::Suit::Spades,
|
||||
});
|
||||
app.update();
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! Bundling rather than runtime-loading guarantees the canonical UI face is
|
||||
//! always available regardless of install or platform. The bytes are
|
||||
//! validated at startup; a parse failure aborts the program with a clear
|
||||
//! error because it means the binary is corrupt.
|
||||
//! validated at startup; a parse failure logs a warning and continues with
|
||||
//! glyph-less UI rather than aborting, since crashing on a corrupt embed is
|
||||
//! worse than degraded text.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ impl Plugin for HelpPlugin {
|
||||
// plugin under `DefaultPlugins`; register them explicitly so
|
||||
// scroll systems run cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
.add_message::<TouchInput>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -86,13 +86,18 @@ fn toggle_help_screen(
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button. F1 toggles the overlay
|
||||
/// the same way; this just exposes the close action to mouse / touch.
|
||||
/// the same way; Esc closes too, so dismissal matches every other
|
||||
/// modal (Phase C dismissal audit). Nothing ever stacks above Help,
|
||||
/// so Esc needs no topmost gate.
|
||||
fn handle_help_close_button(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HelpScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||
if !clicked && !esc {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
@@ -583,4 +588,33 @@ mod tests {
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// Esc must dismiss the Help modal like Done and F1 do (Phase C
|
||||
/// dismissal audit).
|
||||
#[test]
|
||||
fn escape_closes_help_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::F1);
|
||||
input.clear();
|
||||
input.press(KeyCode::Escape);
|
||||
}
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HelpScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"Esc must close the Help modal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||
use solitaire_data::save_settings_to;
|
||||
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
@@ -24,7 +24,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::{
|
||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent,
|
||||
ToggleProfileRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
@@ -264,6 +265,7 @@ impl Plugin for HomePlugin {
|
||||
.add_message::<StartPlayBySeedRequestEvent>()
|
||||
.add_message::<StartDifficultyRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleHomeRequestEvent>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
// Defensively register MouseWheel so `scroll_home_panel`
|
||||
@@ -371,6 +373,7 @@ fn spawn_home_on_launch(
|
||||
fn toggle_home_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleHomeRequestEvent>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
@@ -380,7 +383,8 @@ fn toggle_home_screen(
|
||||
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyM) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyM) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
@@ -432,7 +436,9 @@ fn build_home_context<'a>(
|
||||
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||
daily_today,
|
||||
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
|
||||
draw_mode: settings
|
||||
.map(|s| s.0.draw_mode)
|
||||
.unwrap_or(DrawStockConfig::DrawOne),
|
||||
font_res,
|
||||
difficulty_expanded,
|
||||
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||
@@ -620,9 +626,9 @@ fn handle_home_draw_mode_buttons(
|
||||
return;
|
||||
};
|
||||
let target = if want_one {
|
||||
DrawMode::DrawOne
|
||||
DrawStockConfig::DrawOne
|
||||
} else {
|
||||
DrawMode::DrawThree
|
||||
DrawStockConfig::DrawThree
|
||||
};
|
||||
if settings.0.draw_mode == target {
|
||||
return; // already in this mode — avoid a redundant respawn.
|
||||
@@ -857,7 +863,7 @@ struct HomeContext<'a> {
|
||||
challenge_best: u32,
|
||||
daily_streak: u32,
|
||||
daily_today: Option<DailyToday>,
|
||||
draw_mode: DrawMode,
|
||||
draw_mode: DrawStockConfig,
|
||||
font_res: Option<&'a FontResource>,
|
||||
/// Whether the difficulty section header is currently expanded.
|
||||
difficulty_expanded: bool,
|
||||
@@ -1038,7 +1044,7 @@ fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>)
|
||||
..default()
|
||||
};
|
||||
|
||||
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
|
||||
let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne);
|
||||
|
||||
parent
|
||||
.spawn(Node {
|
||||
@@ -1619,6 +1625,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The HUD Menu popover's "Home" row fires
|
||||
/// `ToggleHomeRequestEvent`; it must open Home exactly like the
|
||||
/// `M` accelerator (Phase C: the popover's Play section replaces
|
||||
/// the old Modes row).
|
||||
#[test]
|
||||
fn toggle_home_event_opens_home_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
|
||||
.write(ToggleHomeRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"ToggleHomeRequestEvent must open the Home modal"
|
||||
);
|
||||
|
||||
// A second request toggles it closed, matching the M key.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
|
||||
.write(ToggleHomeRequestEvent);
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"second ToggleHomeRequestEvent must close the Home modal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_m_twice_closes_home_screen() {
|
||||
let mut app = headless_app();
|
||||
@@ -1878,7 +1922,7 @@ mod tests {
|
||||
|
||||
let states: Vec<(HomeMode, bool)> = app
|
||||
.world_mut()
|
||||
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
|
||||
.query::<(&HomeModeCard, Has<Disabled>)>()
|
||||
.iter(app.world())
|
||||
.map(|(c, d)| (c.0, d))
|
||||
.collect();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user