Compare commits

..

5 Commits

Author SHA1 Message Date
funman300 aebb401c44 docs: update for card_game v0.4.0 / klondike v0.3.0 — undo scoring + solver
Correct Gap 1 undo penalty: SessionState::score() already includes
undos × undo_penalty via SessionStats — undo IS tracked upstream,
just in SessionStats not KlondikeStats. Mark as  upstream.

Add Gap 3 upstream-merged note: Session::solve() in card_game v0.4.0
is a budget-bounded DFS that replaces our 767-line solver. Document
SolveError mapping (both variants → Inconclusive).

Update 'Already has' table for v0.4.0: Session now derives Clone,
uses snapshot-based O(1) undo (StateSnapshot stores pre-move state +
instruction), and carries SessionConfig with solve budgets.

Mark Gap 8 (undo O(1)) resolved: card_game v0.4.0 uses snapshots,
same approach as our existing undo_stack.

Update integration path: steps 1/3/4/5 marked ; steps 2/6/7 remain.
Update references with new release commits and solver PR #14.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:08:32 -07:00
funman300 a550a0cdf9 docs: update integration doc to reflect klondike v0.2.0 / card_game v0.3.0
Both upstream issues are now merged:
- PR #13 (closes #10): ScoringConfig with 5 configurable deltas lands
  in KlondikeConfig; KlondikeStats gains flip_up_bonus_count and
  move_from_foundation_count; score() takes &ScoringConfig
- PR #12 (closes #11): MoveFromFoundationConfig (Allowed/Disallowed)
  lands in KlondikeConfig; is_instruction_valid enforces it

Doc changes:
- "Already has" table updated with ScoringConfig, MoveFromFoundationConfig,
  richer KlondikeStats counters, and version numbers (v0.3.0 / v0.2.0)
- Gap 1 scoring table gains a "Handled by" column showing which deltas
  upstream now owns vs. which remain in our adapter (undo penalty,
  recycle-with-free-allowance, score floor, time bonus)
- Gap 1 adds note that ScoringConfig::recycle is a flat delta and cannot
  express the "N free recycles then penalty" WXP rule
- Gap 4 marked as upstream merged; notes that upstream default is
  MoveFromFoundationConfig::Allowed — we must explicitly set Disallowed
- Integration path: steps renumbered (8→7), step 3 now configures
  MoveFromFoundationConfig, step 4 splits upstream-handled vs.
  adapter-owned scoring; dependency versions pinned
- References updated with PR links and release commit hashes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:07:34 -07:00
funman300 8f5193035b docs: update card-game-integration with PR discussion outcomes
- Approach: note Quaternions is addressing gaps 1 and 4 upstream
  (card_game issues #10 and #11)
- Gap 1: replace comparison table with exact WXP scoring table from
  solitaire_core/src/scoring.rs; add solitaireparadise.com reference;
  note time bonus stays in adapter (not wasm-portable)
- Gap 2: expand mode table with full Scoring + Undo columns; add
  descriptions for Zen (relaxed, score = 0) and Challenge (timed
  daily puzzle, undo disabled)
- Gap 4: clarify the flag *enables* an optional move (off by default),
  not disables; link upstream issue #11
- Gap 5: note Quaternions confirmed newtypes approach, no upstream
  changes needed
- Gap 6: document that MoveError is generated at instruction-
  construction boundary in solitaire_core, not by wrapping
  is_instruction_valid's bool
- Gap 8: mark resolved; 0.02 ms worst case at 1M moves/s; drop
  snapshot ring-buffer plan
- Integration path: updated steps to reflect resolved gaps and
  upstream issue dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:44:36 -07:00
funman300 c21c0ebf99 docs: revise integration plan — all gaps closed in Ferrous Solitaire wrapper
Reframe the integration approach: klondike is a read-only dependency;
all 8 gaps (scoring, game modes, solver, take_from_foundation, serde,
MoveError, waste pile, undo stack) are closed in solitaire_core via a
KlondikeAdapter wrapper layer. No upstream changes to card_game or
klondike are required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 16:23:05 -07:00
funman300 ccccdd2b40 docs: add card-game integration gap analysis
Documents what Quaternions/card_game already provides, what
solitaire_core requires that is currently missing, and the
suggested step-by-step integration path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:28:44 -07:00
171 changed files with 28084 additions and 34444 deletions
-5
View File
@@ -1,5 +0,0 @@
[registries.Quaternions]
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
-11
View File
@@ -6,14 +6,10 @@ on:
branches: [master]
paths:
- 'solitaire_server/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_sync/**'
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'solitaire_server/Dockerfile'
- '.gitea/workflows/docker-build.yml'
env:
@@ -36,13 +32,6 @@ jobs:
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
# and commits it back (CI is the single source of truth — the artifacts
# aren't byte-reproducible on contributor machines). That pkg/ commit then
# triggers this workflow, so the deployed image always ships fresh wasm.
# No drift check is needed here.
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
-76
View File
@@ -1,76 +0,0 @@
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
# locally, run on every master push and pull request. Until this workflow
# existed, nothing in CI ran the test suite at all — a direct push to
# master (or the web-wasm-rebuild bot commit) was entirely unguarded.
name: Test
on:
push:
branches: [master]
paths:
- 'solitaire_app/**'
- 'solitaire_assetgen/**'
- 'solitaire_core/**'
- 'solitaire_data/**'
- 'solitaire_engine/**'
- 'solitaire_server/src/**'
- 'solitaire_server/tests/**'
- 'solitaire_server/migrations/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.cargo/**'
- '.sqlx/**'
- '.gitea/workflows/test.yml'
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
# Full debuginfo made the solitaire_engine test-binary link peak past the
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
# line-tables-only keeps file:line in panic backtraces while cutting the
# link's memory footprint enough to fit the runner.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
components: clippy, rustfmt
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
# Native link deps for the Bevy crates (engine/app/web) on a bare
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
- name: Install Bevy native dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
- name: Format check
run: cargo fmt --check
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
# same as the web-e2e workflow's server prebuild.
- name: Clippy (deny warnings)
env:
SQLX_OFFLINE: 'true'
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test
env:
SQLX_OFFLINE: 'true'
run: cargo test --workspace
-62
View File
@@ -1,62 +0,0 @@
name: Web E2E
on:
push:
branches: [master]
paths:
- 'solitaire_server/web/**'
- 'solitaire_server/src/**'
- 'solitaire_server/e2e/**'
- 'solitaire_wasm/**'
- 'solitaire_core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.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
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
# 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
-89
View File
@@ -1,89 +0,0 @@
name: Web WASM Rebuild
# CI is the single source of truth for solitaire_server/web/pkg/.
#
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
# flags, the same Cargo.lock and remapped source paths, the output still differs
# by host environment. So rather than police freshness with a rebuild-and-diff
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
# itself on every master change to a wasm-feeding crate and commits them back.
#
# Result: the deployed pkg/ can't silently rot, and contributors never need to
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
# workflow's trigger paths (so it does not re-trigger here) but does match
# docker-build's, so the refreshed wasm deploys.
on:
push:
branches: [master]
paths:
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-wasm-rebuild.yml'
workflow_dispatch:
concurrency:
group: web-wasm-rebuild
cancel-in-progress: false
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_TOKEN }}
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
targets: wasm32-unknown-unknown
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
- 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: Rebuild WASM artifacts
run: ./build_wasm.sh
- name: Commit refreshed artifacts if changed
run: |
set -euo pipefail
if git diff --quiet -- solitaire_server/web/pkg/; then
echo "pkg/ already up to date — nothing to commit."
exit 0
fi
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
git add solitaire_server/web/pkg/
git commit -m "chore(web): regenerate wasm artifacts"
# master is unprotected; retry once if the tip moved under us.
git push origin HEAD:master || {
git fetch origin master
git rebase origin/master
git push origin HEAD:master
}
-16
View File
@@ -15,11 +15,6 @@ 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
@@ -30,14 +25,3 @@ solitaire_server/e2e/test-results/
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
+41 -89
View File
@@ -1,11 +1,9 @@
# Ferrous Solitaire — Architecture Document
> **Version:** 1.4
> **Version:** 1.3
> **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable)
> **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.
> **Last Updated:** 2026-05-12
---
@@ -84,15 +82,13 @@ ferrous_solitaire/
│ ├── win_fanfare.wav
│ └── ambient_loop.wav
├── solitaire_core/ # Pure Rust game rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── 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) + 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)
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
── solitaire_app/ # Main binary entry point
```
---
@@ -100,42 +96,18 @@ ferrous_solitaire/
## 3. Crate Responsibilities
### `solitaire_core`
**Dependencies:** `serde`, `thiserror`, plus the upstream `card_game` and
`klondike` crates (pinned via the Quaternions registry — never edit upstream).
**Dependencies:** `rand`, `serde`, `chrono` only.
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.
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`.
Owns:
- `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`)
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`)
- Move validation logic
- Scoring engine
- Undo stack
- Win / auto-complete detection
- Achievement unlock condition evaluation
- 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.)
- Seeded RNG for reproducible deals
### `solitaire_sync`
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
@@ -205,13 +177,9 @@ 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`, `solitaire_data` (+ `jni` on Android).
**Dependencies:** `bevy`, `solitaire_engine`.
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)`.
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
---
@@ -579,36 +547,26 @@ 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
// 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 enum Suit { Clubs, Diamonds, Hearts, Spades }
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
// 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
pub struct Card {
pub id: u32,
pub suit: Suit,
pub rank: Rank,
pub face_up: bool,
}
// 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+) */ }
```
Owned by `solitaire_core`:
pub enum PileType {
Stock,
Waste,
Foundation(Suit),
Tableau(usize), // 06
}
pub enum DrawMode { DrawOne, DrawThree }
```rust
/// Active game mode. Classic is the default; others unlock at level 5.
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
@@ -619,30 +577,24 @@ 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, // same seed ⇒ same deal
pub take_from_foundation: bool,
session: Session<Klondike>, // private — the single source of truth
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)
}
```
**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
@@ -680,7 +632,7 @@ pub struct AchievementRecord {
}
pub struct Settings {
pub draw_mode: DrawStockConfig,
pub draw_mode: DrawMode,
pub sfx_volume: f32, // 0.01.0
pub music_volume: f32,
pub animation_speed: AnimSpeed,
-373
View File
@@ -6,379 +6,6 @@ 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.0v0.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
+6 -15
View File
@@ -30,9 +30,7 @@ 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_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)
solitaire_app/ # Entry binary
assets/ # Runtime assets (except audio + default theme)
```
@@ -210,14 +208,9 @@ 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 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.
Do NOT embed card face PNGs, background images, or user fonts —
these are loaded via `AssetServer` so art can be swapped without recompile.
---
@@ -437,11 +430,9 @@ explicitly replacing the current one (despawn first, then spawn).
## 14.3 Safe area
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.
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.
## 14.4 Z-ordering
Generated
+15 -419
View File
@@ -364,12 +364,6 @@ 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"
@@ -723,28 +717,6 @@ 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"
@@ -906,35 +878,6 @@ 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"
@@ -958,7 +901,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bevy_ecs_macros",
"bevy_platform",
"bevy_ptr",
@@ -1002,36 +945,6 @@ 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"
@@ -1154,17 +1067,14 @@ 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",
@@ -1172,7 +1082,6 @@ dependencies = [
"bevy_log",
"bevy_math",
"bevy_mesh",
"bevy_pbr",
"bevy_platform",
"bevy_ptr",
"bevy_reflect",
@@ -1192,27 +1101,6 @@ 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"
@@ -1250,7 +1138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
dependencies = [
"approx",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bevy_reflect",
"derive_more",
"glam 0.30.10",
@@ -1273,9 +1161,7 @@ dependencies = [
"bevy_asset",
"bevy_derive",
"bevy_ecs",
"bevy_image",
"bevy_math",
"bevy_mikktspace",
"bevy_platform",
"bevy_reflect",
"bevy_transform",
@@ -1288,71 +1174,6 @@ 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"
@@ -1679,7 +1500,6 @@ dependencies = [
"bevy_input",
"bevy_input_focus",
"bevy_math",
"bevy_picking",
"bevy_platform",
"bevy_reflect",
"bevy_sprite",
@@ -1692,7 +1512,6 @@ dependencies = [
"taffy",
"thiserror 2.0.18",
"tracing",
"uuid",
]
[[package]]
@@ -1726,26 +1545,6 @@ 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"
@@ -1873,7 +1672,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"bytemuck",
"serde_core",
]
@@ -1905,7 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
@@ -2081,17 +1879,6 @@ 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"
@@ -2152,17 +1939,6 @@ 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"
@@ -3681,17 +3457,6 @@ 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"
@@ -3720,27 +3485,6 @@ 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"
@@ -4307,7 +4051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error 2.0.1",
"quick-error",
]
[[package]]
@@ -4565,23 +4309,6 @@ 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"
@@ -4599,25 +4326,13 @@ 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 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"euclid",
"smallvec",
]
@@ -5025,7 +4740,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bit-set",
"bitflags 2.11.1",
"cfg-if",
@@ -6063,25 +5778,6 @@ 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"
@@ -6126,12 +5822,6 @@ 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"
@@ -6257,16 +5947,6 @@ 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"
@@ -6305,12 +5985,6 @@ 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"
@@ -6330,15 +6004,6 @@ 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"
@@ -6828,18 +6493,6 @@ 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"
@@ -7306,7 +6959,6 @@ name = "solitaire_app"
version = "0.1.0"
dependencies = [
"bevy",
"jni 0.21.1",
"keyring",
"solitaire_data",
"solitaire_engine",
@@ -7328,9 +6980,7 @@ dependencies = [
name = "solitaire_core"
version = "0.1.0"
dependencies = [
"card_game",
"klondike",
"proptest",
"rand 0.9.4",
"serde",
"thiserror 2.0.18",
]
@@ -7341,13 +6991,12 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"card_game",
"bevy",
"chrono",
"dirs",
"jni 0.21.1",
"jsonwebtoken",
"keyring-core",
"klondike",
"reqwest",
"serde",
"serde_json",
@@ -7441,18 +7090,6 @@ 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"
@@ -7865,7 +7502,7 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
@@ -7964,7 +7601,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"grid",
"serde",
"slotmap",
@@ -8233,7 +7870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
@@ -8247,7 +7884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
@@ -8896,12 +8533,6 @@ 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"
@@ -9108,15 +8739,6 @@ 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"
@@ -9422,13 +9044,12 @@ version = "27.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"js-sys",
"log",
"naga",
"portable-atomic",
@@ -9436,8 +9057,6 @@ dependencies = [
"raw-window-handle",
"smallvec",
"static_assertions",
"wasm-bindgen",
"web-sys",
"wgpu-core",
"wgpu-hal",
"wgpu-types",
@@ -9449,7 +9068,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bit-set",
"bit-vec",
"bitflags 2.11.1",
@@ -9469,7 +9088,6 @@ dependencies = [
"smallvec",
"thiserror 2.0.18",
"wgpu-core-deps-apple",
"wgpu-core-deps-wasm",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-types",
@@ -9484,15 +9102,6 @@ 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"
@@ -9509,7 +9118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
dependencies = [
"android_system_properties",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"ash",
"bit-set",
"bitflags 2.11.1",
@@ -9518,20 +9127,15 @@ 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",
@@ -9544,8 +9148,6 @@ dependencies = [
"renderdoc-sys",
"smallvec",
"thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
"wgpu-types",
"windows 0.58.0",
"windows-core 0.58.0",
@@ -10428,12 +10030,6 @@ 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"
+1 -36
View File
@@ -8,7 +8,6 @@ members = [
"solitaire_app",
"solitaire_assetgen",
"solitaire_wasm",
"solitaire_web",
]
resolver = "2"
@@ -18,27 +17,11 @@ 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", "wasmbind"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
rand = "0.9"
async-trait = "0.1"
@@ -54,8 +37,6 @@ 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.
@@ -156,19 +137,3 @@ 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.
-20
View File
@@ -118,28 +118,8 @@ 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
+29 -140
View File
@@ -1,103 +1,16 @@
# Ferrous Solitaire — Session Handoff
**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.
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
---
## Current state
- **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.
- **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
---
@@ -168,46 +81,37 @@ Three bugs fixed:
## Open punch list
### 1. Physical-device smoke test — DONE (2026-07-06, Galaxy Fold 7)
### 1. CHANGELOG documentation debt
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.
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.
### 2. Matomo analytics live validation (independent — NOT a release blocker)
### 2. Android APK launch verification (Option A)
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.
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.
---
## 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
@@ -224,18 +128,3 @@ validation checklist and the current web/WASM decision notes.
- **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`.
+7 -66
View File
@@ -1,43 +1,24 @@
#!/usr/bin/env bash
# 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)
# Rebuild the solitaire_wasm crate and install the output into
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
#
# Prerequisites:
# cargo install wasm-pack wasm-bindgen-cli
# cargo install wasm-pack
# 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 pkg/ files are committed to git so self-hosters who don't
# touch the WASM crates can skip this step. Regenerate after any change to
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
# 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/.
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
@@ -55,45 +36,5 @@ 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 \
"$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"
+22 -57
View File
@@ -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 physical-device validation, supported platform
surfaces, and remaining Android follow-ups.
later sections document what's known to compile, what's stubbed, and
the next milestones.
> **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.
> **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.
---
@@ -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/30.0.14904198"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
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,15 +49,10 @@ sdkmanager \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0" \
"ndk;30.0.14904198" \
"ndk;26.3.11579264" \
"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 \
@@ -168,8 +163,8 @@ accepted workaround.
Physical device:
```bash
adb devices # confirm connection
adb install -r target/debug/apk/ferrous-solitaire.apk
adb devices # confirm connection
adb install target/debug/apk/ferrous-solitaire.apk
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
```
@@ -190,65 +185,35 @@ AVD.
---
## 4. Physical-device smoke test
## 4. What's wired vs. what's stubbed
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.
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.
| 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 | Android Keystore via JNI |
| Data directory | Platform data dir | Android app files dir |
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
Remaining Android follow-ups:
What's NOT yet ported / not yet measured:
- `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).
---
## 6. Iteration loop
## 5. Iteration loop
```bash
# Edit code…
-67
View File
@@ -1,67 +0,0 @@
# 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.
+19 -63
View File
@@ -2,10 +2,7 @@
**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`.
**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
---
@@ -45,12 +42,10 @@ and typed UI errors in `solitaire_core`.
---
## What Ferrous Solitaire's `solitaire_core` Still Owns
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
### 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`.
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
| Event | Delta | Handled by |
|---|---|---|
@@ -66,13 +61,11 @@ parts in `GameState` / `KlondikeAdapter`.
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.
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The 15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
**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.
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
### 2. Game Modes
Ferrous has three modes that alter scoring and undo behaviour:
@@ -85,9 +78,7 @@ Ferrous has three modes that alter scoring and undo behaviour:
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.
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
### 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:
@@ -96,33 +87,23 @@ session.
`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.
Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
**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.
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → 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`.
**Important:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in. Our adapter explicitly sets `Disallowed` in the default `KlondikeConfig` and switches to `Allowed` only when the user toggles the house-rule option.
**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.
**In our wrapper:** Construct `KlondikeConfig { move_from_foundation: MoveFromFoundationConfig::Disallowed, .. }` by default; mirror the user's settings toggle to `Allowed`. 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.
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
**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"]`.
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
**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.
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. 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.):
@@ -148,9 +129,7 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
### 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.
**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
---
@@ -159,35 +138,12 @@ state that upstream snapshots do not own.
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).
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (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`
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
7. **Implement `serde`**define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
---
@@ -207,5 +163,5 @@ The script enforces:
- 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`
- Scoring spec: `solitaire_core/src/scoring.rs`
- Architecture overview: `ARCHITECTURE.md`
-408
View File
@@ -1,408 +0,0 @@
# In-Place card_game / klondike Rewrite Plan
**Date:** 2026-06-08
**Upstream rev:** `99b49e62`
**Status:** All phases complete (03). 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)
-115
View File
@@ -1,115 +0,0 @@
# 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.
-228
View File
@@ -1,228 +0,0 @@
# 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 projects 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.
-362
View File
@@ -1,362 +0,0 @@
#!/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"
+16 -108
View File
@@ -6,15 +6,11 @@
# ndk-build crate that we couldn't isolate; running each Android toolchain
# step explicitly gives us a debuggable pipeline.
#
# 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.
# 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"
#
# Optional environment:
# PROFILE "debug" (default) | "release"
@@ -23,8 +19,7 @@
# 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)
# STRIP_NATIVE_LIBS 1 to strip .so files before packaging (default: 1)
# KEYSTORE Path to keystore for signing (default: target/android/debug.keystore)
# KEYSTORE Path to keystore for signing (default: generates a 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)
@@ -33,63 +28,18 @@
# $APK_OUT Signed, zipaligned APK
set -euo pipefail
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}"
: "${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)}"
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"
@@ -119,24 +69,6 @@ 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"
@@ -188,15 +120,11 @@ rm -f "$STAGING/app-unsigned.apk"
# --- 5. sign ---------------------------------------------------------------
if [ -z "${KEYSTORE:-}" ]; then
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")"
# 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}"
echo ">>> generating debug keystore at $KEYSTORE"
keytool -genkeypair -v \
-keystore "$KEYSTORE" \
@@ -213,35 +141,15 @@ 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 --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
"$BT/apksigner" verify --verbose "$APK_OUT"
echo ">>> done: $APK_OUT"
-51
View File
@@ -1,51 +0,0 @@
#!/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"
-66
View File
@@ -1,66 +0,0 @@
#!/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 and no
# web-wasm-rebuild is still pending — i.e. the full fix is live.
#
# 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"))]
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" 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" and not wr_pending)
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
PY
)"
echo "$out" | grep -v '^STATE='
if echo "$out" | grep -q '^STATE=LIVE'; then
echo ""
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
break
fi
sleep "$INTERVAL"
done
-22
View File
@@ -22,13 +22,6 @@ 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
@@ -106,18 +99,3 @@ 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"
+12 -68
View File
@@ -25,10 +25,7 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use bevy::winit::WinitWindows;
#[cfg(target_os = "android")]
use bevy::winit::{UpdateMode, WinitSettings};
use solitaire_data::{
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
settings_file_path,
};
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
fn load_settings() -> Settings {
@@ -52,12 +49,6 @@ 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.
@@ -65,9 +56,10 @@ 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). The Android
// auth-token path uses Android Keystore via JNI; `android_main` passes
// the process JavaVM pointer into `solitaire_data` before `run()`.
// 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.
#[cfg(not(target_os = "android"))]
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
@@ -144,7 +136,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: WindowResizeConstraints {
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
@@ -166,7 +158,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(AssetPlugin {
.set(bevy::asset::AssetPlugin {
#[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(),
..default()
@@ -180,16 +172,13 @@ 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.
//
// 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.
// 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.
#[cfg(target_os = "android")]
app.insert_resource(WinitSettings {
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
focused_mode: UpdateMode::Continuous,
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
});
@@ -363,57 +352,12 @@ 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
-3
View File
@@ -30,6 +30,3 @@ 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** 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).
//! 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).
//!
//! # Usage
//!
@@ -19,12 +19,12 @@
//! --per-tier Seeds to emit per tier (default 40)
//! --help Print this message
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::GameState;
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
// Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable.
const BUDGETS: &[(&str, u64, u64)] = &[
const BUDGETS: &[(&str, u64, usize)] = &[
("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 = DrawStockConfig::DrawOne;
let draw_mode = DrawMode::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,8 +99,12 @@ fn main() {
if buckets[i].len() >= per_tier {
continue;
}
match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) {
Ok(Some(_)) => {
let cfg = SolverConfig {
move_budget,
state_budget,
};
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable => {
buckets[i].push(seed);
eprintln!(
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
@@ -109,13 +113,13 @@ fn main() {
);
break 'tier; // assign to the cheapest tier that proves it winnable
}
Ok(None) => {
SolverResult::Unwinnable => {
// Definitely unsolvable — skip all remaining tiers.
break 'tier;
}
Err(_) => {
SolverResult::Inconclusive => {
// 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;
+6 -14
View File
@@ -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 proven winnable (`Ok(Some(_))`; inconclusive is
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
//! rejected — the curated list wants proof). Prints Rust source suitable for
//! pasting into `solitaire_data/src/challenge.rs`.
//!
@@ -17,9 +17,8 @@
//! --count Number of Winnable seeds to emit (default 75)
//! --help Print this message
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::GameState;
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
fn main() {
let mut args = std::env::args().skip(1).peekable();
@@ -68,7 +67,8 @@ fn main() {
std::process::exit(1);
}
let draw_mode = DrawStockConfig::DrawOne;
let cfg = SolverConfig::default();
let draw_mode = DrawMode::DrawOne;
let mut found: Vec<u64> = Vec::with_capacity(count);
let mut tried: u64 = 0;
let mut seed = start;
@@ -77,15 +77,7 @@ fn main() {
while found.len() < count {
tried += 1;
if matches!(
GameState::solve_fresh_deal(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET
),
Ok(Some(_))
) {
if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) {
found.push(seed);
eprintln!(
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
+1 -12
View File
@@ -4,18 +4,7 @@ version.workspace = true
license.workspace = true
edition.workspace = true
[features]
default = []
test-support = []
[dev-dependencies]
proptest = "1"
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
[lints]
workspace = true
rand = { workspace = true }
+169
View File
@@ -0,0 +1,169 @@
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());
}
}
+193
View File
@@ -0,0 +1,193 @@
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 051).
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<_>>());
}
}
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
//! 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,
}
}
+5 -49
View File
@@ -1,53 +1,9 @@
pub mod achievement;
pub mod card;
pub mod deck;
pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod pile;
pub mod rules;
pub mod scoring;
// 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, Session, 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};
/// 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;
pub mod solver;
+133
View File
@@ -0,0 +1,133 @@
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 (06).
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));
}
}
-266
View File
@@ -1,266 +0,0 @@
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 14 → tableaux 17.
///
/// 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:?}",
);
}
}
}
+228
View File
@@ -0,0 +1,228 @@
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),
]));
}
}
+143 -6
View File
@@ -1,9 +1,50 @@
//! 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.
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 }
}
/// 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.
@@ -13,3 +54,99 @@ 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
+6 -14
View File
@@ -7,23 +7,15 @@ 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 }
# `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in
@@ -32,14 +24,17 @@ tokio = { 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(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
[target.'cfg(not(target_os = "android"))'.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 }
@@ -47,6 +42,3 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
[lints]
workspace = true
-65
View File
@@ -1,65 +0,0 @@
//! 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()))
}
+44 -35
View File
@@ -14,7 +14,7 @@
///
/// Only compiled and linked on `target_os = "android"`.
use jni::{
JNIEnv,
JNIEnv, JavaVM,
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
};
use serde::{Deserialize, Serialize};
@@ -36,15 +36,23 @@ 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>,
{
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
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}")))
}
// ---------------------------------------------------------------------------
@@ -199,10 +207,9 @@ 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()?;
let iv_arr = JByteArray::from(iv_jobj);
// SAFETY: the method signature guarantees a byte array return.
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
let iv = env.convert_byte_array(&iv_arr)?;
let pt_arr = env.byte_array_from_slice(plaintext)?;
@@ -210,7 +217,8 @@ fn encrypt_gcm(
let ct_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
.l()?;
let ct_arr = JByteArray::from(ct_jobj);
// SAFETY: doFinal([B) returns [B.
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
let ciphertext = env.convert_byte_array(&ct_arr)?;
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
@@ -261,7 +269,8 @@ fn decrypt_gcm(
let pt_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
.l()?;
let pt_arr = JByteArray::from(pt_jobj);
// SAFETY: doFinal([B) returns [B.
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
env.convert_byte_array(&pt_arr)
}
@@ -348,29 +357,29 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
}
// --- 2. Legacy path migration ---
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 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 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);
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);
}
return Ok(map);
}
// Legacy file corrupt or unrecognised — treat as empty.
}
// Legacy file corrupt or unrecognised — treat as empty.
}
// --- 3. No file found ---
@@ -459,11 +468,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()
&& 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() {
if 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.
+6 -5
View File
@@ -14,14 +14,15 @@
//! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`].
//!
//! # Android
//! # Android stub
//!
//! `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 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.
//! 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.
//!
//! # Note: no unit tests — requires live OS keychain.
+184 -184
View File
@@ -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-06-04)
// 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,
0xD1FF_0000_0000_0009,
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,
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,
];
/// 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-06-04)
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
0xD1FF_0000_0000_0000,
0xD1FF_0000_0000_0012,
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_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_0099,
0xD1FF_0000_0000_00A9,
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_00AF,
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,
0xD1FF_0000_0000_00B0,
];
/// 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-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,
// 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,
0xD1FF_0000_0000_0079,
0xD1FF_0000_0000_007C,
0xD1FF_0000_0000_0080,
0xD1FF_0000_0000_0081,
0xD1FF_0000_0000_0083,
0xD1FF_0000_0000_0091,
0xD1FF_0000_0000_009B,
0xD1FF_0000_0000_00A1,
0xD1FF_0000_0000_008A,
0xD1FF_0000_0000_0097,
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_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,
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,
];
/// 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-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,
// 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,
0xD1FF_0000_0000_0082,
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_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_00ED,
0xD1FF_0000_0000_00EE,
0xD1FF_0000_0000_00EF,
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,
];
/// 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-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,
// 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,
0xD1FF_0000_0000_0135,
0xD1FF_0000_0000_0137,
0xD1FF_0000_0000_0139,
0xD1FF_0000_0000_013A,
0xD1FF_0000_0000_013B,
0xD1FF_0000_0000_013D,
0xD1FF_0000_0000_013F,
0xD1FF_0000_0000_0140,
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_014E,
0xD1FF_0000_0000_014C,
0xD1FF_0000_0000_014D,
0xD1FF_0000_0000_014F,
0xD1FF_0000_0000_0150,
0xD1FF_0000_0000_0155,
0xD1FF_0000_0000_0151,
0xD1FF_0000_0000_0152,
0xD1FF_0000_0000_0153,
0xD1FF_0000_0000_0157,
0xD1FF_0000_0000_0158,
0xD1FF_0000_0000_0159,
0xD1FF_0000_0000_015A,
0xD1FF_0000_0000_015B,
0xD1FF_0000_0000_015C,
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,
0xD1FF_0000_0000_015E,
0xD1FF_0000_0000_0162,
0xD1FF_0000_0000_0164,
];
// ---------------------------------------------------------------------------
+7 -19
View File
@@ -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: &Replay) -> Result<String, SyncError> {
async fn push_replay(&self, _replay: &crate::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: &Replay) -> Result<String, SyncError> {
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
(**self).push_replay(replay).await
}
}
@@ -118,8 +118,8 @@ pub use achievements::{
pub mod progress;
pub use progress::{
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
save_progress_to, xp_for_win,
};
pub mod weekly;
@@ -143,39 +143,27 @@ pub use settings::{
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;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
};
pub mod sync_client;
pub use sync_client::LocalOnlyProvider;
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
ReplayHistory, append_replay_to_history, load_replay_history_from,
ReplayHistory, ReplayMove, 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;
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
#[cfg(not(target_arch = "wasm32"))]
pub mod matomo_client;
#[cfg(not(target_arch = "wasm32"))]
pub use matomo_client::MatomoClient;
pub mod platform;
-59
View File
@@ -114,62 +114,3 @@ 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");
}
}
+1 -9
View File
@@ -55,15 +55,7 @@ pub fn data_dir() -> Option<PathBuf> {
{
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
}
#[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")))]
#[cfg(not(target_os = "android"))]
{
dirs::data_dir()
}
+5 -35
View File
@@ -25,34 +25,12 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
y * 10_000 + m * 100 + d
}
/// 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.
/// XP awarded for winning a game.
///
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
/// the player did not use undo.
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
let base: u64 = 50;
let speed_bonus: u64 = if time_seconds >= 120 {
0
} else {
@@ -61,16 +39,8 @@ pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
scaled.max(10)
};
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()
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
base + speed_bonus + no_undo_bonus
}
/// Platform-specific default path for `progress.json`.
+65 -57
View File
@@ -12,22 +12,13 @@
//! carries any other version so older replays are silently dropped instead
//! of crashing the loader.
//!
//! 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) +
//! 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) +
//! [`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;
@@ -35,7 +26,8 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -73,17 +65,14 @@ fn history_schema_v0() -> u32 {
/// seeing a broken one.
///
/// History:
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
/// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input.
/// - 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;
/// - 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;
/// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
@@ -92,6 +81,32 @@ 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
@@ -109,7 +124,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: DrawStockConfig,
pub draw_mode: DrawMode,
/// Game mode the recorded game was played in.
pub mode: GameMode,
/// Total wall-clock seconds the win took. Used for the Stats UI
@@ -119,11 +134,9 @@ pub struct Replay {
pub final_score: i32,
/// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate,
/// 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>,
/// Ordered move list. Each entry is what the player did, replayable
/// against a fresh `GameState` constructed from the seed.
pub moves: Vec<ReplayMove>,
/// 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
@@ -167,12 +180,12 @@ impl Replay {
/// latter directly when the upload task resolves.
pub fn new(
seed: u64,
draw_mode: DrawStockConfig,
draw_mode: DrawMode,
mode: GameMode,
time_seconds: u64,
final_score: i32,
recorded_at: NaiveDate,
moves: Vec<KlondikeInstruction>,
moves: Vec<ReplayMove>,
) -> Self {
Self {
schema_version: REPLAY_SCHEMA_VERSION,
@@ -429,9 +442,6 @@ 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 {
@@ -442,22 +452,24 @@ mod tests {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
12345,
DrawStockConfig::DrawThree,
DrawMode::DrawThree,
GameMode::Classic,
134,
5_120,
date,
vec![
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: Tableau::Tableau4,
}),
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(Tableau::Tableau4),
foundation: Foundation::Foundation1,
}),
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,
},
],
)
}
@@ -583,12 +595,12 @@ mod tests {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
id as u64,
DrawStockConfig::DrawOne,
DrawMode::DrawOne,
GameMode::Classic,
60,
id,
date,
vec![KlondikeInstruction::RotateStock],
vec![ReplayMove::StockClick],
)
}
@@ -824,11 +836,9 @@ mod tests {
let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path);
// 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},
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
let v2_no_field = r#"{
"schema_version": 2,
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
@@ -836,10 +846,8 @@ mod tests {
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
fs::write(&path, no_field).expect("write fixture");
}"#;
fs::write(&path, v2_no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None);
+11 -13
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
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: DrawStockConfig,
pub draw_mode: DrawMode,
/// 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,10 +161,8 @@ 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"` (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.
/// Older `settings.json` files that stored `"default"` or `"classic"`
/// are migrated to `"dark"` by [`Settings::sanitized`].
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been
@@ -202,7 +200,7 @@ pub struct Settings {
#[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// the solver cannot prove winnable, retrying
/// [`solitaire_core::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
@@ -290,8 +288,8 @@ pub struct Settings {
pub touch_input_mode: TouchInputMode,
}
fn default_draw_mode() -> DrawStockConfig {
DrawStockConfig::DrawOne
fn default_draw_mode() -> DrawMode {
DrawMode::DrawOne
}
fn default_sfx_volume() -> f32 {
@@ -383,9 +381,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 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.
/// 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.
///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on.
@@ -394,7 +392,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
impl Default for Settings {
fn default() -> Self {
Self {
draw_mode: DrawStockConfig::DrawOne,
draw_mode: DrawMode::DrawOne,
sfx_volume: default_sfx_volume(),
music_volume: default_music_volume(),
animation_speed: AnimSpeed::Normal,
+26 -26
View File
@@ -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 [`DrawStockConfig`] from `solitaire_core`.
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
use chrono::Utc;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::game_state::{DrawMode, 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 [`DrawStockConfig`] keep
/// long-standing call sites that only know about [`DrawMode`] keep
/// compiling.
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig);
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
/// 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: &DrawStockConfig) {
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
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 {
DrawStockConfig::DrawOne => self.draw_one_wins += 1,
DrawStockConfig::DrawThree => self.draw_three_wins += 1,
DrawMode::DrawOne => self.draw_one_wins += 1,
DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(1500, 120, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(100, 120, &DrawStockConfig::DrawOne);
s.update_on_win(100, 500, &DrawStockConfig::DrawOne);
s.update_on_win(100, 300, &DrawMode::DrawOne);
s.update_on_win(100, 120, &DrawMode::DrawOne);
s.update_on_win(100, 500, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(100, 200, &DrawStockConfig::DrawOne);
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
s.update_on_win(100, 100, &DrawMode::DrawOne);
s.update_on_win(100, 200, &DrawMode::DrawOne);
s.update_on_win(100, 300, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(300, 60, &DrawStockConfig::DrawOne);
s.update_on_win(500, 60, &DrawMode::DrawOne);
s.update_on_win(300, 60, &DrawMode::DrawOne);
assert_eq!(s.best_single_score, 500);
s.update_on_win(800, 60, &DrawStockConfig::DrawOne);
s.update_on_win(800, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(-50, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawThree);
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(
s.lifetime_score,
u64::MAX,
+43 -178
View File
@@ -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::GameState;
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
use crate::stats::StatsSnapshot;
@@ -85,13 +85,16 @@ 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.is_won() { None } else { Some(gs) }
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
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() {
@@ -231,7 +234,9 @@ 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 = Utc::now().timestamp().max(0) as u64;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
load_time_attack_session_from_at(path, now)
}
@@ -249,7 +254,9 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
/// 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 = Utc::now().timestamp().max(0) as u64;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
TimeAttackSession {
remaining_secs,
wins,
@@ -279,7 +286,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
mod tests {
use super::*;
use crate::stats::{StatsExt, StatsSnapshot};
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::DrawMode;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
@@ -292,7 +299,7 @@ mod tests {
let _ = fs::remove_file(&path);
let mut stats = StatsSnapshot::default();
stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne);
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
save_stats_to(&path, &stats).expect("save");
let loaded = load_stats_from(&path);
@@ -377,17 +384,17 @@ mod tests {
#[test]
fn game_state_round_trip() {
use solitaire_core::game_state::GameState;
use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("round_trip");
let _ = fs::remove_file(&path);
let gs = GameState::new(12345, DrawStockConfig::DrawOne);
let gs = GameState::new(12345, DrawMode::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]
@@ -406,12 +413,12 @@ mod tests {
#[test]
fn save_game_state_skips_won_games() {
use solitaire_core::game_state::GameState;
use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("won_skip");
let _ = fs::remove_file(&path);
let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
gs.set_test_won(true);
let mut gs = GameState::new(99, DrawMode::DrawOne);
gs.is_won = true;
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!(
!path.exists(),
@@ -419,11 +426,28 @@ 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::GameState;
use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("delete");
let gs = GameState::new(1, DrawStockConfig::DrawOne);
let gs = GameState::new(1, DrawMode::DrawOne);
save_game_state_to(&path, &gs).expect("save");
assert!(path.exists());
delete_game_state_at(&path).expect("delete");
@@ -439,9 +463,9 @@ mod tests {
#[test]
fn save_game_state_is_atomic() {
use solitaire_core::game_state::GameState;
use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("atomic");
let gs = GameState::new(55, DrawStockConfig::DrawThree);
let gs = GameState::new(55, DrawMode::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");
@@ -492,165 +516,6 @@ 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 -54
View File
@@ -12,14 +12,10 @@
//! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait;
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
use solitaire_sync::{SyncPayload, SyncResponse};
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, 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,
@@ -58,17 +54,12 @@ 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.
@@ -77,14 +68,8 @@ 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.
///
@@ -95,7 +80,6 @@ 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(()),
}
}
@@ -178,27 +162,7 @@ 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.
///
/// `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(());
}
async fn refresh_token(&self) -> Result<(), SyncError> {
let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
@@ -237,7 +201,6 @@ impl SolitaireServerClient {
}
}
#[cfg(not(target_arch = "wasm32"))]
#[async_trait]
impl SyncProvider for SolitaireServerClient {
/// Fetch the latest sync payload from the server.
@@ -257,7 +220,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -290,7 +253,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -357,7 +320,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -392,7 +355,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -432,7 +395,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -472,7 +435,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -506,7 +469,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -523,7 +486,6 @@ 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.
@@ -568,7 +530,7 @@ impl SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -619,10 +581,9 @@ impl SolitaireServerClient {
}
// ---------------------------------------------------------------------------
// Response extraction helpers (native-only, use reqwest::Response)
// Response extraction helpers
// ---------------------------------------------------------------------------
#[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`].
///
@@ -646,7 +607,6 @@ 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,
@@ -661,7 +621,6 @@ 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`].
///
@@ -693,7 +652,6 @@ 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),
+5 -5
View File
@@ -4,7 +4,7 @@
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
use chrono::{Datelike, NaiveDate};
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::DrawMode;
/// 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: DrawStockConfig,
pub draw_mode: DrawMode,
}
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 == DrawStockConfig::DrawThree,
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
}
}
}
@@ -106,7 +106,7 @@ mod tests {
WeeklyGoalContext {
time_seconds: time,
used_undo: undo,
draw_mode: DrawStockConfig::DrawOne,
draw_mode: DrawMode::DrawOne,
}
}
@@ -114,7 +114,7 @@ mod tests {
WeeklyGoalContext {
time_seconds: time,
used_undo: false,
draw_mode: DrawStockConfig::DrawThree,
draw_mode: DrawMode::DrawThree,
}
}
+11 -19
View File
@@ -7,11 +7,14 @@ 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 }
@@ -19,24 +22,17 @@ 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` 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` 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 = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
@@ -51,7 +47,3 @@ 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
+15 -14
View File
@@ -116,7 +116,7 @@ impl Plugin for AchievementPlugin {
// achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<TouchInput>()
.add_message::<bevy::input::touch::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).
@@ -176,9 +176,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,
};
@@ -671,7 +671,7 @@ mod tests {
.add_plugins(AchievementPlugin::headless());
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered.
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.update();
app
}
@@ -779,7 +779,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_undos(1);
.undo_count = 1;
app.world_mut().write_message(GameWonEvent {
score: 1000,
@@ -819,7 +819,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_draw_mode(DrawStockConfig::DrawThree);
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_draw_mode(DrawStockConfig::DrawThree);
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -911,7 +911,8 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
@@ -945,7 +946,7 @@ mod tests {
// Default GameMode is Classic; assert and rely on it.
assert_eq!(
app.world().resource::<GameStateResource>().0.mode,
GameMode::Classic
solitaire_core::game_state::GameMode::Classic
);
app.world_mut().write_message(GameWonEvent {
@@ -1249,7 +1250,7 @@ mod tests {
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
.add_plugins(AchievementPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.update();
app
}
@@ -1392,8 +1393,8 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate;
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
use solitaire_data::Replay;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
/// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
@@ -1408,12 +1409,12 @@ mod tests {
fn dummy_replay() -> Replay {
Replay::new(
1,
DrawStockConfig::DrawOne,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![KlondikeInstruction::RotateStock],
vec![ReplayMove::StockClick],
)
}
-58
View File
@@ -204,61 +204,3 @@ 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"
);
}
}
+26 -7
View File
@@ -1,19 +1,37 @@
/// Android clipboard bridge via JNI.
///
/// Writes text to the system clipboard by calling into `ClipboardManager`
/// through the safe [`solitaire_data::android_jni`] bridge. Only compiled and
/// linked on `target_os = "android"`.
/// through the JNI. Only compiled and linked on `target_os = "android"`.
#[cfg(target_os = "android")]
pub fn set_text(text: &str) -> Result<(), String> {
use jni::objects::JValueOwned;
use solitaire_data::android_jni;
use bevy::android::ANDROID_APP;
use jni::{
JavaVM,
objects::{JObject, JValueOwned},
};
android_jni::with_activity_env(|env, activity| {
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<()> {
// 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()],
@@ -42,5 +60,6 @@ pub fn set_text(text: &str) -> Result<(), String> {
&[clip_val.borrow()],
)?
.v()
})
})()
.map_err(|e| format!("clipboard JNI: {e}"))
}
+4 -6
View File
@@ -13,7 +13,6 @@
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_data::{AnimSpeed, Settings};
use crate::achievement_plugin::display_name_for;
@@ -181,7 +180,6 @@ 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>()
@@ -354,7 +352,7 @@ fn handle_win_cascade(
end: target.truncate(),
elapsed: 0.0,
duration,
curve: MotionCurve::Expressive,
curve: crate::card_animation::MotionCurve::Expressive,
delay: i as f32 * step,
start_z: start.z,
end_z: target.z,
@@ -1078,7 +1076,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::{KlondikePile, Tableau};
use solitaire_core::pile::PileType;
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
@@ -1090,8 +1088,8 @@ mod tests {
.count();
app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
+4 -6
View File
@@ -22,7 +22,7 @@
//! red/black colour split.
use bevy::math::UVec2;
use solitaire_core::{Rank, Suit};
use solitaire_core::card::{Rank, Suit};
/// Target rasterisation size in pixels (2:3 aspect, half the default
/// `SvgLoaderSettings` resolution).
@@ -74,11 +74,9 @@ pub const ALL_RANKS: [Rank; 13] = [
Rank::King,
];
/// 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.
/// 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]`.
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`,
+5 -20
View File
@@ -47,16 +47,12 @@
//! 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
@@ -115,10 +111,6 @@ 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"),
@@ -176,8 +168,6 @@ 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"),
@@ -245,16 +235,11 @@ 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 {
// 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()))),
);
}
let root = user_theme_dir();
app.register_asset_source(
USER_THEMES,
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
);
app
}
+2 -2
View File
@@ -192,7 +192,7 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
use usvg::FontResolver;
FontResolver {
usvg::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 + Serialize + serde::de::DeserializeOwned>() {}
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
assert_loader_settings::<SvgLoaderSettings>();
}
}
+17 -30
View File
@@ -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.
///
/// 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.
/// # 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`]).
pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone();
@@ -76,32 +76,19 @@ 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).
///
/// 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.
/// 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.
fn detected_platform_data_dir() -> PathBuf {
solitaire_data::data_dir().unwrap_or_else(|| {
#[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()
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()."
)
})
}
+5 -1
View File
@@ -34,6 +34,7 @@ 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;
@@ -373,7 +374,10 @@ 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().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
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
if is_recycle(stock_len) {
let mut data = lib.flip.clone();
+56 -106
View File
@@ -9,9 +9,7 @@
//! 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;
@@ -22,18 +20,11 @@ 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 {
@@ -46,27 +37,18 @@ 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_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),
);
app.init_resource::<AutoCompleteState>().add_systems(
Update,
(
detect_auto_complete,
on_auto_complete_start,
drive_auto_complete,
)
.chain()
.after(GameMutation),
);
}
}
@@ -83,28 +65,21 @@ 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 = 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;
state.cooldown = 0.0; // fire first move immediately
}
// 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.
@@ -113,7 +88,6 @@ 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>,
@@ -134,12 +108,6 @@ 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>,
@@ -174,9 +142,9 @@ mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
fn headless_app() -> App {
let mut app = App::new();
@@ -184,49 +152,36 @@ mod tests {
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(AutoCompletePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.update();
app
}
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());
/// 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();
}
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)
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
}
#[test]
@@ -238,9 +193,8 @@ mod tests {
#[test]
fn detect_activates_when_auto_completable() {
let mut app = headless_app();
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
g.set_test_auto_completable(true);
app.world_mut().resource_mut::<GameStateResource>().0 = g;
// Install a nearly-won state and fire StateChangedEvent.
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().write_message(StateChangedEvent);
app.update();
@@ -250,14 +204,9 @@ mod tests {
#[test]
fn drive_fires_move_request_when_active() {
let mut app = headless_app();
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
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>>();
@@ -265,16 +214,17 @@ 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, expected_from);
assert_eq!(fired[0].to, expected_to);
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));
}
#[test]
fn drive_deactivates_on_win() {
let mut app = headless_app();
// Inject a won game state — active should not be set.
let (mut gs, _) = seeded_state_with_auto_move();
gs.set_test_won(true);
let mut gs = nearly_won_state();
gs.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().write_message(StateChangedEvent);
app.update();
+1 -1
View File
@@ -36,7 +36,7 @@ pub struct AvatarFetchEvent {
pub url: String,
}
impl Message for AvatarFetchEvent {}
impl bevy::prelude::Message for AvatarFetchEvent {}
/// In-flight avatar download task. Returns the raw image bytes on success,
/// or `None` on any network / decode error.
@@ -33,7 +33,6 @@ use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::Card;
use super::animation::CardAnimation;
use super::tuning::AnimationTuning;
@@ -72,7 +71,9 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move { from: MoveRequestEvent },
Move {
from: crate::events::MoveRequestEvent,
},
Draw,
Undo,
}
@@ -209,12 +210,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_cards, committed): (&[Card], bool) = drag
let (dragged_ids, committed): (&[u32], 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_cards.contains(&card.card);
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
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);
@@ -92,7 +92,6 @@ 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};
@@ -126,7 +125,6 @@ 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>()
@@ -100,7 +100,7 @@ impl AnimationTuning {
platform: InputPlatform::Mouse,
duration_scale: 1.0,
overshoot_scale: 1.0,
drag_threshold_px: 6.0,
drag_threshold_px: 4.0,
drag_scale: 1.08,
hover_scale: 1.04,
hover_lerp_speed: 14.0,
File diff suppressed because it is too large Load Diff
-197
View File
@@ -1,197 +0,0 @@
//! 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
// ---------------------------------------------------------------------------
@@ -1,273 +0,0 @@
//! 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
// ---------------------------------------------------------------------------
-206
View File
@@ -1,206 +0,0 @@
//! 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+2660U+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+2660U+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+2660U+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
// ---------------------------------------------------------------------------
-346
View File
@@ -1,346 +0,0 @@
//! 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);
}
-612
View File
@@ -1,612 +0,0 @@
//! 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 04). 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;
-286
View File
@@ -1,286 +0,0 @@
//! 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;
}
}
}
}
}
}
-702
View File
@@ -1,702 +0,0 @@
//! 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
+3 -3
View File
@@ -117,7 +117,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::game_state::{DrawMode, 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, DrawStockConfig::DrawOne, GameMode::Challenge);
GameState::new_with_mode(1, DrawMode::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, DrawStockConfig::DrawOne, GameMode::Challenge);
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
app.world_mut().write_message(GameWonEvent {
score: 500,
+16 -24
View File
@@ -13,18 +13,16 @@ use crate::platform::{
default_storage_backend,
};
use crate::{
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, 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,
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,
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
};
/// Groups all Ferrous Solitaire gameplay plugins.
@@ -47,7 +45,6 @@ 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");
@@ -107,26 +104,21 @@ impl Plugin for CoreGamePlugin {
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(AvatarPlugin)
.add_plugins(ProfilePlugin)
.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(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin);
}
}
+240 -85
View File
@@ -34,9 +34,9 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
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 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
/// `KlondikePile` identifies which pile this overlay highlights, so test
/// `PileType` 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 KlondikePile);
pub struct DropTargetOverlay(pub PileType);
/// 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::<DragState>),
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
update_drop_target_overlays,
),
);
@@ -163,34 +163,33 @@ 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 = [
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),
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),
];
for pile in piles {
let pile_cards = pile_cards(game, &pile);
if pile_cards.is_empty() {
let Some(pile_cards) = game.piles.get(&pile) else {
continue;
}
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
};
let is_tableau = matches!(pile, PileType::Tableau(_));
let base = layout.pile_positions[&pile];
for (i, card) in pile_cards.iter().enumerate().rev() {
if !card.1 {
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
if !card.face_up {
continue;
}
// Only the topmost card is draggable on non-tableau piles.
if !is_tableau && i != pile_cards.len() - 1 {
if !is_tableau && i != pile_cards.cards.len() - 1 {
continue;
}
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
@@ -227,14 +226,38 @@ fn update_drop_highlights(
let Some(game) = game else { return };
let drag_count = drag.cards.len();
let Some(origin) = drag.origin_pile.as_ref() else {
// 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();
for (marker, mut sprite, _rch) in &mut markers {
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
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,
};
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
}
}
@@ -274,7 +297,20 @@ fn update_drop_target_overlays(
return;
};
let Some(origin) = drag.origin_pile.as_ref() else {
// 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 {
return;
};
let drag_count = drag.cards.len();
@@ -282,24 +318,44 @@ 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 = [
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),
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),
];
// Compute the new set of valid piles for this frame.
let mut valid: Vec<KlondikePile> = Vec::new();
let mut valid: Vec<PileType> = Vec::new();
for pile in &candidates {
if game.0.can_move_cards(origin, pile, drag_count) {
valid.push(*pile);
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());
}
}
@@ -311,9 +367,9 @@ fn update_drop_target_overlays(
}
// Spawn overlays for piles that are now valid but don't yet have one.
let already_overlaid: Vec<KlondikePile> = overlays
let already_overlaid: Vec<PileType> = overlays
.iter()
.map(|(_, m)| m.0)
.map(|(_, m)| m.0.clone())
.filter(|p| valid.contains(p))
.collect();
@@ -332,14 +388,10 @@ 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: &KlondikePile,
layout: &Layout,
game: &GameState,
) -> Option<(Vec2, Vec2)> {
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
let centre = layout.pile_positions.get(pile).copied()?;
if matches!(pile, KlondikePile::Tableau(_)) {
let card_count = game.pile(*pile).len();
if matches!(pile, PileType::Tableau(_)) {
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.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;
@@ -360,7 +412,7 @@ fn drop_overlay_rect(
/// the appropriate world position for `pile`.
fn spawn_drop_target_overlay(
commands: &mut Commands,
pile: &KlondikePile,
pile: &PileType,
layout: &Layout,
game: &GameState,
) {
@@ -378,7 +430,7 @@ fn spawn_drop_target_overlay(
..default()
},
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
DropTargetOverlay(*pile),
DropTargetOverlay(pile.clone()),
))
.with_children(|parent| {
// Top edge.
@@ -427,7 +479,7 @@ fn spawn_drop_target_overlay(
fn tableau_or_stack_pos(
game: &GameState,
layout: &Layout,
pile: &KlondikePile,
pile: &PileType,
index: usize,
base: Vec2,
is_tableau: bool,
@@ -437,9 +489,8 @@ fn tableau_or_stack_pos(
base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
{
let pile_len = game.waste_cards().len();
} 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());
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)
@@ -448,14 +499,6 @@ 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
@@ -564,9 +607,9 @@ mod tests {
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use crate::layout::compute_layout;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::game_state::{DrawMode, GameState};
let game = GameState::new(42, DrawStockConfig::DrawOne);
let game = GameState::new(42, DrawMode::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(
@@ -581,11 +624,8 @@ mod tests {
// -----------------------------------------------------------------------
use crate::layout::compute_layout;
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
/// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers
@@ -609,8 +649,12 @@ 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 tableau = GameState::tableau_from_index(idx).expect("tableau pile exists");
game.set_test_tableau_cards(tableau, vec![card]);
let pile = game
.piles
.get_mut(&PileType::Tableau(idx))
.expect("tableau pile exists");
pile.cards.clear();
pile.cards.push(card);
}
/// Inserts a single face-up dragged card into the waste pile and
@@ -620,37 +664,148 @@ mod tests {
// Place the dragged card on the waste pile (origin).
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
game.0.set_test_waste_cards(vec![dragged.clone()]);
let waste = game
.0
.piles
.get_mut(&PileType::Waste)
.expect("waste pile exists");
waste.cards.clear();
waste.cards.push(dragged.clone());
}
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![dragged];
drag.origin_pile = Some(KlondikePile::Stock);
drag.cards = vec![dragged.id];
drag.origin_pile = Some(PileType::Waste);
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, 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 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 app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let overlays: Vec<KlondikePile> = app
let overlays: Vec<PileType> = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.map(|o| o.0)
.map(|o| o.0.clone())
.collect();
assert!(
!overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)),
!overlays.contains(&PileType::Tableau(2)),
"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"
);
}
}
+7 -19
View File
@@ -13,11 +13,9 @@
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::{
@@ -27,7 +25,6 @@ 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.
@@ -80,13 +77,8 @@ 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.
///
@@ -124,21 +116,17 @@ 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
@@ -154,7 +142,6 @@ 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`
@@ -354,6 +341,7 @@ fn check_date_rollover(
}
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
@@ -362,7 +350,7 @@ mod tests {
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
#[allow(unused_imports)]
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -391,7 +379,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, DrawStockConfig::DrawOne);
GameState::new(daily_seed, DrawMode::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -419,7 +407,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), DrawStockConfig::DrawOne);
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -442,7 +430,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, DrawStockConfig::DrawOne);
GameState::new(daily_seed, DrawMode::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
+6 -8
View File
@@ -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 chrono::Utc;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, GameMode};
@@ -54,10 +54,7 @@ impl DifficultyIndexResource {
DifficultyLevel::Hard => &mut self.hard,
DifficultyLevel::Expert => &mut self.expert,
DifficultyLevel::Grandmaster => &mut self.grandmaster,
// 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(),
DifficultyLevel::Random => unreachable!("Random has no catalog"),
};
let seed = catalog[*cursor % catalog.len()];
*cursor = cursor.wrapping_add(1);
@@ -107,9 +104,10 @@ fn handle_difficulty_request(
}
fn seed_from_system_time() -> u64 {
// 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
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
}
// ---------------------------------------------------------------------------
+13 -13
View File
@@ -1,9 +1,9 @@
//! Cross-system events used by the engine's plugins.
use bevy::prelude::Message;
use solitaire_core::KlondikePile;
use solitaire_core::card::Suit;
use solitaire_core::game_state::GameMode;
use solitaire_core::{Card, Suit};
use solitaire_core::pile::PileType;
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: KlondikePile,
pub to: KlondikePile,
pub from: PileType,
pub to: PileType,
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: KlondikePile,
pub to: KlondikePile,
pub from: PileType,
pub to: PileType,
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)]
pub struct CardFlippedEvent(pub Card);
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32);
/// 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 Card);
/// 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)]
pub struct CardFaceRevealedEvent(pub Card);
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFaceRevealedEvent(pub u32);
/// Achievement unlocked notification carrying the full `AchievementRecord` for
/// the newly unlocked achievement. Consumed by the toast renderer and any
@@ -299,8 +299,8 @@ pub struct ScanThemesRequestEvent;
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
#[derive(Message, Debug, Clone)]
pub struct HintVisualEvent {
/// The source card to be highlighted.
pub source_card: Card,
/// The `Card::id` of the source card to be highlighted.
pub source_card_id: u32,
/// The destination pile whose `PileMarker` should be tinted gold.
pub dest_pile: KlondikePile,
pub dest_pile: solitaire_core::pile::PileType,
}
+48 -68
View File
@@ -42,10 +42,7 @@ use std::f32::consts::PI;
use std::hash::{Hash, Hasher};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_core::pile::PileType;
use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim;
@@ -207,7 +204,6 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<RequestRedraw>()
.add_systems(
Update,
(
@@ -247,16 +243,18 @@ fn start_shake_anim(
continue;
}
let dest_pile = &ev.to;
// 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();
// 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();
if dest_card_set.is_empty() {
if dest_card_ids.is_empty() {
continue;
}
for (entity, card_marker, transform) in card_entities.iter() {
if dest_card_set.contains(&card_marker.card) {
if dest_card_ids.contains(&card_marker.card_id) {
commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0,
origin_x: transform.translation.x,
@@ -313,27 +311,27 @@ fn start_settle_anim(
card_entities: Query<(Entity, &CardEntity)>,
mut commands: Commands,
) {
// Build the list of cards that should bounce this frame from every
// Build the list of card ids 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<Card> = Vec::new();
let mut bounce_ids: Vec<u32> = Vec::new();
for ev in moves.read() {
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 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());
if n > 0 {
let start = pile.len() - n;
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
let start = pile.cards.len() - n;
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
}
}
}
if draws.read().next().is_some()
&& let Some((top, _)) = game.0.waste_cards().last()
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
&& let Some(top) = pile.cards.last()
{
bounce_ids.push(top.clone());
bounce_ids.push(top.id);
}
if bounce_ids.is_empty() {
@@ -341,7 +339,7 @@ fn start_settle_anim(
}
for (entity, card_marker) in card_entities.iter() {
if bounce_ids.contains(&card_marker.card) {
if bounce_ids.contains(&card_marker.card_id) {
commands.entity(entity).insert(SettleAnim::default());
}
}
@@ -395,11 +393,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(&KlondikePile::Stock) else {
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
return;
};
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
@@ -409,14 +407,10 @@ 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, 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));
// ±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));
commands.entity(entity).insert((
Transform::from_translation(stock_start.with_z(final_pos.z)),
CardAnim {
@@ -524,19 +518,21 @@ fn start_foundation_flourish(
if reduce_motion {
continue;
}
let Some(foundation) = foundation_from_slot(ev.slot) else {
continue;
};
let pile_type = KlondikePile::Foundation(foundation);
let pile_type = PileType::Foundation(ev.slot);
// Top card of the completed foundation is the King.
let cards = game.0.pile(pile_type);
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
let Some(king_id) = game
.0
.piles
.get(&pile_type)
.and_then(|p| p.cards.last())
.map(|c| c.id)
else {
continue;
};
// Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() {
if card_marker.card == king_card {
if card_marker.card_id == king_id {
commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot,
elapsed: 0.0,
@@ -636,16 +632,6 @@ 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)
// ---------------------------------------------------------------------------
@@ -845,17 +831,13 @@ mod tests {
#[test]
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::Tableau;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
@@ -863,25 +845,26 @@ mod tests {
app.update();
// Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
let card = app
let dest_pile = PileType::Tableau(0);
let card_id = app
.world()
.resource::<GameStateResource>()
.0
.pile(dest_pile)
.last()
.map(|(c, _)| c.clone())
.piles
.get(&dest_pile)
.and_then(|p| p.cards.last())
.map(|c| c.id)
.expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that card so the system would
// Spawn a minimal CardEntity matching that id so the system would
// find it and insert ShakeAnim if the gate were absent.
app.world_mut()
.spawn((CardEntity { card }, Transform::default()));
.spawn((CardEntity { card_id }, Transform::default()));
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()
.write(MoveRejectedEvent {
from: KlondikePile::Stock,
from: PileType::Stock,
to: dest_pile,
count: 1,
});
@@ -903,16 +886,13 @@ mod tests {
#[test]
fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
@@ -923,7 +903,7 @@ mod tests {
.resource_mut::<Messages<FoundationCompletedEvent>>()
.write(FoundationCompletedEvent {
slot: 0,
suit: solitaire_core::Suit::Spades,
suit: solitaire_core::card::Suit::Spades,
});
app.update();
+2 -3
View File
@@ -2,9 +2,8 @@
//!
//! 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 logs a warning and continues with
//! glyph-less UI rather than aborting, since crashing on a corrupt embed is
//! worse than degraded text.
//! validated at startup; a parse failure aborts the program with a clear
//! error because it means the binary is corrupt.
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
+1 -1
View File
@@ -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::<TouchInput>()
.add_message::<bevy::input::touch::TouchInput>()
.add_systems(
Update,
(
+7 -9
View File
@@ -16,7 +16,7 @@
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
@@ -432,9 +432,7 @@ fn build_home_context<'a>(
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings
.map(|s| s.0.draw_mode)
.unwrap_or(DrawStockConfig::DrawOne),
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
@@ -622,9 +620,9 @@ fn handle_home_draw_mode_buttons(
return;
};
let target = if want_one {
DrawStockConfig::DrawOne
DrawMode::DrawOne
} else {
DrawStockConfig::DrawThree
DrawMode::DrawThree
};
if settings.0.draw_mode == target {
return; // already in this mode — avoid a redundant respawn.
@@ -859,7 +857,7 @@ struct HomeContext<'a> {
challenge_best: u32,
daily_streak: u32,
daily_today: Option<DailyToday>,
draw_mode: DrawStockConfig,
draw_mode: DrawMode,
font_res: Option<&'a FontResource>,
/// Whether the difficulty section header is currently expanded.
difficulty_expanded: bool,
@@ -1040,7 +1038,7 @@ fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>)
..default()
};
let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne);
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
parent
.spawn(Node {
@@ -1880,7 +1878,7 @@ mod tests {
let states: Vec<(HomeMode, bool)> = app
.world_mut()
.query::<(&HomeModeCard, Has<Disabled>)>()
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
.iter(app.world())
.map(|(c, d)| (c.0, d))
.collect();
File diff suppressed because it is too large Load Diff
-431
View File
@@ -1,431 +0,0 @@
//! HUD feedback effects: action-bar fades, score pulses and floaters,
//! and streak flourishes.
use super::*;
/// Auto-fade state for the action button bar. The bar fades out when
/// the cursor is in the play area (below the HUD band) and back in when
/// the cursor approaches the top of the window — same UX as a video
/// player's auto-hide controls. Buttons remain fully interactive when
/// visible; when faded out they're geometrically out of cursor reach
/// (hover requires the cursor to be on a button), so no extra
/// pointer-events guard is needed.
#[derive(Resource, Debug, Clone, Copy)]
pub struct HudActionFade {
/// Currently displayed alpha. Lerped toward `target` each frame.
pub alpha: f32,
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
pub target: f32,
}
impl Default for HudActionFade {
fn default() -> Self {
// Start visible so the player sees the controls on first launch
// before they've moved the cursor anywhere.
Self {
alpha: 1.0,
target: 1.0,
}
}
}
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
/// cursor approaches, not only when it crosses into the band itself.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
/// transition — fast enough to feel responsive without flashing on
/// brief cursor wanders into the reveal zone.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// Updates the fade state from cursor position. Sets `target = 1.0` if
/// the cursor is in the reveal zone (bottom of window) or off-screen
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
#[cfg(not(target_os = "android"))]
pub(super) fn update_action_fade(
windows: Query<&Window>,
time: Res<Time>,
mut fade: ResMut<HudActionFade>,
) {
let Ok(window) = windows.single() else {
return;
};
let height = window.resolution.height();
fade.target = match window.cursor_position() {
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
Some(_) => 0.0,
// Off-window cursor: assume keyboard navigation and keep the
// bar visible so Tab cycling doesn't lead to invisible focus.
None => 1.0,
};
let dt = time.delta_secs();
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
let diff = fade.target - fade.alpha;
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
}
/// Applies the current fade alpha to every action button's
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
/// same frame doesn't override the fade with an opaque idle / hover
/// colour.
#[cfg(not(target_os = "android"))]
#[allow(clippy::type_complexity)]
pub(super) fn apply_action_fade(
fade: Res<HudActionFade>,
// Excludes `PopoverRow` so the auto-fade only applies to the
// top-level action bar buttons. Popover rows live inside an
// explicitly-opened dropdown panel and need to stay visible
// regardless of the bar's fade state — without the exclusion
// the rows fade to invisible while the popover container stays
// visible, leaving a solid background block with no readable
// content.
mut buttons: Query<
(&Children, &mut BackgroundColor),
(With<ActionButton>, Without<PopoverRow>),
>,
mut text_q: Query<&mut TextColor>,
) {
for (children, mut bg) in &mut buttons {
let mut c = bg.0;
c.set_alpha(fade.alpha);
bg.0 = c;
for child in children.iter() {
if let Ok(mut tc) = text_q.get_mut(child) {
let mut cc = tc.0;
cc.set_alpha(fade.alpha);
tc.0 = cc;
}
}
}
}
/// Visual feedback for every action button — paints idle / hover / pressed
/// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared
/// `ActionButton` marker.
#[allow(clippy::type_complexity)]
pub(super) fn paint_action_buttons(
mut buttons: Query<
(&Interaction, &mut BackgroundColor),
(With<ActionButton>, Changed<Interaction>),
>,
) {
for (interaction, mut bg) in &mut buttons {
bg.0 = match interaction {
Interaction::Pressed => ACTION_BTN_PRESSED,
Interaction::Hovered => ACTION_BTN_HOVER,
Interaction::None => ACTION_BTN_IDLE,
};
}
}
/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure
/// function so the test suite can assert on the curve directly
/// without spinning up a Bevy app.
///
/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`,
/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and
/// ends at 1.1 instead of 1.0. The corrected form below preserves the
/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous
/// triangle peaking at 1.1.
pub(super) fn score_pulse_scale(t: f32) -> f32 {
let clamped = t.clamp(0.0, 1.0);
if clamped < 0.5 {
1.0 + 0.2 * clamped
} else {
1.1 - 0.2 * (clamped - 0.5)
}
}
/// Vertical pixels the floating "+N" drifts up over its lifetime.
const FLOATER_DRIFT_PX: f32 = 40.0;
/// Diffs the current `GameStateResource.score` against
/// [`PreviousScore`]. On a positive delta:
///
/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity
/// so the readout pulses 1.0 → 1.1 → 1.0.
/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating
/// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score
/// readout (see the doc comment on [`ScoreFloater`] for why this is a
/// UI Node rather than a `Text2d`).
pub(super) fn detect_score_change(
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
mut prev: ResMut<PreviousScore>,
font_res: Option<Res<FontResource>>,
score_q: Query<Entity, With<HudScore>>,
mut commands: Commands,
) {
let current = game.0.score();
let delta = current - prev.0;
prev.0 = current;
if delta <= 0 {
return;
}
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
if reduce_motion {
return;
}
let speed = settings
.as_ref()
.map(|s| s.0.animation_speed)
.unwrap_or_default();
let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed);
let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed);
// Refresh ScorePulse on every score readout entity (in practice
// there's exactly one, but iterating is cheaper than asserting).
for entity in &score_q {
commands.entity(entity).insert(ScorePulse {
elapsed: 0.0,
duration: pulse_secs,
});
}
if delta < SCORE_FLOATER_THRESHOLD {
return;
}
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY_LG,
..default()
};
// Spawned as an absolutely-positioned UI Node so the floater rides
// the same screen-coordinate system as the score readout. Using a
// `Text2d` here would require translating UI layout coordinates to
// world space every frame; a UI node piggybacks on the same
// anchoring `update_hud` already uses for the score and stays
// testable under `MinimalPlugins`.
commands.spawn((
ScoreFloater {
elapsed: 0.0,
duration: floater_secs,
},
Node {
position_type: PositionType::Absolute,
// Anchored next to the HUD column; matches the
// `spawn_hud` left/top offsets so the floater appears
// overlaid on the score line and drifts up from there.
left: VAL_SPACE_3,
top: Val::Px(0.0),
..default()
},
ZIndex(Z_HUD_TOP),
Text::new(format!("+{delta}")),
font,
TextColor(ACCENT_PRIMARY),
));
}
/// Advances every [`ScorePulse`], scaling its entity's `Transform`
/// using [`score_pulse_scale`]. Removes the component once
/// `elapsed >= duration` (or immediately under
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is
/// 0) and pins the scale back to 1.0 so no float drift survives.
pub(super) fn advance_score_pulse(
time: Res<Time>,
mut commands: Commands,
mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
) {
let dt = time.delta_secs();
for (entity, mut pulse, mut transform) in &mut q {
let t = if pulse.duration <= 0.0 {
1.0
} else {
pulse.elapsed += dt;
(pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
};
let scale = score_pulse_scale(t);
transform.scale = Vec3::new(scale, scale, 1.0);
if t >= 1.0 {
transform.scale = Vec3::ONE;
commands.entity(entity).remove::<ScorePulse>();
}
}
}
/// Advances every [`ScoreFloater`]: drifts the node upward by up to
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
/// its lifetime. Despawns the entity once `elapsed >= duration`.
pub(super) fn advance_score_floater(
time: Res<Time>,
mut commands: Commands,
mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
) {
let dt = time.delta_secs();
for (entity, mut floater, mut node, mut color) in &mut nodes {
let t = if floater.duration <= 0.0 {
1.0
} else {
floater.elapsed += dt;
(floater.elapsed / floater.duration).clamp(0.0, 1.0)
};
// Drift upward: top decreases as t grows. Starting top=0 keeps
// the floater on the score line; ending at -FLOATER_DRIFT_PX
// pulls it up off the readout.
node.top = Val::Px(-FLOATER_DRIFT_PX * t);
// Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
let mut c = ACCENT_PRIMARY;
c.set_alpha(1.0 - t);
color.0 = c;
if t >= 1.0 {
commands.entity(entity).despawn();
}
}
}
// ---------------------------------------------------------------------------
// Streak-milestone flourish
//
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
// completion celebration; the streak flourish is its lifetime equivalent —
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
// readout pulses larger than a normal score-change pulse and tints magenta
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
//
// Why the score readout: there is no always-on streak number on the HUD
// today (the readout lives in the Stats overlay), and the score is the
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
// player who isn't watching the score still sees the celebration land.
// ---------------------------------------------------------------------------
/// Pure helper for unit tests — returns the per-frame scale factor for
/// the streak flourish at `elapsed_secs` over `duration_secs`.
///
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
/// Out-of-range values are clamped so the score readout never freezes
/// at a non-1.0 scale on the frame after the flourish ends.
///
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
/// without dividing by zero.
pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
if duration_secs <= 0.0 {
return 1.0;
}
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
let peak = STREAK_FLOURISH_PEAK_SCALE;
if t < 0.5 {
// Climb from 1.0 at t=0 to peak at t=0.5.
1.0 + (peak - 1.0) * (t / 0.5)
} else {
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
}
}
/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
/// `TextColor` so `advance_streak_flourish` can restore it when the
/// timer expires; reuses any existing flourish's `original_color` so
/// re-entering the system mid-flourish doesn't snapshot the magenta
/// tint as the new "original".
///
/// Removes any concurrent [`ScorePulse`] from the same entity so the
/// flourish takes over the scale slot cleanly — score pulses last
/// 250 ms, the flourish 600 ms, and the streak crossing always
/// coincides with a positive score delta, so the flourish is the
/// louder of the two celebrations.
pub(super) fn start_streak_flourish(
mut events: MessageReader<WinStreakMilestoneEvent>,
settings: Option<Res<SettingsResource>>,
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
mut commands: Commands,
) {
let Some(latest) = events.read().last() else {
return;
};
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
return;
}
let speed = settings
.as_ref()
.map(|s| s.0.animation_speed)
.unwrap_or_default();
let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
for (entity, color, existing) in &score_q {
let original_color = existing.map_or(color.0, |f| f.original_color);
commands
.entity(entity)
.remove::<ScorePulse>()
.insert(StreakFlourish {
streak: latest.streak,
elapsed: 0.0,
duration,
original_color,
});
}
}
/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
/// `original_color`. Removes the component once `elapsed >= duration`
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
/// where duration is 0) and pins the scale back to 1.0 / restores the
/// original colour so no half-state is ever shown.
///
/// Filtered with `Without<ScorePulse>` so the streak flourish never
/// races a score pulse for the same `Transform.scale` slot —
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
/// score entity before this system runs, so the filter is purely a
/// belt-and-braces invariant.
pub(super) fn advance_streak_flourish(
time: Res<Time>,
mut commands: Commands,
mut q: Query<
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
Without<ScorePulse>,
>,
) {
let dt = time.delta_secs();
for (entity, mut anim, mut transform, mut color) in &mut q {
let t = if anim.duration <= 0.0 {
1.0
} else {
anim.elapsed += dt;
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
};
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
transform.scale = Vec3::new(scale, scale, 1.0);
// Tint mix: full magenta at t=0..=0.5, fades back to the
// original colour over t=0.5..=1.0.
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
if t >= 1.0 {
transform.scale = Vec3::ONE;
color.0 = anim.original_color;
commands.entity(entity).remove::<StreakFlourish>();
}
}
}
/// sRGB-space linear interpolation between two `Color`s — small local
/// helper so `advance_streak_flourish` stays readable. sRGB-space
/// lerping is fine for a brief decorative tint (a perceptually-uniform
/// space would be overkill).
pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
let from = from.to_srgba();
let to = to.to_srgba();
let t = t.clamp(0.0, 1.0);
Color::srgba(
from.red + (to.red - from.red) * t,
from.green + (to.green - from.green) * t,
from.blue + (to.blue - from.blue) * t,
from.alpha + (to.alpha - from.alpha) * t,
)
}
@@ -1,613 +0,0 @@
//! HUD interaction: action-button handlers, Modes/Menu popovers, and
//! the chrome tap-to-toggle gesture.
use super::*;
/// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button
/// is held down. Each click handler fires the corresponding request event,
/// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside
/// their existing keyboard handlers.
pub(super) fn handle_new_game_button(
interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
new_game.write(NewGameRequestEvent::default());
}
}
}
pub(super) fn handle_undo_button(
interaction_query: Query<&Interaction, (With<UndoButton>, Changed<Interaction>)>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
undo.write(UndoRequestEvent);
}
}
}
pub(super) fn handle_pause_button(
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
mut pause: MessageWriter<PauseRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
pause.write(PauseRequestEvent);
}
}
}
pub(super) fn handle_help_button(
interaction_query: Query<&Interaction, (With<HelpButton>, Changed<Interaction>)>,
mut help: MessageWriter<HelpRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
help.write(HelpRequestEvent);
}
}
}
pub(super) fn handle_hint_button(
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>,
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
for interaction in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
if paused.as_ref().is_some_and(|p| p.0) {
return;
}
let Some(ref g) = game else { return };
if g.0.is_won() {
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
return;
}
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
}
}
}
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
/// second click. Mode rows are populated per the player's current level so
/// only unlocked options appear.
pub(super) fn handle_modes_button(
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
} else {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
}
/// Spawns the modes popover anchored just below the action bar's right
/// edge. Always includes Classic; includes Daily Challenge when a daily
/// resource is loaded; includes Zen / Challenge / Time Attack once the
/// player reaches the challenge unlock level.
pub(super) fn spawn_modes_popover(
commands: &mut Commands,
progress: Option<&ProgressResource>,
daily: Option<&DailyChallengeResource>,
font_res: Option<&FontResource>,
) {
let level = progress.map_or(0, |p| p.0.level);
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 15.0,
..default()
};
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what the mode does — mirroring the
// tooltips on the action-bar buttons that opened this popover.
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
ModeOption::Classic,
"Classic",
"Standard Klondike. Score, timer, and full progression.",
)];
if daily.is_some() {
rows.push((
ModeOption::DailyChallenge,
"Daily Challenge",
"Today's seeded deal. Same for every player worldwide.",
));
}
if level >= CHALLENGE_UNLOCK_LEVEL {
rows.push((
ModeOption::Zen,
"Zen",
"No timer, no score, no penalties. Just play.",
));
rows.push((
ModeOption::Challenge,
"Challenge",
"Hand-picked hard seeds. No undo allowed.",
));
rows.push((
ModeOption::TimeAttack,
"Time Attack",
"Win as many games as you can in ten minutes.",
));
}
// Popover opens upward from just above the bottom action bar.
// Use a platform-aware offset that clears the bar height + safe-area
// gesture zone on Android, and the flat bar height on desktop.
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
commands
.spawn((
ModesPopover,
HudPopoverOpen,
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
bottom: popover_bottom,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_2),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD_POPOVER),
))
.with_children(|panel| {
for (option, label, tooltip) in rows {
panel
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
});
}
});
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
commands.spawn((
ModesPopoverBackdrop,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::NONE),
ZIndex(Z_HUD_POPOVER_BACKDROP),
));
}
/// Dispatches the click on a popover row to the matching request event,
/// then despawns the popover.
///
/// Classic uses [`NewGameRequestEvent`] directly; the other modes use
/// their `Start*RequestEvent` so the existing keyboard handler runs
/// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) —
/// the popover stays a thin entry point and never duplicates that logic.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_mode_option_click(
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut commands: Commands,
) {
let mut clicked_any = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
ModeOption::Classic => {
new_game.write(NewGameRequestEvent::default());
}
ModeOption::DailyChallenge => {
daily.write(StartDailyChallengeRequestEvent);
}
ModeOption::Zen => {
zen.write(StartZenRequestEvent);
}
ModeOption::Challenge => {
challenge.write(StartChallengeRequestEvent);
}
ModeOption::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
}
}
if clicked_any && let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
}
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
/// second click. The popover lists the five overlays previously only
/// reachable via the S / A / P / O / L hotkeys.
pub(super) fn handle_menu_button(
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
scrims: Query<(), With<ModalScrim>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
} else if scrims.is_empty() {
spawn_menu_popover(&mut commands, font_res.as_deref());
}
}
/// Spawns the menu popover anchored just below the action bar, with one
/// row per overlay. Each row dispatches its corresponding
/// `Toggle*RequestEvent` so the existing toggle handler runs (and the
/// HUD never duplicates spawn / despawn / fetch logic).
pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 15.0,
..default()
};
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what each overlay shows — mirroring
// the tooltips on the action-bar buttons that opened this popover.
let rows: [(MenuOption, &'static str, &'static str); 7] = [
(
MenuOption::Help,
"Help",
"Show controls, rules, and keyboard shortcuts.",
),
(
MenuOption::Modes,
"Game Modes",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
),
(
MenuOption::Stats,
"Stats",
"Lifetime totals: wins, streaks, fastest time, best score.",
),
(
MenuOption::Achievements,
"Achievements",
"Browse unlocked achievements and the rewards still ahead.",
),
(
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
),
(
MenuOption::Settings,
"Settings",
"Audio, animations, theme, draw mode, and sync.",
),
(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
),
];
// Same upward-opening placement as ModesPopover.
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
commands
.spawn((
MenuPopover,
HudPopoverOpen,
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
bottom: popover_bottom,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_2),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD_POPOVER),
))
.with_children(|panel| {
for (option, label, tooltip) in rows {
panel
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
});
}
});
// Transparent fullscreen backdrop behind the popover — tapping anywhere
// outside the panel light-dismisses it via handle_menu_backdrop_click.
commands.spawn((
MenuPopoverBackdrop,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::NONE),
ZIndex(Z_HUD_POPOVER_BACKDROP),
));
}
/// Dispatches the click on a menu row to the matching toggle event,
/// then despawns the popover.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_menu_option_click(
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
MenuOption::Help => {
help.write(HelpRequestEvent);
}
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent);
}
MenuOption::Achievements => {
achievements.write(ToggleAchievementsRequestEvent);
}
MenuOption::Profile => {
profile.write(ToggleProfileRequestEvent);
}
MenuOption::Settings => {
settings.write(ToggleSettingsRequestEvent);
}
MenuOption::Leaderboard => {
leaderboard.write(ToggleLeaderboardRequestEvent);
}
}
}
if clicked_any && let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
if open_modes {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
}
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
pub(super) fn close_modes_popover_on_escape(
keys: Res<ButtonInput<KeyCode>>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut commands: Commands,
) {
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
/// is pressed while the popover is open.
pub(super) fn close_menu_popover_on_escape(
keys: Res<ButtonInput<KeyCode>>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut commands: Commands,
) {
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
/// anywhere outside the panel.
pub(super) fn handle_modes_backdrop_click(
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
pub(super) fn handle_menu_backdrop_click(
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
#[cfg(target_os = "android")]
#[allow(clippy::too_many_arguments)]
pub(super) fn toggle_hud_on_tap(
mut touch_events: MessageReader<TouchInput>,
drag: Res<DragState>,
scrims: Query<(), With<ModalScrim>>,
paused: Option<Res<PausedResource>>,
mut tracker: ResMut<HudTapTracker>,
mut hud_vis: ResMut<HudVisibility>,
buttons: Query<&Interaction, With<ActionButton>>,
mut game_consumed: ResMut<GameInputConsumedResource>,
) {
use bevy::input::touch::TouchPhase;
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
// Drain buffered events so they don't replay in the frame after
// the scrim despawns, which would trigger a spurious visibility
// toggle as the resume/close button tap's Started+Ended pair
// replays in the now-scrim-free frame.
for _ in touch_events.read() {}
tracker.start_pos = None;
tracker.started_on_button = false;
game_consumed.0 = false;
return;
}
for event in touch_events.read() {
match event.phase {
TouchPhase::Started => {
tracker.start_pos = Some(event.position);
// Record whether the finger-down landed on a button so
// the finger-up doesn't double-fire (toggle bar + press
// button at the same time).
tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None);
}
TouchPhase::Ended if drag.is_idle() => {
// Also treat taps where game logic consumed the touch (e.g.
// drawing from stock) as "on button" so they don't toggle
// the HUD. The flag is set on TouchPhase::Started by the
// input system that consumed the tap and must be cleared here
// regardless of whether we toggle.
let on_button = tracker.started_on_button || game_consumed.0;
game_consumed.0 = false;
if let Some(start) = tracker.start_pos.take()
&& !on_button
&& (event.position - start).length() < HUD_TAP_SLOP_PX
{
*hud_vis = match *hud_vis {
HudVisibility::Visible => HudVisibility::Hidden,
HudVisibility::Hidden => HudVisibility::Visible,
};
}
tracker.started_on_button = false;
}
// Moved: don't clear start_pos — Android fires Moved for normal
// tap jitter, and the distance check at Ended already rejects
// real drags. Clearing here would silently swallow tap toggles.
TouchPhase::Canceled => {
tracker.start_pos = None;
tracker.started_on_button = false;
game_consumed.0 = false;
}
_ => {}
}
}
}
-612
View File
@@ -1,612 +0,0 @@
//! Persistent in-game HUD: score, move count, elapsed time, mode badge,
//! daily-challenge constraint, and undo count.
//!
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
//! refreshed whenever `GameStateResource` changes (which happens on every move
//! and every elapsed-time tick), so score, moves, and timer all stay current
//! without a separate tick system.
use bevy::prelude::*;
use bevy::window::WindowResized;
mod fx;
mod interaction;
mod spawn;
mod updates;
pub use fx::*;
use interaction::*;
use spawn::*;
use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None.
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
UndoRequestEvent, WinStreakMilestoneEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
#[cfg(target_os = "android")]
use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT;
use crate::layout::LayoutSystem;
use crate::pause_plugin::PausedResource;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
#[cfg(target_os = "android")]
use crate::resources::{DragState, GameInputConsumedResource};
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
use crate::selection_plugin::SelectionState;
use crate::settings_plugin::SettingsResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::SPACE_2;
use crate::ui_theme::UiTextFx;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
scaled_duration,
};
use crate::ui_tooltip::Tooltip;
use solitaire_data::SyncBackend;
/// Marker on the score text node.
#[derive(Component, Debug)]
pub struct HudScore;
/// Marker on the move-count text node.
#[derive(Component, Debug)]
pub struct HudMoves;
/// Marker on the elapsed-time text node.
#[derive(Component, Debug)]
pub struct HudTime;
/// Marker on the mode badge text node.
#[derive(Component, Debug)]
pub struct HudMode;
/// Marker on the daily-challenge constraint text node.
///
/// Displays the active goal (time limit or score target) when a daily challenge
/// is in progress. Empty string when no challenge is active or the game is won.
#[derive(Component, Debug)]
pub struct HudChallenge;
/// Marker on the "won this deal before" indicator text node.
///
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
/// mode triple matches one of the entries in `ReplayHistoryResource`.
/// Empty string otherwise (including won games — the score readout
/// already conveys the win on the active deal). Only meaningful for
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
/// are filtered out implicitly because their replay entries always
/// carry a different mode tag.
#[derive(Component, Debug)]
pub struct HudWonPreviously;
/// Marker on the undo-count text node.
///
/// Shows how many undos have been used this game. Displayed in amber when
/// `undo_count > 0` because using undo blocks the no-undo achievement.
#[derive(Component, Debug)]
pub struct HudUndos;
/// Marker on the auto-complete badge text node.
///
/// Displays `"AUTO"` in green while `AutoCompleteState.active` is true;
/// empty string otherwise.
#[derive(Component, Debug)]
pub struct HudAutoComplete;
/// Marker on the stock-recycle counter text node.
///
/// Displays `"Recycles: N"` whenever `recycle_count > 0`, regardless of draw
/// mode, so the player can track stock recycling in both Draw-One and
/// Draw-Three (relevant to the `comeback` achievement). Hidden (empty string)
/// until the first recycle occurs.
#[derive(Component, Debug)]
pub struct HudRecycles;
/// Marker on the draw-cycle indicator text node.
///
/// Only shown in Draw-Three mode. Displays `"Cycle: N/3"` where N is the
/// number of cards that will be drawn on the next stock click
/// (`min(stock_len, 3)`). Shows `"Cycle: 0/3"` when the stock is empty
/// (recycle available). Hidden (empty string) in Draw-One mode or after the
/// game is won.
#[derive(Component, Debug)]
pub struct HudDrawCycle;
/// Marker on the keyboard-selection indicator text node.
///
/// Displays `"▶ {pile_name}"` while a pile is selected via Tab, or an empty
/// string when no pile is selected. Uses a light-yellow colour so it stands
/// out from the other white HUD items.
#[derive(Component, Debug)]
pub struct HudSelection;
/// Marker on the HUD band background node (the translucent band behind buttons).
#[derive(Component, Debug)]
pub struct HudBand;
/// Marker on the HUD score/info column root node.
#[derive(Component, Debug)]
pub struct HudColumn;
/// Marker on the action button bar root node.
#[derive(Component, Debug)]
pub struct HudActionBar;
/// Set wrapping the chained HUD button/popover interaction systems. Other
/// keyboard consumers order themselves around it (e.g.
/// [`crate::ui_focus::FocusKeys`] runs after) so input-consumption order is
/// deterministic (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct HudButtons;
/// Marker on the text node inside each touch-layout action-bar button.
/// Used by `resize_action_bar_labels` to update font size on window resize.
#[derive(Component, Debug)]
struct ActionButtonLabel;
/// Marker on the circular profile-picture button anchored to the
/// top-right of the HUD band. Pressing it opens the Profile overlay.
/// Shows the server avatar image when loaded; falls back to the player's
/// initial on a filled disc when no image is available.
#[derive(Component, Debug)]
pub struct HudAvatar;
/// Controls whether the in-game HUD (band, score column, action buttons) is
/// visible. Toggled on Android by tapping empty board space; always `Visible`
/// on desktop. Resets to `Visible` whenever a modal opens.
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HudVisibility {
#[default]
Visible,
Hidden,
}
#[cfg(target_os = "android")]
#[derive(Resource, Debug, Default)]
struct HudTapTracker {
start_pos: Option<Vec2>,
/// Set `true` when the finger-down hit an action button so the
/// finger-up never toggles bar visibility.
started_on_button: bool,
}
#[cfg(target_os = "android")]
const HUD_TAP_SLOP_PX: f32 = 25.0;
/// Drives the score-readout pulse: scales the [`HudScore`] text from
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
/// entity whenever the score increases; removed once `elapsed >=
/// duration`.
#[derive(Component, Debug, Clone, Copy)]
pub struct ScorePulse {
/// Seconds elapsed since the pulse started.
pub elapsed: f32,
/// Total duration. Zero under `AnimSpeed::Instant` — the system
/// snaps the scale back to 1.0 on first tick so no half-state
/// is ever shown.
pub duration: f32,
}
/// Marker on a transient floating "+N" text spawned next to the score
/// readout when the score jumps by [`SCORE_FLOATER_THRESHOLD`] or more.
/// Drifts upward and fades out over `MOTION_SCORE_PULSE_SECS * 2`,
/// then despawns. Kept rare/meaningful by the threshold gate.
#[derive(Component, Debug, Clone, Copy)]
pub struct ScoreFloater {
/// Seconds elapsed since the floater spawned.
pub elapsed: f32,
/// Total lifetime. Zero under `AnimSpeed::Instant` — the system
/// despawns it on first tick.
pub duration: f32,
}
/// Drives the streak-milestone flourish: scales the [`HudScore`] text
/// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over
/// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by
/// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it
/// [`ACCENT_SECONDARY`] for the same window before restoring the
/// original colour.
///
/// The streak readout currently lives in the Stats overlay (press
/// `S`) — there is no always-on HUD streak counter — so the flourish
/// piggybacks on the score readout, which is the most prominent
/// always-visible HUD number. Mirrors the `FoundationFlourish`
/// pattern: triangular scale curve, fixed duration, restores state
/// when the timer expires.
///
/// Inserted on `HudScore` entities by `start_streak_flourish` when a
/// `WinStreakMilestoneEvent` fires; removed once `elapsed >=
/// duration` so the readout returns to its rest state for the next
/// frame's transform sync.
///
/// Coexists with [`ScorePulse`]: the streak flourish lives on a
/// dedicated marker so a streak-crossing win that also ticks the
/// score (every win does) doesn't have the two animations stomp on
/// each other's `Transform.scale` writes — the streak flourish runs
/// in a `Without<ScorePulse>` query so only the loudest of the two
/// celebrations is active at a time.
#[derive(Component, Debug, Clone, Copy)]
pub struct StreakFlourish {
/// The streak milestone that triggered this flourish (3, 5, 10).
/// Carried for diagnostic logging only — the visual is identical
/// for every threshold so play-testing can decide later whether
/// to differentiate.
pub streak: u32,
/// Seconds elapsed since the flourish began.
pub elapsed: f32,
/// Total animation length in seconds. Zero under
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system
/// snaps the scale back to 1.0 on the first tick so no half-state
/// is ever shown.
pub duration: f32,
/// The score readout's colour before the flourish began —
/// restored when the timer expires so the readout returns to its
/// resting `TEXT_PRIMARY` (or whatever it was) tint.
pub original_color: Color,
}
/// Tracks the score from the previous frame so the HUD can detect
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
/// pulse + floater systems on every `Update`.
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct PreviousScore(pub i32);
/// Score increase (in points) below which no floating "+N" is spawned.
/// 50 keeps the feedback for foundation drops and tableau-to-foundation
/// promotions; single-card placements (which can earn as little as +5)
/// stay quiet so the floater feels like a reward instead of noise.
pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
/// Marker shared by every clickable HUD action button so a single
/// `paint_action_buttons` system can recolour them on hover/press without
/// each button needing its own paint handler.
#[derive(Component, Debug)]
pub struct ActionButton;
/// Marker on rows inside a popover panel ([`ModesPopover`] or
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
/// hover/press paint path applies to them, but the auto-fade applied
/// to the top-level action bar must NOT also fade these rows — the
/// popover only renders when the player has explicitly opened it, so
/// its content should always be at full opacity. `apply_action_fade`
/// excludes entities with this marker via `Without<PopoverRow>`.
#[derive(Component, Debug)]
pub struct PopoverRow;
/// Marker on the "New Game" action button anchored top-right of the play
/// area. Click fires [`NewGameRequestEvent`]; the existing
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
/// progress.
#[derive(Component, Debug)]
pub struct NewGameButton;
/// Marker on the "Undo" action button. Click fires [`UndoRequestEvent`],
/// mirroring the `U` keyboard accelerator.
#[derive(Component, Debug)]
pub struct UndoButton;
/// Marker on the "Pause" action button. Click fires [`PauseRequestEvent`],
/// mirroring the `Esc` keyboard accelerator. The pause overlay's own resume
/// affordance dismisses it from the paused state.
#[derive(Component, Debug)]
pub struct PauseButton;
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
/// mirroring the `F1` keyboard accelerator.
#[derive(Component, Debug)]
pub struct HelpButton;
/// Marker on the "Hint" action button. Click spawns an async solver task
/// (same as the `H` keyboard accelerator) and highlights the suggested card.
#[derive(Component, Debug)]
pub struct HintButton;
/// Android HUD label for the Hint button — shared with the help screen's
/// controls reference so both always agree.
#[cfg(target_os = "android")]
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
#[cfg(target_os = "android")]
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu",
"Undo",
"Pause",
"Help",
ANDROID_HINT_LABEL,
"Mode",
"New",
];
#[cfg(not(target_os = "android"))]
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu \u{2193}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{2193}",
"New Game",
];
#[cfg(target_os = "android")]
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
#[cfg(not(target_os = "android"))]
const ACTION_BAR_COLUMN_GAP: Val = VAL_SPACE_2;
#[cfg(target_os = "android")]
const ACTION_POPOVER_BOTTOM_PX: f32 = 200.0;
#[cfg(not(target_os = "android"))]
const ACTION_POPOVER_BOTTOM_PX: f32 = 80.0;
#[cfg(target_os = "android")]
const HINT_WON_MSG: &str = "Game won! Tap New Game to play again";
#[cfg(not(target_os = "android"))]
const HINT_WON_MSG: &str = "Game won! Press N for a new game";
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
/// (a small dropdown panel) below the action bar. Each popover row starts
/// the corresponding game mode.
#[derive(Component, Debug)]
pub struct ModesButton;
/// Marker on the dropdown panel that opens below the [`ModesButton`].
/// Spawned on first click, despawned on second click or on mode select.
#[derive(Component, Debug)]
pub struct ModesPopover;
/// One row inside the [`ModesPopover`]. The variant carries which event
/// the click handler should fire — Classic uses `NewGameRequestEvent`
/// directly, the others go through their `Start*RequestEvent` so the
/// existing keyboard handler's level gate / resource setup runs.
#[derive(Component, Debug, Clone, Copy)]
pub enum ModeOption {
Classic,
DailyChallenge,
Zen,
Challenge,
TimeAttack,
}
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
/// which exposes the Stats / Achievements / Profile / Settings /
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
#[derive(Component, Debug)]
pub struct MenuButton;
/// Marker on the dropdown panel that opens below the [`MenuButton`].
#[derive(Component, Debug)]
pub struct MenuPopover;
/// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities
/// while they are open. External systems (e.g. `PausePlugin`) query this to
/// determine whether a HUD popover is currently visible without importing the
/// individual popover types.
#[derive(Component, Debug)]
pub struct HudPopoverOpen;
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
#[derive(Component, Debug)]
struct MenuPopoverBackdrop;
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
#[derive(Component, Debug)]
struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which
/// `Toggle*RequestEvent` the click handler fires.
#[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption {
Help,
Modes,
Stats,
Achievements,
Profile,
Settings,
Leaderboard,
}
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
/// Idle / hover / pressed colours shared by every action button. Aliased
/// to the theme tokens so the HUD picks up palette changes for free.
const ACTION_BTN_IDLE: Color = BG_ELEVATED;
const ACTION_BTN_HOVER: Color = BG_ELEVATED_HI;
const ACTION_BTN_PRESSED: Color = BG_ELEVATED_PRESSED;
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
pub struct HudPlugin;
impl Plugin for HudPlugin {
fn build(&self, app: &mut App) {
// The click handlers write to messages registered elsewhere by their
// owning plugins (`GamePlugin`, `PausePlugin`, `HelpPlugin`,
// `challenge_plugin`, `daily_challenge_plugin`, `time_attack_plugin`,
// `input_plugin`). Re-register defensively so the HUD plugin works in
// isolation under `MinimalPlugins` (tests). `add_message` is
// idempotent.
app.add_message::<NewGameRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<PauseRequestEvent>()
.add_message::<HelpRequestEvent>()
.add_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<ToggleLeaderboardRequestEvent>()
.add_message::<WinStreakMilestoneEvent>()
.init_resource::<PreviousScore>()
.init_resource::<HudActionFade>()
.init_resource::<HudVisibility>()
// Escape-close handlers for popovers read this; init defensively
// so HudPlugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
// WindowResized is registered by table_plugin; re-register
// defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
// HUD text updaters run as one deterministic chain (they write
// disjoint Text nodes, but Bevy can't prove it); update_hud also
// reads AutoCompleteState, so the chain sits after the
// auto-complete detect/drive chain (#143).
.add_systems(
Update,
(
update_hud,
update_selection_hud.run_if(
resource_exists_and_changed::<SelectionState>
.or(resource_exists_and_changed::<GameStateResource>),
),
update_won_previously,
)
.chain()
.after(GameMutation)
.after(crate::auto_complete_plugin::AutoComplete)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
)
// HUD chrome visibility: modal-restore writes HudVisibility, the
// applier consumes it, and the layout recompute reads it — a
// fixed chain instead of three racing systems (#143).
.add_systems(
Update,
(restore_hud_on_modal, apply_hud_visibility)
.chain()
.before(LayoutSystem::UpdateOnResize),
)
.add_systems(
Update,
(
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
handle_avatar_button.ambiguous_with(HudButtons),
),
)
.add_systems(
Update,
announce_auto_complete
.after(GameMutation)
.after(crate::auto_complete_plugin::AutoComplete)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// Typography rescale touches HUD TextFont only, but orders after
// the board painters that resize card/label text (#143).
.add_systems(
Update,
update_hud_typography.after(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(
detect_score_change,
advance_score_pulse,
advance_score_floater,
)
.chain()
.after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(start_streak_flourish, advance_streak_flourish)
.chain()
.after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(
handle_new_game_button
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_undo_button
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation),
handle_pause_button,
handle_help_button,
handle_hint_button
.after(GameMutation)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_modes_button,
handle_mode_option_click
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_modes_backdrop_click,
close_modes_popover_on_escape,
handle_menu_button,
handle_menu_option_click,
handle_menu_backdrop_click,
close_menu_popover_on_escape,
paint_action_buttons,
)
.chain()
.in_set(HudButtons)
.before(crate::ui_focus::FocusKeys),
)
// Fade lives in `Last` so it always overrides whatever the
// hover/paint pass set on `BackgroundColor` this frame.
// Otherwise on a hover-state change (`Changed<Interaction>`),
// `paint_action_buttons` would clobber the alpha back to 1.0
// mid-fade and produce a visible blip.
;
// Desktop-only: cursor-proximity fade. On Android the bar
// visibility is toggled explicitly; cursor_position() returning
// Some(touch_pos) during a tap would otherwise fade the bar out.
#[cfg(not(target_os = "android"))]
app.add_systems(Last, (update_action_fade, apply_action_fade).chain());
#[cfg(target_os = "android")]
{
app.init_resource::<HudTapTracker>()
.add_message::<TouchInput>()
.add_systems(
Update,
toggle_hud_on_tap
.after(TouchDragSet::AfterStartDrag)
.in_set(TouchDragSet::BeforeEndDrag),
);
app.add_systems(
Update,
resize_action_bar_labels
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
);
}
}
}
#[cfg(test)]
mod tests;
-552
View File
@@ -1,552 +0,0 @@
//! HUD construction: band, columns, avatar, and action-bar spawning.
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource;
/// Spawns the invisible HUD band that reserves vertical space at the top of
/// the screen so the card layout (computed by `layout::compute_layout` using
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
///
/// The entity carries no `BackgroundColor` — the green felt shows through.
/// A slim grey background is handled by each content section individually
/// (the bottom action bar has its own `BG_HUD_BAND` background).
pub(super) fn spawn_hud_band(mut commands: Commands) {
const BASE_TOP: f32 = 0.0;
commands.spawn((
Node {
position_type: PositionType::Absolute,
top: Val::Px(BASE_TOP),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(HUD_BAND_HEIGHT),
..default()
},
ZIndex(Z_HUD - 1),
SafeAreaAnchoredTop { base_top: BASE_TOP },
HudBand,
));
}
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
/// top-left of the play area.
///
/// Tiers (top to bottom):
/// 1. **Primary** — Score (display weight) · Moves · Timer.
/// Always visible during gameplay.
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
/// Draw-cycle indicator. Each cell is empty when not relevant; the
/// row collapses visually when all cells are empty.
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
/// Both penalty counters share `STATE_WARNING` (the audit found
/// they were inconsistent: Undos amber, Recycles white).
/// 4. **Selection** — keyboard-driven pile selector chip.
///
/// The audit identified the original single-row layout (10 readouts in
/// one horizontal flex row, 5+ colour families competing) as the
/// player's #1 complaint. This restructure groups by purpose, lets
/// transient items disappear cleanly, and uses the typography scale to
/// make Score the visual protagonist.
pub(super) fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
};
let font_lg = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_body = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
let row_node = || Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3,
// On a narrow viewport the four tier rows (Score/Moves/Timer,
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
// Auto-complete, selection chip) can collectively be wider than
// the available space and overflow into the action-button column
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
// a second line; on a desktop window the rows stay single-line
// because the parent column has no width cap and the row never
// exceeds the natural line width.
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_1,
align_items: AlignItems::Baseline,
..default()
};
commands
.spawn((
Node {
position_type: PositionType::Absolute,
left: VAL_SPACE_3,
top: Val::Px(SPACE_2),
flex_direction: FlexDirection::Column,
// Cap the column at 50% of viewport so on narrow
// (mobile) widths the inner tier rows have a bounded
// width to wrap against, and the column can't bleed
// into the right-anchored action button row (also
// capped at 50%). On desktop 50% of 1920 = 960 px,
// wider than any tier row's natural width, so the
// visible layout is unaffected.
max_width: Val::Percent(50.0),
row_gap: VAL_SPACE_1,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
HudColumn,
))
.with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
// Moves and Timer are supporting context (BODY_LG, secondary tone).
hud.spawn(row_node()).with_children(|t1| {
t1.spawn((
HudScore,
Tooltip::new("Points earned this game. Hidden in Zen mode."),
Text::new("Score: 0"),
font_score.clone(),
TextColor(TEXT_PRIMARY),
));
t1.spawn((
HudMoves,
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
Text::new("Moves: 0"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
));
t1.spawn((
HudTime,
Tooltip::new("Time on this game. Counts down in Time Attack."),
Text::new("0:00"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
));
});
// Tier 2 — mode context. Each cell is empty until update_hud
// populates it (and clears it when no longer relevant), so the
// row collapses when nothing in this tier applies.
hud.spawn(row_node()).with_children(|t2| {
t2.spawn((
HudMode,
Tooltip::new("Active game mode. Click Modes to switch."),
Text::new(""),
font_body.clone(),
TextColor(ACCENT_PRIMARY),
));
t2.spawn((
HudChallenge,
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
Text::new(""),
font_body.clone(),
TextColor(STATE_INFO),
));
t2.spawn((
HudDrawCycle,
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
Text::new(""),
font_body.clone(),
TextColor(STATE_INFO),
));
t2.spawn((
HudWonPreviously,
Tooltip::new("You've won this deal before. Same seed in your replay history."),
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
));
});
// Tier 3 — penalty / bonus. Undos and Recycles share the
// warning hue so they read as the same category ("you took a
// penalty"); the auto-complete badge stays success-green.
hud.spawn(row_node()).with_children(|t3| {
t3.spawn((
HudUndos,
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudRecycles,
Tooltip::new(
"Times you've recycled the stock. Three or more unlocks Comeback.",
),
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudAutoComplete,
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
));
});
// Tier 4 — selection chip. Stays in HUD for now; a future
// pass can reposition it next to the selected pile.
hud.spawn(row_node()).with_children(|t4| {
t4.spawn((
HudSelection,
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
Text::new(""),
font_body,
TextColor(ACCENT_SECONDARY),
));
});
});
}
/// Spawns the circular avatar / initials button anchored to the top-right
/// of the HUD band. Initial content is seeded from whatever resources are
/// available at startup; `update_hud_avatar` replaces the children whenever
/// `AvatarResource` or `SettingsResource` later changes.
pub(super) fn spawn_hud_avatar(
font_res: Option<Res<FontResource>>,
avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands,
) {
const SIZE: f32 = 32.0;
let id = commands
.spawn((
HudAvatar,
Button,
Tooltip::new("Your profile — tap to open."),
Node {
position_type: PositionType::Absolute,
top: Val::Px(SPACE_2),
right: VAL_SPACE_3,
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(ACCENT_PRIMARY),
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
))
.id();
spawn_avatar_child(
&mut commands,
id,
avatar.as_deref(),
settings.as_deref(),
font_res.as_deref(),
);
}
/// Re-spawns the avatar circle content (image or initials) whenever either
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
/// image arriving after download and the username changing after login.
pub(super) fn update_hud_avatar(
avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
q: Query<Entity, With<HudAvatar>>,
mut commands: Commands,
) {
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
if !avatar_changed && !settings_changed {
return;
}
let Ok(entity) = q.single() else {
return;
};
commands.entity(entity).despawn_related::<Children>();
spawn_avatar_child(
&mut commands,
entity,
avatar.as_deref(),
settings.as_deref(),
font_res.as_deref(),
);
}
/// Populates the avatar container with either the downloaded image or an
/// initials fallback disc. Called from both the startup spawn and the
/// reactive update system so the rendering logic lives in one place.
pub(super) fn spawn_avatar_child(
commands: &mut Commands,
parent: Entity,
avatar: Option<&AvatarResource>,
settings: Option<&SettingsResource>,
font_res: Option<&FontResource>,
) {
const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it.
commands
.entity(parent)
.insert(BackgroundColor(ACCENT_PRIMARY));
// Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| {
b.spawn((
ImageNode::new(handle),
Node {
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
..default()
},
));
});
} else {
let initial = settings
.and_then(|s| match &s.0.sync_backend {
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
SyncBackend::Local => None,
})
.and_then(|c| c.to_uppercase().next())
.unwrap_or('?');
// Real initial (logged in) keeps the red accent disc; the '?'
// unauthenticated fallback uses a neutral grey so it reads as a
// "tap to log in" affordance rather than an error.
let disc_bg = if initial == '?' {
BG_ELEVATED_HI
} else {
ACCENT_PRIMARY
};
commands.entity(parent).insert(BackgroundColor(disc_bg));
commands.entity(parent).with_children(|b| {
b.spawn((
Text::new(initial.to_string()),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 14.0,
..default()
},
TextColor(TEXT_PRIMARY),
));
});
}
}
/// Opens the Profile overlay when the avatar button is pressed.
pub(super) fn handle_avatar_button(
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
toggle_profile.write(ToggleProfileRequestEvent);
}
}
}
/// Spawns the action button bar anchored to the top-right of the window.
/// Each child is a clickable button mirroring a keyboard accelerator —
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
/// are the primary entry point and the hotkeys are optional.
///
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
/// because it's the most consequential action; the destructive button sits
/// on its own visual edge.
pub(super) fn spawn_action_buttons(
font_res: Option<Res<FontResource>>,
windows: Query<&Window>,
mut commands: Commands,
) {
let action_font_size =
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: action_font_size,
..default()
};
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
// Android reports it (frames 1-3); initial value is 0.0.
commands
.spawn((
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(0.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
column_gap: ACTION_BAR_COLUMN_GAP,
row_gap: VAL_SPACE_2,
align_items: AlignItems::Center,
padding: UiRect {
left: VAL_SPACE_3,
right: VAL_SPACE_3,
top: VAL_SPACE_2,
bottom: VAL_SPACE_2,
},
..default()
},
BackgroundColor(BG_HUD_BAND),
ZIndex(Z_HUD),
SafeAreaAnchoredBottom { base_bottom: 0.0 },
HudActionBar,
))
.with_children(|row| {
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(
row,
MenuButton,
ACTION_BAR_LABELS[0],
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
&font,
0,
TEXT_SECONDARY,
);
spawn_action_button(
row,
UndoButton,
ACTION_BAR_LABELS[1],
Some("U"),
"Take back your last move. Costs points and blocks No Undo.",
&font,
1,
TEXT_PRIMARY,
);
spawn_action_button(
row,
PauseButton,
ACTION_BAR_LABELS[2],
Some("Esc"),
"Pause the game and freeze the timer.",
&font,
2,
TEXT_PRIMARY,
);
spawn_action_button(
row,
HelpButton,
ACTION_BAR_LABELS[3],
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
&font,
3,
TEXT_SECONDARY,
);
spawn_action_button(
row,
HintButton,
ACTION_BAR_LABELS[4],
Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
&font,
4,
TEXT_SECONDARY,
);
spawn_action_button(
row,
ModesButton,
ACTION_BAR_LABELS[5],
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font,
5,
TEXT_SECONDARY,
);
spawn_action_button(
row,
NewGameButton,
ACTION_BAR_LABELS[6],
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
&font,
6,
TEXT_SECONDARY,
);
});
}
/// Spawns a single action button as a child of `row`. Each button shares
/// the same node geometry, idle colour, and `ActionButton` marker so
/// `paint_action_buttons` can recolour all of them with one query.
///
/// `order` is the button's index inside the action bar (0 for the
/// leftmost). It propagates into the [`Focusable`] this function inserts
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
///
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
/// action button ships with one — there is no opt-out — because each button
/// represents a player-triggered action and benefits from a one-line
/// reminder of what it does.
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_action_button<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
hotkey: Option<&'static str>,
tooltip: &'static str,
font: &TextFont,
order: i32,
text_color: Color,
) {
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
// touch device — the button itself is the affordance — and they
// visibly clutter the narrow-viewport action row. The chevrons on
// Menu/Modes remain because they indicate dropdown behaviour.
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
hotkey
} else {
None
};
let hotkey_font = TextFont {
font: font.font.clone(),
font_size: TYPE_CAPTION,
..default()
};
let (pad, min_w, min_h) = action_button_metrics();
row.spawn((
marker,
ActionButton,
Button,
Tooltip::new(tooltip),
Focusable {
group: FocusGroup::Hud,
order,
},
Node {
padding: pad,
min_width: min_w,
min_height: min_h,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
column_gap: VAL_SPACE_2,
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
spawn_action_button_label(b, label, font, text_color);
if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label —
// keeps the keyboard accelerator discoverable without
// hijacking the button's primary affordance.
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
}
});
}
-901
View File
@@ -1,901 +0,0 @@
use super::*;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use chrono::Local;
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(HudPlugin);
app.update();
app
}
#[test]
fn hud_plugin_registers_without_panic() {
let _app = headless_app();
}
#[test]
fn update_hud_runs_after_game_mutation_without_panic() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawStockConfig::DrawOne);
app.update();
}
fn read_hud_text<M: Component>(app: &mut App) -> String {
app.world_mut()
.query_filtered::<&Text, With<M>>()
.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
#[test]
fn score_reflects_game_state() {
let mut app = headless_app();
let score = app
.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(20);
app.update();
assert_eq!(
read_hud_text::<HudScore>(&mut app),
format!("Score: {score}")
);
}
#[test]
fn moves_reflects_game_state() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(42);
app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
}
#[test]
fn draw_three_mode_shows_draw_3_badge() {
use solitaire_core::game_state::GameMode;
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
app.update();
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
}
#[test]
fn zen_mode_hides_score() {
use solitaire_core::game_state::GameMode;
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
app.update();
// Zen mode spec: "No score display" → text must be empty.
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
}
#[test]
fn time_display_uses_mm_ss_format() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.elapsed_seconds = 125;
app.update();
// 125 seconds = 2 minutes 5 seconds → "2:05"
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
}
// -----------------------------------------------------------------------
// format_time_limit (pure function)
// -----------------------------------------------------------------------
#[test]
fn format_time_limit_300_is_5_00() {
assert_eq!(format_time_limit(300), "5:00");
}
#[test]
fn format_time_limit_zero() {
assert_eq!(format_time_limit(0), "0:00");
}
#[test]
fn format_time_limit_pads_seconds() {
assert_eq!(format_time_limit(65), "1:05");
}
// -----------------------------------------------------------------------
// challenge_hud_text (pure function)
// -----------------------------------------------------------------------
#[test]
fn challenge_hud_text_shows_time_limit() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: Some(300),
};
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
}
#[test]
fn challenge_hud_text_shows_score_goal() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: Some(4000),
max_time_secs: None,
};
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
}
#[test]
fn challenge_hud_text_empty_when_no_constraints() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: None,
};
assert_eq!(challenge_hud_text(&dc), "");
}
#[test]
fn challenge_time_color_above_60_is_info() {
let c = challenge_time_color(61);
assert_eq!(c, STATE_INFO);
}
#[test]
fn challenge_time_color_exactly_60_is_info() {
let c = challenge_time_color(60);
assert_eq!(c, STATE_INFO);
}
#[test]
fn challenge_time_color_59_is_warning() {
let c = challenge_time_color(59);
assert_eq!(c, STATE_WARNING);
}
#[test]
fn challenge_time_color_30_is_warning() {
let c = challenge_time_color(30);
assert_eq!(c, STATE_WARNING);
}
#[test]
fn challenge_time_color_29_is_danger() {
let c = challenge_time_color(29);
assert_eq!(c, STATE_DANGER);
}
#[test]
fn challenge_time_color_zero_is_danger() {
let c = challenge_time_color(0);
assert_eq!(c, STATE_DANGER);
}
// -----------------------------------------------------------------------
// HudChallenge in-app tests
// -----------------------------------------------------------------------
#[test]
fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
#[test]
fn challenge_hud_shows_time_limit_when_resource_present() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: Some("Win fast".to_string()),
target_score: None,
max_time_secs: Some(300),
});
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
}
#[test]
fn challenge_hud_shows_score_goal_when_resource_present() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: None,
target_score: Some(4000),
max_time_secs: None,
});
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
}
#[test]
fn challenge_hud_clears_on_win() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: None,
target_score: None,
max_time_secs: Some(300),
});
// Mark the game as won — HudChallenge should be empty.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_won(true);
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
// -----------------------------------------------------------------------
// HudUndos in-app tests
// -----------------------------------------------------------------------
#[test]
fn undos_hud_empty_at_game_start() {
let mut app = headless_app();
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
}
#[test]
fn undos_hud_shows_count_after_undo() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_undos(3);
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
}
// -----------------------------------------------------------------------
// HudAutoComplete in-app tests (Task #56)
// -----------------------------------------------------------------------
fn headless_app_with_auto_complete() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(HudPlugin);
app.init_resource::<AutoCompleteState>();
app.update();
app
}
#[test]
fn auto_complete_badge_shows_auto_when_active() {
let mut app = headless_app_with_auto_complete();
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
// Also trigger game state change so the update fires.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
}
#[test]
fn auto_complete_badge_empty_when_inactive() {
let mut app = headless_app_with_auto_complete();
// active is false by default.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
}
// -----------------------------------------------------------------------
// HudRecycles in-app tests
// -----------------------------------------------------------------------
#[test]
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
let mut app = headless_app();
// Draw-One, no recycles yet — text must be empty.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawStockConfig::DrawOne);
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
}
#[test]
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
let mut app = headless_app();
// Draw-Three, no recycles yet — text must also be empty.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawStockConfig::DrawThree);
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
}
#[test]
fn recycles_hud_shows_count_draw_three() {
let mut app = headless_app();
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
gs.force_test_recycles(3);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
}
#[test]
fn recycles_hud_shows_count_draw_one() {
let mut app = headless_app();
// Draw-One with recycle_count > 0 must now show the counter too.
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
gs.force_test_recycles(2);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
}
// -----------------------------------------------------------------------
// Score-change feedback (G2)
// -----------------------------------------------------------------------
/// Tells `TimePlugin` to advance by `secs` on every subsequent
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
/// local to avoid coupling the two test modules.
fn set_manual_time_step(app: &mut App, secs: f32) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
secs,
)));
}
/// Counts entities matching component `M` currently in the world.
fn count_with<M: Component>(app: &mut App) -> usize {
app.world_mut().query::<&M>().iter(app.world()).count()
}
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
/// component is also inserted on the score readout — both signals
/// fire from the same delta detection.
#[test]
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
let mut app = headless_app();
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
// can be asserted exactly: with Automatic strategy a few ms
// of wall-clock time leaks in between updates and the alpha
// drifts below 1.0 by `dt / lifetime`.
set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
// One floater should now exist.
let count = count_with::<ScoreFloater>(&mut app);
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
// detect system spawns the floater coloured ACCENT_PRIMARY
// and at dt=0 the first advance tick leaves alpha = 1.0.
let world = app.world_mut();
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
assert_eq!(color.0, ACCENT_PRIMARY);
}
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
/// the floater has reached the end of its lifetime and despawned.
#[test]
fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
// Advance by a delta well past the floater's lifetime — the
// single oversized tick clamps t at 1.0 and the entity is
// despawned in the same `Update`.
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
app.update();
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"floater should have despawned after its full lifetime"
);
}
/// A small score change (below the threshold) inserts a pulse on
/// the readout but never spawns a floater — keeping the floating
/// "+N" reserved for meaningful score jumps.
#[test]
fn score_increase_below_threshold_does_not_spawn_floater() {
let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(5);
app.update();
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"delta of +5 must not spawn a floater"
);
}
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
/// returns to 1.0 at the endpoints. Pure-function check that
/// guards the curve shape against future tweaks.
#[test]
fn score_pulse_scale_is_triangular() {
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
// Values outside [0,1] are clamped before the curve runs.
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
}
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
/// curve test — the two animations share a triangular shape so a
/// future tweak that desyncs them shows up here.
#[test]
fn streak_flourish_scale_curves_through_one_one_one() {
let dur = MOTION_STREAK_FLOURISH_SECS;
assert!(
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
"streak flourish scale at t=0 must be 1.0",
);
assert!(
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
);
assert!(
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
"streak flourish scale at t=duration must return to 1.0",
);
}
/// Out-of-range values are clamped, not extrapolated. Matches the
/// foundation flourish's clamp behaviour so the score readout never
/// freezes at a non-1.0 scale on the frame after the flourish ends.
#[test]
fn streak_flourish_scale_clamps_out_of_range() {
let dur = MOTION_STREAK_FLOURISH_SECS;
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
}
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
/// divides by zero.
#[test]
fn streak_flourish_scale_zero_duration_is_one() {
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
}
// -----------------------------------------------------------------------
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
// -----------------------------------------------------------------------
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
#[test]
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
use solitaire_data::Settings;
let mut app = headless_app();
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
0,
"ScorePulse must not spawn under reduce-motion"
);
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"ScoreFloater must not spawn under reduce-motion"
);
}
// -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — HUD action bar
// -----------------------------------------------------------------------
/// Returns the `Focusable` carried by the unique entity matching
/// marker `M`. Helper for the HUD focus tests.
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
app.world_mut()
.query_filtered::<&Focusable, With<M>>()
.iter(app.world())
.next()
.copied()
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
}
#[test]
fn hud_buttons_get_focusable_marker() {
let mut app = headless_app();
// Every action-bar button is in `FocusGroup::Hud`.
for f in [
focusable_for::<MenuButton>(&mut app),
focusable_for::<UndoButton>(&mut app),
focusable_for::<PauseButton>(&mut app),
focusable_for::<HelpButton>(&mut app),
focusable_for::<HintButton>(&mut app),
focusable_for::<ModesButton>(&mut app),
focusable_for::<NewGameButton>(&mut app),
] {
assert_eq!(
f.group,
FocusGroup::Hud,
"every HUD action button must be in FocusGroup::Hud"
);
}
}
/// Returns the tooltip string carried by the unique entity matching
/// marker `M`. Panics if zero or more than one such entity exists,
/// which is the invariant we want to enforce for HUD readouts and
/// action buttons (each marker is spawned exactly once).
fn tooltip_for<M: Component>(app: &mut App) -> String {
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
let world = app.world();
let mut iter = q.iter(world);
let first = iter
.next()
.unwrap_or_else(|| {
panic!(
"expected a Tooltip on the {} entity",
std::any::type_name::<M>()
)
})
.0
.clone()
.into_owned();
assert!(
iter.next().is_none(),
"expected exactly one Tooltip-bearing entity for {}",
std::any::type_name::<M>()
);
first
}
/// Every HUD readout and action button must spawn with a `Tooltip`
/// carrying the approved canonical microcopy. Mirrors the structure
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
/// invariant — one marker entity, one tooltip, exact text — is
/// asserted consistently across every element.
#[test]
fn hud_elements_carry_expected_tooltip_strings() {
let mut app = headless_app();
// HUD readouts (left column, top to bottom).
assert_eq!(
tooltip_for::<HudScore>(&mut app),
"Points earned this game. Hidden in Zen mode."
);
assert_eq!(
tooltip_for::<HudMoves>(&mut app),
"Moves you've made this game. Counts placements and stock draws."
);
assert_eq!(
tooltip_for::<HudTime>(&mut app),
"Time on this game. Counts down in Time Attack."
);
assert_eq!(
tooltip_for::<HudMode>(&mut app),
"Active game mode. Click Modes to switch."
);
assert_eq!(
tooltip_for::<HudChallenge>(&mut app),
"Today's daily challenge target. Beat it for bonus XP."
);
assert_eq!(
tooltip_for::<HudDrawCycle>(&mut app),
"Cards drawn on the next stock click in Draw-Three."
);
assert_eq!(
tooltip_for::<HudUndos>(&mut app),
"Undos used this game. Any undo blocks the No Undo achievement."
);
assert_eq!(
tooltip_for::<HudRecycles>(&mut app),
"Times you've recycled the stock. Three or more unlocks Comeback."
);
assert_eq!(
tooltip_for::<HudAutoComplete>(&mut app),
"Board is solvable from here. Press Enter to auto-finish."
);
assert_eq!(
tooltip_for::<HudSelection>(&mut app),
"Pile selected with Tab. Use arrows or Enter to act."
);
// Action bar (left to right).
assert_eq!(
tooltip_for::<MenuButton>(&mut app),
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
);
assert_eq!(
tooltip_for::<UndoButton>(&mut app),
"Take back your last move. Costs points and blocks No Undo."
);
assert_eq!(
tooltip_for::<PauseButton>(&mut app),
"Pause the game and freeze the timer."
);
assert_eq!(
tooltip_for::<HelpButton>(&mut app),
"Show controls, rules, and keyboard shortcuts."
);
assert_eq!(
tooltip_for::<HintButton>(&mut app),
"Highlight a suggested move. Cycles through alternatives on repeat taps."
);
assert_eq!(
tooltip_for::<ModesButton>(&mut app),
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
);
assert_eq!(
tooltip_for::<NewGameButton>(&mut app),
"Start a fresh deal. Confirms first if a game is in progress."
);
}
/// Every interior row of the Modes and Menu popovers must carry a
/// `Tooltip`. The popovers open from action-bar buttons whose own
/// tooltips are already covered above; this test extends the
/// invariant inward so hover discoverability is uniform across the
/// HUD's nested controls.
///
/// We invoke the popover spawn helpers directly with a maxed-out
/// `ProgressResource` and a `DailyChallengeResource` so every row
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
/// Headless click simulation isn't needed — the contract under
/// test is "every popover row spawns with a tooltip", which is a
/// property of the spawn helpers themselves.
#[test]
fn popover_rows_carry_tooltip_strings() {
use crate::progress_plugin::ProgressResource;
use solitaire_sync::progress::PlayerProgress;
let mut app = headless_app();
// Force every mode row to render: level past the challenge
// unlock threshold, plus a daily challenge resource so the
// Daily row appears.
let progress = ProgressResource(PlayerProgress {
level: CHALLENGE_UNLOCK_LEVEL,
..Default::default()
});
let daily = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: None,
};
// Spawn both popovers via their helpers. Mirrors how the click
// handlers invoke them in production — we just skip the click.
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
spawn_menu_popover(&mut commands, None);
world.flush();
}
app.update();
// Every ModeOption-tagged entity must also carry a Tooltip,
// and the count must match the five canonical modes.
let mut mode_q = app
.world_mut()
.query_filtered::<&Tooltip, With<ModeOption>>();
let mode_tooltips: Vec<String> = mode_q
.iter(app.world())
.map(|t| t.0.clone().into_owned())
.collect();
assert_eq!(
mode_tooltips.len(),
5,
"expected a tooltip on each of the 5 mode rows, got {}",
mode_tooltips.len()
);
// Every approved mode tooltip string must be present somewhere
// among the ModeOption rows. Order isn't asserted — the spawn
// order test elsewhere already covers that.
for expected in [
"Standard Klondike. Score, timer, and full progression.",
"Today's seeded deal. Same for every player worldwide.",
"No timer, no score, no penalties. Just play.",
"Hand-picked hard seeds. No undo allowed.",
"Win as many games as you can in ten minutes.",
] {
assert!(
mode_tooltips.iter().any(|s| s == expected),
"missing mode tooltip: {expected:?}"
);
}
// Same contract for MenuOption rows: seven entries, each with a
// tooltip, exact strings matching the approved microcopy.
let mut menu_q = app
.world_mut()
.query_filtered::<&Tooltip, With<MenuOption>>();
let menu_tooltips: Vec<String> = menu_q
.iter(app.world())
.map(|t| t.0.clone().into_owned())
.collect();
assert_eq!(
menu_tooltips.len(),
7,
"expected a tooltip on each of the 7 menu rows, got {}",
menu_tooltips.len()
);
for expected in [
"Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.",
"Audio, animations, theme, draw mode, and sync.",
"Top players from your sync server. Opt in from Profile.",
] {
assert!(
menu_tooltips.iter().any(|s| s == expected),
"missing menu tooltip: {expected:?}"
);
}
}
#[test]
fn hud_button_order_matches_spawn_order() {
let mut app = headless_app();
// Visual reading order (left → right): Menu, Undo, Pause, Help,
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
// that order so Tab cycles them as the player reads them.
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
}
#[test]
fn hud_focus_only_engages_when_button_hovered() {
// Phase 2 declares membership in `FocusGroup::Hud`; the
// engagement rule lives in `handle_focus_keys`. Two halves to
// this test:
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
// still holds when nothing is hovered).
// (b) no modal + a HUD button hovered ⇒ Tab advances
// `FocusedButton` to a Hud-grouped entity.
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(HudPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
// (a) Sanity: HUD buttons exist and are focusable, but no
// modal open and no hover ⇒ FocusedButton stays None.
assert!(
app.world().resource::<FocusedButton>().0.is_none(),
"no modal open, no auto-focus"
);
// Press Tab. With no modal and no hover, `handle_focus_keys`
// resolves no active group and returns early — Tab must not
// advance the HUD focus ring on its own.
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release_all();
input.clear();
input.press(KeyCode::Tab);
}
app.update();
assert!(
app.world().resource::<FocusedButton>().0.is_none(),
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
);
// (b) Hover the Menu button — the leftmost HUD action — and
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
let menu_entity = app
.world_mut()
.query_filtered::<Entity, With<MenuButton>>()
.iter(app.world())
.next()
.expect("MenuButton entity should exist");
app.world_mut()
.entity_mut(menu_entity)
.insert(Interaction::Hovered);
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release_all();
input.clear();
input.press(KeyCode::Tab);
}
app.update();
let focused = app
.world()
.resource::<FocusedButton>()
.0
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
// The focused entity must itself be Hud-grouped (i.e. one of
// the action-bar buttons), not anything else in the world.
let focusable = app
.world()
.entity(focused)
.get::<Focusable>()
.expect("focused entity must carry Focusable");
assert_eq!(
focusable.group,
FocusGroup::Hud,
"Hud-engaged Tab must focus a Hud-grouped entity"
);
}
-611
View File
@@ -1,611 +0,0 @@
//! Per-frame HUD text/typography/visibility updater systems.
use super::*;
use bevy::window::WindowResized;
use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState;
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
///
/// For example `format_time_limit(300)` returns `"5:00"`.
pub fn format_time_limit(secs: u64) -> String {
let m = secs / 60;
let s = secs % 60;
format!("{m}:{s:02}")
}
// ---------------------------------------------------------------------------
// Score-change feedback (G2)
//
// The flow for each Update tick:
// 1. `detect_score_change` diffs `GameStateResource.score` against
// `PreviousScore`. On any positive delta it inserts/refreshes
// `ScorePulse` on the score readout; on a delta ≥
// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
// anchored just below the score.
// 2. `advance_score_pulse` ticks the pulse component, applies the
// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
// component on completion.
// 3. `advance_score_floater` drifts each floater upward, fades it to
// transparent, and despawns it when its lifetime expires.
//
// The threshold of 50 (a foundation promotion's typical bonus) keeps
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
// ---------------------------------------------------------------------------
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
/// current deal's seed + draw_mode + mode triple matches an entry in
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
/// (the on-screen "Game won!" cue already conveys victory) and on
/// fresh deals the player hasn't won before.
///
/// Lives in its own system rather than `update_hud` to keep this
/// orthogonal: `update_hud`'s query disambiguation is already busy
/// enough; threading another marker through every Without filter
/// would touch ~10 unrelated queries for no benefit.
pub(super) fn update_won_previously(
game: Res<GameStateResource>,
// Optional because the HUD plugin's headless tests run without
// `StatsPlugin` and therefore without this resource. With the
// resource absent there's no history to compare against; the
// indicator just stays empty.
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut q: Query<&mut Text, With<HudWonPreviously>>,
) {
let Ok(mut text) = q.single_mut() else {
return;
};
let won_before = !game.0.is_won()
&& history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| {
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
})
});
let next = if won_before {
"\u{2713} Won before"
} else {
""
};
if text.0 != next {
text.0 = next.to_string();
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn update_hud(
game: Res<GameStateResource>,
time_attack: Option<Res<TimeAttackResource>>,
daily: Option<Res<DailyChallengeResource>>,
auto_complete: Option<Res<AutoCompleteState>>,
mut score_q: Query<
&mut Text,
(
With<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut moves_q: Query<
&mut Text,
(
With<HudMoves>,
Without<HudScore>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut time_q: Query<
&mut Text,
(
With<HudTime>,
Without<HudScore>,
Without<HudMoves>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut mode_q: Query<
&mut Text,
(
With<HudMode>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut challenge_q: Query<
(&mut Text, &mut TextColor),
(
With<HudChallenge>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut undos_q: Query<
(&mut Text, &mut TextColor),
(
With<HudUndos>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut auto_q: Query<
&mut Text,
(
With<HudAutoComplete>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut recycles_q: Query<
&mut Text,
(
With<HudRecycles>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut draw_cycle_q: Query<
&mut Text,
(
With<HudDrawCycle>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudSelection>,
),
>,
) {
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
// Score, moves, mode, challenge, and undos only need updating when game state changes.
if game.is_changed() {
let g = &game.0;
let is_zen = g.mode == GameMode::Zen;
if let Ok(mut t) = score_q.single_mut() {
// Zen mode suppresses score display per spec ("No score display").
**t = if is_zen {
String::new()
} else {
format!("Score: {}", g.score())
};
}
if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count());
}
if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode {
GameMode::Classic => match g.draw_mode() {
DrawStockConfig::DrawOne => String::new(),
DrawStockConfig::DrawThree => "Draw 3".to_string(),
},
GameMode::Zen => "ZEN".to_string(),
GameMode::Challenge => "CHALLENGE".to_string(),
GameMode::TimeAttack => "TIME ATTACK".to_string(),
GameMode::Difficulty(level) => level.label().to_uppercase(),
};
}
// --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won() {
**t = String::new();
} else if let Some(dc) = daily.as_deref() {
**t = challenge_hud_text(dc);
if let Some(max_secs) = dc.max_time_secs {
let remaining = max_secs.saturating_sub(g.elapsed_seconds);
*color = TextColor(challenge_time_color(remaining));
}
} else {
**t = String::new();
}
}
// --- Undo count ---
if let Ok((mut t, mut color)) = undos_q.single_mut() {
let count = g.undo_count();
if count == 0 {
**t = String::new();
*color = TextColor(TEXT_PRIMARY);
} else {
**t = format!("Undos: {count}");
// STATE_WARNING signals "you took a penalty" — same hue
// as the Recycles counter so they read as one category.
*color = TextColor(STATE_WARNING);
}
}
// --- Recycle counter (both modes, hidden until first recycle) ---
if let Ok(mut t) = recycles_q.single_mut() {
**t = if g.recycle_count() > 0 {
format!("Recycles: {}", g.recycle_count())
} else {
String::new()
};
}
// --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
// Hide when not in Draw-Three or after the game is won.
String::new()
} else {
let stock_len = g.stock_cards().len();
let next_draw = stock_len.min(3);
format!("Cycle: {next_draw}/3")
};
}
}
// Time display: show Time Attack countdown every frame when active;
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
// every frame so it disappears immediately on the frame Z is pressed.
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
let is_zen = game.0.mode == GameMode::Zen;
let update_time = (ta_active || game.is_changed()) && !is_zen;
if update_time {
if let Ok(mut t) = time_q.single_mut() {
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
let remaining = ta.remaining_secs.max(0.0) as u64;
let m = remaining / 60;
let s = remaining % 60;
**t = format!("{m}:{s:02}");
} else {
let secs = game.0.elapsed_seconds;
let m = secs / 60;
let s = secs % 60;
**t = format!("{m}:{s:02}");
}
}
} else if is_zen {
// Clear the time display immediately whenever Zen mode is active —
// do not guard on game.is_changed() so it clears on the same frame
// the player presses Z, before any move is made.
if let Ok(mut t) = time_q.single_mut() {
**t = String::new();
}
}
// --- Auto-complete badge ---
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
if (ac_changed || game.is_changed())
&& let Ok(mut t) = auto_q.single_mut()
{
**t = if ac_active {
"AUTO".to_string()
} else {
String::new()
};
}
}
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
///
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
/// or an empty string when no pile is selected. Runs every frame so the
/// indicator stays in sync with the selection resource.
pub(super) fn update_selection_hud(
selection: Option<Res<SelectionState>>,
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<HudSelection>>,
) {
let Ok(mut t) = q.single_mut() else { return };
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
None => String::new(),
Some(KlondikePile::Stock) => "▶ Waste".to_string(),
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
Some(g) => foundation_selection_label(*slot, &g.0),
// No game resource means we can't probe claimed_suit; show the
// slot-based placeholder so the HUD still surfaces the selection.
None => format!("▶ Foundation {}", foundation_number(*slot)),
},
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
};
**t = label;
}
/// Returns the HUD selection label for a foundation slot.
///
/// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
pub(super) fn foundation_selection_label(
slot: Foundation,
game: &solitaire_core::game_state::GameState,
) -> String {
let claimed = game
.pile(KlondikePile::Foundation(slot))
.first()
.map(|c| c.0.suit());
match claimed {
Some(suit) => {
let s = match suit {
Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds",
Suit::Hearts => "Hearts",
Suit::Spades => "Spades",
};
format!("{s} Foundation")
}
None => format!("▶ Foundation {}", foundation_number(slot)),
}
}
const fn foundation_number(foundation: Foundation) -> u8 {
match foundation {
Foundation::Foundation1 => 1,
Foundation::Foundation2 => 2,
Foundation::Foundation3 => 3,
Foundation::Foundation4 => 4,
}
}
const fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
Tableau::Tableau2 => 2,
Tableau::Tableau3 => 3,
Tableau::Tableau4 => 4,
Tableau::Tableau5 => 5,
Tableau::Tableau6 => 6,
Tableau::Tableau7 => 7,
}
}
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
/// to debounce so the toast only appears on the leading edge.
pub(super) fn announce_auto_complete(
auto_complete: Option<Res<AutoCompleteState>>,
mut toast: MessageWriter<InfoToastEvent>,
mut was_active: Local<bool>,
) {
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
if now_active && !*was_active {
toast.write(InfoToastEvent("Auto-completing...".to_string()));
}
*was_active = now_active;
}
/// Builds the HUD text for the active daily challenge constraints.
///
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
/// score target is set, or an empty string when the challenge has no extra
/// constraints.
pub(super) fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
if let Some(secs) = dc.max_time_secs {
format!("Limit: {}", format_time_limit(secs))
} else if let Some(score) = dc.target_score {
format!("Goal: {score} pts")
} else {
String::new()
}
}
/// Returns the colour for the challenge time-limit HUD label based on
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
/// palette changes for free.
///
/// | Remaining | Token |
/// |-------------|------------------|
/// | ≥ 60 s | `STATE_INFO` |
/// | 30 59 s | `STATE_WARNING` |
/// | < 30 s | `STATE_DANGER` |
pub fn challenge_time_color(remaining: u64) -> Color {
if remaining < 30 {
STATE_DANGER
} else if remaining < 60 {
STATE_WARNING
} else {
STATE_INFO
}
}
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
///
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
/// all three items remain on one row inside the 50 %-wide HUD column
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
/// restored so desktop/tablet layouts are unaffected.
type HudScoreFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
type HudMovesFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
type HudTimeFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
pub(super) fn update_hud_typography(
mut events: MessageReader<WindowResized>,
mut score_q: HudScoreFont,
mut moves_q: HudMovesFont,
mut time_q: HudTimeFont,
) {
let Some(ev) = events.read().last() else {
return;
};
let (score_size, secondary_size) = if ev.width < 480.0 {
(TYPE_BODY_LG, TYPE_CAPTION)
} else {
(TYPE_HEADLINE, TYPE_BODY_LG)
};
for mut font in &mut score_q {
font.font_size = score_size;
}
for mut font in &mut moves_q {
font.font_size = secondary_size;
}
for mut font in &mut time_q {
font.font_size = secondary_size;
}
}
pub(super) fn apply_hud_visibility(
hud_vis: Res<HudVisibility>,
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
) {
if !hud_vis.is_changed() {
return;
}
let v = if *hud_vis == HudVisibility::Visible {
Visibility::Visible
} else {
Visibility::Hidden
};
for mut vis in &mut action_bar {
*vis = v;
}
// The bottom action bar is a pure overlay — it does not claim any
// space in the card layout, so no WindowResized event is needed.
}
pub(super) fn restore_hud_on_modal(
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
mut hud_vis: ResMut<HudVisibility>,
) {
if !new_scrims.is_empty() {
*hud_vis = HudVisibility::Visible;
}
}
/// Returns the action-bar label font size for a given logical window width.
pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
if USE_TOUCH_UI_LAYOUT {
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
// must share one row. The widest characters are in FiraMono (a
// monospace whose advance is ~0.62 of the font size). On a 900
// logical-px phone the row budget after bar padding (2*12) and six
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
// labels are advance-bound only on very narrow viewports; the real
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
// at 900 px — comfortably one row with margin to spare — clamped so it
// never drops below the 12 px legibility floor or grows past 18 px on
// landscape tablets where it would crowd the row again.
(window_width / 60.0).clamp(12.0, 18.0)
} else {
TYPE_BODY
}
}
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
if USE_TOUCH_UI_LAYOUT {
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
// shortest labels ("New", "Help") shrink to their text rather than
// padding the row out past the 900 logical-px viewport. min_height
// stays at 44 px to preserve the comfortable touch target.
(
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
Val::Px(44.0),
Val::Px(44.0),
)
} else {
(
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
Val::Px(48.0),
Val::Px(48.0),
)
}
}
pub(super) fn spawn_action_button_label(
parent: &mut ChildSpawnerCommands,
label: &str,
font: &TextFont,
text_color: Color,
) {
if USE_TOUCH_UI_LAYOUT {
parent.spawn((
ActionButtonLabel,
Text::new(label),
font.clone(),
TextColor(text_color),
));
} else {
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
}
}
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
/// current viewport width whenever [`LayoutResource`] changes (orientation
/// change or window resize).
#[cfg(target_os = "android")]
pub(super) fn resize_action_bar_labels(
layout: Res<crate::layout::LayoutResource>,
windows: Query<&Window>,
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
) {
let w = windows
.iter()
.next()
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
let new_size = action_bar_font_size(w);
for mut font in &mut labels {
font.font_size = new_size;
}
}
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