Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbe2addc30 | |||
| c286593415 | |||
| ddba5c0b26 | |||
| 5b37f35eb8 | |||
| d1e87765af | |||
| b3b53c4adf | |||
| ba76936aba | |||
| 59ba7ba4c3 | |||
| 1ca1efb3b6 | |||
| c5ad487256 | |||
| 8274581edf | |||
| 704e70f60a | |||
| b81b314197 | |||
| a2375d2fd9 | |||
| 3f5e4f4290 | |||
| 0797e9a993 | |||
| c53b42342b | |||
| 123aa6d099 | |||
| 331d932c4b | |||
| 716e5f04cf | |||
| ef9d914b99 | |||
| 005efa2ee4 | |||
| 1190ed3ce6 | |||
| 2869e1c34e | |||
| 783f01628e | |||
| 94a6feb5db | |||
| 3daaf47689 | |||
| c00656ec8f | |||
| aea2167eee | |||
| 0ed91ea24c | |||
| 18af49c0f3 | |||
| e208245036 | |||
| 0247efcb07 | |||
| 1d5266a811 | |||
| 26283b5478 | |||
| 3627e9f9cf | |||
| b81a79c51c | |||
| 968721eeb4 | |||
| 780e82ca4b | |||
| 207747db4b | |||
| c66baceb10 | |||
| 329f224ffd | |||
| 2fc190ee42 | |||
| 060efaee7b | |||
| ef599ffa17 | |||
| 22334e0dd5 | |||
| 942b9c2161 | |||
| fde863a4e4 | |||
| 0cf5fc4293 | |||
| 79ddfbc034 | |||
| 7919365775 | |||
| f88b6f61d0 | |||
| 189e0afd24 | |||
| a32d666751 | |||
| a5902ac0af | |||
| c3b83f30d1 | |||
| a46505fe45 | |||
| 1602f1952d | |||
| 81893788c1 | |||
| c232444ef0 | |||
| 56e05caaa9 | |||
| 090b5e789e | |||
| 1e0619897a | |||
| 1b5dfa3e27 | |||
| 4850e9417e | |||
| 8a2a22ff1b | |||
| b3b282ec2d | |||
| 7b5d69e164 | |||
| 923a67dc7b | |||
| d4ad184324 | |||
| 0b7a24a75b | |||
| fc45b6d261 | |||
| 2328643223 | |||
| 5b2b234c54 | |||
| 9c473d6a51 | |||
| b3a8575bbd | |||
| 5091d1b397 | |||
| da165f1622 | |||
| 43076a48d6 | |||
| 5af69d7551 | |||
| 6c9259beff | |||
| dde65a7e30 | |||
| e3b8a403ef | |||
| 9299176b2d | |||
| 6a9352cde1 | |||
| 8995a8ae9c | |||
| 0d5c9cdb1d | |||
| ceb9c950a1 | |||
| 9bbb57134f | |||
| e0a858d4e8 | |||
| 5c992cbdca | |||
| d045781119 | |||
| f0871c03e8 | |||
| e841a7ab4f | |||
| 424c8b2d50 | |||
| 372b6423d8 | |||
| 9e3c6b06b0 | |||
| f0832f3dfa | |||
| ef1efdc3b5 | |||
| dc4cf45ea0 | |||
| 0d3f037672 | |||
| cac77a54a6 | |||
| 2d0359c2ee | |||
| 056459619b | |||
| 1438fd6265 | |||
| 920f2c8597 | |||
| 37a21b9b42 | |||
| 712ed6be80 | |||
| 324003562b | |||
| a69a774edf | |||
| df4887fb36 | |||
| 159774f811 | |||
| b3c4d08dfc | |||
| f313cfd8b7 | |||
| 7fe6ac6c1c | |||
| 6193d31497 | |||
| 26f1b00186 | |||
| 56e3b62269 | |||
| 9bcf13d8f2 | |||
| 7dbf34c163 | |||
| 7fa91b6fb4 | |||
| becfda0f6c | |||
| fa786bafcf | |||
| d864d985c8 | |||
| ae1ecc8559 | |||
| 5e8735886f | |||
| 8bd2fb89eb | |||
| 2b1ad2161a | |||
| 2cf728210e | |||
| 8b262afcd2 | |||
| 8b736cae3c | |||
| de7ae16830 | |||
| d45b7cb82b | |||
| 763fdb486f | |||
| 1cdb78caf2 | |||
| baf524ec75 | |||
| 9ff0585454 | |||
| 64f975ed6d | |||
| 20e5222148 | |||
| 44e90ff582 | |||
| 0bae839e3b | |||
| c68cf96488 | |||
| a92ac066a6 | |||
| f464aab543 | |||
| 835a48fe9d | |||
| 9260ca7994 | |||
| ca612f51f1 | |||
| dba154cf92 | |||
| 258abd198e | |||
| 389fdd1fb0 | |||
| 6309d3325f | |||
| 862f7e4b48 | |||
| 6496e130f3 | |||
| d4796fa252 | |||
| 57c4b5aacf | |||
| f1914b4398 | |||
| 0a6eb8c610 | |||
| bb92bb333b | |||
| 38e4c0341e | |||
| ccf280ea50 |
@@ -0,0 +1,5 @@
|
|||||||
|
[registries.Quaternions]
|
||||||
|
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||||
|
|
||||||
|
[target.wasm32-unknown-unknown]
|
||||||
|
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
|
||||||
@@ -6,10 +6,14 @@ on:
|
|||||||
branches: [master]
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'solitaire_server/**'
|
- 'solitaire_server/**'
|
||||||
|
- 'solitaire_wasm/**'
|
||||||
|
- 'solitaire_web/**'
|
||||||
- 'solitaire_sync/**'
|
- 'solitaire_sync/**'
|
||||||
- 'solitaire_core/**'
|
- 'solitaire_core/**'
|
||||||
|
- 'solitaire_engine/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
|
- 'solitaire_server/Dockerfile'
|
||||||
- '.gitea/workflows/docker-build.yml'
|
- '.gitea/workflows/docker-build.yml'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -32,6 +36,13 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
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
|
- name: Log in to Gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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
@@ -15,6 +15,11 @@ agentdb.rvf.lock
|
|||||||
# IDE project files
|
# IDE project files
|
||||||
.idea/
|
.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
|
# Android signing keystores — never commit
|
||||||
*.jks
|
*.jks
|
||||||
*.jks.bak
|
*.jks.bak
|
||||||
@@ -25,3 +30,14 @@ agentdb.rvf.lock
|
|||||||
deploy/matomo-secret.yaml
|
deploy/matomo-secret.yaml
|
||||||
deploy/*-secret.yaml
|
deploy/*-secret.yaml
|
||||||
deploy/*-auth-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
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ Owns:
|
|||||||
- Achievement unlock condition evaluation
|
- Achievement unlock condition evaluation
|
||||||
- Seeded RNG for reproducible deals
|
- Seeded RNG for reproducible deals
|
||||||
|
|
||||||
|
**Rules decisions:**
|
||||||
|
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
||||||
|
passes through the stock are discouraged via the upstream score penalty
|
||||||
|
(applied by the `card_game`/`klondike` session), never blocked with a
|
||||||
|
`MoveError`. This matches mainstream digital solitaire (unlimited redeals
|
||||||
|
in Draw-1) rather than strict tournament rules (3-pass cap). It is load-
|
||||||
|
bearing: the difficulty seed catalog and the winnable-deal solver are
|
||||||
|
verified under unlimited recycling, so introducing a hard pass limit would
|
||||||
|
invalidate both. Locked in by the
|
||||||
|
`draw_one_recycling_is_unlimited_by_design` test in
|
||||||
|
`solitaire_core/src/game_state.rs`. (Decision record: Gitea issue #117.)
|
||||||
|
|
||||||
### `solitaire_sync`
|
### `solitaire_sync`
|
||||||
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
||||||
|
|
||||||
|
|||||||
+314
@@ -6,6 +6,320 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.41.0] — 2026-07-06
|
||||||
|
|
||||||
|
> Consolidates everything shipped since v0.39.0, including the v0.40.0–v0.40.3
|
||||||
|
> patch tags (which were cut from this section without renaming it at the time).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Rules decision record: unlimited stock recycling.** Documented in
|
||||||
|
`ARCHITECTURE.md` that unlimited recycling with score penalties (matching
|
||||||
|
mainstream digital solitaire) is intentional, and locked it in with a core
|
||||||
|
test — the difficulty seed catalog and winnable-deal solver are verified
|
||||||
|
under this rule. Resolves the last open finding from the June 500-game
|
||||||
|
audit (issue #117).
|
||||||
|
- **Analytics validation runbook.** Documented native Matomo live validation,
|
||||||
|
expected event payloads, and the current web/WASM analytics split.
|
||||||
|
- **Android smoke-test runbook.** Updated the Android doc with the current
|
||||||
|
platform status, support matrix, and a physical-device
|
||||||
|
launch/touch/safe-area checklist.
|
||||||
|
- **Browser Bevy canvas route and automation support.** Added the `solitaire_web`
|
||||||
|
Bevy WASM build, wired `/play` to the Bevy canvas, added a
|
||||||
|
`window.__FERROUS_DEBUG__` bridge, and introduced Playwright coverage for the
|
||||||
|
web routes and interactive canvas behavior.
|
||||||
|
- **Card-game / klondike integration.** Began replacing in-house card and pile
|
||||||
|
internals with upstream `card_game` / `klondike` types, including adapter
|
||||||
|
work, GameMode-aware scoring, upstream instruction serde, `KlondikePile`
|
||||||
|
migration, and documentation for the in-place rewrite phases.
|
||||||
|
- **Android keystore integration.** Added Android Keystore JNI wiring via
|
||||||
|
`OnceLock` and improved Android token handling around the app directory.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Engine plugin modules restructured.** The five oversized plugin files
|
||||||
|
(card, hud, settings, game, input) are now module directories with their
|
||||||
|
test suites in sibling `tests.rs` files — no behaviour change; first phase
|
||||||
|
of the module-split plan (#118).
|
||||||
|
- **Core type ownership.** Routed all klondike/card imports through
|
||||||
|
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
|
||||||
|
types.
|
||||||
|
- **Web/WASM build reliability.** Rebuilt WASM packages, cleaned up wasm32 build
|
||||||
|
warnings, added a Binaryen `wasm-opt` pass, pinned upstream git dependencies,
|
||||||
|
and added a CI guard for canvas WASM drift.
|
||||||
|
- **Difficulty seed catalog.** Regenerated the difficulty seed list for the
|
||||||
|
latest verified catalog.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Safe-area insets now re-polled after app resume.** The inset poller settled
|
||||||
|
permanently once insets first resolved, so inset changes while backgrounded
|
||||||
|
(fold/unfold, rotation, gesture ↔ 3-button nav switch) kept stale layout until
|
||||||
|
the app was killed. Each resume now re-arms a fresh poll window; the cached
|
||||||
|
value is rewritten only when it actually changed, so unchanged resumes still
|
||||||
|
cause no relayout flash. (#116)
|
||||||
|
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
|
||||||
|
each column's total depth (face-down cards included) and refills on the
|
||||||
|
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
|
||||||
|
resize and fold/unfold), so a near-square viewport such as an unfolded Galaxy
|
||||||
|
Fold no longer leaves the bottom of the screen empty. The fan spread cap was
|
||||||
|
raised so very tall / narrow viewports (e.g. a foldable cover screen) fill
|
||||||
|
further.
|
||||||
|
- **Card-move animation jank.** A move now rebuilds a card's child visuals only
|
||||||
|
when its appearance changes (flip / resize / accessibility) instead of
|
||||||
|
despawning and respawning every card's children each `StateChangedEvent`,
|
||||||
|
removing the per-move spike that stuttered the slide animation on
|
||||||
|
high-resolution devices.
|
||||||
|
- **Android and modal safe-area layout.** Modal cards now center within the
|
||||||
|
usable area between status and gesture bars, additional modal-spawn guards were
|
||||||
|
added, and Android build scripts now auto-discover SDK/NDK paths and strip
|
||||||
|
native libraries.
|
||||||
|
- **Core scoring and undo correctness.** Fixed recycle-count drift, undo score
|
||||||
|
compounding, foundation-to-tableau instruction coverage, and several
|
||||||
|
illegal-move paths discovered during the card-game migration.
|
||||||
|
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
|
||||||
|
clicks, delayed first-run onboarding until splash teardown, and kept dragged
|
||||||
|
stacks above all piles.
|
||||||
|
- **Draw-Three waste fan hit testing on Android.** The renderer and the click
|
||||||
|
hit-test now share a single `waste_fan_step` / `tableau_col_step` source. They
|
||||||
|
previously diverged under Android's tighter column spacing, shifting the top
|
||||||
|
fanned waste card's hit target onto the card beneath it, so dragging the visible
|
||||||
|
card played the wrong one.
|
||||||
|
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
|
||||||
|
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
|
||||||
|
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
|
||||||
|
file paths to async I/O where needed, and validated `JWT_SECRET` at startup.
|
||||||
|
- **CI and deployment workflow.** Fixed deploy-branch handling, Docker registry
|
||||||
|
secret usage, and related release automation issues.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Ran an Android AVD `Pixel_7` launch smoke for the x86_64 debug APK,
|
||||||
|
including install, NativeActivity launch, safe-area log validation, screenshot
|
||||||
|
render check, onboarding input, and crash-log review.
|
||||||
|
- Added direct coverage for Android/touch card corner labels using Unicode suit
|
||||||
|
glyphs.
|
||||||
|
- Added schema-v3 persistence round-trip coverage, foundation-to-tableau
|
||||||
|
instruction coverage, expanded WASM unit tests, and Playwright E2E specs for
|
||||||
|
browser routes and game-canvas behavior.
|
||||||
|
|
||||||
|
## [0.39.0] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **No-legal-moves detection and banner.** Corrected no-move detection across
|
||||||
|
engine, WASM, and web paths, then surfaced the state to players with an
|
||||||
|
in-game banner instead of silently leaving the board stuck.
|
||||||
|
- **Release/deploy automation.** Updated deployment automation so kustomization
|
||||||
|
changes are pushed to the deploy branch instead of the main development
|
||||||
|
branch.
|
||||||
|
|
||||||
|
## [0.38.0] — 2026-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Klondike scoring parity.** Added tableau flip bonuses and stock recycle
|
||||||
|
penalties to align scoring with standard Klondike expectations.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Core rule enforcement.** Auto-complete now requires an empty waste pile,
|
||||||
|
waste-origin moves reject multi-card transfers, foundation-to-foundation moves
|
||||||
|
are blocked, and undo restores score from the snapshot baseline.
|
||||||
|
- **Modal lifecycle guards.** Added missing `ModalScrim` guards to New Game,
|
||||||
|
restore prompt, and no-moves modal spawn sites.
|
||||||
|
- **Runtime and server robustness.** Tokio runtime setup degrades gracefully
|
||||||
|
instead of panicking; web replay submission casing/date formatting now matches
|
||||||
|
server expectations; avatar routes are publicly reachable when intended.
|
||||||
|
- **Android token and sync merge correctness.** Android tokens are namespaced
|
||||||
|
under the application directory, stored per user, and migrated safely; sync
|
||||||
|
merges preserve draw-one / draw-three win invariants.
|
||||||
|
|
||||||
|
## [0.37.0] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Foundation-to-tableau default.** Made `take_from_foundation` default to true
|
||||||
|
across clients so restored, startup, and web games use the same supported move
|
||||||
|
rules.
|
||||||
|
|
||||||
|
## [0.36.12] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Foundation-to-tableau default.** Set `take_from_foundation` true by default
|
||||||
|
in core so every client inherits the intended house rule without special-case
|
||||||
|
setup.
|
||||||
|
|
||||||
|
## [0.36.11] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Web foundation moves.** Enabled take-from-foundation moves in the web game
|
||||||
|
client.
|
||||||
|
|
||||||
|
## [0.36.10] — 2026-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Web resume flow.** Browser games now persist state across page refreshes and
|
||||||
|
can resume through a dialog instead of starting over.
|
||||||
|
|
||||||
|
## [0.36.9] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Settings sync connection flow.** Clicking Connect from Settings now opens the
|
||||||
|
sync-setup modal.
|
||||||
|
|
||||||
|
## [0.36.8] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Restored/startup foundation moves.** Enabled take-from-foundation behavior
|
||||||
|
for restored and startup games, not only newly-created sessions.
|
||||||
|
|
||||||
|
## [0.36.7] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Remaining Android UI issues.** Resolved the final Android UI defects from
|
||||||
|
the review pass, including action-bar/tableau interaction and safe visual
|
||||||
|
spacing.
|
||||||
|
|
||||||
|
## [0.36.6] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Action-bar layout reservation.** Reserved action-bar height in layout so
|
||||||
|
tableau columns do not extend behind bottom controls.
|
||||||
|
|
||||||
|
## [0.36.5] — 2026-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Responsive Android action-bar glyphs.** Action-bar glyph font size now scales
|
||||||
|
dynamically on Android to fit available space.
|
||||||
|
|
||||||
|
## [0.36.4] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Classic card labels and HUD overlap.** Corrected classic-card corner-label
|
||||||
|
colors and fixed HUD-band overlap in the Android layout.
|
||||||
|
|
||||||
|
## [0.36.3] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Core, animation, and modal review fixes.** Added the foundation-to-tableau
|
||||||
|
score penalty, hardened solver win validation, guarded zero-duration card
|
||||||
|
animations, aligned initial and dynamic tableau fan spacing, and added missing
|
||||||
|
modal guards for play-by-seed and win-summary paths.
|
||||||
|
- **Pause, messages, credentials, and server validation.** Auto-complete respects
|
||||||
|
pause state, standalone plugins register their events, sync passwords are
|
||||||
|
cleared from ECS buffers after auth task spawn, and avatar MIME validation uses
|
||||||
|
exact matches.
|
||||||
|
- **Foundation pile rendering.** Raised stack fan z-order above corner labels to
|
||||||
|
prevent bleed-through.
|
||||||
|
- **Android release workflow.** Added a manual `workflow_dispatch` trigger to
|
||||||
|
the Android release workflow.
|
||||||
|
|
||||||
|
## [0.36.2] — 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Comprehensive review fixes.** Addressed 26 issues across core rules, replay
|
||||||
|
controls, modal guards, sync payload timing, server replay casing, time-attack
|
||||||
|
overlays, theme refresh, auth overlays, stats ordering, animations, cursor
|
||||||
|
fallbacks, achievements, server temp-file cleanup, and runtime fallback paths.
|
||||||
|
- **Animation and Android label polish.** Cancelled stale win-cascade animations
|
||||||
|
on new game, refreshed Android corner labels on resize, lifted animating cards
|
||||||
|
above lower z-layers, and froze the web timer when auto-complete starts.
|
||||||
|
- **Web package and tooling updates.** Rebuilt the WASM package for
|
||||||
|
foundation-to-tableau moves, added ruflo scaffolding, and ignored ruflo runtime
|
||||||
|
state files.
|
||||||
|
- **Leaderboard test stability.** Made opt-in / opt-out tests robust under
|
||||||
|
parallel test execution.
|
||||||
|
|
||||||
|
## [0.36.1] — 2026-05-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Android HUD gesture conflict.** Stock taps no longer toggle HUD visibility on
|
||||||
|
Android.
|
||||||
|
|
||||||
|
## [0.36.0] — 2026-05-18
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Rank model cleanup.** `Rank` now uses explicit discriminants and checked
|
||||||
|
arithmetic, making rank conversions and sequencing more robust.
|
||||||
|
- **Instruction generation.** Refined `possible_instructions` alongside the rank
|
||||||
|
arithmetic cleanup.
|
||||||
|
- **Session handoff.** Recreated `SESSION_HANDOFF.md` to reflect the `0.35.1`
|
||||||
|
state.
|
||||||
|
|
||||||
|
## [0.35.1] — 2026-05-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Leaderboard profile sync.** Fixed three leaderboard/profile issues: wrong
|
||||||
|
toast type for failures, stale display-name label after update, and display
|
||||||
|
name not syncing to the server.
|
||||||
|
|
||||||
|
## [0.35.0] — 2026-05-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Reduced-motion support.** Decorative motion animations are now gated behind
|
||||||
|
`reduce_motion_mode`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Performance and runtime cleanup.** Shared a single Tokio runtime across
|
||||||
|
network tasks and gated frame-hot ECS systems on resource changes.
|
||||||
|
- **Core/data refactors.** Consolidated the application directory name, added
|
||||||
|
`#[must_use]` to pure helpers, derived `Copy` for `DrawMode`, removed
|
||||||
|
redundant clones, added missing derives to `AchievementContext`, and used
|
||||||
|
saturating move-count arithmetic.
|
||||||
|
- **HUD z-layer naming.** Replaced raw HUD popover z-index arithmetic with named
|
||||||
|
layer constants.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Android UI and font safety.** Wired FiraMono to stock-empty labels, removed
|
||||||
|
raw physical safe-area pixels from HUD spawns, replaced unsupported chevrons,
|
||||||
|
corrected the Android help hint label, and fixed touch/drop-zone behavior.
|
||||||
|
- **Engine modal and panic hardening.** Eliminated several runtime panics, added
|
||||||
|
required transforms to modal scrims, constrained dismiss hit-tests, and guarded
|
||||||
|
home overlay respawns.
|
||||||
|
- **Sync/data/server correctness.** Deterministic pile serialization, undo skip
|
||||||
|
handling, byte URL encoding, merge timestamp handling, auth-guarded avatar
|
||||||
|
serving, atomic server writes, and user-id assertions were corrected.
|
||||||
|
- **Display-name and token-file boundaries.** Enforced the 32-character display
|
||||||
|
name limit in the sync client and aligned Android keystore temp-file cleanup
|
||||||
|
with the cleanup glob.
|
||||||
|
- **WASM error reporting.** `state()` and `step()` now return `Result` so errors
|
||||||
|
surface as JavaScript exceptions.
|
||||||
|
- **Sync and leaderboard toasts.** Pull failures and leaderboard opt-in /
|
||||||
|
opt-out failures now produce the intended warning/error feedback.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Corrected stale focus-ring color documentation.
|
||||||
|
|
||||||
|
## [0.34.0] — 2026-05-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Android waste fan and resume layout.** Corrected Android waste-pile fan
|
||||||
|
overlap and a layout desynchronization after resume.
|
||||||
|
- **Card-face artwork.** Fixed the wrong bottom-right suit symbol on the jack,
|
||||||
|
queen, and king of spades.
|
||||||
|
- **Android corner-label font coverage.** Wired FiraMono into Android corner
|
||||||
|
labels and added `CardImageSet` tests to guard the asset path behavior.
|
||||||
|
|
||||||
## [0.33.0] — 2026-05-16
|
## [0.33.0] — 2026-05-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -208,9 +208,14 @@ Embed via `include_bytes!()` only when ALL of the following are true:
|
|||||||
Currently embedded:
|
Currently embedded:
|
||||||
* **Audio** — all `.wav` files in `audio_plugin.rs`
|
* **Audio** — all `.wav` files in `audio_plugin.rs`
|
||||||
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
|
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
|
||||||
|
* **Bundled UI font** — `assets/fonts/main.ttf` (FiraMono) via `include_bytes!`
|
||||||
|
in `font_plugin.rs` and `assets/svg_loader.rs`; it is the canonical UI face
|
||||||
|
and must always be present, so it is embedded rather than `AssetServer`-loaded
|
||||||
|
|
||||||
Do NOT embed card face PNGs, background images, or user fonts —
|
Do NOT embed card face PNGs or background images — these are loaded via
|
||||||
these are loaded via `AssetServer` so art can be swapped without recompile.
|
`AssetServer` so art can be swapped without recompile. User-supplied fonts
|
||||||
|
(if ever added) likewise go through `AssetServer`; only the bundled FiraMono
|
||||||
|
face above is embedded.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -430,9 +435,11 @@ explicitly replacing the current one (despawn first, then spawn).
|
|||||||
|
|
||||||
## 14.3 Safe area
|
## 14.3 Safe area
|
||||||
|
|
||||||
Every `ModalScrim` automatically receives `padding.bottom` equal to the
|
Every `ModalScrim` automatically receives `padding.top` equal to the logical
|
||||||
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
|
status-bar height and `padding.bottom` equal to the logical gesture-bar height
|
||||||
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
|
via `apply_safe_area_to_modal_scrims` in `SafeAreaInsetsPlugin`. This centres
|
||||||
|
the modal card within the usable area between both system bars. Do not manually
|
||||||
|
add top or bottom padding to scrim nodes.
|
||||||
|
|
||||||
## 14.4 Z-ordering
|
## 14.4 Z-ordering
|
||||||
|
|
||||||
|
|||||||
Generated
+419
-15
@@ -364,6 +364,12 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||||
|
checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "as-raw-xcb-connection"
|
name = "as-raw-xcb-connection"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -717,6 +723,28 @@ dependencies = [
|
|||||||
"android-activity",
|
"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]]
|
[[package]]
|
||||||
name = "bevy_app"
|
name = "bevy_app"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -878,6 +906,35 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "bevy_diagnostic"
|
name = "bevy_diagnostic"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -901,7 +958,7 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
|
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"bevy_ecs_macros",
|
"bevy_ecs_macros",
|
||||||
"bevy_platform",
|
"bevy_platform",
|
||||||
"bevy_ptr",
|
"bevy_ptr",
|
||||||
@@ -945,6 +1002,36 @@ dependencies = [
|
|||||||
"encase_derive_impl",
|
"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]]
|
[[package]]
|
||||||
name = "bevy_gizmos"
|
name = "bevy_gizmos"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1067,14 +1154,17 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy_a11y",
|
"bevy_a11y",
|
||||||
"bevy_android",
|
"bevy_android",
|
||||||
|
"bevy_anti_alias",
|
||||||
"bevy_app",
|
"bevy_app",
|
||||||
"bevy_asset",
|
"bevy_asset",
|
||||||
"bevy_camera",
|
"bevy_camera",
|
||||||
"bevy_color",
|
"bevy_color",
|
||||||
"bevy_core_pipeline",
|
"bevy_core_pipeline",
|
||||||
"bevy_derive",
|
"bevy_derive",
|
||||||
|
"bevy_dev_tools",
|
||||||
"bevy_diagnostic",
|
"bevy_diagnostic",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
|
"bevy_feathers",
|
||||||
"bevy_gizmos_render",
|
"bevy_gizmos_render",
|
||||||
"bevy_image",
|
"bevy_image",
|
||||||
"bevy_input",
|
"bevy_input",
|
||||||
@@ -1082,6 +1172,7 @@ dependencies = [
|
|||||||
"bevy_log",
|
"bevy_log",
|
||||||
"bevy_math",
|
"bevy_math",
|
||||||
"bevy_mesh",
|
"bevy_mesh",
|
||||||
|
"bevy_pbr",
|
||||||
"bevy_platform",
|
"bevy_platform",
|
||||||
"bevy_ptr",
|
"bevy_ptr",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
@@ -1101,6 +1192,27 @@ dependencies = [
|
|||||||
"bevy_winit",
|
"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]]
|
[[package]]
|
||||||
name = "bevy_log"
|
name = "bevy_log"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1138,7 +1250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
|
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"glam 0.30.10",
|
"glam 0.30.10",
|
||||||
@@ -1161,7 +1273,9 @@ dependencies = [
|
|||||||
"bevy_asset",
|
"bevy_asset",
|
||||||
"bevy_derive",
|
"bevy_derive",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
|
"bevy_image",
|
||||||
"bevy_math",
|
"bevy_math",
|
||||||
|
"bevy_mikktspace",
|
||||||
"bevy_platform",
|
"bevy_platform",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"bevy_transform",
|
"bevy_transform",
|
||||||
@@ -1174,6 +1288,71 @@ dependencies = [
|
|||||||
"wgpu-types",
|
"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]]
|
[[package]]
|
||||||
name = "bevy_platform"
|
name = "bevy_platform"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1500,6 +1679,7 @@ dependencies = [
|
|||||||
"bevy_input",
|
"bevy_input",
|
||||||
"bevy_input_focus",
|
"bevy_input_focus",
|
||||||
"bevy_math",
|
"bevy_math",
|
||||||
|
"bevy_picking",
|
||||||
"bevy_platform",
|
"bevy_platform",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"bevy_sprite",
|
"bevy_sprite",
|
||||||
@@ -1512,6 +1692,7 @@ dependencies = [
|
|||||||
"taffy",
|
"taffy",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1545,6 +1726,26 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "bevy_utils"
|
name = "bevy_utils"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1672,6 +1873,7 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1703,7 +1905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
@@ -1879,6 +2081,17 @@ dependencies = [
|
|||||||
"wayland-client",
|
"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]]
|
[[package]]
|
||||||
name = "cbc"
|
name = "cbc"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1939,6 +2152,17 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -3457,6 +3681,17 @@ dependencies = [
|
|||||||
"weezl",
|
"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]]
|
[[package]]
|
||||||
name = "glam"
|
name = "glam"
|
||||||
version = "0.30.10"
|
version = "0.30.10"
|
||||||
@@ -3485,6 +3720,27 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
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]]
|
[[package]]
|
||||||
name = "governor"
|
name = "governor"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -4051,7 +4307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
"quick-error",
|
"quick-error 2.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4309,6 +4565,23 @@ dependencies = [
|
|||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "kira"
|
name = "kira"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -4326,13 +4599,25 @@ dependencies = [
|
|||||||
"triple_buffer",
|
"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]]
|
[[package]]
|
||||||
name = "kurbo"
|
name = "kurbo"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"euclid",
|
"euclid",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
@@ -4740,7 +5025,7 @@ version = "27.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
|
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -5778,6 +6063,25 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
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]]
|
[[package]]
|
||||||
name = "prost"
|
name = "prost"
|
||||||
version = "0.14.3"
|
version = "0.14.3"
|
||||||
@@ -5822,6 +6126,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -5947,6 +6257,16 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"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]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -5985,6 +6305,12 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_distr"
|
name = "rand_distr"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -6004,6 +6330,15 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "range-alloc"
|
name = "range-alloc"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -6493,6 +6828,18 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
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]]
|
[[package]]
|
||||||
name = "rustybuzz"
|
name = "rustybuzz"
|
||||||
version = "0.20.1"
|
version = "0.20.1"
|
||||||
@@ -6959,6 +7306,7 @@ name = "solitaire_app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy",
|
"bevy",
|
||||||
|
"jni 0.21.1",
|
||||||
"keyring",
|
"keyring",
|
||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
"solitaire_engine",
|
"solitaire_engine",
|
||||||
@@ -6980,7 +7328,9 @@ dependencies = [
|
|||||||
name = "solitaire_core"
|
name = "solitaire_core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand 0.9.4",
|
"card_game",
|
||||||
|
"klondike",
|
||||||
|
"proptest",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
@@ -6991,12 +7341,13 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"bevy",
|
"card_game",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
|
"klondike",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -7090,6 +7441,18 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
@@ -7502,7 +7865,7 @@ version = "0.5.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
@@ -7601,7 +7964,7 @@ version = "0.9.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
|
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"grid",
|
"grid",
|
||||||
"serde",
|
"serde",
|
||||||
"slotmap",
|
"slotmap",
|
||||||
@@ -7870,7 +8233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"log",
|
"log",
|
||||||
@@ -7884,7 +8247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"log",
|
"log",
|
||||||
@@ -8533,6 +8896,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "uncased"
|
name = "uncased"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -8739,6 +9108,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
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]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -9044,12 +9422,13 @@ version = "27.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
|
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
@@ -9057,6 +9436,8 @@ dependencies = [
|
|||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
"wgpu-core",
|
"wgpu-core",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
@@ -9068,7 +9449,7 @@ version = "27.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
|
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
@@ -9088,6 +9469,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"wgpu-core-deps-apple",
|
"wgpu-core-deps-apple",
|
||||||
|
"wgpu-core-deps-wasm",
|
||||||
"wgpu-core-deps-windows-linux-android",
|
"wgpu-core-deps-windows-linux-android",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
@@ -9102,6 +9484,15 @@ dependencies = [
|
|||||||
"wgpu-hal",
|
"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]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-windows-linux-android"
|
name = "wgpu-core-deps-windows-linux-android"
|
||||||
version = "27.0.0"
|
version = "27.0.0"
|
||||||
@@ -9118,7 +9509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
|
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec",
|
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"ash",
|
"ash",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
@@ -9127,15 +9518,20 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics-types 0.2.0",
|
"core-graphics-types 0.2.0",
|
||||||
|
"glow",
|
||||||
|
"glutin_wgl_sys",
|
||||||
"gpu-alloc",
|
"gpu-alloc",
|
||||||
"gpu-allocator",
|
"gpu-allocator",
|
||||||
"gpu-descriptor",
|
"gpu-descriptor",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
|
"js-sys",
|
||||||
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
"metal",
|
"metal",
|
||||||
"naga",
|
"naga",
|
||||||
|
"ndk-sys",
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
@@ -9148,6 +9544,8 @@ dependencies = [
|
|||||||
"renderdoc-sys",
|
"renderdoc-sys",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
"windows 0.58.0",
|
"windows 0.58.0",
|
||||||
"windows-core 0.58.0",
|
"windows-core 0.58.0",
|
||||||
@@ -10030,6 +10428,12 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xml-rs"
|
||||||
|
version = "0.8.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xmlwriter"
|
name = "xmlwriter"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+20
-1
@@ -8,6 +8,7 @@ members = [
|
|||||||
"solitaire_app",
|
"solitaire_app",
|
||||||
"solitaire_assetgen",
|
"solitaire_assetgen",
|
||||||
"solitaire_wasm",
|
"solitaire_wasm",
|
||||||
|
"solitaire_web",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -17,11 +18,27 @@ version = "0.1.0"
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.95"
|
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]
|
[workspace.dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
@@ -37,6 +54,8 @@ solitaire_core = { path = "solitaire_core" }
|
|||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
solitaire_data = { path = "solitaire_data" }
|
solitaire_data = { path = "solitaire_data" }
|
||||||
solitaire_engine = { path = "solitaire_engine" }
|
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 with `default-features = false` to avoid the unused
|
||||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||||
|
|||||||
@@ -118,8 +118,28 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
|
|||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
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
|
## Credits
|
||||||
|
|
||||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||||
|
|||||||
+93
-26
@@ -1,16 +1,62 @@
|
|||||||
# Ferrous Solitaire — Session Handoff
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
|
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
|
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105–#108).
|
||||||
- **Latest tag:** `v0.35.1`
|
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
|
||||||
- **Working tree:** clean
|
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
|
||||||
- **Build:** `cargo clippy --workspace -- -D warnings` clean
|
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
|
||||||
- **Tests:** 1277 passing / 0 failing across the workspace
|
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.40.0 release (2026-06-25)
|
||||||
|
|
||||||
|
Released via tag push → `.gitea/workflows/android-release.yml` built and signed the
|
||||||
|
arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`,
|
||||||
|
29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo
|
||||||
|
pick it up automatically.
|
||||||
|
|
||||||
|
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
|
||||||
|
|
||||||
|
| PR | Summary |
|
||||||
|
|----|---------|
|
||||||
|
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
|
||||||
|
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
|
||||||
|
| #108 | **docs(android):** NDK reference updated `26.3.11579264` → `30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
|
||||||
|
|
||||||
|
Pre-release validation performed locally this session: workspace clippy/test/build
|
||||||
|
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
|
||||||
|
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
|
||||||
|
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
|
||||||
|
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
|
||||||
|
proving the `build_android_apk.sh` packaging pipeline end-to-end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What shipped since v0.39.0
|
||||||
|
|
||||||
|
- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`.
|
||||||
|
- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up:
|
||||||
|
- `5e87358` integrates upstream deps cleanly.
|
||||||
|
- `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types.
|
||||||
|
- `d864d98` routes klondike/card imports through `solitaire_core`.
|
||||||
|
- `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs.
|
||||||
|
- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed.
|
||||||
|
- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`.
|
||||||
|
- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels.
|
||||||
|
- Native analytics and Android physical-device validation now have runbooks in
|
||||||
|
`docs/analytics-validation.md` and `docs/ANDROID.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historical notes before v0.39.0
|
||||||
|
|
||||||
|
See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,32 +127,38 @@ Three bugs fixed:
|
|||||||
|
|
||||||
## Open punch list
|
## Open punch list
|
||||||
|
|
||||||
### 1. CHANGELOG documentation debt
|
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
|
||||||
|
|
||||||
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
|
This is the **single outstanding task** for the v0.40.0 Android release. Everything
|
||||||
are missing. Low priority (git log is authoritative) but worth closing before the
|
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
|
||||||
next release.
|
clippy, release manifest sanity, a full local signed APK, the published release, and
|
||||||
|
Obtainium-facing delivery (public releases API, latest non-draft release, APK
|
||||||
|
downloadable anonymously). The only thing that cannot be done without hardware is
|
||||||
|
running the app on a real phone.
|
||||||
|
|
||||||
### 2. Android APK launch verification (Option A)
|
Install the published APK on a real Android device (not AVD) and run the checklist
|
||||||
|
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
|
||||||
|
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
|
||||||
|
|
||||||
Physical device test: install the latest APK on a real Android device (not AVD),
|
The signed release APK is published (grab it from the release page, or use the local
|
||||||
confirm:
|
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
|
||||||
- App launches without crash
|
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
|
||||||
- Safe area insets arrive and shift HUD correctly after ~3 frames
|
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
|
||||||
- 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
|
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
|
||||||
touch events, so physical-device smoke testing is the only gate.
|
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
|
||||||
|
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
|
||||||
|
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
|
||||||
|
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
|
||||||
|
filtered logcat showed no Ferrous panic/fatal/ANR.
|
||||||
|
|
||||||
### 3. Matomo analytics wiring
|
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
|
||||||
|
|
||||||
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
|
Separate, ongoing task unrelated to the Android release. `Settings` has
|
||||||
engine code consumes them — the analytics toggle in Settings is a no-op. If
|
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
||||||
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
|
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
|
||||||
and wired to `GameStateResource` events.
|
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
|
||||||
|
validation checklist and the current web/WASM decision notes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -128,3 +180,18 @@ and wired to `GameStateResource` events.
|
|||||||
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
||||||
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
||||||
with `input.release(key); input.clear()` between updates.
|
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`.
|
||||||
|
|||||||
+64
-7
@@ -1,24 +1,43 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Rebuild the solitaire_wasm crate and install the output into
|
# Rebuild WASM artifacts and install them into solitaire_server/web/pkg/.
|
||||||
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
#
|
||||||
|
# Two artifacts are produced:
|
||||||
|
# solitaire_wasm.* — thin replay-viewer + interactive JS API (wasm-pack)
|
||||||
|
# canvas.* — full Bevy WASM app for play.html (cargo + wasm-bindgen)
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# cargo install wasm-pack
|
# cargo install wasm-pack wasm-bindgen-cli
|
||||||
# rustup target add wasm32-unknown-unknown
|
# rustup target add wasm32-unknown-unknown
|
||||||
|
# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm
|
||||||
#
|
#
|
||||||
# Run from the repo root:
|
# Run from the repo root:
|
||||||
# ./build_wasm.sh
|
# ./build_wasm.sh
|
||||||
#
|
#
|
||||||
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
# The generated pkg/ files are committed to git so self-hosters who don't
|
||||||
# committed to git so self-hosters who don't touch the WASM crate can
|
# touch the WASM crates can skip this step. Regenerate after any change to
|
||||||
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
|
||||||
# solitaire_core/.
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
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
|
if ! command -v wasm-pack &> /dev/null; then
|
||||||
echo "error: wasm-pack not found." >&2
|
echo "error: wasm-pack not found." >&2
|
||||||
echo " Install with: cargo install wasm-pack" >&2
|
echo " Install with: cargo install wasm-pack" >&2
|
||||||
@@ -36,5 +55,43 @@ wasm-pack build \
|
|||||||
# Remove them — we manage the output directory ourselves.
|
# Remove them — we manage the output directory ourselves.
|
||||||
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
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)..."
|
||||||
|
cargo build --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/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:"
|
echo "Done. Output:"
|
||||||
ls -lh "$OUT_DIR"
|
ls -lh "$OUT_DIR"
|
||||||
|
|||||||
+56
-21
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
This doc captures the toolchain install + build invocation for the
|
This doc captures the toolchain install + build invocation for the
|
||||||
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
|
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
|
||||||
later sections document what's known to compile, what's stubbed, and
|
later sections document physical-device validation, supported platform
|
||||||
the next milestones.
|
surfaces, and remaining Android follow-ups.
|
||||||
|
|
||||||
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
> **Status (2026-06-09):** Android build plumbing, app-directory storage,
|
||||||
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
|
> JNI keystore wiring, and safe-area layout fixes have landed. The remaining
|
||||||
> NOT yet been verified to launch on a device or emulator — that's
|
> release gate is a physical-device smoke test; AVD tap injection does not
|
||||||
> the next milestone.
|
> exercise the real touch path reliably enough for launch verification.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
|
|||||||
echo ''
|
echo ''
|
||||||
echo '# Android dev'
|
echo '# Android dev'
|
||||||
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
|
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
|
||||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
|
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.14904198"'
|
||||||
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
|
echo 'export 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"'
|
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
|
||||||
} >> ~/.bashrc
|
} >> ~/.bashrc
|
||||||
@@ -49,10 +49,15 @@ sdkmanager \
|
|||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-34" \
|
"platforms;android-34" \
|
||||||
"build-tools;34.0.0" \
|
"build-tools;34.0.0" \
|
||||||
"ndk;26.3.11579264" \
|
"ndk;30.0.14904198" \
|
||||||
"emulator" \
|
"emulator" \
|
||||||
"system-images;android-34;google_apis;x86_64"
|
"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).
|
# 6. AVD for testing (one-time).
|
||||||
echo no | avdmanager create avd \
|
echo no | avdmanager create avd \
|
||||||
-n bevy_test \
|
-n bevy_test \
|
||||||
@@ -164,7 +169,7 @@ Physical device:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
adb devices # confirm connection
|
adb devices # confirm connection
|
||||||
adb install target/debug/apk/ferrous-solitaire.apk
|
adb install -r target/debug/apk/ferrous-solitaire.apk
|
||||||
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
||||||
```
|
```
|
||||||
@@ -185,35 +190,65 @@ AVD.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. What's wired vs. what's stubbed
|
## 4. Physical-device smoke test
|
||||||
|
|
||||||
The first build pass (commit `fb8b2ac`) gates four desktop-only
|
Run this on a real phone, preferably a modern 64-bit ARM device with gesture
|
||||||
crates / call sites so the workspace cross-compiles. Each gate is
|
navigation enabled.
|
||||||
documented at its call site.
|
|
||||||
|
Build and install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo apk build -p solitaire_app --target aarch64-linux-android --lib
|
||||||
|
adb install -r target/debug/apk/ferrous-solitaire.apk
|
||||||
|
adb logcat -c
|
||||||
|
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||||
|
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic|WindowInsets"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- App launches without panic or ANR.
|
||||||
|
- Safe-area insets arrive after the first few frames and shift HUD/modal
|
||||||
|
content away from the status and gesture bars.
|
||||||
|
- Every modal's Done button remains above the gesture bar:
|
||||||
|
Settings, Help, Pause, Win Summary, and Leaderboard-related dialogs.
|
||||||
|
- Drag-and-drop works on tableau, waste, foundation, and stock/recycle paths.
|
||||||
|
- Tap-to-select and one-tap modes both respond correctly on card stacks.
|
||||||
|
- Leaderboard panel opens, "Set Name" saves, and the "Public name" label updates
|
||||||
|
while the panel remains open.
|
||||||
|
- Rotate the device once, then repeat one modal and one drag operation.
|
||||||
|
- Close and relaunch the app; settings/progress still load.
|
||||||
|
|
||||||
|
Record the device model, Android version, APK commit, and pass/fail notes in the
|
||||||
|
release notes or session handoff. If a failure occurs, keep the filtered logcat
|
||||||
|
and note the exact screen/control path that reproduced it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Platform support matrix
|
||||||
|
|
||||||
|
Desktop-only crates and call sites are gated so the workspace cross-compiles.
|
||||||
|
Each gate is documented at its call site.
|
||||||
|
|
||||||
| Surface | Desktop | Android |
|
| Surface | Desktop | Android |
|
||||||
|---------|---------|---------|
|
|---------|---------|---------|
|
||||||
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
|
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
|
||||||
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
|
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
|
||||||
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
|
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Android Keystore via JNI |
|
||||||
|
| Data directory | Platform data dir | Android app files dir |
|
||||||
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
|
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
|
||||||
|
|
||||||
What's NOT yet ported / not yet measured:
|
Remaining Android follow-ups:
|
||||||
|
|
||||||
- `dirs::data_dir()` returns `None` on Android. Callers in
|
|
||||||
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
|
||||||
`achievements.rs`, `settings.rs` all need an Android-aware
|
|
||||||
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
|
|
||||||
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
||||||
app lifecycle (suspend / resume), font scaling.
|
app lifecycle (suspend / resume), font scaling.
|
||||||
- Android Keystore via JNI for `auth_tokens`.
|
|
||||||
- JNI ClipboardManager for share links.
|
- JNI ClipboardManager for share links.
|
||||||
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
|
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
|
||||||
in older docs doesn't yet exist).
|
in older docs doesn't yet exist).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Iteration loop
|
## 6. Iteration loop
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Edit code…
|
# Edit code…
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Analytics Validation Runbook
|
||||||
|
|
||||||
|
Ferrous Solitaire currently has two analytics paths:
|
||||||
|
|
||||||
|
- Native desktop/Android gameplay events use `solitaire_engine::AnalyticsPlugin`
|
||||||
|
and `solitaire_data::MatomoClient`.
|
||||||
|
- Hosted web pages include Matomo page-view snippets in
|
||||||
|
`solitaire_server/web/*.html`.
|
||||||
|
|
||||||
|
The Bevy `/play` WASM canvas does not emit the native gameplay events because
|
||||||
|
`AnalyticsPlugin` is intentionally gated out on `wasm32`; it depends on the
|
||||||
|
native Tokio/reqwest stack.
|
||||||
|
|
||||||
|
## Native Matomo Validation
|
||||||
|
|
||||||
|
Use this when a deployed Matomo instance and a native build are available.
|
||||||
|
|
||||||
|
1. Configure `settings.json` with a Matomo URL and site ID:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"analytics_enabled": true,
|
||||||
|
"matomo_url": "https://analytics.example.com",
|
||||||
|
"matomo_site_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Launch the native app and open Settings.
|
||||||
|
3. Confirm the Privacy section appears and "Share usage data" is `ON`.
|
||||||
|
4. Start a new confirmed game.
|
||||||
|
5. Win or forfeit the game.
|
||||||
|
6. Unlock an achievement if practical, or use an existing achievement path that
|
||||||
|
is easy to trigger in a test profile.
|
||||||
|
7. Wait at least 60 seconds, or close after the win/forfeit path has fired its
|
||||||
|
immediate flush.
|
||||||
|
8. In Matomo, confirm the following custom events arrived:
|
||||||
|
|
||||||
|
| Category | Action | Name |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Game` | `Start` | `classic`, `zen`, `challenge`, `time_attack`, or `difficulty` |
|
||||||
|
| `Game` | `Won` | empty |
|
||||||
|
| `Game` | `Forfeit` | empty |
|
||||||
|
| `Achievement` | `Unlocked` | achievement id |
|
||||||
|
|
||||||
|
## Web/WASM Decision
|
||||||
|
|
||||||
|
Keep the current split unless the project explicitly needs in-canvas gameplay
|
||||||
|
events for `/play`.
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- `/`, `/play-classic`, `/account`, `/leaderboard`, and `/replays` emit Matomo
|
||||||
|
page views through the hosted HTML snippets.
|
||||||
|
- `/play` hosts the Bevy canvas but does not emit gameplay events from the
|
||||||
|
engine.
|
||||||
|
- The browser Content-Security-Policy already allows the deployed Matomo host
|
||||||
|
for scripts, images, and connections.
|
||||||
|
|
||||||
|
If gameplay events are needed on `/play`, add a small `wasm32`-only analytics
|
||||||
|
bridge instead of trying to compile the native plugin:
|
||||||
|
|
||||||
|
- keep the same event contract as native (`Game / Start`, `Game / Won`,
|
||||||
|
`Game / Forfeit`, `Achievement / Unlocked`);
|
||||||
|
- read `Settings::analytics_enabled`, `matomo_url`, and `matomo_site_id`;
|
||||||
|
- send through browser APIs or the existing `_paq` queue;
|
||||||
|
- keep the Settings opt-in behavior identical to native;
|
||||||
|
- add Playwright coverage that stubs Matomo and verifies emitted payloads.
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
**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.
|
**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:** 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.
|
**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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,10 +45,12 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
|
## What Ferrous Solitaire's `solitaire_core` Still Owns
|
||||||
|
|
||||||
### 1. Scoring — remaining adapter responsibilities
|
### 1. Scoring — remaining adapter responsibilities
|
||||||
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
|
Ferrous uses **Windows XP Standard** scoring. The upstream library handles the
|
||||||
|
per-move counters and configurable deltas; Ferrous adds the product-specific
|
||||||
|
parts in `GameState` / `KlondikeAdapter`.
|
||||||
|
|
||||||
| Event | Delta | Handled by |
|
| Event | Delta | Handled by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -61,11 +66,13 @@ Ferrous uses **Windows XP Standard** scoring. The exact table already implemente
|
|||||||
|
|
||||||
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
||||||
|
|
||||||
**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.
|
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty.
|
||||||
|
|
||||||
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
**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:** 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.
|
**In our wrapper:** `KlondikeAdapter::config_for` configures the upstream rules
|
||||||
|
and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor,
|
||||||
|
time bonus, game-mode suppression, and undo score restoration.
|
||||||
|
|
||||||
### 2. Game Modes
|
### 2. Game Modes
|
||||||
Ferrous has three modes that alter scoring and undo behaviour:
|
Ferrous has three modes that alter scoring and undo behaviour:
|
||||||
@@ -78,7 +85,9 @@ 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.
|
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:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
|
**In our wrapper:** `GameMode` lives on `solitaire_core::GameState`; undo and
|
||||||
|
scoring behavior are applied before/after delegating legal moves to the upstream
|
||||||
|
session.
|
||||||
|
|
||||||
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
### 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:
|
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
||||||
@@ -87,23 +96,33 @@ Zen is intended for relaxed play where the score does not matter. Challenge is a
|
|||||||
|
|
||||||
`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).
|
`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).
|
||||||
|
|
||||||
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`.
|
The old local DFS has been replaced. `solitaire_core::solver` is now a small
|
||||||
|
adapter around `Session::solve()` that preserves the engine-facing
|
||||||
|
`SolverResult`, `SolverConfig`, and first-move payload contract.
|
||||||
|
|
||||||
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
|
**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested
|
||||||
|
budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget
|
||||||
|
errors → Inconclusive.
|
||||||
|
|
||||||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
### 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.
|
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
||||||
|
|
||||||
**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.
|
**Default behaviour:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire **also defaults to Allowed** (`take_from_foundation: true` in `GameState`, `Settings`). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (`take_from_foundation = false`), which maps to `Disallowed`.
|
||||||
|
|
||||||
**In our wrapper:** 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.
|
**In our wrapper:** `KlondikeAdapter::config_for(draw_mode, take_from_foundation)` constructs `KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }`. No custom intercept needed — `klondike` enforces the rule automatically.
|
||||||
|
|
||||||
### 5. JSON Serialisation / Persistence
|
### 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. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
|
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch.
|
||||||
|
|
||||||
**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.
|
**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"]`.
|
||||||
|
|
||||||
**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.
|
**Schema v4 (current):** `saved_moves` serialises as `Vec<KlondikeInstruction>` using upstream named-variant serde. Example: `{"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}`.
|
||||||
|
|
||||||
|
**Schema v3 (legacy, auto-migrated):** `saved_moves` used local `SavedInstruction` mirror types with u8 indices. Example: `{"DstFoundation": {"src": "Stock", "foundation": 0}}`. On load, an `AnyInstruction` untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The `SavedInstruction` bridge types are retained in `solitaire_core::klondike_adapter` for this migration path and for backward-compatible `solitaire_data::ReplayMove` / WASM replay formats.
|
||||||
|
|
||||||
|
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
|
||||||
|
|
||||||
|
**In our wrapper:** `GameState::Serialize` emits schema v4 (upstream instruction types). `GameState::Deserialize` accepts v3 (auto-migrates) and v4 (direct). Schema version field lives on our wrapper.
|
||||||
|
|
||||||
### 6. Typed Move Errors
|
### 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.):
|
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
|
||||||
@@ -129,7 +148,9 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
|
|||||||
### 8. Undo Stack Approach *(resolved — not an issue)*
|
### 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`.
|
`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:** 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`.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,12 +159,35 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
|
|||||||
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
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.
|
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 `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
|
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::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
|
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (gap 4, upstream).
|
||||||
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
|
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).
|
5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
|
||||||
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
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`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
|
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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -163,5 +207,5 @@ Steps in dependency order. Upstream issues #10, #11, and the solver are all merg
|
|||||||
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
||||||
- Upstream solver PR: #14
|
- Upstream solver PR: #14
|
||||||
- `solitaire_core` source: `solitaire_core/src/`
|
- `solitaire_core` source: `solitaire_core/src/`
|
||||||
- Scoring spec: `solitaire_core/src/scoring.rs`
|
- Scoring implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs`
|
||||||
- Architecture overview: `ARCHITECTURE.md`
|
- Architecture overview: `ARCHITECTURE.md`
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# In-Place card_game / klondike Rewrite Plan
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Upstream rev:** `99b49e62`
|
||||||
|
**Status:** All phases complete (0–3). recycle_count drift and score compound error on undo fixed in `56e3b62`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What Is Already Integrated
|
||||||
|
|
||||||
|
The integration is substantially complete. `solitaire_core` already delegates all
|
||||||
|
authoritative Klondike logic to the upstream crates.
|
||||||
|
|
||||||
|
| Area | Status | Location |
|
||||||
|
|---|---|---|
|
||||||
|
| `Session<Klondike>` ownership | ✅ complete | `GameState.session` |
|
||||||
|
| `draw()` → `session.process_instruction(RotateStock)` | ✅ complete | `game_state.rs` |
|
||||||
|
| `move_cards()` → `session.process_instruction(KlondikeInstruction)` | ✅ complete | `game_state.rs` |
|
||||||
|
| `undo()` → `session.undo()` | ✅ complete | `game_state.rs` |
|
||||||
|
| `possible_instructions()` → `session.state().state().get_sorted_moves()` | ✅ complete | `game_state.rs` |
|
||||||
|
| `can_move_cards()` → `session.state().state().is_instruction_valid()` | ✅ complete | `game_state.rs` |
|
||||||
|
| `solver.rs` → `session.solve()` | ✅ complete | `solver.rs` |
|
||||||
|
| `Suit`, `Rank` → re-export from `card_game` | ✅ complete | `card.rs` |
|
||||||
|
| `Foundation`, `Klondike`, `KlondikePile`, `Session`, `Tableau` → `solitaire_core::lib` | ✅ complete | `lib.rs` |
|
||||||
|
| Move legality enforcement | ✅ upstream (`is_instruction_valid`) | `klondike/src/lib.rs` |
|
||||||
|
| Foundation placement rules (Ace start, suit match) | ✅ upstream | `klondike/src/lib.rs` |
|
||||||
|
| Tableau placement rules (alternating colour, King on empty) | ✅ upstream | `klondike/src/lib.rs` |
|
||||||
|
| Multi-card stack moves via `SkipCards` | ✅ upstream | `klondike/src/lib.rs` |
|
||||||
|
| Session history / snapshot undo | ✅ upstream | `card_game/src/lib.rs` |
|
||||||
|
| DFS solver with budget limits | ✅ upstream | `card_game/src/lib.rs` |
|
||||||
|
| Instruction history → `SavedInstruction` serde mirrors | ✅ in adapter | `klondike_adapter.rs` |
|
||||||
|
| Schema v3 save/load (instruction replay) | ✅ complete | `game_state.rs`, `storage.rs` |
|
||||||
|
| `take_from_foundation` house rule → `MoveFromFoundationConfig` | ✅ complete | `klondike_adapter.rs` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Duplicated / Replaceable Logic
|
||||||
|
|
||||||
|
These are local implementations that either replicate upstream or could be removed.
|
||||||
|
|
||||||
|
### 2a. `SavedInstruction` mirror types (~300 lines, `klondike_adapter.rs`)
|
||||||
|
|
||||||
|
**What:** A full hand-written serde mirror for every upstream klondike instruction type
|
||||||
|
(`SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
|
||||||
|
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
|
||||||
|
`SavedSkipCards`, `InvalidSavedInstruction`) plus ~20 `From`/`TryFrom` conversion impls.
|
||||||
|
|
||||||
|
**Why written:** At the time, upstream klondike had no serde feature.
|
||||||
|
|
||||||
|
**Current upstream status:** At rev `99b49e62`, the `serde` feature is present and active.
|
||||||
|
`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`,
|
||||||
|
`TableauStack`, `Tableau`, `Foundation`, `SkipCards` all derive
|
||||||
|
`#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]`.
|
||||||
|
|
||||||
|
**Blocker — JSON format incompatibility:**
|
||||||
|
| Field | Local `SavedInstruction` JSON | Upstream `KlondikeInstruction` JSON |
|
||||||
|
|---|---|---|
|
||||||
|
| Tableau index | `{ "Tableau": 0 }` (u8) | `{ "Tableau": "Tableau1" }` (named) |
|
||||||
|
| Foundation slot | `{ "Foundation": 0 }` (u8) | `{ "Foundation": "Foundation1" }` (named) |
|
||||||
|
| Skip count | `{ "skip_cards": 0 }` (u8) | `{ "skip_cards": "Skip0" }` (named) |
|
||||||
|
|
||||||
|
Switching to direct upstream serde **changes the `saved_moves` JSON shape** stored in
|
||||||
|
`game_state.json`. Any existing v3 save file would fail to deserialize after the switch.
|
||||||
|
This requires either:
|
||||||
|
- A schema bump to v4 **with a migration** (deserialize v3 manually then re-save as v4), or
|
||||||
|
- A schema bump to v4 **with graceful fallback** (v3 files rejected → fresh game).
|
||||||
|
|
||||||
|
**Recommendation:** Schema v4 with graceful fallback (v3 saves start fresh). Migration
|
||||||
|
is feasible but adds ~100 lines of throwaway code; the in-progress game loss is modest
|
||||||
|
since schema v3 was never shipped to users (it landed in the current dev branch, not a
|
||||||
|
release).
|
||||||
|
|
||||||
|
### 2b. `GameState::check_win()` (~15 lines)
|
||||||
|
|
||||||
|
**What:** Iterates all four foundation slots checking 13-card A→K sequences.
|
||||||
|
**Upstream equivalent:** `session.state().state().is_win()` on `Klondike`.
|
||||||
|
**Status:** Local check is correct but redundant. Trivially replaceable with no format change.
|
||||||
|
**Risk:** None — only affects `is_won` flag update path.
|
||||||
|
|
||||||
|
### 2c. `GameState::check_auto_complete()` (~15 lines)
|
||||||
|
|
||||||
|
**What:** Checks stock empty, waste empty, all tableau cards face-up.
|
||||||
|
**Upstream equivalent:** `session.state().state().is_win_trivial()` on `Klondike`.
|
||||||
|
**Semantic difference:** Upstream `is_win_trivial` checks `stock.is_empty()` (both faces)
|
||||||
|
and all `tableau.face_down().is_empty()`. Ferrous additionally checks `waste.is_empty()`.
|
||||||
|
These are logically equivalent for a valid game state (waste = stock face-up half).
|
||||||
|
**Risk:** Low — validated by existing auto-complete engine tests.
|
||||||
|
|
||||||
|
### 2c. `recycle_count` drift on undo (existing bug, not new)
|
||||||
|
|
||||||
|
**What:** `GameState.recycle_count` is incremented in `draw()` when stock is empty.
|
||||||
|
`undo()` does not decrement it. After undoing a recycle, `recycle_count` is stale and
|
||||||
|
may cause incorrect future penalty application.
|
||||||
|
**Upstream:** `KlondikeStats.recycle_count()` has the same problem — it is cumulative
|
||||||
|
and not restored on undo (stats are not part of the session snapshot, only game state is).
|
||||||
|
**Fix approach:** After each undo, recompute `recycle_count` by scanning
|
||||||
|
`session.history()` for `RotateStock` instructions that caused recycling.
|
||||||
|
**Priority:** Medium — affects scoring correctness in rare paths. File as a separate bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What Must Remain Ferrous-Specific
|
||||||
|
|
||||||
|
These responsibilities are product-layer, not Klondike-rules-layer, and must stay in `solitaire_core`.
|
||||||
|
|
||||||
|
| Responsibility | Why upstream cannot own it |
|
||||||
|
|---|---|
|
||||||
|
| WXP recycle penalties (free allowance + -100/-20) | `ScoringConfig::recycle` is a flat delta; no free-allowance concept exists upstream |
|
||||||
|
| Score floor (`score.max(0)`) | Not modelled upstream |
|
||||||
|
| Time bonus (`700_000 / elapsed_seconds`) | Not modelled upstream |
|
||||||
|
| `DrawMode` / `GameMode` enums | Product concept; not in upstream |
|
||||||
|
| Challenge mode undo block | Product rule |
|
||||||
|
| Zen mode scoring suppression | Product rule |
|
||||||
|
| `MoveError` variants for UI feedback | Upstream returns `bool`; Ferrous needs typed errors |
|
||||||
|
| `card::Card` projection (adds `id`, `face_up`) | Renderer requires stable `id` and face orientation |
|
||||||
|
| `Pile` DTO for engine sync | Renderer-facing snapshot type |
|
||||||
|
| `stock_cards()` / `waste_cards()` distinction | Engine models waste as a separate pile; upstream uses stock face-up half |
|
||||||
|
| `recycle_count` tracking | Needed for free-allowance penalty calculation |
|
||||||
|
| Persistence format + schema versioning | Product concern |
|
||||||
|
| `SavedInstruction` (currently) or upstream serde (after migration) | Either way, Ferrous owns the save contract |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key Audit Findings
|
||||||
|
|
||||||
|
### Finding 1 — Upstream serde claim in docs is stale
|
||||||
|
|
||||||
|
`docs/card-game-integration.md` (last section "JSON Serialisation") states:
|
||||||
|
|
||||||
|
> Current verification (2026-06-01): klondike v0.3.0 and card_game v0.4.0 crate manifests
|
||||||
|
> expose no serde dependency/feature.
|
||||||
|
|
||||||
|
**This is wrong at rev 99b49e62.** The `serde` feature is present and active. All nine
|
||||||
|
instruction/pile types have `#[cfg_attr(feature = "serde", derive(...))]`. The doc must
|
||||||
|
be updated.
|
||||||
|
|
||||||
|
### Finding 2 — `take_from_foundation` default: docs vs code
|
||||||
|
|
||||||
|
`docs/card-game-integration.md` says:
|
||||||
|
> Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the
|
||||||
|
> default, with the house rule as an opt-in.
|
||||||
|
|
||||||
|
**The code and settings say the opposite:** `Settings::take_from_foundation` defaults to
|
||||||
|
`true` (Allowed); `GameState.take_from_foundation` also initializes to `true`. Multiple
|
||||||
|
tests assert this is the intended behavior. The upstream default is also `Allowed`.
|
||||||
|
|
||||||
|
**Resolution:** The docs are wrong. Default = Allowed (house rule on by default for
|
||||||
|
beginner-friendliness) is intentional. Update the docs; do not change the code.
|
||||||
|
|
||||||
|
### Finding 3 — `KlondikeStats` cumulative vs session-history-aware counts
|
||||||
|
|
||||||
|
`KlondikeStats.moves()` and `KlondikeStats.recycle_count()` accumulate monotonically.
|
||||||
|
They are NOT restored when `Session::undo()` is called (only `Klondike` game state is
|
||||||
|
restored from the snapshot, not the stats). Ferrous correctly uses
|
||||||
|
`session.history().len()` for `move_count` (history-aware). But `recycle_count` is
|
||||||
|
stored separately in `GameState` and also not decremented on undo — making them
|
||||||
|
equivalent in this one bug.
|
||||||
|
|
||||||
|
### Finding 4 — `SkipCards as usize` cast is correct
|
||||||
|
|
||||||
|
Upstream `SkipCards` has no explicit discriminants, so `Skip0 = 0 .. Skip12 = 12`.
|
||||||
|
`skip_cards as usize` in `solver.rs` and `game_state.rs` is correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Staged Migration
|
||||||
|
|
||||||
|
### Phase 0 — Doc fixes only (no code change)
|
||||||
|
|
||||||
|
Files: `docs/card-game-integration.md`
|
||||||
|
|
||||||
|
- Correct the serde claim (upstream has serde at rev 99b49e62).
|
||||||
|
- Correct the `take_from_foundation` default description.
|
||||||
|
- Update integration status table.
|
||||||
|
|
||||||
|
### Phase 1 — Delegate `is_win` / `is_win_trivial` (safe, no format change)
|
||||||
|
|
||||||
|
Files: `solitaire_core/src/game_state.rs`
|
||||||
|
|
||||||
|
Replace local `check_win()` and `check_auto_complete()` with upstream delegation:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
pub fn check_win(&self) -> bool { ... 40 lines ... }
|
||||||
|
|
||||||
|
// after
|
||||||
|
pub fn check_win(&self) -> bool {
|
||||||
|
self.session.state().state().is_win()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
pub fn check_auto_complete(&self) -> bool { ... 15 lines ... }
|
||||||
|
|
||||||
|
// after
|
||||||
|
pub fn check_auto_complete(&self) -> bool {
|
||||||
|
self.session.state().state().is_win_trivial()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Risk:** Very low. Both methods are tested by existing integration tests. The semantic
|
||||||
|
difference in `check_auto_complete` (upstream vs Ferrous definition) is equivalent for
|
||||||
|
valid game states.
|
||||||
|
|
||||||
|
### Phase 2 — Replace `SavedInstruction` with upstream serde (schema v4)
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- `solitaire_core/src/klondike_adapter.rs` (remove ~300 lines)
|
||||||
|
- `solitaire_core/src/game_state.rs` (update `Serialize`/`Deserialize` impls)
|
||||||
|
- `solitaire_core/src/proptest_tests.rs` (remove now-redundant SavedInstruction tests)
|
||||||
|
- `solitaire_data/src/storage.rs` (add schema v4 rejection test)
|
||||||
|
- `solitaire_data/src/replay.rs` (no change — uses `SavedKlondikePile` independently)
|
||||||
|
- `solitaire_wasm/src/lib.rs` (uses `SavedKlondikePileStack` in its own mirror — evaluate)
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. In `game_state.rs`, change `PersistedGameState.saved_moves` from
|
||||||
|
`Vec<SavedInstruction>` to `Vec<KlondikeInstruction>` (upstream serde now works).
|
||||||
|
2. Update `GameState::Serialize` to emit `KlondikeInstruction` directly.
|
||||||
|
3. Update `GameState::Deserialize` to parse `KlondikeInstruction` directly.
|
||||||
|
4. Increment `GAME_STATE_SCHEMA_VERSION` to 4.
|
||||||
|
5. In `GameState::Deserialize`, reject schema != 4 with graceful fallback (already
|
||||||
|
handled by `load_game_state_from` returning `None` on serde error or wrong version).
|
||||||
|
6. Delete `SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
|
||||||
|
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
|
||||||
|
`SavedSkipCards`, `InvalidSavedInstruction` from `klondike_adapter.rs`.
|
||||||
|
7. Delete the 20 `From`/`TryFrom` impls.
|
||||||
|
8. Remove `SavedInstruction` proptest and boundary tests (no longer needed).
|
||||||
|
9. Add schema v4 round-trip test and v3 rejection test.
|
||||||
|
|
||||||
|
**Note on `solitaire_data::replay.rs`:**
|
||||||
|
`replay.rs` uses `SavedKlondikePile` independently (for `ReplayMove`). This is a
|
||||||
|
separate type from the game-state save format and is NOT changed by this phase.
|
||||||
|
`ReplayMove` has its own schema (`REPLAY_SCHEMA_VERSION`) and can keep using the local
|
||||||
|
mirror types.
|
||||||
|
|
||||||
|
**Note on `solitaire_wasm/src/lib.rs`:**
|
||||||
|
Uses `SavedKlondikePileStack` in its own `ReplayMove` mirror. Same as above — separate
|
||||||
|
type, not affected.
|
||||||
|
|
||||||
|
### Pre-Phase 3 — Undo Field Audit (completed 2026-06-08)
|
||||||
|
|
||||||
|
Full audit of every Ferrous-owned field in `GameState` for undo correctness.
|
||||||
|
|
||||||
|
| Field | Correctly updated by `undo()`? | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `score` | ✅ By design | −15 WXP undo penalty applied; Zen: stays 0 |
|
||||||
|
| `move_count` | ✅ Correct | Recomputed from `session.history().len()` |
|
||||||
|
| `is_won` | ✅ Correct | Recomputed; undo blocked on won game |
|
||||||
|
| `is_auto_completable` | ✅ Correct | Recomputed |
|
||||||
|
| `undo_count` | ✅ By design | Total undos ever, intentionally non-reversible |
|
||||||
|
| `elapsed_seconds` | ✅ Intentional | Timer is independent of moves |
|
||||||
|
| `seed` / `draw_mode` / `mode` / `take_from_foundation` | ✅ Immutable | |
|
||||||
|
| **`recycle_count`** | ❌ **Bug** | Not decremented — see below |
|
||||||
|
|
||||||
|
**`recycle_count` drift bug:**
|
||||||
|
|
||||||
|
`draw()` increments `recycle_count` when `stock.face_down().is_empty()` (the rotation
|
||||||
|
is a recycle, not just a draw). `undo()` calls `session.undo()` which restores the
|
||||||
|
`Klondike` card state, but does NOT decrement `recycle_count`.
|
||||||
|
|
||||||
|
Consequence: if the player recycles, undoes it, then recycles again, `recycle_count`
|
||||||
|
is `2` instead of `1` — the free-recycle allowance is consumed even though the first
|
||||||
|
recycle was undone. On Draw-1, the 2nd recycle costs −100; after the undo-and-replay
|
||||||
|
bug the player pays −100 for what should be their still-free recycle.
|
||||||
|
|
||||||
|
**Score compound effect:** When `undo()` is applied to a recycle that incurred a
|
||||||
|
penalty, the penalty amount (`score_after_recycle - 100`) is already in `self.score`.
|
||||||
|
`apply_undo_score` then adds `−15` on top. The recycle penalty is never reversed.
|
||||||
|
|
||||||
|
**Fix approach for Phase 3:**
|
||||||
|
- After `session.undo()`, recompute `recycle_count` by scanning the new
|
||||||
|
`session.history()` for `RotateStock` snapshots where
|
||||||
|
`snapshot.state().state().stock().face_down().is_empty()` (indicating the rotation
|
||||||
|
was a recycle, not a draw from a populated stock).
|
||||||
|
- Restore `score` to `snapshot_score` **before** the undone move, then apply only
|
||||||
|
the −15 undo penalty. This requires reading the score stored in `StateSnapshot`
|
||||||
|
or keeping a pre-move score stack alongside the session history.
|
||||||
|
|
||||||
|
**Simpler alternative:** Store `(score_before, recycle_count_before)` in `GameState`
|
||||||
|
alongside each `session.process_instruction` call, mirroring the snapshot stack.
|
||||||
|
Undo pops this alongside the session undo.
|
||||||
|
|
||||||
|
### Phase 3 — Fix `recycle_count` drift on undo (optional, post-approval)
|
||||||
|
|
||||||
|
Files: `solitaire_core/src/game_state.rs`
|
||||||
|
|
||||||
|
After `session.undo()`, recompute `recycle_count` by scanning `session.history()` for
|
||||||
|
`RotateStock` snapshots where the pre-instruction stock face-down was empty (indicating
|
||||||
|
a recycle). Also correct the score: restore to the pre-undone-move score and apply only
|
||||||
|
the −15 undo penalty.
|
||||||
|
|
||||||
|
**Tests to add:**
|
||||||
|
- `recycle_count_decrements_when_recycle_is_undone`
|
||||||
|
- `score_recycle_penalty_is_reversed_on_undo`
|
||||||
|
|
||||||
|
**Risk:** Medium — changes observable scoring behavior. The fix is strictly more
|
||||||
|
correct, but any golden-file or regression test that recorded the old (buggy) score
|
||||||
|
after undo-of-recycle will need updating.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Files Likely to Change Per Phase
|
||||||
|
|
||||||
|
| Phase | Files |
|
||||||
|
|---|---|
|
||||||
|
| Phase 0 | `docs/card-game-integration.md` |
|
||||||
|
| Phase 1 | `solitaire_core/src/game_state.rs` |
|
||||||
|
| Phase 2 | `solitaire_core/src/klondike_adapter.rs`, `solitaire_core/src/game_state.rs`, `solitaire_core/src/proptest_tests.rs`, `solitaire_data/src/storage.rs` |
|
||||||
|
| Phase 3 | `solitaire_core/src/game_state.rs`, new test module |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks
|
||||||
|
|
||||||
|
### R1 — Save file format break (Phase 2, HIGH)
|
||||||
|
Users with v3 saves lose their in-progress game. Mitigated by the fact that v3 is
|
||||||
|
not in any shipped release (dev branch only). Graceful fallback (start fresh) is
|
||||||
|
acceptable; a migration shim is possible but not required.
|
||||||
|
|
||||||
|
### R2 — `solitaire_wasm` / `solitaire_data::replay` breakage (Phase 2, MEDIUM)
|
||||||
|
`SavedKlondikePile` and `SavedKlondikePileStack` are also used in `replay.rs` and
|
||||||
|
`wasm/src/lib.rs`. These are separate from the game-state save format and must be
|
||||||
|
left in place. Plan is to keep them in `klondike_adapter.rs` (or relocate to
|
||||||
|
`replay.rs`) after the game-state mirror types are deleted.
|
||||||
|
|
||||||
|
### R3 — `check_auto_complete` semantic drift (Phase 1, LOW)
|
||||||
|
Upstream `is_win_trivial` checks `stock.is_empty()` (no cards at all in stock)
|
||||||
|
whereas Ferrous also checks waste. These are equivalent for a valid game state but
|
||||||
|
could differ under test-support pile overrides. Existing auto-complete tests will
|
||||||
|
catch any regression.
|
||||||
|
|
||||||
|
### R4 — `SkipCards as usize` cast correctness
|
||||||
|
Already verified: enums have implicit 0..12 discriminants. No risk.
|
||||||
|
|
||||||
|
### R5 — Upstream changes after rev pin
|
||||||
|
The workspace is pinned to `rev = "99b49e62"`. No upstream drift risk until explicitly
|
||||||
|
re-pinned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Test Plan
|
||||||
|
|
||||||
|
### Phase 1 tests (all currently pass)
|
||||||
|
- `game_state::tests::take_from_foundation_allows_legal_return_move`
|
||||||
|
- `game_state::tests::take_from_foundation_disabled_blocks_return_move_everywhere`
|
||||||
|
- `proptest_tests::*` (card conservation, deal determinism, undo invariant, legal moves)
|
||||||
|
|
||||||
|
### Phase 2 tests to add
|
||||||
|
- `storage::tests::game_state_v4_mid_game_round_trip` — verify upstream serde round-trip
|
||||||
|
after migrating to `KlondikeInstruction` directly
|
||||||
|
- `storage::tests::save_format_v3_is_rejected` — v3 files must return `None`
|
||||||
|
- Update `game_state::tests::*` — all existing tests must continue to pass
|
||||||
|
|
||||||
|
### Phase 2 tests to remove
|
||||||
|
- `proptest_tests::saved_instruction_round_trip` — no longer needed (no mirror types)
|
||||||
|
- `proptest_tests::saved_instruction_boundary_tests::*` — no longer needed
|
||||||
|
|
||||||
|
### Phase 3 tests to add
|
||||||
|
- `game_state::tests::recycle_count_decrements_on_undo` — after recycling and undoing,
|
||||||
|
`recycle_count` must reflect the correct post-undo count
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Validation Commands
|
||||||
|
|
||||||
|
Run after each phase:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Targeted (fast)
|
||||||
|
cargo test -p solitaire_core
|
||||||
|
cargo clippy -p solitaire_core -- -D warnings
|
||||||
|
|
||||||
|
# Broader
|
||||||
|
cargo test -p solitaire_wasm
|
||||||
|
cargo test -p solitaire_data
|
||||||
|
|
||||||
|
# Full workspace (run before declaring phase complete)
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: What Would Be Removed vs Kept
|
||||||
|
|
||||||
|
### Removed after all phases complete
|
||||||
|
| Code | Lines est. | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `SavedInstruction` + 8 mirror types | ~150 | Upstream serde now available |
|
||||||
|
| 20 `From`/`TryFrom` impls | ~150 | Upstream serde now available |
|
||||||
|
| `InvalidSavedInstruction` error type | ~10 | Upstream serde now available |
|
||||||
|
| `check_win()` local impl | ~20 | Replaced by `is_win()` delegation |
|
||||||
|
| `check_auto_complete()` local impl | ~15 | Replaced by `is_win_trivial()` delegation |
|
||||||
|
| `SavedInstruction` proptest + boundary tests | ~60 | Mirror types removed |
|
||||||
|
|
||||||
|
**Total: ~400 lines removed from `solitaire_core`**
|
||||||
|
|
||||||
|
### Remains Ferrous-specific
|
||||||
|
- `KlondikeAdapter` scoring helpers (recycle penalties, score floor, time bonus, Zen/mode suppression)
|
||||||
|
- `DrawMode`, `GameMode`, `DifficultyLevel`
|
||||||
|
- `MoveError` and all boundary-checking logic
|
||||||
|
- `card::Card` (id + face_up projection)
|
||||||
|
- `Pile` DTO
|
||||||
|
- `stock_cards()` / `waste_cards()` projections
|
||||||
|
- Persistence format (`GameState` serde, schema version, `PersistedGameState`)
|
||||||
|
- `solitaire_data::replay` types (`ReplayMove`, `SavedKlondikePile` mirror — unchanged)
|
||||||
|
- `solitaire_wasm` replay mirror types (unchanged)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Testing Architecture — Engine-first Validation
|
||||||
|
|
||||||
|
Ferrous Solitaire validation is split into three layers with clear ownership:
|
||||||
|
|
||||||
|
1. **Rust unit tests (`solitaire_core`)**
|
||||||
|
- move generation and legality
|
||||||
|
- deal generation determinism
|
||||||
|
- scoring and penalties
|
||||||
|
- undo semantics
|
||||||
|
- win detection
|
||||||
|
|
||||||
|
2. **Engine integration tests (`solitaire_wasm` debug API)**
|
||||||
|
- autonomous game execution without UI/pointer simulation
|
||||||
|
- invariant checks after every move
|
||||||
|
- deterministic seed replay
|
||||||
|
- high-volume seeded runs (including long-running soak tests)
|
||||||
|
|
||||||
|
3. **Playwright UI tests**
|
||||||
|
- verify rendering vs engine state
|
||||||
|
- drag/drop and keyboard UX behavior
|
||||||
|
- responsive layout behavior
|
||||||
|
- browser-compatibility checks
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
The Rust engine is authoritative. Browser tests must interact with the game via
|
||||||
|
debug API hooks, not via pixel/OCR solving or hardcoded screen coordinates.
|
||||||
|
|
||||||
|
## Debug API surfaces
|
||||||
|
|
||||||
|
Two automation surfaces are exposed:
|
||||||
|
|
||||||
|
- `solitaire_wasm::SolitaireGame` methods:
|
||||||
|
- `debug_snapshot()`
|
||||||
|
- `debug_legal_moves()`
|
||||||
|
- `debug_move_history()`
|
||||||
|
- `debug_apply_legal_move(index)`
|
||||||
|
- `debug_apply_move_json(json)`
|
||||||
|
- Browser bridge on `game.html`:
|
||||||
|
- `window.__FERROUS_DEBUG__.snapshot()`
|
||||||
|
- `window.__FERROUS_DEBUG__.legalMoves()`
|
||||||
|
- `window.__FERROUS_DEBUG__.moveHistory()`
|
||||||
|
- `window.__FERROUS_DEBUG__.applyLegalMove(index)`
|
||||||
|
- `window.__FERROUS_DEBUG__.applyMove(move)`
|
||||||
|
- `window.__FERROUS_DEBUG__.failureReport()`
|
||||||
|
- `window.__FERROUS_DEBUG__.runAutoplay(options)`
|
||||||
|
|
||||||
|
## Required failure payload
|
||||||
|
|
||||||
|
Every automation failure should capture:
|
||||||
|
|
||||||
|
- seed
|
||||||
|
- move history
|
||||||
|
- current game state
|
||||||
|
- screenshot
|
||||||
|
- browser trace
|
||||||
|
- console logs
|
||||||
|
|
||||||
|
`failureReport()` provides the engine-side fields (`seed`, `moveHistory`,
|
||||||
|
`currentState`) so UI harnesses only need to attach browser artifacts.
|
||||||
|
|
||||||
|
## Execution guidance
|
||||||
|
|
||||||
|
- Fast verification:
|
||||||
|
- `cargo test -p solitaire_core -p solitaire_wasm`
|
||||||
|
- Full verification:
|
||||||
|
- `cargo test --workspace`
|
||||||
|
- `cargo clippy --workspace -- -D warnings`
|
||||||
|
- Long unattended soak:
|
||||||
|
- `cargo test -p solitaire_wasm debug_api_autonomous_thousands_seed_soak -- --ignored`
|
||||||
|
|
||||||
|
### Browser e2e harness
|
||||||
|
|
||||||
|
The Playwright suite lives under `solitaire_server/e2e/` and boots
|
||||||
|
`solitaire_server` via Playwright `webServer` config.
|
||||||
|
|
||||||
|
- Install + run:
|
||||||
|
- `cd solitaire_server/e2e`
|
||||||
|
- `npm ci`
|
||||||
|
- `npx playwright install chromium`
|
||||||
|
- `npm test`
|
||||||
|
- Cycle metrics batch run:
|
||||||
|
- `cd solitaire_server/e2e`
|
||||||
|
- `npm run review:cycles -- --games 1000 --steps 350 --policy baseline --max-visits 1 --out /tmp/cycle-baseline.json`
|
||||||
|
- `npm run review:cycles -- --games 1000 --steps 350 --policy loop_aware --max-visits 2 --out /tmp/cycle-loop-aware.json`
|
||||||
|
- `npm run review:cycles:regression` (thresholded gate, writes `test-results/cycle-regression.json`)
|
||||||
|
- `npm run review:cycles:candidate` (loop-aware candidate run, writes `test-results/cycle-candidate.json`)
|
||||||
|
|
||||||
|
### Cycle-risk regression baseline and guardrails
|
||||||
|
|
||||||
|
- Current regression gate command:
|
||||||
|
- `npm run review:cycles:regression`
|
||||||
|
- config: `games=240`, `steps=350`, `policy=baseline`, `max-visits=1`
|
||||||
|
- Current guardrail thresholds:
|
||||||
|
- `all.cycle_rate_pct <= 86`
|
||||||
|
- `draw1.cycle_rate_pct <= 76`
|
||||||
|
- `draw3.cycle_rate_pct <= 95`
|
||||||
|
- `all.win_rate_pct >= 14`
|
||||||
|
- zero invariant/apply/page/console issue counts
|
||||||
|
- Baseline sample (240 games):
|
||||||
|
- overall: `win_rate=15.8%`, `cycle_rate=84.2%`
|
||||||
|
- draw-one: `win_rate=25.8%`, `cycle_rate=74.2%`
|
||||||
|
- draw-three: `win_rate=5.8%`, `cycle_rate=94.2%`
|
||||||
|
- Candidate loop-aware sample (240 games, lookahead via simulated move + restore):
|
||||||
|
- overall: `win_rate=20.4%`, `cycle_rate=32.5%`
|
||||||
|
- draw-one: `win_rate=33.3%`, `cycle_rate=16.7%`
|
||||||
|
- draw-three: `win_rate=7.5%`, `cycle_rate=48.3%`
|
||||||
|
- no invariant/apply/page/console issues in the sampled run
|
||||||
|
- Additional 500-game candidate soak:
|
||||||
|
- overall: `win_rate=20.2%`, `cycle_rate=28.6%`, `step_budget=51.2%`
|
||||||
|
- draw-three remains the dominant risk (`cycle_rate=45.2%`)
|
||||||
|
- Fix applied: cycle metrics regression now supports explicit
|
||||||
|
`max_step_budget_rate_*` thresholds. Candidate command now enforces
|
||||||
|
`max_step_budget_rate_all <= 60` to prevent silent drift from cycles into
|
||||||
|
step-budget stalls.
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Android testing
|
||||||
|
|
||||||
|
This directory contains lightweight Android test helpers for Ferrous Solitaire.
|
||||||
|
They are intended to run against either a physical Android device or an emulator
|
||||||
|
connected through `adb`. When no device is connected the smoke script can
|
||||||
|
automatically launch an AVD for you.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Android SDK and NDK installed.
|
||||||
|
- `adb` available on `PATH`.
|
||||||
|
- One device/emulator visible in `adb devices`, **or** at least one AVD created
|
||||||
|
(the script will launch one automatically if `LAUNCH_AVD=1`, which is the default).
|
||||||
|
- If multiple devices are connected, set `ADB_SERIAL` to the target device serial.
|
||||||
|
- Environment variables required by `scripts/build_android_apk.sh` when building:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ANDROID_HOME=/path/to/android-sdk
|
||||||
|
export ANDROID_NDK_HOME=/path/to/android-ndk
|
||||||
|
export BUILD_TOOLS_VERSION=34.0.0
|
||||||
|
export PLATFORM=android-34
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke test
|
||||||
|
|
||||||
|
From the workspace root (`Rusty_Solitaire/`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke test first checks whether `adb` can see a ready device. If no device
|
||||||
|
is connected and `LAUNCH_AVD=1` (default), it:
|
||||||
|
|
||||||
|
1. locates the `emulator` binary under `ANDROID_HOME` or `PATH`,
|
||||||
|
2. picks the first available AVD (or uses `AVD_NAME`),
|
||||||
|
3. launches the emulator in the foreground (or headless with `AVD_HEADLESS=1`),
|
||||||
|
4. waits for `sys.boot_completed=1` before proceeding,
|
||||||
|
5. dismisses the lock screen so the screenshot shows the app.
|
||||||
|
|
||||||
|
Once a device is ready (auto-launched or pre-existing) the script:
|
||||||
|
|
||||||
|
1. builds the APK using `scripts/build_android_apk.sh`,
|
||||||
|
2. installs it with `adb install -r -d` so debug smoke builds can replace newer local builds,
|
||||||
|
3. force-stops the package by default for a clean launch,
|
||||||
|
4. clears `logcat`,
|
||||||
|
5. launches `com.ferrousapp.solitaire/android.app.NativeActivity`,
|
||||||
|
6. waits for the app to settle,
|
||||||
|
7. verifies the process is still running,
|
||||||
|
8. captures a screenshot and `logcat`, and
|
||||||
|
9. fails on fatal log patterns such as native crashes, JNI fatal errors, real ANRs,
|
||||||
|
and Rust panics.
|
||||||
|
|
||||||
|
On exit the script kills any emulator it launched (`SHUTDOWN_AVD_ON_EXIT=1` by
|
||||||
|
default). Set `SHUTDOWN_AVD_ON_EXIT=0` to keep the emulator open for inspection.
|
||||||
|
|
||||||
|
Artifacts are written to `target/android-smoke/<timestamp>/` by default. A successful run includes:
|
||||||
|
|
||||||
|
- `device.txt` — selected device and display metadata,
|
||||||
|
- `df-data-before.txt` / `df-data-after.txt` — emulator/device storage snapshots,
|
||||||
|
- `emulator.log` — stdout/stderr from the emulator process (AVD runs only),
|
||||||
|
- `emulator.pid` — PID of the emulator process (AVD runs only),
|
||||||
|
- `launch.png` — screenshot after the wait period,
|
||||||
|
- `logcat.txt` — full captured log,
|
||||||
|
- `log-summary.txt` — grep summary for warnings, errors, JNI, safe-area, and crash terms, and
|
||||||
|
- `pid.txt` — running app process id.
|
||||||
|
|
||||||
|
## Creating an AVD
|
||||||
|
|
||||||
|
If no AVDs exist, create one before running the smoke test:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Install a system image
|
||||||
|
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
|
'system-images;android-34;google_apis;x86_64'
|
||||||
|
|
||||||
|
# Create the AVD
|
||||||
|
"$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd \
|
||||||
|
-n Pixel_7_API_34 \
|
||||||
|
-k 'system-images;android-34;google_apis;x86_64' \
|
||||||
|
--device 'pixel_7'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the smoke test — it will pick `Pixel_7_API_34` automatically:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Faster iteration
|
||||||
|
|
||||||
|
If you already built the APK and only want to reinstall/relaunch:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BUILD_APK=0 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If the APK is already installed and you only want to relaunch/capture logs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BUILD_APK=0 INSTALL_APK=0 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the script force-stops the package before launch so logcat and screenshots represent a clean app start. To test warm-launch behavior instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BUILD_APK=0 INSTALL_APK=0 FORCE_STOP=0 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This is also useful when an already-installed build is good enough for launch/log checks. On install failure, the script writes `adb-install.txt`, storage snapshots, and installed-package diagnostics to the output directory.
|
||||||
|
|
||||||
|
If install fails with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, the smoke script uninstalls the package and retries once by default (`RESET_ON_SIGNATURE_MISMATCH=1`). This resets app data on the device/emulator. Disable it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RESET_ON_SIGNATURE_MISMATCH=0 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To write artifacts to a stable path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
When reusing an output directory, previous files are removed by default so stale artifacts do not contaminate the latest result. To keep existing files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CLEAN_OUT_DIR=0 OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To target a specific device when more than one is attached:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ADB_SERIAL=emulator-5554 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To wait longer for safe-area inset polling or slow devices:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
WAIT_SECS=8 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## AVD options
|
||||||
|
|
||||||
|
To pick a specific AVD by name instead of auto-selecting the first one:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To run headless (no emulator window) — useful in CI or on a display-less machine:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
AVD_HEADLESS=1 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To give a slow machine more time to boot the emulator (default is 120 s):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
AVD_BOOT_TIMEOUT=180 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To keep the emulator running after the test (useful for manual inspection):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To pass extra flags to the emulator (e.g. disable snapshot for a completely
|
||||||
|
cold boot, or change GPU mode):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
AVD_EXTRA_ARGS="-gpu swiftshader_indirect" scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To disable AVD auto-launch entirely and fail immediately if no device is
|
||||||
|
connected:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
LAUNCH_AVD=0 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For build-only validation without requiring a connected device, use the lower-level APK builder directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build_android_apk.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For smoke testing, `scripts/android_smoke.sh` defaults to the connected device's primary ABI when `BUILD_APK=1`, which keeps emulator APKs much smaller than the full multi-ABI default. You can still override it explicitly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ABIS=x86_64 scripts/android_smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For build-only validation, `scripts/build_android_apk.sh` still defaults to all configured ABIs unless you set `ABIS` yourself:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ABIS=x86_64 scripts/build_android_apk.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The APK builder signs debug builds with a persistent keystore at `target/android/debug.keystore` by default. This avoids signature churn across smoke-test runs.
|
||||||
|
|
||||||
|
The APK builder also strips native debug symbols by default before packaging (`STRIP_NATIVE_LIBS=1`). This keeps debug APKs installable on emulators with limited `/data` storage. To preserve native debug symbols for low-level debugging:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
STRIP_NATIVE_LIBS=0 ABIS=x86_64 scripts/build_android_apk.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device checklist
|
||||||
|
|
||||||
|
The script is only a smoke test. Before shipping Android builds, also verify:
|
||||||
|
|
||||||
|
- safe-area insets arrive and shift the HUD after a few seconds,
|
||||||
|
- HUD does not overlap the top status bar,
|
||||||
|
- modal Done buttons are above the gesture/navigation bar,
|
||||||
|
- stock tap works,
|
||||||
|
- drag-and-drop works on tableau, waste, and foundation piles,
|
||||||
|
- Settings/Help/Profile modals open and close,
|
||||||
|
- login tokens persist after app restart, and
|
||||||
|
- `target/android-smoke/.../logcat.txt` contains no fatal JNI/native crash output.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `adb shell input tap X Y` uses physical pixels, not Bevy logical pixels.
|
||||||
|
- The project’s common test device mapping is physical `1080×2400`, Bevy logical
|
||||||
|
`900×2000`, scale factor `1.20`; multiply logical coordinates by `1.20` for
|
||||||
|
scripted `adb shell input` commands on that device.
|
||||||
|
- Keep generated screenshots/logs under `target/android-smoke/` so they stay out
|
||||||
|
of source control.
|
||||||
Executable
+362
@@ -0,0 +1,362 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Android smoke test for Ferrous Solitaire.
|
||||||
|
#
|
||||||
|
# Builds (optional), installs, launches, captures logcat + screenshot, and
|
||||||
|
# fails on fatal Android log patterns. Designed as a lightweight device/emulator
|
||||||
|
# sanity check rather than a full UI automation suite.
|
||||||
|
#
|
||||||
|
# Required:
|
||||||
|
# adb on PATH
|
||||||
|
# Android SDK/NDK env required by scripts/build_android_apk.sh when BUILD_APK=1
|
||||||
|
#
|
||||||
|
# Optional environment:
|
||||||
|
# BUILD_APK=1|0 Build APK before install (default: 1)
|
||||||
|
# INSTALL_APK=1|0 Install APK before launch (default: 1)
|
||||||
|
# RESET_ON_SIGNATURE_MISMATCH=1|0
|
||||||
|
# Uninstall/retry if debug signatures differ (default: 1)
|
||||||
|
# LAUNCH_APP=1|0 Launch app before checks (default: 1)
|
||||||
|
# FORCE_STOP=1|0 Force-stop package before launch for clean logs (default: 1)
|
||||||
|
# CAPTURE_SCREENSHOT=1|0 Capture screenshot (default: 1)
|
||||||
|
# ADB_SERIAL=... Device serial to use when multiple devices are connected
|
||||||
|
# APK_PATH=... APK to install (default: target/debug/apk/ferrous-solitaire.apk)
|
||||||
|
# PACKAGE=... Android package (default: com.ferrousapp.solitaire)
|
||||||
|
# ACTIVITY=... Activity class (default: android.app.NativeActivity)
|
||||||
|
# OUT_DIR=... Artifact directory (default: target/android-smoke/<timestamp>)
|
||||||
|
# CLEAN_OUT_DIR=1|0 Remove prior artifacts from OUT_DIR first (default: 1)
|
||||||
|
# WAIT_SECS=... Seconds to wait after launch (default: 5)
|
||||||
|
# ABIS=... Passed to build script. If unset and BUILD_APK=1,
|
||||||
|
# defaults to the connected device's primary ABI.
|
||||||
|
#
|
||||||
|
# AVD auto-launch (used when no device/emulator is already connected):
|
||||||
|
# LAUNCH_AVD=1|0 Auto-launch an AVD when no device is ready (default: 1)
|
||||||
|
# AVD_NAME=... AVD name to launch (default: first from `emulator -list-avds`)
|
||||||
|
# AVD_BOOT_TIMEOUT=... Seconds to wait for the emulator to finish booting (default: 120)
|
||||||
|
# AVD_HEADLESS=1|0 Run with -no-window -no-audio for CI/no-display environments (default: 0)
|
||||||
|
# AVD_EXTRA_ARGS=... Extra arguments appended verbatim to the emulator command line
|
||||||
|
# SHUTDOWN_AVD_ON_EXIT=1|0
|
||||||
|
# Kill the AVD this script launched on exit (default: 1).
|
||||||
|
# Set to 0 to leave the emulator running after the test.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# scripts/android_smoke.sh
|
||||||
|
# BUILD_APK=0 scripts/android_smoke.sh
|
||||||
|
# LAUNCH_AVD=0 scripts/android_smoke.sh # error out if no device, never auto-launch
|
||||||
|
# AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
|
||||||
|
# AVD_HEADLESS=1 scripts/android_smoke.sh # CI / no-display
|
||||||
|
# SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh # keep emulator open after test
|
||||||
|
# OUT_DIR=target/android-smoke/latest WAIT_SECS=8 scripts/android_smoke.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
BUILD_APK="${BUILD_APK:-1}"
|
||||||
|
INSTALL_APK="${INSTALL_APK:-1}"
|
||||||
|
RESET_ON_SIGNATURE_MISMATCH="${RESET_ON_SIGNATURE_MISMATCH:-1}"
|
||||||
|
LAUNCH_APP="${LAUNCH_APP:-1}"
|
||||||
|
FORCE_STOP="${FORCE_STOP:-1}"
|
||||||
|
CAPTURE_SCREENSHOT="${CAPTURE_SCREENSHOT:-1}"
|
||||||
|
APK_PATH="${APK_PATH:-target/debug/apk/ferrous-solitaire.apk}"
|
||||||
|
PACKAGE="${PACKAGE:-com.ferrousapp.solitaire}"
|
||||||
|
ACTIVITY="${ACTIVITY:-android.app.NativeActivity}"
|
||||||
|
WAIT_SECS="${WAIT_SECS:-5}"
|
||||||
|
OUT_DIR="${OUT_DIR:-target/android-smoke/$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
CLEAN_OUT_DIR="${CLEAN_OUT_DIR:-1}"
|
||||||
|
REMOTE_SCREENSHOT="/sdcard/ferrous-solitaire-smoke.png"
|
||||||
|
|
||||||
|
LAUNCH_AVD="${LAUNCH_AVD:-1}"
|
||||||
|
AVD_NAME="${AVD_NAME:-}"
|
||||||
|
AVD_BOOT_TIMEOUT="${AVD_BOOT_TIMEOUT:-120}"
|
||||||
|
AVD_HEADLESS="${AVD_HEADLESS:-0}"
|
||||||
|
AVD_EXTRA_ARGS="${AVD_EXTRA_ARGS:-}"
|
||||||
|
SHUTDOWN_AVD_ON_EXIT="${SHUTDOWN_AVD_ON_EXIT:-1}"
|
||||||
|
|
||||||
|
ADB=(adb)
|
||||||
|
if [ -n "${ADB_SERIAL:-}" ]; then
|
||||||
|
ADB+=( -s "$ADB_SERIAL" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PID of any emulator we start so the EXIT trap can clean it up.
|
||||||
|
_LAUNCHED_EMULATOR_PID=""
|
||||||
|
|
||||||
|
_cleanup_emulator() {
|
||||||
|
if [ -n "$_LAUNCHED_EMULATOR_PID" ] && [ "$SHUTDOWN_AVD_ON_EXIT" = "1" ]; then
|
||||||
|
echo ">>> shutdown emulator (PID $_LAUNCHED_EMULATOR_PID)"
|
||||||
|
kill "$_LAUNCHED_EMULATOR_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap _cleanup_emulator EXIT
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
echo "missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
if [ "$CLEAN_OUT_DIR" = "1" ]; then
|
||||||
|
find "$OUT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||||
|
fi
|
||||||
|
require_cmd adb
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device / emulator availability
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
||||||
|
if [ "$DEVICE_STATE" != "device" ]; then
|
||||||
|
if [ "$LAUNCH_AVD" != "1" ]; then
|
||||||
|
adb devices > "$OUT_DIR/adb-devices.txt" 2>&1 || true
|
||||||
|
if [ -n "${ADB_SERIAL:-}" ]; then
|
||||||
|
echo "Android device '$ADB_SERIAL' is not connected/ready (state: ${DEVICE_STATE:-unknown})." >&2
|
||||||
|
else
|
||||||
|
echo "No Android device/emulator is connected and ready." >&2
|
||||||
|
fi
|
||||||
|
echo "Run 'adb devices' or start an emulator, then retry." >&2
|
||||||
|
echo "Device list saved to $OUT_DIR/adb-devices.txt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- locate emulator binary -----------------------------------------------
|
||||||
|
# Priority: ANDROID_HOME env → PATH → common SDK install locations.
|
||||||
|
_find_sdk_root() {
|
||||||
|
for candidate in \
|
||||||
|
"$HOME/Android/Sdk" \
|
||||||
|
"$HOME/Library/Android/sdk" \
|
||||||
|
"/opt/android-sdk" \
|
||||||
|
"/usr/lib/android-sdk"; do
|
||||||
|
[ -d "$candidate" ] && echo "$candidate" && return
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
EMULATOR_BIN=""
|
||||||
|
if [ -n "${ANDROID_HOME:-}" ] && [ -x "$ANDROID_HOME/emulator/emulator" ]; then
|
||||||
|
EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
|
||||||
|
elif command -v emulator >/dev/null 2>&1; then
|
||||||
|
EMULATOR_BIN="$(command -v emulator)"
|
||||||
|
else
|
||||||
|
_SDK_ROOT="$(_find_sdk_root)"
|
||||||
|
if [ -n "$_SDK_ROOT" ] && [ -x "$_SDK_ROOT/emulator/emulator" ]; then
|
||||||
|
EMULATOR_BIN="$_SDK_ROOT/emulator/emulator"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$EMULATOR_BIN" ]; then
|
||||||
|
echo "No Android device found and 'emulator' binary is not available." >&2
|
||||||
|
echo " • Install the Android SDK emulator component, or" >&2
|
||||||
|
echo " • Set ANDROID_HOME to your SDK root, or" >&2
|
||||||
|
echo " • Start a device/emulator manually then retry with LAUNCH_AVD=0." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ">>> emulator binary: $EMULATOR_BIN"
|
||||||
|
|
||||||
|
# --- select AVD -----------------------------------------------------------
|
||||||
|
if [ -z "$AVD_NAME" ]; then
|
||||||
|
AVD_NAME="$("$EMULATOR_BIN" -list-avds 2>/dev/null | head -n 1 | tr -d '\r')"
|
||||||
|
if [ -z "$AVD_NAME" ]; then
|
||||||
|
echo "No AVDs found. Create one first, for example:" >&2
|
||||||
|
echo " sdkmanager 'system-images;android-34;google_apis;x86_64'" >&2
|
||||||
|
echo " avdmanager create avd -n Pixel_7_API_34 \\" >&2
|
||||||
|
echo " -k 'system-images;android-34;google_apis;x86_64' --device 'pixel_7'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ">>> auto-selected AVD: $AVD_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- launch emulator -------------------------------------------------------
|
||||||
|
EMULATOR_ARGS=( -avd "$AVD_NAME" -no-snapshot-load )
|
||||||
|
[ "$AVD_HEADLESS" = "1" ] && EMULATOR_ARGS+=( -no-window -no-audio )
|
||||||
|
# Split AVD_EXTRA_ARGS on whitespace only (disable glob expansion).
|
||||||
|
set -f
|
||||||
|
# shellcheck disable=SC2206
|
||||||
|
[ -n "$AVD_EXTRA_ARGS" ] && EMULATOR_ARGS+=( $AVD_EXTRA_ARGS )
|
||||||
|
set +f
|
||||||
|
|
||||||
|
echo ">>> launch emulator: $AVD_NAME"
|
||||||
|
"$EMULATOR_BIN" "${EMULATOR_ARGS[@]}" > "$OUT_DIR/emulator.log" 2>&1 &
|
||||||
|
_LAUNCHED_EMULATOR_PID=$!
|
||||||
|
echo "$_LAUNCHED_EMULATOR_PID" > "$OUT_DIR/emulator.pid"
|
||||||
|
echo " emulator PID: $_LAUNCHED_EMULATOR_PID"
|
||||||
|
echo " emulator log: $OUT_DIR/emulator.log"
|
||||||
|
|
||||||
|
# --- wait for adb transport -----------------------------------------------
|
||||||
|
# Poll adb get-state (≠ wait-for-device which blocks indefinitely) so we can
|
||||||
|
# honour AVD_BOOT_TIMEOUT for the whole boot sequence.
|
||||||
|
echo ">>> waiting for device to appear in adb (timeout: ${AVD_BOOT_TIMEOUT}s)"
|
||||||
|
_ELAPSED=0
|
||||||
|
while true; do
|
||||||
|
_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
||||||
|
if [ "$_STATE" = "device" ] || [ "$_STATE" = "offline" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
|
||||||
|
echo "Device did not appear in adb within ${AVD_BOOT_TIMEOUT}s" >&2
|
||||||
|
echo "emulator log:" >&2
|
||||||
|
tail -20 "$OUT_DIR/emulator.log" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
_ELAPSED=$(( _ELAPSED + 3 ))
|
||||||
|
echo " ... ${_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Capture emulator serial (emulator-5554 etc.) so all subsequent adb calls
|
||||||
|
# target the right device when ADB_SERIAL was not set by the caller.
|
||||||
|
if [ -z "${ADB_SERIAL:-}" ]; then
|
||||||
|
_EMU_SERIAL="$(adb devices 2>/dev/null | awk '/^emulator-/{print $1; exit}' | tr -d '\r')"
|
||||||
|
if [ -n "$_EMU_SERIAL" ]; then
|
||||||
|
ADB_SERIAL="$_EMU_SERIAL"
|
||||||
|
ADB=(adb -s "$ADB_SERIAL")
|
||||||
|
echo ">>> detected emulator serial: $ADB_SERIAL"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- wait for full Android boot -------------------------------------------
|
||||||
|
# adb get-state returning "device" means the transport is up, but the
|
||||||
|
# Android framework may still be initialising. Poll sys.boot_completed.
|
||||||
|
echo ">>> waiting for boot_completed (timeout: ${AVD_BOOT_TIMEOUT}s)"
|
||||||
|
_BOOT_ELAPSED=0
|
||||||
|
_BOOT_INTERVAL=5
|
||||||
|
while true; do
|
||||||
|
_BOOT="$("${ADB[@]}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
|
||||||
|
if [ "$_BOOT" = "1" ]; then
|
||||||
|
echo ">>> emulator boot complete"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$_BOOT_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
|
||||||
|
echo "Emulator did not finish booting within ${AVD_BOOT_TIMEOUT}s" >&2
|
||||||
|
echo "emulator log:" >&2
|
||||||
|
tail -20 "$OUT_DIR/emulator.log" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep "$_BOOT_INTERVAL"
|
||||||
|
_BOOT_ELAPSED=$(( _BOOT_ELAPSED + _BOOT_INTERVAL ))
|
||||||
|
echo " ... ${_BOOT_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s (boot_completed='${_BOOT}')"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Dismiss the lock screen so later screencap shows the app, not the keyguard.
|
||||||
|
"${ADB[@]}" shell input keyevent 82 2>/dev/null || true
|
||||||
|
|
||||||
|
# Final sanity check — device must be fully ready before we proceed.
|
||||||
|
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
||||||
|
if [ "$DEVICE_STATE" != "device" ]; then
|
||||||
|
echo "Emulator is running but adb state is '${DEVICE_STATE:-unknown}'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
echo "adb_serial=${ADB_SERIAL:-default}"
|
||||||
|
echo "package=$PACKAGE"
|
||||||
|
echo "activity=$ACTIVITY"
|
||||||
|
echo "device_state=$DEVICE_STATE"
|
||||||
|
"${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' | sed 's/^/product_model=/'
|
||||||
|
"${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' | sed 's/^/android_release=/'
|
||||||
|
"${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' | sed 's/^/android_sdk=/'
|
||||||
|
"${ADB[@]}" shell wm size 2>/dev/null | tr -d '\r' | sed 's/^/wm_size=/'
|
||||||
|
"${ADB[@]}" shell wm density 2>/dev/null | tr -d '\r' | sed 's/^/wm_density=/'
|
||||||
|
} > "$OUT_DIR/device.txt"
|
||||||
|
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-before.txt" 2>&1 || true
|
||||||
|
|
||||||
|
if [ "$BUILD_APK" = "1" ]; then
|
||||||
|
if [ -z "${ABIS:-}" ]; then
|
||||||
|
DEVICE_ABI="$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')"
|
||||||
|
case "$DEVICE_ABI" in
|
||||||
|
x86_64|arm64-v8a|armeabi-v7a)
|
||||||
|
export ABIS="$DEVICE_ABI"
|
||||||
|
;;
|
||||||
|
armeabi*)
|
||||||
|
export ABIS="armeabi-v7a"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Could not map device ABI '$DEVICE_ABI'; using build script default ABIS." >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
echo ">>> build Android APK${ABIS:+ (ABIS=$ABIS)}"
|
||||||
|
scripts/build_android_apk.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$INSTALL_APK" = "1" ]; then
|
||||||
|
[ -f "$APK_PATH" ] || {
|
||||||
|
echo "APK not found: $APK_PATH" >&2
|
||||||
|
echo "Set APK_PATH or run with BUILD_APK=1." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
ls -lh "$APK_PATH" > "$OUT_DIR/apk.txt"
|
||||||
|
echo ">>> install $APK_PATH"
|
||||||
|
if ! "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install.txt" 2>&1; then
|
||||||
|
if [ "$RESET_ON_SIGNATURE_MISMATCH" = "1" ] && grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE" "$OUT_DIR/adb-install.txt"; then
|
||||||
|
echo ">>> signature mismatch; uninstalling $PACKAGE and retrying install"
|
||||||
|
"${ADB[@]}" uninstall "$PACKAGE" > "$OUT_DIR/adb-uninstall-before-retry.txt" 2>&1 || true
|
||||||
|
if "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install-retry.txt" 2>&1; then
|
||||||
|
cat "$OUT_DIR/adb-install-retry.txt" >> "$OUT_DIR/adb-install.txt"
|
||||||
|
else
|
||||||
|
cat "$OUT_DIR/adb-install.txt" >&2
|
||||||
|
cat "$OUT_DIR/adb-install-retry.txt" >&2
|
||||||
|
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
|
||||||
|
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
|
||||||
|
echo "APK install retry failed. Diagnostics saved in $OUT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
cat "$OUT_DIR/adb-install.txt" >&2
|
||||||
|
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
|
||||||
|
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
|
||||||
|
echo "APK install failed. Diagnostics saved in $OUT_DIR" >&2
|
||||||
|
echo "If the package is already installed and you only need launch/log checks, retry with INSTALL_APK=0." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FORCE_STOP" = "1" ]; then
|
||||||
|
echo ">>> force-stop $PACKAGE"
|
||||||
|
"${ADB[@]}" shell am force-stop "$PACKAGE" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> clear logcat"
|
||||||
|
"${ADB[@]}" logcat -c
|
||||||
|
|
||||||
|
if [ "$LAUNCH_APP" = "1" ]; then
|
||||||
|
echo ">>> launch $PACKAGE/$ACTIVITY"
|
||||||
|
"${ADB[@]}" shell am start -n "$PACKAGE/$ACTIVITY" > "$OUT_DIR/am-start.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> wait ${WAIT_SECS}s"
|
||||||
|
sleep "$WAIT_SECS"
|
||||||
|
|
||||||
|
PID="$("${ADB[@]}" shell pidof "$PACKAGE" | tr -d '\r' || true)"
|
||||||
|
if [ -z "$PID" ]; then
|
||||||
|
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt" || true
|
||||||
|
echo "app process is not running after launch: $PACKAGE" >&2
|
||||||
|
echo "logcat saved to $OUT_DIR/logcat.txt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$PID" > "$OUT_DIR/pid.txt"
|
||||||
|
|
||||||
|
if [ "$CAPTURE_SCREENSHOT" = "1" ]; then
|
||||||
|
echo ">>> capture screenshot"
|
||||||
|
"${ADB[@]}" shell screencap -p "$REMOTE_SCREENSHOT"
|
||||||
|
"${ADB[@]}" pull "$REMOTE_SCREENSHOT" "$OUT_DIR/launch.png" >/dev/null
|
||||||
|
"${ADB[@]}" shell rm -f "$REMOTE_SCREENSHOT" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> capture logcat"
|
||||||
|
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt"
|
||||||
|
grep -iE "panic|fatal|jni|native crash|\bANR\b|exception|error|warn|keystore|safe_area" "$OUT_DIR/logcat.txt" > "$OUT_DIR/log-summary.txt" || true
|
||||||
|
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after.txt" 2>&1 || true
|
||||||
|
|
||||||
|
# Fatal patterns only. Avoid matching generic "error" because Android logs are
|
||||||
|
# noisy and many non-fatal framework lines contain that word.
|
||||||
|
if grep -iE "fatal exception|jni detected error|native crash|signal [0-9]+|ANR in|Application Not Responding|Input dispatching timed out|thread exiting with uncaught exception|panicked at" "$OUT_DIR/logcat.txt"; then
|
||||||
|
echo "Android smoke test found fatal log output" >&2
|
||||||
|
echo "Artifacts saved in $OUT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> Android smoke test passed"
|
||||||
|
echo "Artifacts saved in $OUT_DIR"
|
||||||
+105
-13
@@ -6,11 +6,15 @@
|
|||||||
# ndk-build crate that we couldn't isolate; running each Android toolchain
|
# ndk-build crate that we couldn't isolate; running each Android toolchain
|
||||||
# step explicitly gives us a debuggable pipeline.
|
# step explicitly gives us a debuggable pipeline.
|
||||||
#
|
#
|
||||||
# Required environment:
|
# Environment:
|
||||||
# ANDROID_HOME Path to Android SDK root
|
# ANDROID_HOME Path to Android SDK root. If unset, common SDK
|
||||||
# ANDROID_NDK_HOME Path to the specific NDK version
|
# locations such as ~/Android/Sdk are tried.
|
||||||
# BUILD_TOOLS_VERSION e.g. "34.0.0"
|
# ANDROID_NDK_HOME Path to the specific NDK version. If unset, the
|
||||||
# PLATFORM e.g. "android-34"
|
# newest $ANDROID_HOME/ndk/* directory is used.
|
||||||
|
# BUILD_TOOLS_VERSION e.g. "34.0.0". If unset, newest installed build-tools
|
||||||
|
# version is used.
|
||||||
|
# PLATFORM e.g. "android-34". If unset, newest installed
|
||||||
|
# $ANDROID_HOME/platforms/android-* platform is used.
|
||||||
#
|
#
|
||||||
# Optional environment:
|
# Optional environment:
|
||||||
# PROFILE "debug" (default) | "release"
|
# PROFILE "debug" (default) | "release"
|
||||||
@@ -19,7 +23,8 @@
|
|||||||
# fit the runner's disk budget — a full three-ABI
|
# fit the runner's disk budget — a full three-ABI
|
||||||
# debug build can exceed 25 GB of target/ output.
|
# debug build can exceed 25 GB of target/ output.
|
||||||
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
|
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
|
||||||
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
|
# STRIP_NATIVE_LIBS 1 to strip .so files before packaging (default: 1)
|
||||||
|
# KEYSTORE Path to keystore for signing (default: target/android/debug.keystore)
|
||||||
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
||||||
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
||||||
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
|
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
|
||||||
@@ -28,18 +33,63 @@
|
|||||||
# $APK_OUT Signed, zipaligned APK
|
# $APK_OUT Signed, zipaligned APK
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${ANDROID_HOME:?ANDROID_HOME must be set}"
|
infer_latest_dir_name() {
|
||||||
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
|
local pattern="$1"
|
||||||
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
|
local latest=""
|
||||||
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
|
shopt -s nullglob
|
||||||
|
local dirs=( $pattern )
|
||||||
|
shopt -u nullglob
|
||||||
|
if [ ${#dirs[@]} -gt 0 ]; then
|
||||||
|
latest="$(printf '%s\n' "${dirs[@]}" | sort -V | tail -n 1)"
|
||||||
|
basename "$latest"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "${ANDROID_HOME:-}" ]; then
|
||||||
|
for candidate in "$HOME/Android/Sdk" "$HOME/Library/Android/sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do
|
||||||
|
if [ -d "$candidate" ]; then
|
||||||
|
ANDROID_HOME="$candidate"
|
||||||
|
export ANDROID_HOME
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
: "${ANDROID_HOME:?ANDROID_HOME must be set or discoverable under a common SDK path}"
|
||||||
|
|
||||||
|
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||||
|
NDK_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/ndk/*")"
|
||||||
|
if [ -n "$NDK_VERSION" ]; then
|
||||||
|
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||||
|
export ANDROID_NDK_HOME
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set or discoverable under ANDROID_HOME/ndk}"
|
||||||
|
|
||||||
|
if [ -z "${BUILD_TOOLS_VERSION:-}" ]; then
|
||||||
|
BUILD_TOOLS_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/build-tools/*")"
|
||||||
|
export BUILD_TOOLS_VERSION
|
||||||
|
fi
|
||||||
|
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set or discoverable under ANDROID_HOME/build-tools}"
|
||||||
|
|
||||||
|
if [ -z "${PLATFORM:-}" ]; then
|
||||||
|
PLATFORM="$(infer_latest_dir_name "$ANDROID_HOME/platforms/android-*")"
|
||||||
|
export PLATFORM
|
||||||
|
fi
|
||||||
|
: "${PLATFORM:?PLATFORM must be set or discoverable under ANDROID_HOME/platforms}"
|
||||||
|
|
||||||
PROFILE="${PROFILE:-debug}"
|
PROFILE="${PROFILE:-debug}"
|
||||||
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
||||||
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
|
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)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
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"
|
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
|
||||||
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
|
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
|
||||||
MANIFEST="solitaire_app/android/AndroidManifest.xml"
|
MANIFEST="solitaire_app/android/AndroidManifest.xml"
|
||||||
@@ -69,6 +119,24 @@ fi
|
|||||||
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
|
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
|
||||||
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 ------------------------------
|
# --- 2. compile + link resources and manifest ------------------------------
|
||||||
if [ -d "$RES_DIR" ]; then
|
if [ -d "$RES_DIR" ]; then
|
||||||
echo ">>> aapt2 compile resources"
|
echo ">>> aapt2 compile resources"
|
||||||
@@ -120,11 +188,15 @@ rm -f "$STAGING/app-unsigned.apk"
|
|||||||
|
|
||||||
# --- 5. sign ---------------------------------------------------------------
|
# --- 5. sign ---------------------------------------------------------------
|
||||||
if [ -z "${KEYSTORE:-}" ]; then
|
if [ -z "${KEYSTORE:-}" ]; then
|
||||||
# Generate a deterministic debug keystore on the fly.
|
KEYSTORE="target/android/debug.keystore"
|
||||||
KEYSTORE="$STAGING/debug.keystore"
|
fi
|
||||||
|
|
||||||
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
|
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
|
||||||
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
|
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
|
||||||
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
||||||
|
|
||||||
|
if [ ! -f "$KEYSTORE" ]; then
|
||||||
|
mkdir -p "$(dirname "$KEYSTORE")"
|
||||||
echo ">>> generating debug keystore at $KEYSTORE"
|
echo ">>> generating debug keystore at $KEYSTORE"
|
||||||
keytool -genkeypair -v \
|
keytool -genkeypair -v \
|
||||||
-keystore "$KEYSTORE" \
|
-keystore "$KEYSTORE" \
|
||||||
@@ -141,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
|||||||
|
|
||||||
mkdir -p "$(dirname "$APK_OUT")"
|
mkdir -p "$(dirname "$APK_OUT")"
|
||||||
echo ">>> apksigner sign -> $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 \
|
"$BT/apksigner" sign \
|
||||||
--ks "$KEYSTORE" \
|
--ks "$KEYSTORE" \
|
||||||
--ks-pass "pass:$KEYSTORE_PASS" \
|
--ks-pass "pass:$KEYSTORE_PASS" \
|
||||||
--ks-key-alias "$KEY_ALIAS" \
|
--ks-key-alias "$KEY_ALIAS" \
|
||||||
--key-pass "pass:$KEY_PASS" \
|
--key-pass "pass:$KEY_PASS" \
|
||||||
|
--min-sdk-version 26 \
|
||||||
|
--v1-signing-enabled false \
|
||||||
|
--v2-signing-enabled true \
|
||||||
|
--v3-signing-enabled true \
|
||||||
--out "$APK_OUT" \
|
--out "$APK_OUT" \
|
||||||
"$STAGING/app-aligned.apk"
|
"$STAGING/app-aligned.apk"
|
||||||
|
|
||||||
echo ">>> verify"
|
echo ">>> verify"
|
||||||
"$BT/apksigner" verify --verbose "$APK_OUT"
|
"$BT/apksigner" verify --min-sdk-version 26 --verbose "$APK_OUT"
|
||||||
|
|
||||||
|
# Guard: no leftover v1/JAR signature files may remain — their presence (valid or
|
||||||
|
# not) is what tripped Obtainium. Fail the build if any slipped through.
|
||||||
|
if unzip -l "$APK_OUT" 2>/dev/null | grep -qiE 'META-INF/.*\.(SF|RSA|DSA|EC)$'; then
|
||||||
|
echo "ERROR: APK still contains v1/JAR signature files; expected v2+v3 only" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo ">>> done: $APK_OUT"
|
echo ">>> done: $APK_OUT"
|
||||||
|
|||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Update Quaternions registry dependencies and run the full safety gate.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# scripts/update_quaternions_deps.sh 0.3.1 0.4.1
|
||||||
|
#
|
||||||
|
# This script updates Cargo.lock to the requested versions (within the semver
|
||||||
|
# ranges already declared in Cargo.toml), then runs the project's required
|
||||||
|
# verification steps plus deterministic replay checks.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 2 ]; then
|
||||||
|
echo "usage: $0 <klondike_version> <card_game_version>"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
KLONDIKE_VERSION="$1"
|
||||||
|
CARD_GAME_VERSION="$2"
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
echo ">>> Quaternions registry:"
|
||||||
|
echo " https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||||
|
echo
|
||||||
|
echo ">>> Review upstream release notes / changelogs before proceeding:"
|
||||||
|
echo " - https://git.aleshym.co/Quaternions/card_game"
|
||||||
|
echo " - https://git.aleshym.co/Quaternions/klondike"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo ">>> Updating lockfile to klondike=$KLONDIKE_VERSION card_game=$CARD_GAME_VERSION"
|
||||||
|
cargo update -p klondike --precise "$KLONDIKE_VERSION"
|
||||||
|
cargo update -p card_game --precise "$CARD_GAME_VERSION"
|
||||||
|
|
||||||
|
echo ">>> Verifying dependency graph"
|
||||||
|
cargo tree -p solitaire_core --depth 2 | cat
|
||||||
|
|
||||||
|
echo ">>> Running workspace tests"
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
echo ">>> Running workspace clippy"
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
echo ">>> Running deterministic replay / debug-api smoke checks"
|
||||||
|
cargo test -p solitaire_wasm debug_snapshot_exposes_replayable_seed_and_history -- --exact
|
||||||
|
cargo test -p solitaire_wasm debug_api_autonomous_seed_batch_smoke -- --exact
|
||||||
|
|
||||||
|
echo ">>> Quaternions dependency upgrade gate passed"
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/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,6 +22,13 @@ bevy = { workspace = true }
|
|||||||
solitaire_engine = { workspace = true }
|
solitaire_engine = { workspace = true }
|
||||||
solitaire_data = { 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
|
# Desktop-only deps. `keyring`'s default-store init only matters on
|
||||||
# platforms with a real keychain backend (Linux Secret Service,
|
# platforms with a real keychain backend (Linux Secret Service,
|
||||||
# macOS Keychain, Windows Credential Store), and its transitive
|
# macOS Keychain, Windows Credential Store), and its transitive
|
||||||
@@ -99,3 +106,18 @@ icon = "@mipmap/ic_launcher"
|
|||||||
# in portrait orientation. Remove (or add a landscape layout) before
|
# in portrait orientation. Remove (or add a landscape layout) before
|
||||||
# enabling auto-rotate.
|
# enabling auto-rotate.
|
||||||
orientation = "portrait"
|
orientation = "portrait"
|
||||||
|
|
||||||
|
# `solitaire_app` is the one crate that cannot inherit the workspace
|
||||||
|
# `forbid(unsafe_code)`: as the Android cdylib it must export the
|
||||||
|
# `#[unsafe(no_mangle)]` entry point and reconstruct the raw JNI handles
|
||||||
|
# there (a `no_mangle` symbol cannot live in a dependency rlib). It mirrors
|
||||||
|
# the workspace lints but at `deny`, so the two `#[allow(unsafe_code)]`
|
||||||
|
# scopes in the Android entry point are the only unsafe in the whole tree.
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "deny"
|
||||||
|
single_use_lifetimes = "warn"
|
||||||
|
trivial_casts = "warn"
|
||||||
|
unused_lifetimes = "warn"
|
||||||
|
unused_qualifications = "warn"
|
||||||
|
variant_size_differences = "warn"
|
||||||
|
unexpected_cfgs = "warn"
|
||||||
|
|||||||
+68
-12
@@ -25,7 +25,10 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
|||||||
use bevy::winit::WinitWindows;
|
use bevy::winit::WinitWindows;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use bevy::winit::{UpdateMode, WinitSettings};
|
use bevy::winit::{UpdateMode, WinitSettings};
|
||||||
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
|
use solitaire_data::{
|
||||||
|
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
|
||||||
|
settings_file_path,
|
||||||
|
};
|
||||||
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
||||||
|
|
||||||
fn load_settings() -> Settings {
|
fn load_settings() -> Settings {
|
||||||
@@ -49,6 +52,12 @@ pub fn run() {
|
|||||||
// and any debugger attached still sees the panic).
|
// and any debugger attached still sees the panic).
|
||||||
install_crash_log_hook();
|
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.
|
// Initialise the platform keyring store before any token operations.
|
||||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||||
@@ -56,10 +65,9 @@ pub fn run() {
|
|||||||
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||||
//
|
//
|
||||||
// Android: `keyring` isn't compiled in (its `rpassword` transitive
|
// Android: `keyring` isn't compiled in (its `rpassword` transitive
|
||||||
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
|
// pulls a libc symbol Android's bionic doesn't expose). The Android
|
||||||
// ships an Android stub that returns KeychainUnavailable for every
|
// auth-token path uses Android Keystore via JNI; `android_main` passes
|
||||||
// call — the runtime behaviour is "session login required each launch"
|
// the process JavaVM pointer into `solitaire_data` before `run()`.
|
||||||
// until we wire Android Keystore via JNI in the Phase-Android round.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
if let Err(e) = keyring::use_native_store(true) {
|
if let Err(e) = keyring::use_native_store(true) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -136,7 +144,7 @@ fn build_app_with_settings(
|
|||||||
// Android windows always fill the screen; max_width/max_height
|
// Android windows always fill the screen; max_width/max_height
|
||||||
// default to 0.0, which panics Bevy's clamp when min > max.
|
// default to 0.0, which panics Bevy's clamp when min > max.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
resize_constraints: WindowResizeConstraints {
|
||||||
min_width: 800.0,
|
min_width: 800.0,
|
||||||
min_height: 600.0,
|
min_height: 600.0,
|
||||||
..default()
|
..default()
|
||||||
@@ -158,7 +166,7 @@ fn build_app_with_settings(
|
|||||||
// default makes it walk *out* of the APK's assets root and
|
// default makes it walk *out* of the APK's assets root and
|
||||||
// all loads fail silently — which is what produced the
|
// all loads fail silently — which is what produced the
|
||||||
// solid-red card-back fallback in the v0.22.3 screenshot.
|
// solid-red card-back fallback in the v0.22.3 screenshot.
|
||||||
.set(bevy::asset::AssetPlugin {
|
.set(AssetPlugin {
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
file_path: "../assets".to_string(),
|
file_path: "../assets".to_string(),
|
||||||
..default()
|
..default()
|
||||||
@@ -172,13 +180,16 @@ fn build_app_with_settings(
|
|||||||
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
||||||
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
||||||
//
|
//
|
||||||
// The focused mode stays Continuous so that card-slide animations remain
|
// focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when
|
||||||
// smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the
|
// an event arrives (touch, resize, etc.) or an animation system writes
|
||||||
// display refresh rate (~60 Hz) when foregrounded, which already prevents
|
// RequestRedraw. The 100 ms ceiling is a fallback that ensures the game
|
||||||
// the GPU from spinning at 200+ fps between vsync intervals.
|
// timer ticks at least 10×/s even with no input, while keeping the GPU
|
||||||
|
// completely idle between frames when the board is static.
|
||||||
|
// PresentMode::AutoVsync (set above) still caps the GPU at the display
|
||||||
|
// refresh rate when frames do render.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.insert_resource(WinitSettings {
|
app.insert_resource(WinitSettings {
|
||||||
focused_mode: UpdateMode::Continuous,
|
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
|
||||||
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,12 +363,57 @@ fn set_window_icon(
|
|||||||
/// works on a function named `main`; our shared entry point is `run`, so
|
/// works on a function named `main`; our shared entry point is `run`, so
|
||||||
/// we emit the equivalent expansion manually.
|
/// we emit the equivalent expansion manually.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
|
#[allow(unsafe_code)]
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
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);
|
let _ = bevy::android::ANDROID_APP.set(android_app);
|
||||||
run();
|
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
|
/// 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
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||||
/// still runs afterwards, so stderr output and debugger integration are
|
/// still runs afterwards, so stderr output and debugger integration are
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ path = "src/bin/gen_seeds.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen_difficulty_seeds"
|
name = "gen_difficulty_seeds"
|
||||||
path = "src/bin/gen_difficulty_seeds.rs"
|
path = "src/bin/gen_difficulty_seeds.rs"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
||||||
//! `solitaire_data/src/difficulty_seeds.rs`.
|
//! `solitaire_data/src/difficulty_seeds.rs`.
|
||||||
//!
|
//!
|
||||||
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
|
//! A seed's tier is determined by the **smallest** solve budget at which it is
|
||||||
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
|
//! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget
|
||||||
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
|
//! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded
|
||||||
//! provably-winnable seeds).
|
//! (we only emit provably-winnable seeds).
|
||||||
//!
|
//!
|
||||||
//! # Usage
|
//! # Usage
|
||||||
//!
|
//!
|
||||||
@@ -19,12 +19,12 @@
|
|||||||
//! --per-tier Seeds to emit per tier (default 40)
|
//! --per-tier Seeds to emit per tier (default 40)
|
||||||
//! --help Print this message
|
//! --help Print this message
|
||||||
|
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||||
// whose budget proves it Winnable.
|
// whose budget proves it Winnable.
|
||||||
const BUDGETS: &[(&str, u64, usize)] = &[
|
const BUDGETS: &[(&str, u64, u64)] = &[
|
||||||
("Easy", 1_000, 1_000),
|
("Easy", 1_000, 1_000),
|
||||||
("Medium", 5_000, 5_000),
|
("Medium", 5_000, 5_000),
|
||||||
("Hard", 25_000, 25_000),
|
("Hard", 25_000, 25_000),
|
||||||
@@ -74,7 +74,7 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let draw_mode = DrawMode::DrawOne;
|
let draw_mode = DrawStockConfig::DrawOne;
|
||||||
let num_tiers = BUDGETS.len();
|
let num_tiers = BUDGETS.len();
|
||||||
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||||
let mut tried: u64 = 0;
|
let mut tried: u64 = 0;
|
||||||
@@ -99,12 +99,8 @@ fn main() {
|
|||||||
if buckets[i].len() >= per_tier {
|
if buckets[i].len() >= per_tier {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let cfg = SolverConfig {
|
match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) {
|
||||||
move_budget,
|
Ok(Some(_)) => {
|
||||||
state_budget,
|
|
||||||
};
|
|
||||||
match try_solve(seed, draw_mode, &cfg) {
|
|
||||||
SolverResult::Winnable => {
|
|
||||||
buckets[i].push(seed);
|
buckets[i].push(seed);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
||||||
@@ -113,13 +109,13 @@ fn main() {
|
|||||||
);
|
);
|
||||||
break 'tier; // assign to the cheapest tier that proves it winnable
|
break 'tier; // assign to the cheapest tier that proves it winnable
|
||||||
}
|
}
|
||||||
SolverResult::Unwinnable => {
|
Ok(None) => {
|
||||||
// Definitely unsolvable — skip all remaining tiers.
|
// Definitely unsolvable — skip all remaining tiers.
|
||||||
break 'tier;
|
break 'tier;
|
||||||
}
|
}
|
||||||
SolverResult::Inconclusive => {
|
Err(_) => {
|
||||||
// Budget exhausted without proof — try the next larger tier.
|
// 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").
|
// at max budget means "probably but not provably winnable").
|
||||||
if i == num_tiers - 1 {
|
if i == num_tiers - 1 {
|
||||||
break 'tier;
|
break 'tier;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
||||||
//!
|
//!
|
||||||
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
||||||
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
|
//! collects only those proven winnable (`Ok(Some(_))`; inconclusive is
|
||||||
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
||||||
//! pasting into `solitaire_data/src/challenge.rs`.
|
//! pasting into `solitaire_data/src/challenge.rs`.
|
||||||
//!
|
//!
|
||||||
@@ -17,8 +17,9 @@
|
|||||||
//! --count Number of Winnable seeds to emit (default 75)
|
//! --count Number of Winnable seeds to emit (default 75)
|
||||||
//! --help Print this message
|
//! --help Print this message
|
||||||
|
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut args = std::env::args().skip(1).peekable();
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
@@ -67,8 +68,7 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cfg = SolverConfig::default();
|
let draw_mode = DrawStockConfig::DrawOne;
|
||||||
let draw_mode = DrawMode::DrawOne;
|
|
||||||
let mut found: Vec<u64> = Vec::with_capacity(count);
|
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||||
let mut tried: u64 = 0;
|
let mut tried: u64 = 0;
|
||||||
let mut seed = start;
|
let mut seed = start;
|
||||||
@@ -77,7 +77,15 @@ fn main() {
|
|||||||
|
|
||||||
while found.len() < count {
|
while found.len() < count {
|
||||||
tried += 1;
|
tried += 1;
|
||||||
if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) {
|
if matches!(
|
||||||
|
GameState::solve_fresh_deal(
|
||||||
|
seed,
|
||||||
|
draw_mode,
|
||||||
|
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
|
DEFAULT_SOLVE_STATES_BUDGET
|
||||||
|
),
|
||||||
|
Ok(Some(_))
|
||||||
|
) {
|
||||||
found.push(seed);
|
found.push(seed);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
||||||
|
|||||||
@@ -4,7 +4,18 @@ version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
test-support = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = "1"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
rand = { workspace = true }
|
klondike = { workspace = true }
|
||||||
|
card_game = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Card suit.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
pub enum Suit {
|
|
||||||
Clubs,
|
|
||||||
Diamonds,
|
|
||||||
Hearts,
|
|
||||||
Spades,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Suit {
|
|
||||||
/// All four suits in declaration order.
|
|
||||||
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
|
|
||||||
|
|
||||||
/// Returns `true` for red suits (Diamonds, Hearts).
|
|
||||||
pub fn is_red(self) -> bool {
|
|
||||||
matches!(self, Suit::Diamonds | Suit::Hearts)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` for black suits (Clubs, Spades).
|
|
||||||
pub fn is_black(self) -> bool {
|
|
||||||
!self.is_red()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Card rank, Ace through King.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
pub enum Rank {
|
|
||||||
Ace = 1,
|
|
||||||
Two = 2,
|
|
||||||
Three = 3,
|
|
||||||
Four = 4,
|
|
||||||
Five = 5,
|
|
||||||
Six = 6,
|
|
||||||
Seven = 7,
|
|
||||||
Eight = 8,
|
|
||||||
Nine = 9,
|
|
||||||
Ten = 10,
|
|
||||||
Jack = 11,
|
|
||||||
Queen = 12,
|
|
||||||
King = 13,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Rank {
|
|
||||||
/// All thirteen ranks in ascending order.
|
|
||||||
pub const RANKS: [Self; 13] = [
|
|
||||||
Self::Ace,
|
|
||||||
Self::Two,
|
|
||||||
Self::Three,
|
|
||||||
Self::Four,
|
|
||||||
Self::Five,
|
|
||||||
Self::Six,
|
|
||||||
Self::Seven,
|
|
||||||
Self::Eight,
|
|
||||||
Self::Nine,
|
|
||||||
Self::Ten,
|
|
||||||
Self::Jack,
|
|
||||||
Self::Queen,
|
|
||||||
Self::King,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Numeric value: Ace = 1, King = 13.
|
|
||||||
pub fn value(self) -> u8 {
|
|
||||||
self as u8
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn new(n: u8) -> Option<Self> {
|
|
||||||
match n {
|
|
||||||
1 => Some(Self::Ace),
|
|
||||||
2 => Some(Self::Two),
|
|
||||||
3 => Some(Self::Three),
|
|
||||||
4 => Some(Self::Four),
|
|
||||||
5 => Some(Self::Five),
|
|
||||||
6 => Some(Self::Six),
|
|
||||||
7 => Some(Self::Seven),
|
|
||||||
8 => Some(Self::Eight),
|
|
||||||
9 => Some(Self::Nine),
|
|
||||||
10 => Some(Self::Ten),
|
|
||||||
11 => Some(Self::Jack),
|
|
||||||
12 => Some(Self::Queen),
|
|
||||||
13 => Some(Self::King),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
|
|
||||||
pub const fn checked_add(self, n: u8) -> Option<Self> {
|
|
||||||
Self::new((self as u8).saturating_add(n))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
|
|
||||||
pub const fn checked_sub(self, n: u8) -> Option<Self> {
|
|
||||||
match (self as u8).checked_sub(n) {
|
|
||||||
Some(v) => Self::new(v),
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single playing card.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct Card {
|
|
||||||
/// Unique identifier for this card within the deal. Stable across moves and undo.
|
|
||||||
pub id: u32,
|
|
||||||
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
|
|
||||||
pub suit: Suit,
|
|
||||||
/// The card's rank (Ace through King).
|
|
||||||
pub rank: Rank,
|
|
||||||
/// Whether the card is visible to the player. Face-down cards may not be moved.
|
|
||||||
pub face_up: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_values_are_sequential() {
|
|
||||||
for (i, r) in Rank::RANKS.iter().enumerate() {
|
|
||||||
assert_eq!(r.value(), (i + 1) as u8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_as_u8_matches_value() {
|
|
||||||
for r in Rank::RANKS {
|
|
||||||
assert_eq!(r as u8, r.value());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_checked_add_boundary() {
|
|
||||||
assert_eq!(Rank::King.checked_add(1), None);
|
|
||||||
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
|
|
||||||
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
|
|
||||||
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_checked_sub_boundary() {
|
|
||||||
assert_eq!(Rank::Ace.checked_sub(1), None);
|
|
||||||
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
|
|
||||||
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
|
|
||||||
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn suit_suits_contains_all_four() {
|
|
||||||
assert_eq!(Suit::SUITS.len(), 4);
|
|
||||||
assert!(Suit::SUITS.contains(&Suit::Clubs));
|
|
||||||
assert!(Suit::SUITS.contains(&Suit::Diamonds));
|
|
||||||
assert!(Suit::SUITS.contains(&Suit::Hearts));
|
|
||||||
assert!(Suit::SUITS.contains(&Suit::Spades));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn suit_red_and_black_are_complementary() {
|
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
|
||||||
assert_ne!(
|
|
||||||
suit.is_red(),
|
|
||||||
suit.is_black(),
|
|
||||||
"{suit:?} must be exactly one of red/black"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
|
||||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
use crate::card::{Card, Rank, Suit};
|
|
||||||
use crate::pile::{Pile, PileType};
|
|
||||||
use rand::rngs::StdRng;
|
|
||||||
use rand::{SeedableRng, seq::SliceRandom};
|
|
||||||
|
|
||||||
const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
|
||||||
const ALL_RANKS: [Rank; 13] = [
|
|
||||||
Rank::Ace,
|
|
||||||
Rank::Two,
|
|
||||||
Rank::Three,
|
|
||||||
Rank::Four,
|
|
||||||
Rank::Five,
|
|
||||||
Rank::Six,
|
|
||||||
Rank::Seven,
|
|
||||||
Rank::Eight,
|
|
||||||
Rank::Nine,
|
|
||||||
Rank::Ten,
|
|
||||||
Rank::Jack,
|
|
||||||
Rank::Queen,
|
|
||||||
Rank::King,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// A standard 52-card deck.
|
|
||||||
pub struct Deck {
|
|
||||||
/// All 52 cards in the deck, in deal order.
|
|
||||||
pub cards: Vec<Card>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deck {
|
|
||||||
/// Creates an unshuffled deck with all 52 unique cards (id 0–51).
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let mut cards = Vec::with_capacity(52);
|
|
||||||
let mut id = 0u32;
|
|
||||||
for &suit in &ALL_SUITS {
|
|
||||||
for &rank in &ALL_RANKS {
|
|
||||||
cards.push(Card {
|
|
||||||
id,
|
|
||||||
suit,
|
|
||||||
rank,
|
|
||||||
face_up: false,
|
|
||||||
});
|
|
||||||
id += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self { cards }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shuffles the deck in-place using Fisher-Yates with a seeded `StdRng`.
|
|
||||||
/// The same seed always produces the same order on any platform.
|
|
||||||
pub fn shuffle(&mut self, seed: u64) {
|
|
||||||
let mut rng = StdRng::seed_from_u64(seed);
|
|
||||||
self.cards.shuffle(&mut rng);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Deck {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deals a standard Klondike layout from a pre-shuffled deck.
|
|
||||||
///
|
|
||||||
/// Returns 7 tableau piles and the remaining stock pile.
|
|
||||||
/// Column `i` contains `i + 1` cards; only the top card is face-up.
|
|
||||||
/// Stock receives the remaining 24 cards, all face-down.
|
|
||||||
pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) {
|
|
||||||
debug_assert_eq!(
|
|
||||||
deck.cards.len(),
|
|
||||||
52,
|
|
||||||
"deal_klondike requires a full 52-card deck"
|
|
||||||
);
|
|
||||||
let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i)));
|
|
||||||
// Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded.
|
|
||||||
let mut idx = 0usize;
|
|
||||||
|
|
||||||
for (col, pile) in tableau.iter_mut().enumerate() {
|
|
||||||
for row in 0..=col {
|
|
||||||
let mut card = deck.cards[idx].clone();
|
|
||||||
card.face_up = row == col;
|
|
||||||
pile.cards.push(card);
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stock = Pile::new(PileType::Stock);
|
|
||||||
stock.cards.extend(deck.cards.into_iter().skip(idx));
|
|
||||||
(tableau, stock)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deck_new_has_52_cards() {
|
|
||||||
assert_eq!(Deck::new().cards.len(), 52);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deck_new_has_unique_ids() {
|
|
||||||
let deck = Deck::new();
|
|
||||||
let mut ids: Vec<u32> = deck.cards.iter().map(|c| c.id).collect();
|
|
||||||
ids.sort_unstable();
|
|
||||||
ids.dedup();
|
|
||||||
assert_eq!(ids.len(), 52);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deck_new_has_all_suits_and_ranks() {
|
|
||||||
let deck = Deck::new();
|
|
||||||
for suit in ALL_SUITS {
|
|
||||||
for rank in ALL_RANKS {
|
|
||||||
assert!(
|
|
||||||
deck.cards.iter().any(|c| c.suit == suit && c.rank == rank),
|
|
||||||
"missing {rank:?} {suit:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn same_seed_produces_same_order() {
|
|
||||||
let mut d1 = Deck::new();
|
|
||||||
d1.shuffle(42);
|
|
||||||
let mut d2 = Deck::new();
|
|
||||||
d2.shuffle(42);
|
|
||||||
assert_eq!(d1.cards, d2.cards);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn different_seeds_produce_different_orders() {
|
|
||||||
let mut d1 = Deck::new();
|
|
||||||
d1.shuffle(1);
|
|
||||||
let mut d2 = Deck::new();
|
|
||||||
d2.shuffle(2);
|
|
||||||
assert_ne!(d1.cards, d2.cards);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deal_klondike_correct_tableau_sizes() {
|
|
||||||
let mut deck = Deck::new();
|
|
||||||
deck.shuffle(0);
|
|
||||||
let (tableau, stock) = deal_klondike(deck);
|
|
||||||
for (i, pile) in tableau.iter().enumerate() {
|
|
||||||
assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size");
|
|
||||||
}
|
|
||||||
assert_eq!(stock.cards.len(), 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deal_klondike_top_cards_are_face_up() {
|
|
||||||
let mut deck = Deck::new();
|
|
||||||
deck.shuffle(0);
|
|
||||||
let (tableau, _) = deal_klondike(deck);
|
|
||||||
for pile in &tableau {
|
|
||||||
assert!(pile.cards.last().unwrap().face_up);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deal_klondike_non_top_cards_are_face_down() {
|
|
||||||
let mut deck = Deck::new();
|
|
||||||
deck.shuffle(0);
|
|
||||||
let (tableau, _) = deal_klondike(deck);
|
|
||||||
for pile in &tableau {
|
|
||||||
for card in &pile.cards[..pile.cards.len().saturating_sub(1)] {
|
|
||||||
assert!(!card.face_up);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deal_klondike_stock_is_face_down() {
|
|
||||||
let mut deck = Deck::new();
|
|
||||||
deck.shuffle(0);
|
|
||||||
let (_, stock) = deal_klondike(deck);
|
|
||||||
assert!(stock.cards.iter().all(|c| !c.face_up));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deal_klondike_all_52_cards_present() {
|
|
||||||
let mut deck = Deck::new();
|
|
||||||
deck.shuffle(99);
|
|
||||||
let (tableau, stock) = deal_klondike(deck);
|
|
||||||
let mut ids: Vec<u32> = stock.cards.iter().map(|c| c.id).collect();
|
|
||||||
for pile in &tableau {
|
|
||||||
ids.extend(pile.cards.iter().map(|c| c.id));
|
|
||||||
}
|
|
||||||
ids.sort_unstable();
|
|
||||||
assert_eq!(ids, (0u32..52).collect::<Vec<_>>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1169
-1988
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
|||||||
|
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
|
||||||
|
//!
|
||||||
|
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
||||||
|
//! - building [`KlondikeConfig`] from Ferrous settings
|
||||||
|
//! - translating between local and upstream types
|
||||||
|
//!
|
||||||
|
//! Ferrous-specific scoring policy (the win-time bonus) lives in
|
||||||
|
//! [`crate::scoring`], not here.
|
||||||
|
//!
|
||||||
|
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
|
||||||
|
//! upstream `card_game` / `klondike` types live here so that the product modules
|
||||||
|
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
||||||
|
|
||||||
|
use klondike::{
|
||||||
|
DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
|
||||||
|
SkipCards, Tableau,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Bridges `solitaire_core` game config to the upstream `klondike` crate.
|
||||||
|
///
|
||||||
|
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
||||||
|
/// state, and exists only as a namespace for configuration and conversion
|
||||||
|
/// helpers.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct KlondikeAdapter;
|
||||||
|
|
||||||
|
impl KlondikeAdapter {
|
||||||
|
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
||||||
|
pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig {
|
||||||
|
KlondikeConfig {
|
||||||
|
draw_stock: draw_mode,
|
||||||
|
move_from_foundation: if take_from_foundation {
|
||||||
|
MoveFromFoundationConfig::Allowed
|
||||||
|
} else {
|
||||||
|
MoveFromFoundationConfig::Disallowed
|
||||||
|
},
|
||||||
|
scoring: ScoringConfig::DEFAULT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||||||
|
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
|
||||||
|
match index {
|
||||||
|
0 => Some(Tableau::Tableau1),
|
||||||
|
1 => Some(Tableau::Tableau2),
|
||||||
|
2 => Some(Tableau::Tableau3),
|
||||||
|
3 => Some(Tableau::Tableau4),
|
||||||
|
4 => Some(Tableau::Tableau5),
|
||||||
|
5 => Some(Tableau::Tableau6),
|
||||||
|
6 => Some(Tableau::Tableau7),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
|
||||||
|
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
|
||||||
|
match slot {
|
||||||
|
0 => Some(Foundation::Foundation1),
|
||||||
|
1 => Some(Foundation::Foundation2),
|
||||||
|
2 => Some(Foundation::Foundation3),
|
||||||
|
3 => Some(Foundation::Foundation4),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
|
||||||
|
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
||||||
|
match skip {
|
||||||
|
0 => Some(SkipCards::Skip0),
|
||||||
|
1 => Some(SkipCards::Skip1),
|
||||||
|
2 => Some(SkipCards::Skip2),
|
||||||
|
3 => Some(SkipCards::Skip3),
|
||||||
|
4 => Some(SkipCards::Skip4),
|
||||||
|
5 => Some(SkipCards::Skip5),
|
||||||
|
6 => Some(SkipCards::Skip6),
|
||||||
|
7 => Some(SkipCards::Skip7),
|
||||||
|
8 => Some(SkipCards::Skip8),
|
||||||
|
9 => Some(SkipCards::Skip9),
|
||||||
|
10 => Some(SkipCards::Skip10),
|
||||||
|
11 => Some(SkipCards::Skip11),
|
||||||
|
12 => Some(SkipCards::Skip12),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
pub mod achievement;
|
pub mod achievement;
|
||||||
pub mod card;
|
|
||||||
pub mod deck;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod game_state;
|
pub mod game_state;
|
||||||
pub mod pile;
|
pub mod klondike_adapter;
|
||||||
pub mod rules;
|
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
pub mod solver;
|
|
||||||
|
// 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};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod proptest_tests;
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
use crate::card::{Card, Suit};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Identifies which pile on the board a set of cards belongs to.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
||||||
pub enum PileType {
|
|
||||||
/// The face-down draw pile.
|
|
||||||
Stock,
|
|
||||||
/// The face-up discard pile drawn to.
|
|
||||||
Waste,
|
|
||||||
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
|
||||||
/// is derived from the bottom card of the pile (always an Ace by
|
|
||||||
/// construction).
|
|
||||||
Foundation(u8),
|
|
||||||
/// One of the seven tableau columns (0–6).
|
|
||||||
Tableau(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A named collection of cards in a specific board position.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct Pile {
|
|
||||||
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
|
||||||
pub pile_type: PileType,
|
|
||||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
|
||||||
pub cards: Vec<Card>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Pile {
|
|
||||||
/// Creates a new empty pile of the given type.
|
|
||||||
pub fn new(pile_type: PileType) -> Self {
|
|
||||||
Self {
|
|
||||||
pile_type,
|
|
||||||
cards: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a reference to the top (last) card, or `None` if empty.
|
|
||||||
pub fn top(&self) -> Option<&Card> {
|
|
||||||
self.cards.last()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// For foundation piles: returns `Some(suit)` once at least one card has
|
|
||||||
/// landed (the bottom card is always an Ace of the claimed suit).
|
|
||||||
/// Returns `None` for empty foundations or non-foundation piles.
|
|
||||||
pub fn claimed_suit(&self) -> Option<Suit> {
|
|
||||||
match self.pile_type {
|
|
||||||
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::card::{Card, Rank, Suit};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_pile_is_empty() {
|
|
||||||
let pile = Pile::new(PileType::Stock);
|
|
||||||
assert!(pile.cards.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pile_top_returns_last_card() {
|
|
||||||
let mut pile = Pile::new(PileType::Waste);
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 0,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 1,
|
|
||||||
suit: Suit::Clubs,
|
|
||||||
rank: Rank::Two,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert_eq!(pile.top().unwrap().id, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pile_top_on_empty_is_none() {
|
|
||||||
let pile = Pile::new(PileType::Waste);
|
|
||||||
assert!(pile.top().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pile_type_foundation_uses_slot_index() {
|
|
||||||
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pile_type_tableau_uses_index() {
|
|
||||||
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn claimed_suit_is_none_for_empty_foundation() {
|
|
||||||
let pile = Pile::new(PileType::Foundation(0));
|
|
||||||
assert!(pile.claimed_suit().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn claimed_suit_is_none_for_non_foundation() {
|
|
||||||
let mut pile = Pile::new(PileType::Tableau(0));
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 0,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert!(pile.claimed_suit().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn claimed_suit_returns_bottom_card_suit() {
|
|
||||||
let mut pile = Pile::new(PileType::Foundation(2));
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 0,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 1,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Two,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
use card_game::{Card, Game};
|
||||||
|
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use crate::game_state::GameState;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Collect all cards across every pile in a fixed traversal order:
|
||||||
|
/// stock → waste → foundations 1–4 → tableaux 1–7.
|
||||||
|
///
|
||||||
|
/// The order is deterministic for a given game state, so two calls on
|
||||||
|
/// equivalent states produce identical Vec outputs — the right fingerprint
|
||||||
|
/// for undo-reversibility checks.
|
||||||
|
fn all_cards(game: &GameState) -> Vec<Card> {
|
||||||
|
let foundations = [
|
||||||
|
Foundation::Foundation1,
|
||||||
|
Foundation::Foundation2,
|
||||||
|
Foundation::Foundation3,
|
||||||
|
Foundation::Foundation4,
|
||||||
|
];
|
||||||
|
let tableaux = [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
|
||||||
|
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
|
||||||
|
for f in &foundations {
|
||||||
|
cards.extend(
|
||||||
|
game.pile(KlondikePile::Foundation(*f))
|
||||||
|
.iter()
|
||||||
|
.map(|(c, _)| c.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for t in &tableaux {
|
||||||
|
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
||||||
|
}
|
||||||
|
cards
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||||
|
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||||
|
///
|
||||||
|
/// Each action is `(draw_flag, move_index)`:
|
||||||
|
/// - `draw_flag = true` → call `game.draw()`
|
||||||
|
/// - `draw_flag = false` → pick the `move_index % len`th legal instruction
|
||||||
|
/// from `possible_instructions()` and apply it via `apply_instruction()`.
|
||||||
|
///
|
||||||
|
/// `possible_instructions()` may return `RotateStock`, which
|
||||||
|
/// `apply_instruction()` dispatches to `game.draw()`; ordinary instructions
|
||||||
|
/// are equivalent to `move_cards(from, to, count)`.
|
||||||
|
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
|
||||||
|
for &(do_draw, idx) in actions {
|
||||||
|
if do_draw {
|
||||||
|
let _ = game.draw();
|
||||||
|
} else {
|
||||||
|
let moves = game.possible_instructions();
|
||||||
|
if moves.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let instruction = moves[idx % moves.len()];
|
||||||
|
let _ = game.apply_instruction(instruction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one move from `possible_instructions()` (or a draw if no move is
|
||||||
|
/// available), using `move_idx` to select among the legal options.
|
||||||
|
/// Returns `true` when a move was successfully applied.
|
||||||
|
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
|
||||||
|
if game.is_won() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let moves = game.possible_instructions();
|
||||||
|
if moves.is_empty() {
|
||||||
|
return game.draw().is_ok();
|
||||||
|
}
|
||||||
|
let instruction = moves[move_idx % moves.len()];
|
||||||
|
game.apply_instruction(instruction).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Properties
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
/// `check_auto_complete()` and `is_win_trivial()` must agree on every
|
||||||
|
/// reachable game state.
|
||||||
|
///
|
||||||
|
/// The upstream `Klondike::is_win_trivial()` checks that the stock pile
|
||||||
|
/// (both face-down and face-up halves) is completely empty AND that all
|
||||||
|
/// tableau columns have no face-down cards. Ferrous `check_auto_complete()`
|
||||||
|
/// checks the same three conditions individually (stock empty, waste empty,
|
||||||
|
/// all tableau cards face-up). This property guards against any semantic
|
||||||
|
/// drift between the two implementations so that delegating to upstream is
|
||||||
|
/// safe.
|
||||||
|
///
|
||||||
|
/// If this property ever fails, `check_auto_complete()` must NOT be fully
|
||||||
|
/// replaced — the Ferrous conditions must be preserved and `is_win_trivial()`
|
||||||
|
/// used only as a supplementary guard.
|
||||||
|
#[test]
|
||||||
|
fn check_auto_complete_agrees_with_is_win_trivial(
|
||||||
|
seed in any::<u64>(),
|
||||||
|
draw_mode in draw_mode_strategy(),
|
||||||
|
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||||
|
) {
|
||||||
|
let mut game = GameState::new(seed, draw_mode);
|
||||||
|
apply_random_actions(&mut game, &actions);
|
||||||
|
prop_assert_eq!(
|
||||||
|
game.check_auto_complete(),
|
||||||
|
game.session().state().state().is_win_trivial(),
|
||||||
|
"check_auto_complete() disagreed with is_win_trivial() after {:?} actions",
|
||||||
|
actions.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `check_win()` and `is_win()` must agree on every reachable game state.
|
||||||
|
#[test]
|
||||||
|
fn check_win_agrees_with_is_win(
|
||||||
|
seed in any::<u64>(),
|
||||||
|
draw_mode in draw_mode_strategy(),
|
||||||
|
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||||
|
) {
|
||||||
|
let mut game = GameState::new(seed, draw_mode);
|
||||||
|
apply_random_actions(&mut game, &actions);
|
||||||
|
prop_assert_eq!(
|
||||||
|
game.check_win(),
|
||||||
|
game.session().state().state().is_win(),
|
||||||
|
"check_win() disagreed with is_win()",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All 52 card IDs must be present exactly once across every pile after
|
||||||
|
/// any reachable sequence of draw + move_cards actions.
|
||||||
|
///
|
||||||
|
/// Catches two bug classes at once:
|
||||||
|
/// - Card loss (fewer than 52 unique IDs after the sequence).
|
||||||
|
/// - Card duplication (52 total but deduplication reduces the set).
|
||||||
|
#[test]
|
||||||
|
fn all_52_cards_always_present(
|
||||||
|
seed in any::<u64>(),
|
||||||
|
draw_mode in draw_mode_strategy(),
|
||||||
|
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||||
|
) {
|
||||||
|
let mut game = GameState::new(seed, draw_mode);
|
||||||
|
apply_random_actions(&mut game, &actions);
|
||||||
|
|
||||||
|
let cards = all_cards(&game);
|
||||||
|
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
|
||||||
|
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
|
||||||
|
prop_assert_eq!(
|
||||||
|
unique.len(), 52,
|
||||||
|
"duplicate cards found after dedup — a card was cloned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GameState::new(seed, draw_mode)` must be deterministic: two calls
|
||||||
|
/// with the same arguments must produce identical initial pile layouts.
|
||||||
|
///
|
||||||
|
/// Pins that the deal is seeded from `seed` alone and not from any
|
||||||
|
/// implicit source like wall-clock time or global state.
|
||||||
|
#[test]
|
||||||
|
fn deal_is_deterministic(
|
||||||
|
seed in any::<u64>(),
|
||||||
|
draw_mode in draw_mode_strategy(),
|
||||||
|
) {
|
||||||
|
let a = GameState::new(seed, draw_mode);
|
||||||
|
let b = GameState::new(seed, draw_mode);
|
||||||
|
prop_assert_eq!(
|
||||||
|
all_cards(&a),
|
||||||
|
all_cards(&b),
|
||||||
|
"same seed + draw_mode produced different deals",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After applying any single legal move and immediately undoing it, the
|
||||||
|
/// pile layout and move_count must be identical to their pre-move values.
|
||||||
|
///
|
||||||
|
/// `setup_actions` drives the game to an arbitrary mid-game position;
|
||||||
|
/// `move_idx` selects which legal move to apply and then undo.
|
||||||
|
///
|
||||||
|
/// The score is intentionally excluded: `undo()` applies a −15 penalty
|
||||||
|
/// that is by design, not a regression.
|
||||||
|
#[test]
|
||||||
|
fn undo_restores_pile_layout_and_move_count(
|
||||||
|
seed in any::<u64>(),
|
||||||
|
draw_mode in draw_mode_strategy(),
|
||||||
|
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
||||||
|
move_idx in 0usize..200,
|
||||||
|
) {
|
||||||
|
let mut game = GameState::new(seed, draw_mode);
|
||||||
|
apply_random_actions(&mut game, &setup_actions);
|
||||||
|
|
||||||
|
// Snapshot the state before the move.
|
||||||
|
let before_ids = all_cards(&game);
|
||||||
|
let before_move_count = game.move_count();
|
||||||
|
|
||||||
|
// Apply one move.
|
||||||
|
if !apply_one_move(&mut game, move_idx) || game.is_won() {
|
||||||
|
return Ok(()); // nothing to undo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo and verify.
|
||||||
|
prop_assert!(
|
||||||
|
game.undo().is_ok(),
|
||||||
|
"undo must succeed immediately after a successful move",
|
||||||
|
);
|
||||||
|
prop_assert_eq!(
|
||||||
|
all_cards(&game),
|
||||||
|
before_ids,
|
||||||
|
"pile layout after undo differs from the pre-move snapshot",
|
||||||
|
);
|
||||||
|
prop_assert_eq!(
|
||||||
|
game.move_count(),
|
||||||
|
before_move_count,
|
||||||
|
"move_count after undo must equal the pre-move value",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every move returned by `possible_instructions()` must succeed when
|
||||||
|
/// applied via `move_cards()`.
|
||||||
|
///
|
||||||
|
/// `possible_instructions()` and `move_cards()` both validate moves
|
||||||
|
/// through the same upstream rule engine. This property ensures no
|
||||||
|
/// drift has opened up between what the engine reports as legal and
|
||||||
|
/// what it actually accepts.
|
||||||
|
#[test]
|
||||||
|
fn legal_moves_always_succeed(
|
||||||
|
seed in any::<u64>(),
|
||||||
|
draw_mode in draw_mode_strategy(),
|
||||||
|
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
||||||
|
) {
|
||||||
|
let mut game = GameState::new(seed, draw_mode);
|
||||||
|
apply_random_actions(&mut game, &setup_actions);
|
||||||
|
|
||||||
|
for instruction in game.possible_instructions() {
|
||||||
|
// Clone so each move is tried from the same starting state.
|
||||||
|
let mut trial = game.clone();
|
||||||
|
let result = trial.apply_instruction(instruction);
|
||||||
|
prop_assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"possible_instructions() reported {instruction:?} \
|
||||||
|
as legal but the call returned Err: {result:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
use crate::card::{Card, Rank};
|
|
||||||
use crate::pile::Pile;
|
|
||||||
|
|
||||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
|
||||||
///
|
|
||||||
/// Foundation rules:
|
|
||||||
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
|
||||||
/// becomes the pile's claimed suit (derived from the bottom card via
|
|
||||||
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
|
||||||
/// - When the pile is non-empty, the next card must match the top card's
|
|
||||||
/// suit and be exactly one rank higher.
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
|
||||||
match pile.cards.last() {
|
|
||||||
None => card.rank == Rank::Ace,
|
|
||||||
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
|
||||||
///
|
|
||||||
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
|
||||||
match pile.cards.last() {
|
|
||||||
None => card.rank == Rank::King,
|
|
||||||
Some(top) => {
|
|
||||||
top.face_up
|
|
||||||
&& card.rank.checked_add(1) == Some(top.rank)
|
|
||||||
&& card.suit.is_red() != top.suit.is_red()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if `cards` is a legal tableau run on its own — every
|
|
||||||
/// adjacent pair descends by one rank and alternates colour. A single
|
|
||||||
/// card is trivially valid. The destination check is separate; this
|
|
||||||
/// only validates the sequence's *internal* structure, which the tableau
|
|
||||||
/// move path must enforce so a player can't smuggle an arbitrary stack
|
|
||||||
/// onto another column when the bottom card happens to land legally.
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
|
||||||
cards.windows(2).all(|w| {
|
|
||||||
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::card::{Card, Rank, Suit};
|
|
||||||
use crate::pile::{Pile, PileType};
|
|
||||||
|
|
||||||
fn card(suit: Suit, rank: Rank) -> Card {
|
|
||||||
Card {
|
|
||||||
id: 0,
|
|
||||||
suit,
|
|
||||||
rank,
|
|
||||||
face_up: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
|
|
||||||
Pile { pile_type, cards }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Foundation tests
|
|
||||||
#[test]
|
|
||||||
fn foundation_ace_on_empty_is_valid() {
|
|
||||||
// Every suit's Ace must land on an empty foundation slot regardless of
|
|
||||||
// its slot index; the slot claims the suit only after the Ace lands.
|
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
|
||||||
let c = card(suit, Rank::Ace);
|
|
||||||
let p = Pile::new(PileType::Foundation(0));
|
|
||||||
assert!(
|
|
||||||
can_place_on_foundation(&c, &p),
|
|
||||||
"Ace of {suit:?} must land on empty slot 0",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn foundation_non_ace_on_empty_is_invalid() {
|
|
||||||
let c = card(Suit::Hearts, Rank::Two);
|
|
||||||
let p = Pile::new(PileType::Foundation(0));
|
|
||||||
assert!(!can_place_on_foundation(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn foundation_two_on_ace_same_suit_is_valid() {
|
|
||||||
let c = card(Suit::Clubs, Rank::Two);
|
|
||||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
|
||||||
assert!(can_place_on_foundation(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn foundation_second_card_must_match_claimed_suit() {
|
|
||||||
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
|
||||||
// because the slot's claimed suit is Hearts after the Ace lands.
|
|
||||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
|
|
||||||
let c = card(Suit::Spades, Rank::Two);
|
|
||||||
assert!(!can_place_on_foundation(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn foundation_skipping_rank_is_invalid() {
|
|
||||||
let c = card(Suit::Diamonds, Rank::Three);
|
|
||||||
let p = pile_with(
|
|
||||||
PileType::Foundation(0),
|
|
||||||
vec![card(Suit::Diamonds, Rank::Ace)],
|
|
||||||
);
|
|
||||||
assert!(!can_place_on_foundation(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tableau tests
|
|
||||||
#[test]
|
|
||||||
fn tableau_king_on_empty_is_valid() {
|
|
||||||
let c = card(Suit::Hearts, Rank::King);
|
|
||||||
let p = Pile::new(PileType::Tableau(0));
|
|
||||||
assert!(can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_non_king_on_empty_is_invalid() {
|
|
||||||
let c = card(Suit::Hearts, Rank::Queen);
|
|
||||||
let p = Pile::new(PileType::Tableau(0));
|
|
||||||
assert!(!can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_red_on_black_one_lower_is_valid() {
|
|
||||||
let c = card(Suit::Hearts, Rank::Nine);
|
|
||||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
||||||
assert!(can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_same_color_is_invalid() {
|
|
||||||
let c = card(Suit::Clubs, Rank::Nine);
|
|
||||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
||||||
assert!(!can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_wrong_rank_difference_is_invalid() {
|
|
||||||
let c = card(Suit::Hearts, Rank::Eight);
|
|
||||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
||||||
assert!(!can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_black_on_red_one_lower_is_valid() {
|
|
||||||
let c = card(Suit::Clubs, Rank::Six);
|
|
||||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
|
|
||||||
assert!(can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn foundation_king_on_queen_completes_suit() {
|
|
||||||
// The last card placed to complete a foundation is always King on Queen.
|
|
||||||
let c = card(Suit::Spades, Rank::King);
|
|
||||||
let p = pile_with(
|
|
||||||
PileType::Foundation(0),
|
|
||||||
vec![card(Suit::Spades, Rank::Queen)],
|
|
||||||
);
|
|
||||||
assert!(can_place_on_foundation(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn foundation_king_wrong_suit_is_invalid() {
|
|
||||||
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
|
|
||||||
let c = card(Suit::Hearts, Rank::King);
|
|
||||||
let p = pile_with(
|
|
||||||
PileType::Foundation(0),
|
|
||||||
vec![card(Suit::Spades, Rank::Queen)],
|
|
||||||
);
|
|
||||||
assert!(!can_place_on_foundation(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_ace_on_two_different_color_is_valid() {
|
|
||||||
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
|
|
||||||
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
|
|
||||||
let c = card(Suit::Hearts, Rank::Ace);
|
|
||||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
|
|
||||||
assert!(can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_same_rank_different_color_is_invalid() {
|
|
||||||
// Two cards of the same rank cannot be stacked regardless of colour.
|
|
||||||
let c = card(Suit::Hearts, Rank::Nine);
|
|
||||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
|
|
||||||
assert!(!can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_face_down_destination_top_is_invalid() {
|
|
||||||
// A face-down top card must never be a valid placement target.
|
|
||||||
let c = card(Suit::Hearts, Rank::Nine);
|
|
||||||
let mut top = card(Suit::Spades, Rank::Ten);
|
|
||||||
top.face_up = false;
|
|
||||||
let p = pile_with(PileType::Tableau(0), vec![top]);
|
|
||||||
assert!(!can_place_on_tableau(&c, &p));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tableau_sequence_validation() {
|
|
||||||
// Single card is trivially a valid sequence.
|
|
||||||
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
|
|
||||||
// Valid descending alternating-colour run K♠ Q♥ J♣.
|
|
||||||
assert!(is_valid_tableau_sequence(&[
|
|
||||||
card(Suit::Spades, Rank::King),
|
|
||||||
card(Suit::Hearts, Rank::Queen),
|
|
||||||
card(Suit::Clubs, Rank::Jack),
|
|
||||||
]));
|
|
||||||
// Same colour twice (Q♠ on K♠) — invalid.
|
|
||||||
assert!(!is_valid_tableau_sequence(&[
|
|
||||||
card(Suit::Spades, Rank::King),
|
|
||||||
card(Suit::Spades, Rank::Queen),
|
|
||||||
]));
|
|
||||||
// Rank gap (K♠ → J♥) — invalid.
|
|
||||||
assert!(!is_valid_tableau_sequence(&[
|
|
||||||
card(Suit::Spades, Rank::King),
|
|
||||||
card(Suit::Hearts, Rank::Jack),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,9 @@
|
|||||||
use crate::pile::PileType;
|
//! Ferrous-specific scoring policy layered on top of upstream `klondike`.
|
||||||
|
//!
|
||||||
/// Score delta for moving cards from `from` to `to`.
|
//! Upstream [`klondike::KlondikeStats::score`] owns the per-move point values
|
||||||
///
|
//! (move-to-foundation, flip-up bonus, recycle penalty, etc.). The functions
|
||||||
/// Windows XP Standard scoring:
|
//! here are the Ferrous Solitaire house rules that upstream has no opinion on —
|
||||||
/// - +10 for any card reaching a foundation pile
|
//! currently just the win-time bonus shown in the win modal.
|
||||||
/// - +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`.
|
/// 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.
|
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||||
@@ -54,99 +13,3 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
|||||||
}
|
}
|
||||||
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
(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
@@ -7,15 +7,23 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
solitaire_core = { workspace = true }
|
solitaire_core = { workspace = true }
|
||||||
solitaire_sync = { workspace = true }
|
solitaire_sync = { workspace = true }
|
||||||
|
klondike = { workspace = true }
|
||||||
|
card_game = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
async-trait = { 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 }
|
dirs = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
uuid = { workspace = true }
|
|
||||||
|
|
||||||
# `keyring-core` is the typed Entry/Error API used by
|
# `keyring-core` is the typed Entry/Error API used by
|
||||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||||
@@ -24,17 +32,14 @@ uuid = { workspace = true }
|
|||||||
# on bionic). On Android `auth_tokens` falls back to a stub
|
# on bionic). On Android `auth_tokens` falls back to a stub
|
||||||
# implementation that always returns `KeychainUnavailable`; the
|
# implementation that always returns `KeychainUnavailable`; the
|
||||||
# real backend lands when we wire Android Keystore via JNI.
|
# real backend lands when we wire Android Keystore via JNI.
|
||||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
|
||||||
keyring-core = { workspace = true }
|
keyring-core = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
jni = { workspace = true }
|
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]
|
[dev-dependencies]
|
||||||
|
solitaire_core = { workspace = true, features = ["test-support"] }
|
||||||
solitaire_server = { path = "../solitaire_server" }
|
solitaire_server = { path = "../solitaire_server" }
|
||||||
solitaire_sync = { workspace = true }
|
solitaire_sync = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
@@ -42,3 +47,6 @@ sqlx = { workspace = true }
|
|||||||
jsonwebtoken = { workspace = true }
|
jsonwebtoken = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Safe JNI bridge for Android platform integration.
|
||||||
|
//!
|
||||||
|
//! Reconstructing the raw `JavaVM` / `NativeActivity` handles handed over by
|
||||||
|
//! the Android runtime requires `unsafe` FFI. That single reconstruction lives
|
||||||
|
//! in `solitaire_app`'s entry point ([`solitaire_app::android_main`]); this
|
||||||
|
//! module only ever stores the resulting *safe* [`JavaVM`] and activity
|
||||||
|
//! [`GlobalRef`] and hands callers an attached [`JNIEnv`].
|
||||||
|
//!
|
||||||
|
//! As a result every consumer of Android JNI — the keystore, clipboard, and
|
||||||
|
//! safe-area subsystems — goes through the safe functions here and stays
|
||||||
|
//! `forbid(unsafe_code)`. The only crate carrying `unsafe` is `solitaire_app`.
|
||||||
|
//!
|
||||||
|
//! Only compiled and linked on `target_os = "android"`.
|
||||||
|
|
||||||
|
use jni::objects::{GlobalRef, JObject};
|
||||||
|
use jni::{JNIEnv, JavaVM};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
|
||||||
|
static ANDROID_ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Store the process-wide [`JavaVM`]. Called once from Android startup
|
||||||
|
/// (`solitaire_app::android_main`); subsequent calls are ignored.
|
||||||
|
pub fn set_jvm(vm: JavaVM) {
|
||||||
|
let _ = ANDROID_JVM.set(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a global reference to the `NativeActivity`. Called once from Android
|
||||||
|
/// startup; subsequent calls are ignored.
|
||||||
|
pub fn set_activity(activity: GlobalRef) {
|
||||||
|
let _ = ANDROID_ACTIVITY.set(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` with a [`JNIEnv`] attached to the current thread.
|
||||||
|
///
|
||||||
|
/// Returns an error string if the bridge has not been initialised yet or the
|
||||||
|
/// thread cannot be attached. The closure's JNI errors are surfaced through the
|
||||||
|
/// same `String` channel so callers have a single error type to map.
|
||||||
|
pub fn with_env<F, R>(f: F) -> Result<R, String>
|
||||||
|
where
|
||||||
|
F: for<'local> FnOnce(&mut JNIEnv<'local>) -> jni::errors::Result<R>,
|
||||||
|
{
|
||||||
|
let vm = ANDROID_JVM
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| "Android JavaVM not initialised".to_string())?;
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread_permanently()
|
||||||
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||||
|
f(&mut env).map_err(|e| format!("JNI: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` with an attached [`JNIEnv`] and the `NativeActivity` object.
|
||||||
|
///
|
||||||
|
/// Like [`with_env`] but also resolves the cached activity global reference, so
|
||||||
|
/// callers that need to invoke instance methods on the activity (clipboard,
|
||||||
|
/// window insets) never touch a raw handle.
|
||||||
|
pub fn with_activity_env<F, R>(f: F) -> Result<R, String>
|
||||||
|
where
|
||||||
|
F: for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> jni::errors::Result<R>,
|
||||||
|
{
|
||||||
|
let activity = ANDROID_ACTIVITY
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| "Android activity not initialised".to_string())?;
|
||||||
|
with_env(|env| f(env, activity.as_obj()))
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
///
|
///
|
||||||
/// Only compiled and linked on `target_os = "android"`.
|
/// Only compiled and linked on `target_os = "android"`.
|
||||||
use jni::{
|
use jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv,
|
||||||
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -36,23 +36,15 @@ struct TokenBlob {
|
|||||||
// JVM helper
|
// 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>
|
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
||||||
where
|
where
|
||||||
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
||||||
{
|
{
|
||||||
let app = bevy::android::ANDROID_APP
|
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
|
||||||
.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}")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -207,9 +199,10 @@ fn encrypt_gcm(
|
|||||||
.v()?;
|
.v()?;
|
||||||
|
|
||||||
// IV is generated by Android's provider; read it back after init.
|
// 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_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
|
||||||
// SAFETY: the method signature guarantees a byte array return.
|
let iv_arr = JByteArray::from(iv_jobj);
|
||||||
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
|
|
||||||
let iv = env.convert_byte_array(&iv_arr)?;
|
let iv = env.convert_byte_array(&iv_arr)?;
|
||||||
|
|
||||||
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
||||||
@@ -217,8 +210,7 @@ fn encrypt_gcm(
|
|||||||
let ct_jobj = env
|
let ct_jobj = env
|
||||||
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
||||||
.l()?;
|
.l()?;
|
||||||
// SAFETY: doFinal([B) returns [B.
|
let ct_arr = JByteArray::from(ct_jobj);
|
||||||
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
|
|
||||||
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
||||||
@@ -269,8 +261,7 @@ fn decrypt_gcm(
|
|||||||
let pt_jobj = env
|
let pt_jobj = env
|
||||||
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
||||||
.l()?;
|
.l()?;
|
||||||
// SAFETY: doFinal([B) returns [B.
|
let pt_arr = JByteArray::from(pt_jobj);
|
||||||
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
|
|
||||||
env.convert_byte_array(&pt_arr)
|
env.convert_byte_array(&pt_arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,8 +348,9 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. Legacy path migration ---
|
// --- 2. Legacy path migration ---
|
||||||
if let Some(ref lpath) = legacy_path {
|
if let Some(ref lpath) = legacy_path
|
||||||
if lpath.exists() {
|
&& lpath.exists()
|
||||||
|
{
|
||||||
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||||
other => other,
|
other => other,
|
||||||
@@ -380,7 +372,6 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
|
|||||||
}
|
}
|
||||||
// Legacy file corrupt or unrecognised — treat as empty.
|
// Legacy file corrupt or unrecognised — treat as empty.
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- 3. No file found ---
|
// --- 3. No file found ---
|
||||||
Ok(HashMap::new())
|
Ok(HashMap::new())
|
||||||
@@ -468,12 +459,12 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
|||||||
|
|
||||||
if map.is_empty() {
|
if map.is_empty() {
|
||||||
// No more users — remove the file and the Keystore key.
|
// No more users — remove the file and the Keystore key.
|
||||||
if let Some(path) = token_file_path() {
|
if let Some(path) = token_file_path()
|
||||||
if path.exists() {
|
&& path.exists()
|
||||||
|
{
|
||||||
std::fs::remove_file(&path)
|
std::fs::remove_file(&path)
|
||||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||||
with_jvm(|env| {
|
with_jvm(|env| {
|
||||||
|
|||||||
@@ -14,15 +14,14 @@
|
|||||||
//! the Bevy `App`). If no default store is set, all operations in this module
|
//! the Bevy `App`). If no default store is set, all operations in this module
|
||||||
//! will return [`TokenError::KeychainUnavailable`].
|
//! will return [`TokenError::KeychainUnavailable`].
|
||||||
//!
|
//!
|
||||||
//! # Android stub
|
//! # Android
|
||||||
//!
|
//!
|
||||||
//! `keyring-core` cannot compile for the android target (its `rpassword`
|
//! `keyring-core` cannot compile for the android target (its `rpassword`
|
||||||
//! transitive dep uses `libc::__errno_location`, which Android's bionic
|
//! transitive dep uses `libc::__errno_location`, which Android's bionic
|
||||||
//! doesn't expose). On Android every function in this module returns
|
//! doesn't expose). On Android this module delegates to an Android Keystore
|
||||||
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
|
//! JNI backend. `solitaire_app` must initialise the safe
|
||||||
//! the same way they handle a Linux box without Secret Service. The
|
//! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
|
||||||
//! real Android backend will arrive in the Phase-Android round when we
|
//! startup before token operations can succeed.
|
||||||
//! wire Android Keystore via JNI.
|
|
||||||
//!
|
//!
|
||||||
//! # Note: no unit tests — requires live OS keychain.
|
//! # Note: no unit tests — requires live OS keychain.
|
||||||
|
|
||||||
|
|||||||
@@ -26,227 +26,227 @@ use solitaire_core::game_state::DifficultyLevel;
|
|||||||
|
|
||||||
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
|
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
|
||||||
pub const EASY_SEEDS: &[u64] = &[
|
pub const EASY_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-06-04)
|
||||||
0xD1FF_0000_0000_0001,
|
|
||||||
0xD1FF_0000_0000_0002,
|
|
||||||
0xD1FF_0000_0000_0007,
|
|
||||||
0xD1FF_0000_0000_0008,
|
|
||||||
0xD1FF_0000_0000_0009,
|
0xD1FF_0000_0000_0009,
|
||||||
0xD1FF_0000_0000_000E,
|
0xD1FF_0000_0000_0087,
|
||||||
0xD1FF_0000_0000_0013,
|
0xD1FF_0000_0000_00EB,
|
||||||
0xD1FF_0000_0000_0015,
|
0xD1FF_0000_0000_017F,
|
||||||
0xD1FF_0000_0000_0018,
|
0xD1FF_0000_0000_01CE,
|
||||||
0xD1FF_0000_0000_001D,
|
0xD1FF_0000_0000_020F,
|
||||||
0xD1FF_0000_0000_0021,
|
0xD1FF_0000_0000_0251,
|
||||||
0xD1FF_0000_0000_0022,
|
0xD1FF_0000_0000_0275,
|
||||||
0xD1FF_0000_0000_0026,
|
0xD1FF_0000_0000_029C,
|
||||||
0xD1FF_0000_0000_002C,
|
0xD1FF_0000_0000_02BD,
|
||||||
0xD1FF_0000_0000_002E,
|
0xD1FF_0000_0000_02ED,
|
||||||
0xD1FF_0000_0000_002F,
|
0xD1FF_0000_0000_038F,
|
||||||
0xD1FF_0000_0000_0035,
|
0xD1FF_0000_0000_03C9,
|
||||||
0xD1FF_0000_0000_0036,
|
0xD1FF_0000_0000_0415,
|
||||||
0xD1FF_0000_0000_003C,
|
0xD1FF_0000_0000_045F,
|
||||||
0xD1FF_0000_0000_0045,
|
0xD1FF_0000_0000_04C4,
|
||||||
0xD1FF_0000_0000_0046,
|
0xD1FF_0000_0000_04CC,
|
||||||
0xD1FF_0000_0000_0048,
|
0xD1FF_0000_0000_04EE,
|
||||||
0xD1FF_0000_0000_0049,
|
0xD1FF_0000_0000_0631,
|
||||||
0xD1FF_0000_0000_004D,
|
0xD1FF_0000_0000_0651,
|
||||||
0xD1FF_0000_0000_004F,
|
0xD1FF_0000_0000_0689,
|
||||||
0xD1FF_0000_0000_0050,
|
0xD1FF_0000_0000_0735,
|
||||||
0xD1FF_0000_0000_0051,
|
0xD1FF_0000_0000_0748,
|
||||||
0xD1FF_0000_0000_0053,
|
0xD1FF_0000_0000_0801,
|
||||||
0xD1FF_0000_0000_0054,
|
0xD1FF_0000_0000_0820,
|
||||||
0xD1FF_0000_0000_0057,
|
0xD1FF_0000_0000_08F9,
|
||||||
0xD1FF_0000_0000_0058,
|
0xD1FF_0000_0000_091C,
|
||||||
0xD1FF_0000_0000_005A,
|
0xD1FF_0000_0000_0937,
|
||||||
0xD1FF_0000_0000_005B,
|
0xD1FF_0000_0000_09A6,
|
||||||
0xD1FF_0000_0000_005C,
|
0xD1FF_0000_0000_09C3,
|
||||||
0xD1FF_0000_0000_005D,
|
0xD1FF_0000_0000_09DD,
|
||||||
0xD1FF_0000_0000_005F,
|
0xD1FF_0000_0000_0BD9,
|
||||||
0xD1FF_0000_0000_0061,
|
0xD1FF_0000_0000_0BEC,
|
||||||
0xD1FF_0000_0000_0062,
|
0xD1FF_0000_0000_0BF2,
|
||||||
0xD1FF_0000_0000_0063,
|
0xD1FF_0000_0000_0C1B,
|
||||||
0xD1FF_0000_0000_0069,
|
0xD1FF_0000_0000_0C26,
|
||||||
|
0xD1FF_0000_0000_0C36,
|
||||||
|
0xD1FF_0000_0000_0C4B,
|
||||||
|
0xD1FF_0000_0000_0C78,
|
||||||
|
0xD1FF_0000_0000_0CBC,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
|
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
|
||||||
pub const MEDIUM_SEEDS: &[u64] = &[
|
pub const MEDIUM_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-06-04)
|
||||||
0xD1FF_0000_0000_0000,
|
|
||||||
0xD1FF_0000_0000_0012,
|
0xD1FF_0000_0000_0012,
|
||||||
0xD1FF_0000_0000_0016,
|
0xD1FF_0000_0000_002C,
|
||||||
0xD1FF_0000_0000_001B,
|
0xD1FF_0000_0000_004B,
|
||||||
0xD1FF_0000_0000_001C,
|
0xD1FF_0000_0000_0052,
|
||||||
0xD1FF_0000_0000_0020,
|
0xD1FF_0000_0000_0058,
|
||||||
0xD1FF_0000_0000_002A,
|
0xD1FF_0000_0000_005E,
|
||||||
0xD1FF_0000_0000_0034,
|
0xD1FF_0000_0000_0063,
|
||||||
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_0099,
|
||||||
0xD1FF_0000_0000_009A,
|
0xD1FF_0000_0000_00A9,
|
||||||
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_00AF,
|
||||||
0xD1FF_0000_0000_00B0,
|
0xD1FF_0000_0000_00BB,
|
||||||
|
0xD1FF_0000_0000_00D1,
|
||||||
|
0xD1FF_0000_0000_00E3,
|
||||||
|
0xD1FF_0000_0000_0108,
|
||||||
|
0xD1FF_0000_0000_010D,
|
||||||
|
0xD1FF_0000_0000_0110,
|
||||||
|
0xD1FF_0000_0000_012F,
|
||||||
|
0xD1FF_0000_0000_0139,
|
||||||
|
0xD1FF_0000_0000_013C,
|
||||||
|
0xD1FF_0000_0000_0148,
|
||||||
|
0xD1FF_0000_0000_015E,
|
||||||
|
0xD1FF_0000_0000_016A,
|
||||||
|
0xD1FF_0000_0000_016F,
|
||||||
|
0xD1FF_0000_0000_0179,
|
||||||
|
0xD1FF_0000_0000_019E,
|
||||||
|
0xD1FF_0000_0000_01A8,
|
||||||
|
0xD1FF_0000_0000_01AB,
|
||||||
|
0xD1FF_0000_0000_01B5,
|
||||||
|
0xD1FF_0000_0000_01B8,
|
||||||
|
0xD1FF_0000_0000_01D3,
|
||||||
|
0xD1FF_0000_0000_01EE,
|
||||||
|
0xD1FF_0000_0000_01F3,
|
||||||
|
0xD1FF_0000_0000_0202,
|
||||||
|
0xD1FF_0000_0000_0203,
|
||||||
|
0xD1FF_0000_0000_021E,
|
||||||
|
0xD1FF_0000_0000_022C,
|
||||||
|
0xD1FF_0000_0000_022D,
|
||||||
|
0xD1FF_0000_0000_0233,
|
||||||
|
0xD1FF_0000_0000_0245,
|
||||||
|
0xD1FF_0000_0000_024E,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
|
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
|
||||||
pub const HARD_SEEDS: &[u64] = &[
|
pub const HARD_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-06-04)
|
||||||
0xD1FF_0000_0000_001F,
|
0xD1FF_0000_0000_0006,
|
||||||
0xD1FF_0000_0000_0024,
|
0xD1FF_0000_0000_0008,
|
||||||
0xD1FF_0000_0000_0025,
|
0xD1FF_0000_0000_000F,
|
||||||
0xD1FF_0000_0000_0031,
|
0xD1FF_0000_0000_0011,
|
||||||
0xD1FF_0000_0000_0032,
|
0xD1FF_0000_0000_0022,
|
||||||
0xD1FF_0000_0000_003E,
|
0xD1FF_0000_0000_0023,
|
||||||
0xD1FF_0000_0000_004A,
|
0xD1FF_0000_0000_002A,
|
||||||
0xD1FF_0000_0000_006D,
|
0xD1FF_0000_0000_002D,
|
||||||
|
0xD1FF_0000_0000_0040,
|
||||||
|
0xD1FF_0000_0000_0042,
|
||||||
|
0xD1FF_0000_0000_0050,
|
||||||
|
0xD1FF_0000_0000_005B,
|
||||||
|
0xD1FF_0000_0000_005D,
|
||||||
|
0xD1FF_0000_0000_0067,
|
||||||
|
0xD1FF_0000_0000_0069,
|
||||||
|
0xD1FF_0000_0000_006E,
|
||||||
|
0xD1FF_0000_0000_0072,
|
||||||
0xD1FF_0000_0000_0079,
|
0xD1FF_0000_0000_0079,
|
||||||
0xD1FF_0000_0000_007C,
|
0xD1FF_0000_0000_007C,
|
||||||
0xD1FF_0000_0000_0080,
|
0xD1FF_0000_0000_0080,
|
||||||
0xD1FF_0000_0000_008A,
|
0xD1FF_0000_0000_0081,
|
||||||
0xD1FF_0000_0000_0097,
|
0xD1FF_0000_0000_0083,
|
||||||
|
0xD1FF_0000_0000_0091,
|
||||||
|
0xD1FF_0000_0000_009B,
|
||||||
|
0xD1FF_0000_0000_00A1,
|
||||||
0xD1FF_0000_0000_00B1,
|
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_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_00D6,
|
||||||
0xD1FF_0000_0000_00D7,
|
0xD1FF_0000_0000_00DD,
|
||||||
0xD1FF_0000_0000_00DC,
|
0xD1FF_0000_0000_00E8,
|
||||||
0xD1FF_0000_0000_00DF,
|
0xD1FF_0000_0000_00F2,
|
||||||
0xD1FF_0000_0000_00E0,
|
0xD1FF_0000_0000_0101,
|
||||||
0xD1FF_0000_0000_00E1,
|
0xD1FF_0000_0000_010F,
|
||||||
0xD1FF_0000_0000_00E4,
|
0xD1FF_0000_0000_0113,
|
||||||
0xD1FF_0000_0000_00E6,
|
0xD1FF_0000_0000_0118,
|
||||||
0xD1FF_0000_0000_00E7,
|
0xD1FF_0000_0000_0119,
|
||||||
|
0xD1FF_0000_0000_012D,
|
||||||
|
0xD1FF_0000_0000_0133,
|
||||||
|
0xD1FF_0000_0000_0144,
|
||||||
|
0xD1FF_0000_0000_0147,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
|
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
|
||||||
pub const EXPERT_SEEDS: &[u64] = &[
|
pub const EXPERT_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-06-04)
|
||||||
0xD1FF_0000_0000_0006,
|
0xD1FF_0000_0000_0000,
|
||||||
0xD1FF_0000_0000_000B,
|
0xD1FF_0000_0000_0002,
|
||||||
0xD1FF_0000_0000_0019,
|
0xD1FF_0000_0000_000A,
|
||||||
|
0xD1FF_0000_0000_0013,
|
||||||
|
0xD1FF_0000_0000_0017,
|
||||||
|
0xD1FF_0000_0000_001C,
|
||||||
|
0xD1FF_0000_0000_001F,
|
||||||
|
0xD1FF_0000_0000_0021,
|
||||||
|
0xD1FF_0000_0000_0024,
|
||||||
|
0xD1FF_0000_0000_0029,
|
||||||
|
0xD1FF_0000_0000_002E,
|
||||||
|
0xD1FF_0000_0000_0035,
|
||||||
|
0xD1FF_0000_0000_0045,
|
||||||
|
0xD1FF_0000_0000_0048,
|
||||||
|
0xD1FF_0000_0000_0049,
|
||||||
|
0xD1FF_0000_0000_004F,
|
||||||
|
0xD1FF_0000_0000_0062,
|
||||||
|
0xD1FF_0000_0000_006D,
|
||||||
|
0xD1FF_0000_0000_0074,
|
||||||
|
0xD1FF_0000_0000_0076,
|
||||||
0xD1FF_0000_0000_0082,
|
0xD1FF_0000_0000_0082,
|
||||||
0xD1FF_0000_0000_00CB,
|
0xD1FF_0000_0000_008F,
|
||||||
0xD1FF_0000_0000_00D5,
|
0xD1FF_0000_0000_0090,
|
||||||
0xD1FF_0000_0000_00D8,
|
0xD1FF_0000_0000_0097,
|
||||||
0xD1FF_0000_0000_00E8,
|
0xD1FF_0000_0000_009A,
|
||||||
0xD1FF_0000_0000_00EA,
|
0xD1FF_0000_0000_009F,
|
||||||
0xD1FF_0000_0000_00EB,
|
0xD1FF_0000_0000_00A5,
|
||||||
0xD1FF_0000_0000_00EC,
|
0xD1FF_0000_0000_00A8,
|
||||||
|
0xD1FF_0000_0000_00AD,
|
||||||
|
0xD1FF_0000_0000_00AE,
|
||||||
|
0xD1FF_0000_0000_00B8,
|
||||||
|
0xD1FF_0000_0000_00B9,
|
||||||
|
0xD1FF_0000_0000_00BC,
|
||||||
|
0xD1FF_0000_0000_00C5,
|
||||||
|
0xD1FF_0000_0000_00CA,
|
||||||
|
0xD1FF_0000_0000_00CE,
|
||||||
|
0xD1FF_0000_0000_00DE,
|
||||||
0xD1FF_0000_0000_00ED,
|
0xD1FF_0000_0000_00ED,
|
||||||
0xD1FF_0000_0000_00F2,
|
0xD1FF_0000_0000_00EE,
|
||||||
0xD1FF_0000_0000_00F3,
|
0xD1FF_0000_0000_00EF,
|
||||||
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).
|
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
|
||||||
pub const GRANDMASTER_SEEDS: &[u64] = &[
|
pub const GRANDMASTER_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-06-04)
|
||||||
0xD1FF_0000_0000_0027,
|
0xD1FF_0000_0000_003C,
|
||||||
0xD1FF_0000_0000_00A0,
|
0xD1FF_0000_0000_0047,
|
||||||
0xD1FF_0000_0000_00C4,
|
0xD1FF_0000_0000_005A,
|
||||||
0xD1FF_0000_0000_00D4,
|
0xD1FF_0000_0000_009C,
|
||||||
0xD1FF_0000_0000_00DE,
|
0xD1FF_0000_0000_00D2,
|
||||||
0xD1FF_0000_0000_00F9,
|
0xD1FF_0000_0000_00F4,
|
||||||
0xD1FF_0000_0000_0107,
|
0xD1FF_0000_0000_00F6,
|
||||||
0xD1FF_0000_0000_0108,
|
0xD1FF_0000_0000_0104,
|
||||||
0xD1FF_0000_0000_0130,
|
0xD1FF_0000_0000_0106,
|
||||||
0xD1FF_0000_0000_0132,
|
0xD1FF_0000_0000_0111,
|
||||||
0xD1FF_0000_0000_0133,
|
0xD1FF_0000_0000_0112,
|
||||||
0xD1FF_0000_0000_0134,
|
0xD1FF_0000_0000_0116,
|
||||||
|
0xD1FF_0000_0000_0117,
|
||||||
|
0xD1FF_0000_0000_011A,
|
||||||
|
0xD1FF_0000_0000_0123,
|
||||||
|
0xD1FF_0000_0000_012B,
|
||||||
|
0xD1FF_0000_0000_012E,
|
||||||
0xD1FF_0000_0000_0135,
|
0xD1FF_0000_0000_0135,
|
||||||
0xD1FF_0000_0000_0137,
|
|
||||||
0xD1FF_0000_0000_0139,
|
|
||||||
0xD1FF_0000_0000_013A,
|
0xD1FF_0000_0000_013A,
|
||||||
0xD1FF_0000_0000_013D,
|
0xD1FF_0000_0000_013B,
|
||||||
0xD1FF_0000_0000_013F,
|
|
||||||
0xD1FF_0000_0000_0140,
|
|
||||||
0xD1FF_0000_0000_0141,
|
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_014A,
|
||||||
0xD1FF_0000_0000_014B,
|
0xD1FF_0000_0000_014B,
|
||||||
0xD1FF_0000_0000_014C,
|
0xD1FF_0000_0000_014E,
|
||||||
0xD1FF_0000_0000_014D,
|
|
||||||
0xD1FF_0000_0000_014F,
|
|
||||||
0xD1FF_0000_0000_0150,
|
0xD1FF_0000_0000_0150,
|
||||||
0xD1FF_0000_0000_0151,
|
0xD1FF_0000_0000_0155,
|
||||||
0xD1FF_0000_0000_0152,
|
|
||||||
0xD1FF_0000_0000_0153,
|
|
||||||
0xD1FF_0000_0000_0157,
|
0xD1FF_0000_0000_0157,
|
||||||
0xD1FF_0000_0000_0158,
|
0xD1FF_0000_0000_0158,
|
||||||
0xD1FF_0000_0000_015B,
|
0xD1FF_0000_0000_0159,
|
||||||
|
0xD1FF_0000_0000_015A,
|
||||||
0xD1FF_0000_0000_015C,
|
0xD1FF_0000_0000_015C,
|
||||||
0xD1FF_0000_0000_015E,
|
0xD1FF_0000_0000_015D,
|
||||||
0xD1FF_0000_0000_0162,
|
0xD1FF_0000_0000_015F,
|
||||||
0xD1FF_0000_0000_0164,
|
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,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
||||||
/// silently no-op'd by the engine's push-on-win system, matching
|
/// silently no-op'd by the engine's push-on-win system, matching
|
||||||
/// the same pattern `pull` / `push` follow.
|
/// the same pattern `pull` / `push` follow.
|
||||||
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError> {
|
||||||
Err(SyncError::UnsupportedPlatform)
|
Err(SyncError::UnsupportedPlatform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
|||||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
(**self).delete_account().await
|
(**self).delete_account().await
|
||||||
}
|
}
|
||||||
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
|
||||||
(**self).push_replay(replay).await
|
(**self).push_replay(replay).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,8 +118,8 @@ pub use achievements::{
|
|||||||
|
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub use progress::{
|
pub use progress::{
|
||||||
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
|
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
|
||||||
save_progress_to, xp_for_win,
|
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod weekly;
|
pub mod weekly;
|
||||||
@@ -143,27 +143,39 @@ pub use settings::{
|
|||||||
load_settings_from, save_settings_to, settings_file_path,
|
load_settings_from, save_settings_to, settings_file_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub mod android_jni;
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod android_keystore;
|
mod android_keystore;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use auth_tokens::{
|
pub use auth_tokens::{
|
||||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod sync_client;
|
pub mod sync_client;
|
||||||
pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend};
|
pub use sync_client::LocalOnlyProvider;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
||||||
|
|
||||||
pub mod replay;
|
pub mod replay;
|
||||||
pub use replay::{
|
pub use replay::{
|
||||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||||
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
|
ReplayHistory, append_replay_to_history, load_replay_history_from,
|
||||||
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
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)]
|
#[allow(deprecated)]
|
||||||
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
pub use replay::latest_replay_path;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod matomo_client;
|
pub mod matomo_client;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use matomo_client::MatomoClient;
|
pub use matomo_client::MatomoClient;
|
||||||
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
|||||||
@@ -114,3 +114,62 @@ fn url_encode(s: &str) -> String {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn pending(client: &MatomoClient) -> Vec<String> {
|
||||||
|
client.pending.lock().expect("pending lock").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_buffers_encoded_matomo_query() {
|
||||||
|
let client = MatomoClient::new(
|
||||||
|
"https://analytics.example.com/",
|
||||||
|
7,
|
||||||
|
Some("alice bob".into()),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.event("Game Flow", "Won+Fast", Some("draw three"), Some(42.5));
|
||||||
|
|
||||||
|
let pending = pending(&client);
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
let query = &pending[0];
|
||||||
|
assert!(query.contains("idsite=7"));
|
||||||
|
assert!(query.contains("rec=1"));
|
||||||
|
assert!(query.contains("e_c=Game%20Flow"));
|
||||||
|
assert!(query.contains("e_a=Won%2BFast"));
|
||||||
|
assert!(query.contains("e_n=draw%20three"));
|
||||||
|
assert!(query.contains("e_v=42.5"));
|
||||||
|
assert!(query.contains("uid=alice%20bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_buffer_drops_oldest_entries_when_capacity_exceeded() {
|
||||||
|
let client = MatomoClient::new("https://analytics.example.com", 1, None);
|
||||||
|
|
||||||
|
for idx in 0..101 {
|
||||||
|
client.event("Game", "Start", Some(&format!("event-{idx}")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = pending(&client);
|
||||||
|
assert_eq!(pending.len(), 51);
|
||||||
|
assert!(
|
||||||
|
pending[0].contains("event-50"),
|
||||||
|
"oldest retained event should be event-50, got {}",
|
||||||
|
pending[0]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pending[50].contains("event-100"),
|
||||||
|
"newest retained event should be event-100, got {}",
|
||||||
|
pending[50]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_encode_leaves_unreserved_bytes_and_escapes_everything_else() {
|
||||||
|
assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~");
|
||||||
|
assert_eq!(url_encode("a b+c/d?"), "a%20b%2Bc%2Fd%3F");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,15 @@ pub fn data_dir() -> Option<PathBuf> {
|
|||||||
{
|
{
|
||||||
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
|
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// No filesystem on the browser; all persistence goes through
|
||||||
|
// WasmStorage (localStorage-backed). Return None so every caller
|
||||||
|
// degrades gracefully (the same path they take on a
|
||||||
|
// misconfigured desktop environment).
|
||||||
|
None
|
||||||
|
}
|
||||||
|
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
|
||||||
{
|
{
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
|||||||
y * 10_000 + m * 100 + d
|
y * 10_000 + m * 100 + d
|
||||||
}
|
}
|
||||||
|
|
||||||
/// XP awarded for winning a game.
|
/// Component breakdown of the XP awarded for a win.
|
||||||
|
///
|
||||||
|
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
|
||||||
|
/// it for the total, and UI that displays the individual lines (the win-summary
|
||||||
|
/// modal) reads the parts from here so the breakdown can never drift from the
|
||||||
|
/// total.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct XpBreakdown {
|
||||||
|
/// Flat base XP granted for any win.
|
||||||
|
pub base: u64,
|
||||||
|
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
|
||||||
|
pub speed_bonus: u64,
|
||||||
|
/// Bonus for winning without using undo (25, else 0).
|
||||||
|
pub no_undo_bonus: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XpBreakdown {
|
||||||
|
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
|
||||||
|
pub fn total(self) -> u64 {
|
||||||
|
self.base + self.speed_bonus + self.no_undo_bonus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Component breakdown of the XP awarded for a win.
|
||||||
///
|
///
|
||||||
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
||||||
/// the player did not use undo.
|
/// the player did not use undo.
|
||||||
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
|
||||||
let base: u64 = 50;
|
|
||||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
let speed_bonus: u64 = if time_seconds >= 120 {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
|||||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
||||||
scaled.max(10)
|
scaled.max(10)
|
||||||
};
|
};
|
||||||
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
XpBreakdown {
|
||||||
base + speed_bonus + no_undo_bonus
|
base: 50,
|
||||||
|
speed_bonus,
|
||||||
|
no_undo_bonus: if used_undo { 0 } else { 25 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
|
||||||
|
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||||
|
xp_breakdown(time_seconds, used_undo).total()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform-specific default path for `progress.json`.
|
/// Platform-specific default path for `progress.json`.
|
||||||
|
|||||||
@@ -12,13 +12,22 @@
|
|||||||
//! carries any other version so older replays are silently dropped instead
|
//! carries any other version so older replays are silently dropped instead
|
||||||
//! of crashing the loader.
|
//! of crashing the loader.
|
||||||
//!
|
//!
|
||||||
//! The recording is intentionally minimal — only [`ReplayMove`] entries
|
//! The recording is intentionally minimal — only the
|
||||||
//! that successfully advanced the game. `Undo` is **not** recorded: a
|
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
||||||
//! replay represents the canonical path the player ultimately took to win,
|
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
||||||
//! so backed-out missteps simply do not appear in the move list. The
|
//! represents the canonical path the player ultimately took to win, so
|
||||||
//! starting deal is not stored either — the [`seed`](Replay::seed) +
|
//! 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
|
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
//! 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::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -26,8 +35,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||||
use solitaire_core::pile::PileType;
|
|
||||||
|
|
||||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||||
@@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 {
|
|||||||
/// seeing a broken one.
|
/// seeing a broken one.
|
||||||
///
|
///
|
||||||
/// History:
|
/// History:
|
||||||
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
|
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
|
||||||
/// variants which carried the *outcome* of a stock interaction rather
|
/// variants which carried the *outcome* of a stock interaction rather
|
||||||
/// than the player's atomic input.
|
/// than the player's atomic input.
|
||||||
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
|
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||||
/// variant. The engine resolves draw-vs-recycle deterministically from
|
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||||
/// the current stock state, so the input alone is sufficient and the
|
/// are now stored directly as upstream
|
||||||
/// replay model now stores atomic player inputs end-to-end.
|
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
||||||
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
|
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
||||||
|
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
||||||
|
/// and are discarded by the loader.
|
||||||
|
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||||
@@ -81,32 +92,6 @@ fn schema_v0() -> u32 {
|
|||||||
0
|
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.
|
/// A complete recording of a single winning game.
|
||||||
///
|
///
|
||||||
/// Replays are reconstructed by rebuilding a fresh
|
/// Replays are reconstructed by rebuilding a fresh
|
||||||
@@ -124,7 +109,7 @@ pub struct Replay {
|
|||||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
/// Draw mode the recorded game was played in.
|
/// Draw mode the recorded game was played in.
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
/// Game mode the recorded game was played in.
|
/// Game mode the recorded game was played in.
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
/// Total wall-clock seconds the win took. Used for the Stats UI
|
/// Total wall-clock seconds the win took. Used for the Stats UI
|
||||||
@@ -134,9 +119,11 @@ pub struct Replay {
|
|||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
/// ISO-8601 date the win was recorded.
|
/// ISO-8601 date the win was recorded.
|
||||||
pub recorded_at: NaiveDate,
|
pub recorded_at: NaiveDate,
|
||||||
/// Ordered move list. Each entry is what the player did, replayable
|
/// Ordered move list. Each entry is the atomic
|
||||||
/// against a fresh `GameState` constructed from the seed.
|
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
||||||
pub moves: Vec<ReplayMove>,
|
/// issued, replayable against a fresh `GameState` constructed from the
|
||||||
|
/// seed via `GameState::apply_instruction`.
|
||||||
|
pub moves: Vec<KlondikeInstruction>,
|
||||||
/// Public share URL for this replay on the active sync backend, set
|
/// Public share URL for this replay on the active sync backend, set
|
||||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||||
/// task resolves. `None` when the player won on a local-only
|
/// task resolves. `None` when the player won on a local-only
|
||||||
@@ -180,12 +167,12 @@ impl Replay {
|
|||||||
/// latter directly when the upload task resolves.
|
/// latter directly when the upload task resolves.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
seed: u64,
|
seed: u64,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawStockConfig,
|
||||||
mode: GameMode,
|
mode: GameMode,
|
||||||
time_seconds: u64,
|
time_seconds: u64,
|
||||||
final_score: i32,
|
final_score: i32,
|
||||||
recorded_at: NaiveDate,
|
recorded_at: NaiveDate,
|
||||||
moves: Vec<ReplayMove>,
|
moves: Vec<KlondikeInstruction>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: REPLAY_SCHEMA_VERSION,
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
@@ -442,6 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
|||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use klondike::{
|
||||||
|
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
||||||
|
};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
@@ -452,24 +442,22 @@ mod tests {
|
|||||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||||
Replay::new(
|
Replay::new(
|
||||||
12345,
|
12345,
|
||||||
DrawMode::DrawThree,
|
DrawStockConfig::DrawThree,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
134,
|
134,
|
||||||
5_120,
|
5_120,
|
||||||
date,
|
date,
|
||||||
vec![
|
vec![
|
||||||
ReplayMove::StockClick,
|
KlondikeInstruction::RotateStock,
|
||||||
ReplayMove::Move {
|
KlondikeInstruction::DstTableau(DstTableau {
|
||||||
from: PileType::Waste,
|
src: KlondikePileStack::Stock,
|
||||||
to: PileType::Tableau(3),
|
tableau: Tableau::Tableau4,
|
||||||
count: 1,
|
}),
|
||||||
},
|
KlondikeInstruction::RotateStock,
|
||||||
ReplayMove::StockClick,
|
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
ReplayMove::Move {
|
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
from: PileType::Tableau(3),
|
foundation: Foundation::Foundation1,
|
||||||
to: PileType::Foundation(0),
|
}),
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -595,12 +583,12 @@ mod tests {
|
|||||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||||
Replay::new(
|
Replay::new(
|
||||||
id as u64,
|
id as u64,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
60,
|
60,
|
||||||
id,
|
id,
|
||||||
date,
|
date,
|
||||||
vec![ReplayMove::StockClick],
|
vec![KlondikeInstruction::RotateStock],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,9 +824,11 @@ mod tests {
|
|||||||
let path = tmp_path("legacy_no_win_move_index");
|
let path = tmp_path("legacy_no_win_move_index");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
// Hand-rolled minimal current-schema replay JSON with no
|
||||||
let v2_no_field = r#"{
|
// win_move_index field — the additive field must still default to None.
|
||||||
"schema_version": 2,
|
let no_field = format!(
|
||||||
|
r#"{{
|
||||||
|
"schema_version": {schema},
|
||||||
"seed": 1,
|
"seed": 1,
|
||||||
"draw_mode": "DrawOne",
|
"draw_mode": "DrawOne",
|
||||||
"mode": "Classic",
|
"mode": "Classic",
|
||||||
@@ -846,8 +836,10 @@ mod tests {
|
|||||||
"final_score": 100,
|
"final_score": 100,
|
||||||
"recorded_at": "2026-05-02",
|
"recorded_at": "2026-05-02",
|
||||||
"moves": []
|
"moves": []
|
||||||
}"#;
|
}}"#,
|
||||||
fs::write(&path, v2_no_field).expect("write fixture");
|
schema = REPLAY_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
fs::write(&path, no_field).expect("write fixture");
|
||||||
|
|
||||||
let loaded = load_latest_replay_from(&path).expect("load");
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
assert_eq!(loaded.win_move_index, None);
|
assert_eq!(loaded.win_move_index, None);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||||
|
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ pub struct WindowGeometry {
|
|||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
/// Draw mode selected for new games.
|
/// Draw mode selected for new games.
|
||||||
#[serde(default = "default_draw_mode")]
|
#[serde(default = "default_draw_mode")]
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
||||||
#[serde(default = "default_sfx_volume")]
|
#[serde(default = "default_sfx_volume")]
|
||||||
pub sfx_volume: f32,
|
pub sfx_volume: f32,
|
||||||
@@ -200,7 +200,7 @@ pub struct Settings {
|
|||||||
#[serde(default = "default_time_bonus_multiplier")]
|
#[serde(default = "default_time_bonus_multiplier")]
|
||||||
pub time_bonus_multiplier: f32,
|
pub time_bonus_multiplier: f32,
|
||||||
/// When `true`, the engine rejects new-game deals the
|
/// When `true`, the engine rejects new-game deals the
|
||||||
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
/// the solver cannot prove winnable, retrying
|
||||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||||
/// giving up and using the last tried seed. Off by default —
|
/// giving up and using the last tried seed. Off by default —
|
||||||
/// the solver adds a few hundred milliseconds of latency on the
|
/// the solver adds a few hundred milliseconds of latency on the
|
||||||
@@ -288,8 +288,8 @@ pub struct Settings {
|
|||||||
pub touch_input_mode: TouchInputMode,
|
pub touch_input_mode: TouchInputMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawStockConfig {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sfx_volume() -> f32 {
|
fn default_sfx_volume() -> f32 {
|
||||||
@@ -381,9 +381,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
|||||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||||
/// is willing to attempt before giving up and accepting the latest
|
/// is willing to attempt before giving up and accepting the latest
|
||||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||||
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
/// every retry comes back provably unwinnable (`Ok(None)` from the
|
||||||
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
/// solver, which would be very unusual) we'd rather hand the player a
|
||||||
/// deal than spin forever on the main thread.
|
/// possibly-unwinnable deal than spin forever on the main thread.
|
||||||
///
|
///
|
||||||
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||||
/// the upper bound on UI freeze when the toggle is on.
|
/// the upper bound on UI freeze when the toggle is on.
|
||||||
@@ -392,7 +392,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
|||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
draw_mode: DrawMode::DrawOne,
|
draw_mode: DrawStockConfig::DrawOne,
|
||||||
sfx_volume: default_sfx_volume(),
|
sfx_volume: default_sfx_volume(),
|
||||||
music_volume: default_music_volume(),
|
music_volume: default_music_volume(),
|
||||||
animation_speed: AnimSpeed::Normal,
|
animation_speed: AnimSpeed::Normal,
|
||||||
|
|||||||
+26
-26
@@ -2,10 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
|
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
|
||||||
//! This module adds the [`StatsExt`] extension trait, which supplies the
|
//! This module adds the [`StatsExt`] extension trait, which supplies the
|
||||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
//! `update_on_win` method that depends on [`DrawStockConfig`] from `solitaire_core`.
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
|
||||||
pub use solitaire_sync::StatsSnapshot;
|
pub use solitaire_sync::StatsSnapshot;
|
||||||
|
|
||||||
@@ -18,9 +18,9 @@ pub trait StatsExt {
|
|||||||
///
|
///
|
||||||
/// Tracks lifetime totals only — per-mode best scores and times are
|
/// Tracks lifetime totals only — per-mode best scores and times are
|
||||||
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
|
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
|
||||||
/// long-standing call sites that only know about [`DrawMode`] keep
|
/// long-standing call sites that only know about [`DrawStockConfig`] keep
|
||||||
/// compiling.
|
/// compiling.
|
||||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig);
|
||||||
|
|
||||||
/// Updates the per-mode best score and fastest-win-time fields for the
|
/// Updates the per-mode best score and fastest-win-time fields for the
|
||||||
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
|
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
|
||||||
@@ -37,7 +37,7 @@ pub trait StatsExt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StatsExt for StatsSnapshot {
|
impl StatsExt for StatsSnapshot {
|
||||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig) {
|
||||||
let prev_wins = self.games_won;
|
let prev_wins = self.games_won;
|
||||||
self.games_played += 1;
|
self.games_played += 1;
|
||||||
self.games_won += 1;
|
self.games_won += 1;
|
||||||
@@ -64,8 +64,8 @@ impl StatsExt for StatsSnapshot {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match draw_mode {
|
match draw_mode {
|
||||||
DrawMode::DrawOne => self.draw_one_wins += 1,
|
DrawStockConfig::DrawOne => self.draw_one_wins += 1,
|
||||||
DrawMode::DrawThree => self.draw_three_wins += 1,
|
DrawStockConfig::DrawThree => self.draw_three_wins += 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.last_modified = Utc::now();
|
self.last_modified = Utc::now();
|
||||||
@@ -135,7 +135,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn first_win_sets_all_fields() {
|
fn first_win_sets_all_fields() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(1500, 120, &DrawMode::DrawOne);
|
s.update_on_win(1500, 120, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.games_played, 1);
|
assert_eq!(s.games_played, 1);
|
||||||
assert_eq!(s.games_won, 1);
|
assert_eq!(s.games_won, 1);
|
||||||
assert_eq!(s.win_streak_current, 1);
|
assert_eq!(s.win_streak_current, 1);
|
||||||
@@ -152,7 +152,7 @@ mod tests {
|
|||||||
fn streak_tracks_across_wins() {
|
fn streak_tracks_across_wins() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
}
|
}
|
||||||
assert_eq!(s.win_streak_current, 3);
|
assert_eq!(s.win_streak_current, 3);
|
||||||
assert_eq!(s.win_streak_best, 3);
|
assert_eq!(s.win_streak_best, 3);
|
||||||
@@ -161,8 +161,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn record_abandoned_resets_streak_and_increments_played() {
|
fn record_abandoned_resets_streak_and_increments_played() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.win_streak_current, 2);
|
assert_eq!(s.win_streak_current, 2);
|
||||||
s.record_abandoned();
|
s.record_abandoned();
|
||||||
assert_eq!(s.games_played, 3);
|
assert_eq!(s.games_played, 3);
|
||||||
@@ -174,35 +174,35 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn fastest_win_takes_minimum() {
|
fn fastest_win_takes_minimum() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 120, &DrawMode::DrawOne);
|
s.update_on_win(100, 120, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 500, &DrawMode::DrawOne);
|
s.update_on_win(100, 500, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.fastest_win_seconds, 120);
|
assert_eq!(s.fastest_win_seconds, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn avg_time_is_correct_rolling_average() {
|
fn avg_time_is_correct_rolling_average() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(100, 100, &DrawMode::DrawOne);
|
s.update_on_win(100, 100, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 200, &DrawMode::DrawOne);
|
s.update_on_win(100, 200, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.avg_time_seconds, 200);
|
assert_eq!(s.avg_time_seconds, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_score_updates_only_on_higher_score() {
|
fn best_score_updates_only_on_higher_score() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(500, 60, &DrawMode::DrawOne);
|
s.update_on_win(500, 60, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(300, 60, &DrawMode::DrawOne);
|
s.update_on_win(300, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.best_single_score, 500);
|
assert_eq!(s.best_single_score, 500);
|
||||||
s.update_on_win(800, 60, &DrawMode::DrawOne);
|
s.update_on_win(800, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.best_single_score, 800);
|
assert_eq!(s.best_single_score, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn negative_score_treated_as_zero() {
|
fn negative_score_treated_as_zero() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(-50, 60, &DrawMode::DrawOne);
|
s.update_on_win(-50, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.best_single_score, 0);
|
assert_eq!(s.best_single_score, 0);
|
||||||
assert_eq!(s.lifetime_score, 0);
|
assert_eq!(s.lifetime_score, 0);
|
||||||
}
|
}
|
||||||
@@ -210,8 +210,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn draw_three_wins_tracked_separately() {
|
fn draw_three_wins_tracked_separately() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawThree);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawThree);
|
||||||
assert_eq!(s.draw_one_wins, 1);
|
assert_eq!(s.draw_one_wins, 1);
|
||||||
assert_eq!(s.draw_three_wins, 1);
|
assert_eq!(s.draw_three_wins, 1);
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@ mod tests {
|
|||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
// Build a streak of 5.
|
// Build a streak of 5.
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
}
|
}
|
||||||
assert_eq!(s.win_streak_best, 5);
|
assert_eq!(s.win_streak_best, 5);
|
||||||
// Lose (abandon), resetting current.
|
// Lose (abandon), resetting current.
|
||||||
@@ -229,7 +229,7 @@ mod tests {
|
|||||||
assert_eq!(s.win_streak_current, 0);
|
assert_eq!(s.win_streak_current, 0);
|
||||||
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
|
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
|
||||||
// Win once — current becomes 1, best must remain 5.
|
// Win once — current becomes 1, best must remain 5.
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.win_streak_current, 1);
|
assert_eq!(s.win_streak_current, 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.win_streak_best, 5,
|
s.win_streak_best, 5,
|
||||||
@@ -243,7 +243,7 @@ mod tests {
|
|||||||
lifetime_score: u64::MAX - 100,
|
lifetime_score: u64::MAX - 100,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
s.update_on_win(200, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.lifetime_score,
|
s.lifetime_score,
|
||||||
u64::MAX,
|
u64::MAX,
|
||||||
|
|||||||
+174
-43
@@ -3,13 +3,13 @@
|
|||||||
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
||||||
//! loss during a write never corrupts the saved data.
|
//! loss during a write never corrupts the saved data.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::stats::StatsSnapshot;
|
use crate::stats::StatsSnapshot;
|
||||||
|
|
||||||
@@ -85,16 +85,13 @@ pub fn game_state_file_path() -> Option<PathBuf> {
|
|||||||
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||||
let data = fs::read(path).ok()?;
|
let data = fs::read(path).ok()?;
|
||||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||||
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
if gs.is_won() { None } else { Some(gs) }
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if gs.is_won { None } else { Some(gs) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
|
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
|
||||||
/// because a completed game should not be resumed.
|
/// because a completed game should not be resumed.
|
||||||
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
|
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
|
||||||
if gs.is_won {
|
if gs.is_won() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -234,9 +231,7 @@ pub fn load_time_attack_session_from_at(
|
|||||||
/// See [`load_time_attack_session_from_at`] for the rules under which
|
/// See [`load_time_attack_session_from_at`] for the rules under which
|
||||||
/// the call returns `None` (missing file, corrupt JSON, expired window).
|
/// the call returns `None` (missing file, corrupt JSON, expired window).
|
||||||
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
|
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
|
||||||
let now = SystemTime::now()
|
let now = Utc::now().timestamp().max(0) as u64;
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_or(0, |d| d.as_secs());
|
|
||||||
load_time_attack_session_from_at(path, now)
|
load_time_attack_session_from_at(path, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,9 +249,7 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
|||||||
/// current wall-clock time. Equivalent to constructing the struct
|
/// current wall-clock time. Equivalent to constructing the struct
|
||||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
||||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
||||||
let now = SystemTime::now()
|
let now = Utc::now().timestamp().max(0) as u64;
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_or(0, |d| d.as_secs());
|
|
||||||
TimeAttackSession {
|
TimeAttackSession {
|
||||||
remaining_secs,
|
remaining_secs,
|
||||||
wins,
|
wins,
|
||||||
@@ -286,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::stats::{StatsExt, StatsSnapshot};
|
use crate::stats::{StatsExt, StatsSnapshot};
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
@@ -299,7 +292,7 @@ mod tests {
|
|||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let mut stats = StatsSnapshot::default();
|
let mut stats = StatsSnapshot::default();
|
||||||
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
|
stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne);
|
||||||
save_stats_to(&path, &stats).expect("save");
|
save_stats_to(&path, &stats).expect("save");
|
||||||
|
|
||||||
let loaded = load_stats_from(&path);
|
let loaded = load_stats_from(&path);
|
||||||
@@ -384,17 +377,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_round_trip() {
|
fn game_state_round_trip() {
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::GameState;
|
||||||
let path = gs_path("round_trip");
|
let path = gs_path("round_trip");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let gs = GameState::new(12345, DrawMode::DrawOne);
|
let gs = GameState::new(12345, DrawStockConfig::DrawOne);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path).expect("load");
|
let loaded = load_game_state_from(&path).expect("load");
|
||||||
assert_eq!(loaded.seed, gs.seed);
|
assert_eq!(loaded.seed, gs.seed);
|
||||||
assert_eq!(loaded.draw_mode, gs.draw_mode);
|
assert_eq!(loaded.draw_mode(), gs.draw_mode());
|
||||||
assert!(!loaded.is_won);
|
assert!(!loaded.is_won());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -413,12 +406,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_game_state_skips_won_games() {
|
fn save_game_state_skips_won_games() {
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::GameState;
|
||||||
let path = gs_path("won_skip");
|
let path = gs_path("won_skip");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let mut gs = GameState::new(99, DrawMode::DrawOne);
|
let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
|
||||||
gs.is_won = true;
|
gs.set_test_won(true);
|
||||||
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
|
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
|
||||||
assert!(
|
assert!(
|
||||||
!path.exists(),
|
!path.exists(),
|
||||||
@@ -426,28 +419,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_game_state_ignores_won_games() {
|
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
|
||||||
let path = gs_path("won_load");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
// Write a won game directly (bypassing save_game_state_to's guard).
|
|
||||||
let mut gs = GameState::new(77, DrawMode::DrawOne);
|
|
||||||
gs.is_won = true;
|
|
||||||
let json = serde_json::to_string_pretty(&gs).unwrap();
|
|
||||||
let tmp = path.with_extension("json.tmp");
|
|
||||||
fs::write(&tmp, json.as_bytes()).unwrap();
|
|
||||||
fs::rename(&tmp, &path).unwrap();
|
|
||||||
|
|
||||||
assert!(load_game_state_from(&path).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_game_state_removes_file() {
|
fn delete_game_state_removes_file() {
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::GameState;
|
||||||
let path = gs_path("delete");
|
let path = gs_path("delete");
|
||||||
let gs = GameState::new(1, DrawMode::DrawOne);
|
let gs = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
delete_game_state_at(&path).expect("delete");
|
delete_game_state_at(&path).expect("delete");
|
||||||
@@ -463,9 +439,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_game_state_is_atomic() {
|
fn save_game_state_is_atomic() {
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::GameState;
|
||||||
let path = gs_path("atomic");
|
let path = gs_path("atomic");
|
||||||
let gs = GameState::new(55, DrawMode::DrawThree);
|
let gs = GameState::new(55, DrawStockConfig::DrawThree);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||||
@@ -516,6 +492,161 @@ mod tests {
|
|||||||
assert_eq!(loaded, StatsSnapshot::default());
|
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
|
// Time Attack session persistence
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -12,10 +12,14 @@
|
|||||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
|
||||||
|
use solitaire_sync::{SyncPayload, SyncResponse};
|
||||||
|
|
||||||
|
use crate::{SyncError, SyncProvider};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::{
|
use crate::{
|
||||||
SyncError, SyncProvider,
|
|
||||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||||
replay::Replay,
|
replay::Replay,
|
||||||
settings::SyncBackend,
|
settings::SyncBackend,
|
||||||
@@ -54,12 +58,17 @@ impl SyncProvider for LocalOnlyProvider {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SolitaireServerClient
|
// 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.
|
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
|
||||||
///
|
///
|
||||||
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
||||||
/// client automatically attempts a token refresh and retries the request once
|
/// client automatically attempts a token refresh and retries the request once
|
||||||
/// before returning an error.
|
/// before returning an error.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub struct SolitaireServerClient {
|
pub struct SolitaireServerClient {
|
||||||
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||||
/// Trailing slashes are stripped on construction.
|
/// Trailing slashes are stripped on construction.
|
||||||
@@ -70,6 +79,7 @@ pub struct SolitaireServerClient {
|
|||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
impl SolitaireServerClient {
|
impl SolitaireServerClient {
|
||||||
/// Construct a new client for the given server URL and username.
|
/// Construct a new client for the given server URL and username.
|
||||||
///
|
///
|
||||||
@@ -201,6 +211,7 @@ impl SolitaireServerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SyncProvider for SolitaireServerClient {
|
impl SyncProvider for SolitaireServerClient {
|
||||||
/// Fetch the latest sync payload from the server.
|
/// Fetch the latest sync payload from the server.
|
||||||
@@ -486,6 +497,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
impl SolitaireServerClient {
|
impl SolitaireServerClient {
|
||||||
/// Pulled out of `push_replay` so both the first attempt and the
|
/// Pulled out of `push_replay` so both the first attempt and the
|
||||||
/// post-401-retry attempt go through the same parse path.
|
/// post-401-retry attempt go through the same parse path.
|
||||||
@@ -581,9 +593,10 @@ impl SolitaireServerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Response extraction helpers
|
// Response extraction helpers (native-only, use reqwest::Response)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
||||||
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
||||||
///
|
///
|
||||||
@@ -607,6 +620,7 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
|
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
|
||||||
async fn extract_leaderboard_body(
|
async fn extract_leaderboard_body(
|
||||||
resp: reqwest::Response,
|
resp: reqwest::Response,
|
||||||
@@ -621,6 +635,7 @@ async fn extract_leaderboard_body(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
||||||
/// statuses to the appropriate [`SyncError`].
|
/// statuses to the appropriate [`SyncError`].
|
||||||
///
|
///
|
||||||
@@ -652,6 +667,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
|
|||||||
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
||||||
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
||||||
/// and remains backend-agnostic.
|
/// and remains backend-agnostic.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||||
match backend {
|
match backend {
|
||||||
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
|
|
||||||
/// XP awarded each time a weekly goal is just completed.
|
/// XP awarded each time a weekly goal is just completed.
|
||||||
pub const WEEKLY_GOAL_XP: u64 = 75;
|
pub const WEEKLY_GOAL_XP: u64 = 75;
|
||||||
@@ -36,7 +36,7 @@ pub struct WeeklyGoalDef {
|
|||||||
pub struct WeeklyGoalContext {
|
pub struct WeeklyGoalContext {
|
||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
pub used_undo: bool,
|
pub used_undo: bool,
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WeeklyGoalDef {
|
impl WeeklyGoalDef {
|
||||||
@@ -47,7 +47,7 @@ impl WeeklyGoalDef {
|
|||||||
WeeklyGoalKind::WinGame => true,
|
WeeklyGoalKind::WinGame => true,
|
||||||
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
||||||
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
||||||
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
|
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawStockConfig::DrawThree,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ mod tests {
|
|||||||
WeeklyGoalContext {
|
WeeklyGoalContext {
|
||||||
time_seconds: time,
|
time_seconds: time,
|
||||||
used_undo: undo,
|
used_undo: undo,
|
||||||
draw_mode: DrawMode::DrawOne,
|
draw_mode: DrawStockConfig::DrawOne,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ mod tests {
|
|||||||
WeeklyGoalContext {
|
WeeklyGoalContext {
|
||||||
time_seconds: time,
|
time_seconds: time,
|
||||||
used_undo: false,
|
used_undo: false,
|
||||||
draw_mode: DrawMode::DrawThree,
|
draw_mode: DrawStockConfig::DrawThree,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-11
@@ -7,14 +7,11 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = { workspace = true }
|
bevy = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
|
||||||
kira = { workspace = true }
|
|
||||||
solitaire_core = { workspace = true }
|
solitaire_core = { workspace = true }
|
||||||
solitaire_data = { workspace = true }
|
solitaire_data = { workspace = true }
|
||||||
solitaire_sync = { workspace = true }
|
solitaire_sync = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
tokio = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
@@ -22,17 +19,24 @@ usvg = { workspace = true }
|
|||||||
resvg = { workspace = true }
|
resvg = { workspace = true }
|
||||||
tiny-skia = { workspace = true }
|
tiny-skia = { workspace = true }
|
||||||
ron = { 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 }
|
dirs = { workspace = true }
|
||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
|
|
||||||
# `arboard` provides clipboard access for the Stats overlay's
|
# `arboard` has no Android backend and no wasm32 backend. Gate it out for
|
||||||
# "Copy share link" button. The crate has no Android backend
|
# both; the copy-share-link button surfaces an informational toast instead.
|
||||||
# (its `platform::Clipboard` module is unimplemented for the
|
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
|
||||||
# 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 }
|
arboard = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
@@ -47,3 +51,7 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
solitaire_core = { workspace = true, features = ["test-support"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ impl Plugin for AchievementPlugin {
|
|||||||
// achievements-scroll system also runs cleanly under
|
// achievements-scroll system also runs cleanly under
|
||||||
// `MinimalPlugins` in tests.
|
// `MinimalPlugins` in tests.
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
.add_message::<bevy::input::touch::TouchInput>()
|
.add_message::<TouchInput>()
|
||||||
// Run after GameMutation (so GameWonEvent is available), after
|
// Run after GameMutation (so GameWonEvent is available), after
|
||||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
// (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,
|
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||||
last_win_score: ev.score,
|
last_win_score: ev.score,
|
||||||
last_win_time_seconds: ev.time_seconds,
|
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()),
|
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,
|
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -671,7 +671,7 @@ mod tests {
|
|||||||
.add_plugins(AchievementPlugin::headless());
|
.add_plugins(AchievementPlugin::headless());
|
||||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||||
// MinimalPlugins it isn't auto-registered.
|
// MinimalPlugins it isn't auto-registered.
|
||||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -779,7 +779,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.force_test_undos(1);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1000,
|
score: 1000,
|
||||||
@@ -819,7 +819,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
.set_test_draw_mode(DrawStockConfig::DrawThree);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -868,7 +868,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
.set_test_draw_mode(DrawStockConfig::DrawThree);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -912,7 +912,7 @@ mod tests {
|
|||||||
// Put the active game in Zen mode. evaluate_on_win reads
|
// Put the active game in Zen mode. evaluate_on_win reads
|
||||||
// GameStateResource.mode directly to populate last_win_is_zen.
|
// GameStateResource.mode directly to populate last_win_is_zen.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||||
solitaire_core::game_state::GameMode::Zen;
|
GameMode::Zen;
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 0,
|
score: 0,
|
||||||
@@ -946,7 +946,7 @@ mod tests {
|
|||||||
// Default GameMode is Classic; assert and rely on it.
|
// Default GameMode is Classic; assert and rely on it.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.world().resource::<GameStateResource>().0.mode,
|
app.world().resource::<GameStateResource>().0.mode,
|
||||||
solitaire_core::game_state::GameMode::Classic
|
GameMode::Classic
|
||||||
);
|
);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
@@ -1250,7 +1250,7 @@ mod tests {
|
|||||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||||
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
|
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
|
||||||
.add_plugins(AchievementPlugin::headless());
|
.add_plugins(AchievementPlugin::headless());
|
||||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -1393,8 +1393,8 @@ mod tests {
|
|||||||
|
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||||
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
||||||
@@ -1409,12 +1409,12 @@ mod tests {
|
|||||||
fn dummy_replay() -> Replay {
|
fn dummy_replay() -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
1,
|
1,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![ReplayMove::StockClick],
|
vec![KlondikeInstruction::RotateStock],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,3 +204,61 @@ fn mode_str(mode: GameMode) -> &'static str {
|
|||||||
GameMode::Difficulty(_) => "difficulty",
|
GameMode::Difficulty(_) => "difficulty",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use solitaire_core::game_state::DifficultyLevel;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_for_requires_analytics_opt_in() {
|
||||||
|
let settings = Settings {
|
||||||
|
analytics_enabled: false,
|
||||||
|
matomo_url: Some("https://analytics.example.com".into()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(client_for(&settings).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_for_requires_matomo_url() {
|
||||||
|
let settings = Settings {
|
||||||
|
analytics_enabled: true,
|
||||||
|
matomo_url: None,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(client_for(&settings).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_for_creates_client_when_enabled_and_configured() {
|
||||||
|
let settings = Settings {
|
||||||
|
analytics_enabled: true,
|
||||||
|
matomo_url: Some("https://analytics.example.com".into()),
|
||||||
|
matomo_site_id: 2,
|
||||||
|
sync_backend: SyncBackend::SolitaireServer {
|
||||||
|
url: "https://solitaire.example.com".into(),
|
||||||
|
username: "alice".into(),
|
||||||
|
avatar_url: None,
|
||||||
|
},
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(client_for(&settings).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_labels_match_analytics_payload_contract() {
|
||||||
|
assert_eq!(mode_str(GameMode::Classic), "classic");
|
||||||
|
assert_eq!(mode_str(GameMode::Zen), "zen");
|
||||||
|
assert_eq!(mode_str(GameMode::Challenge), "challenge");
|
||||||
|
assert_eq!(mode_str(GameMode::TimeAttack), "time_attack");
|
||||||
|
assert_eq!(
|
||||||
|
mode_str(GameMode::Difficulty(DifficultyLevel::Grandmaster)),
|
||||||
|
"difficulty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,37 +1,19 @@
|
|||||||
/// Android clipboard bridge via JNI.
|
/// Android clipboard bridge via JNI.
|
||||||
///
|
///
|
||||||
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
||||||
/// through the JNI. Only compiled and linked on `target_os = "android"`.
|
/// through the safe [`solitaire_data::android_jni`] bridge. Only compiled and
|
||||||
|
/// linked on `target_os = "android"`.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn set_text(text: &str) -> Result<(), String> {
|
pub fn set_text(text: &str) -> Result<(), String> {
|
||||||
use bevy::android::ANDROID_APP;
|
use jni::objects::JValueOwned;
|
||||||
use jni::{
|
use solitaire_data::android_jni;
|
||||||
JavaVM,
|
|
||||||
objects::{JObject, JValueOwned},
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = ANDROID_APP
|
android_jni::with_activity_env(|env, activity| {
|
||||||
.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")
|
// ClipboardManager cm = activity.getSystemService("clipboard")
|
||||||
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
||||||
let cm = env
|
let cm = env
|
||||||
.call_method(
|
.call_method(
|
||||||
&activity,
|
activity,
|
||||||
"getSystemService",
|
"getSystemService",
|
||||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
&[svc_name.borrow()],
|
&[svc_name.borrow()],
|
||||||
@@ -60,6 +42,5 @@ pub fn set_text(text: &str) -> Result<(), String> {
|
|||||||
&[clip_val.borrow()],
|
&[clip_val.borrow()],
|
||||||
)?
|
)?
|
||||||
.v()
|
.v()
|
||||||
})()
|
})
|
||||||
.map_err(|e| format!("clipboard JNI: {e}"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
use solitaire_data::{AnimSpeed, Settings};
|
use solitaire_data::{AnimSpeed, Settings};
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
@@ -180,6 +181,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
|
.add_message::<RequestRedraw>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
@@ -352,7 +354,7 @@ fn handle_win_cascade(
|
|||||||
end: target.truncate(),
|
end: target.truncate(),
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration,
|
duration,
|
||||||
curve: crate::card_animation::MotionCurve::Expressive,
|
curve: MotionCurve::Expressive,
|
||||||
delay: i as f32 * step,
|
delay: i as f32 * step,
|
||||||
start_z: start.z,
|
start_z: start.z,
|
||||||
end_z: target.z,
|
end_z: target.z,
|
||||||
@@ -1076,7 +1078,7 @@ mod tests {
|
|||||||
// Pairs the existing audio (`card_invalid.wav`) and visual
|
// Pairs the existing audio (`card_invalid.wav`) and visual
|
||||||
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
|
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
|
||||||
// with an accessibility-focused readable text cue.
|
// with an accessibility-focused readable text cue.
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
@@ -1088,8 +1090,8 @@ mod tests {
|
|||||||
.count();
|
.count();
|
||||||
|
|
||||||
app.world_mut().write_message(MoveRejectedEvent {
|
app.world_mut().write_message(MoveRejectedEvent {
|
||||||
from: PileType::Tableau(0),
|
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
to: PileType::Tableau(1),
|
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
//! red/black colour split.
|
//! red/black colour split.
|
||||||
|
|
||||||
use bevy::math::UVec2;
|
use bevy::math::UVec2;
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
|
|
||||||
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
||||||
/// `SvgLoaderSettings` resolution).
|
/// `SvgLoaderSettings` resolution).
|
||||||
|
|||||||
@@ -47,12 +47,16 @@
|
|||||||
//! comments on each call out the pairing so a future reader doesn't
|
//! comments on each call out the pairing so a future reader doesn't
|
||||||
//! accidentally drop one half.
|
//! accidentally drop one half.
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use bevy::asset::AssetApp;
|
use bevy::asset::AssetApp;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use bevy::asset::io::AssetSourceBuilder;
|
use bevy::asset::io::AssetSourceBuilder;
|
||||||
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
|
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use bevy::asset::io::file::FileAssetReader;
|
use bevy::asset::io::file::FileAssetReader;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::assets::user_dir::user_theme_dir;
|
use crate::assets::user_dir::user_theme_dir;
|
||||||
|
|
||||||
/// `AssetSourceId` of the user-themes asset source. Use it as
|
/// `AssetSourceId` of the user-themes asset source. Use it as
|
||||||
@@ -111,6 +115,10 @@ macro_rules! embed_classic_svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every Dark-theme SVG file bundled into the binary.
|
/// 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])] = &[
|
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||||
embed_dark_svg!("back.svg"),
|
embed_dark_svg!("back.svg"),
|
||||||
embed_dark_svg!("clubs_ace.svg"),
|
embed_dark_svg!("clubs_ace.svg"),
|
||||||
@@ -168,6 +176,8 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Every Classic-theme SVG file bundled into the binary.
|
/// 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])] = &[
|
const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||||
embed_classic_svg!("back.svg"),
|
embed_classic_svg!("back.svg"),
|
||||||
embed_classic_svg!("clubs_ace.svg"),
|
embed_classic_svg!("clubs_ace.svg"),
|
||||||
@@ -235,11 +245,16 @@ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
|
|||||||
/// Returns the `&mut App` so the call can be chained from the binary
|
/// Returns the `&mut App` so the call can be chained from the binary
|
||||||
/// entry point.
|
/// entry point.
|
||||||
pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
|
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();
|
let root = user_theme_dir();
|
||||||
app.register_asset_source(
|
app.register_asset_source(
|
||||||
USER_THEMES,
|
USER_THEMES,
|
||||||
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
|
|||||||
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
||||||
use usvg::FontResolver;
|
use usvg::FontResolver;
|
||||||
|
|
||||||
usvg::FontResolver {
|
FontResolver {
|
||||||
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
|
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
|
||||||
select_fallback: FontResolver::default_fallback_selector(),
|
select_fallback: FontResolver::default_fallback_selector(),
|
||||||
}
|
}
|
||||||
@@ -282,7 +282,7 @@ mod tests {
|
|||||||
/// tightens.
|
/// tightens.
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_satisfies_loader_bounds() {
|
fn settings_satisfies_loader_bounds() {
|
||||||
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
|
fn assert_loader_settings<T: Default + Serialize + serde::de::DeserializeOwned>() {}
|
||||||
assert_loader_settings::<SvgLoaderSettings>();
|
assert_loader_settings::<SvgLoaderSettings>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
|||||||
/// the panic message names the supported workaround.
|
/// the panic message names the supported workaround.
|
||||||
fn detected_platform_data_dir() -> PathBuf {
|
fn detected_platform_data_dir() -> PathBuf {
|
||||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||||
|
// On wasm32, data_dir() always returns None — there is no filesystem.
|
||||||
|
// User themes are not supported in the browser build; return an empty
|
||||||
|
// path so callers produce a benign empty dir rather than panicking.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
PathBuf::new()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
panic!(
|
panic!(
|
||||||
"user_theme_dir(): platform data directory is unavailable. \
|
"user_theme_dir(): platform data directory is unavailable. \
|
||||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||||
@@ -89,6 +98,7 @@ fn detected_platform_data_dir() -> PathBuf {
|
|||||||
As a workaround call solitaire_engine::assets::user_dir::\
|
As a workaround call solitaire_engine::assets::user_dir::\
|
||||||
set_user_theme_dir() before App::run()."
|
set_user_theme_dir() before App::run()."
|
||||||
)
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ use crate::events::{
|
|||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use solitaire_core::pile::PileType;
|
|
||||||
|
|
||||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||||
const RECYCLE_VOLUME: f64 = 0.5;
|
const RECYCLE_VOLUME: f64 = 0.5;
|
||||||
@@ -374,10 +373,7 @@ fn play_on_draw(
|
|||||||
// When the stock pile is empty the draw action recycles the waste pile
|
// 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
|
// back to stock. Play the flip sound at half volume to give audible
|
||||||
// feedback that distinguishes a recycle from a normal draw.
|
// feedback that distinguishes a recycle from a normal draw.
|
||||||
let stock_len = game
|
let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
|
||||||
.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) {
|
if is_recycle(stock_len) {
|
||||||
let mut data = lib.flip.clone();
|
let mut data = lib.flip.clone();
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
@@ -20,11 +22,18 @@ use crate::resources::GameStateResource;
|
|||||||
///
|
///
|
||||||
/// Plays the win fanfare at half volume so it is clearly distinguishable from
|
/// 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.
|
/// 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;
|
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
|
||||||
|
|
||||||
/// Seconds between consecutive auto-complete moves.
|
/// Seconds between consecutive auto-complete moves.
|
||||||
const STEP_INTERVAL: f32 = 0.12;
|
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.
|
/// Tracks whether auto-complete is active and when the next move fires.
|
||||||
#[derive(Resource, Default, Debug)]
|
#[derive(Resource, Default, Debug)]
|
||||||
pub struct AutoCompleteState {
|
pub struct AutoCompleteState {
|
||||||
@@ -39,7 +48,9 @@ pub struct AutoCompletePlugin;
|
|||||||
|
|
||||||
impl Plugin for AutoCompletePlugin {
|
impl Plugin for AutoCompletePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<AutoCompleteState>().add_systems(
|
app.init_resource::<AutoCompleteState>()
|
||||||
|
.add_message::<RequestRedraw>()
|
||||||
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
detect_auto_complete,
|
detect_auto_complete,
|
||||||
@@ -65,21 +76,28 @@ fn detect_auto_complete(
|
|||||||
}
|
}
|
||||||
changed.clear();
|
changed.clear();
|
||||||
|
|
||||||
if game.0.is_won {
|
if game.0.is_won() {
|
||||||
state.active = false;
|
state.active = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if game.0.is_auto_completable && !state.active {
|
if game.0.is_auto_completable() && !state.active {
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.cooldown = 0.0; // fire first move immediately
|
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
||||||
|
} else if !game.0.is_auto_completable() && state.active {
|
||||||
|
// `is_auto_completable` only becomes false after an explicit undo
|
||||||
|
// (which puts a card back on the tableau or re-fills the stock/waste)
|
||||||
|
// or a new-game reset — never as a transient gap during a normal
|
||||||
|
// auto-complete sequence. Deactivate here so `drive_auto_complete`
|
||||||
|
// does not keep retrying indefinitely after the player undoes out of
|
||||||
|
// the sequence.
|
||||||
|
//
|
||||||
|
// Note: the transient-`None` case mentioned in older versions of this
|
||||||
|
// comment referred to `next_auto_complete_move()` returning `None`, not
|
||||||
|
// to `is_auto_completable` being false. Those are independent fields;
|
||||||
|
// `drive_auto_complete` still retries on a transient `None` return from
|
||||||
|
// `next_auto_complete_move` because that check happens there, not here.
|
||||||
|
state.active = false;
|
||||||
}
|
}
|
||||||
// Intentionally no `else if !is_auto_completable` branch here.
|
|
||||||
// Deactivating on every frame where `is_auto_completable` is false
|
|
||||||
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
|
|
||||||
// transiently returns `None` (e.g. while the previous move is still
|
|
||||||
// in-flight). The `is_won` check above already handles the definitive
|
|
||||||
// end-of-game case; `drive_auto_complete` simply retries next tick
|
|
||||||
// when no move is available yet.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays a distinct chime the moment auto-complete first activates.
|
/// Plays a distinct chime the moment auto-complete first activates.
|
||||||
@@ -88,6 +106,7 @@ fn detect_auto_complete(
|
|||||||
/// exactly once on the `false → true` edge. The win fanfare is played at half
|
/// 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
|
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
|
||||||
/// not overwhelm the card-place sounds that follow immediately.
|
/// not overwhelm the card-place sounds that follow immediately.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn on_auto_complete_start(
|
fn on_auto_complete_start(
|
||||||
state: Res<AutoCompleteState>,
|
state: Res<AutoCompleteState>,
|
||||||
mut was_active: Local<bool>,
|
mut was_active: Local<bool>,
|
||||||
@@ -108,6 +127,12 @@ fn on_auto_complete_start(
|
|||||||
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
|
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.
|
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
||||||
fn drive_auto_complete(
|
fn drive_auto_complete(
|
||||||
mut state: ResMut<AutoCompleteState>,
|
mut state: ResMut<AutoCompleteState>,
|
||||||
@@ -142,9 +167,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -152,36 +177,45 @@ mod tests {
|
|||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(AutoCompletePlugin);
|
.add_plugins(AutoCompletePlugin);
|
||||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other
|
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
||||||
/// tableau piles empty, stock/waste empty, Clubs foundation empty.
|
let mut g = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
fn nearly_won_state() -> GameState {
|
g.set_test_stock_cards(Vec::new());
|
||||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
g.set_test_waste_cards(Vec::new());
|
||||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
for foundation in [
|
||||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
Foundation::Foundation1,
|
||||||
for i in 0..7 {
|
Foundation::Foundation2,
|
||||||
g.piles
|
Foundation::Foundation3,
|
||||||
.get_mut(&PileType::Tableau(i))
|
Foundation::Foundation4,
|
||||||
.unwrap()
|
] {
|
||||||
.cards
|
g.set_test_foundation_cards(foundation, Vec::new());
|
||||||
.clear();
|
|
||||||
}
|
}
|
||||||
g.piles
|
for tableau in [
|
||||||
.get_mut(&PileType::Tableau(0))
|
Tableau::Tableau1,
|
||||||
.unwrap()
|
Tableau::Tableau2,
|
||||||
.cards
|
Tableau::Tableau3,
|
||||||
.push(Card {
|
Tableau::Tableau4,
|
||||||
id: 99,
|
Tableau::Tableau5,
|
||||||
suit: Suit::Clubs,
|
Tableau::Tableau6,
|
||||||
rank: Rank::Ace,
|
Tableau::Tableau7,
|
||||||
face_up: true,
|
] {
|
||||||
});
|
g.set_test_tableau_cards(tableau, Vec::new());
|
||||||
g.is_auto_completable = true;
|
}
|
||||||
g
|
g.set_test_tableau_cards(
|
||||||
|
Tableau::Tableau1,
|
||||||
|
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||||
|
);
|
||||||
|
g.set_test_auto_completable(true);
|
||||||
|
let expected = (
|
||||||
|
KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
KlondikePile::Foundation(Foundation::Foundation1),
|
||||||
|
);
|
||||||
|
assert_eq!(g.next_auto_complete_move(), Some(expected));
|
||||||
|
(g, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -193,8 +227,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn detect_activates_when_auto_completable() {
|
fn detect_activates_when_auto_completable() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Install a nearly-won state and fire StateChangedEvent.
|
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
g.set_test_auto_completable(true);
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
||||||
app.world_mut().write_message(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -204,9 +239,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn drive_fires_move_request_when_active() {
|
fn drive_fires_move_request_when_active() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
||||||
app.world_mut().write_message(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update(); // detect runs, sets active
|
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
|
app.update(); // drive fires the move
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||||
@@ -214,17 +254,16 @@ mod tests {
|
|||||||
let fired: Vec<_> = cursor.read(events).collect();
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
// At least one MoveRequestEvent should have been fired.
|
// At least one MoveRequestEvent should have been fired.
|
||||||
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
||||||
assert_eq!(fired[0].from, PileType::Tableau(0));
|
assert_eq!(fired[0].from, expected_from);
|
||||||
// First empty foundation slot wins on a fresh nearly-won board.
|
assert_eq!(fired[0].to, expected_to);
|
||||||
assert_eq!(fired[0].to, PileType::Foundation(0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drive_deactivates_on_win() {
|
fn drive_deactivates_on_win() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Inject a won game state — active should not be set.
|
// Inject a won game state — active should not be set.
|
||||||
let mut gs = nearly_won_state();
|
let (mut gs, _) = seeded_state_with_auto_move();
|
||||||
gs.is_won = true;
|
gs.set_test_won(true);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
app.world_mut().write_message(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ pub struct AvatarFetchEvent {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl bevy::prelude::Message for AvatarFetchEvent {}
|
impl Message for AvatarFetchEvent {}
|
||||||
|
|
||||||
/// In-flight avatar download task. Returns the raw image bytes on success,
|
/// In-flight avatar download task. Returns the raw image bytes on success,
|
||||||
/// or `None` on any network / decode error.
|
/// or `None` on any network / decode error.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use std::collections::VecDeque;
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
|
||||||
use super::animation::CardAnimation;
|
use super::animation::CardAnimation;
|
||||||
use super::tuning::AnimationTuning;
|
use super::tuning::AnimationTuning;
|
||||||
@@ -72,7 +73,7 @@ pub struct HoverState {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BufferedInput {
|
pub enum BufferedInput {
|
||||||
Move {
|
Move {
|
||||||
from: crate::events::MoveRequestEvent,
|
from: MoveRequestEvent,
|
||||||
},
|
},
|
||||||
Draw,
|
Draw,
|
||||||
Undo,
|
Undo,
|
||||||
@@ -210,12 +211,12 @@ pub(crate) fn apply_drag_visual(
|
|||||||
|
|
||||||
// Only lift cards that are in a *committed* drag. Pending drags (below
|
// Only lift cards that are in a *committed* drag. Pending drags (below
|
||||||
// threshold) must stay at scale 1.0 to avoid visible premature lift.
|
// threshold) must stay at scale 1.0 to avoid visible premature lift.
|
||||||
let (dragged_ids, committed): (&[u32], bool) = drag
|
let (dragged_cards, committed): (&[Card], bool) = drag
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
|
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
|
||||||
|
|
||||||
for (_, card, mut transform) in &mut cards {
|
for (_, card, mut transform) in &mut cards {
|
||||||
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
|
let is_active_drag = committed && dragged_cards.contains(&card.card);
|
||||||
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
|
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
|
||||||
let current = transform.scale.x;
|
let current = transform.scale.x;
|
||||||
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ pub use timing::{
|
|||||||
pub use tuning::{AnimationTuning, InputPlatform};
|
pub use tuning::{AnimationTuning, InputPlatform};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||||
@@ -125,6 +126,7 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
.add_message::<DrawRequestEvent>()
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
|
.add_message::<RequestRedraw>()
|
||||||
.init_resource::<DragState>()
|
.init_resource::<DragState>()
|
||||||
.init_resource::<HoverState>()
|
.init_resource::<HoverState>()
|
||||||
.init_resource::<InputBuffer>()
|
.init_resource::<InputBuffer>()
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ impl AnimationTuning {
|
|||||||
platform: InputPlatform::Mouse,
|
platform: InputPlatform::Mouse,
|
||||||
duration_scale: 1.0,
|
duration_scale: 1.0,
|
||||||
overshoot_scale: 1.0,
|
overshoot_scale: 1.0,
|
||||||
drag_threshold_px: 4.0,
|
drag_threshold_px: 6.0,
|
||||||
drag_scale: 1.08,
|
drag_scale: 1.08,
|
||||||
hover_scale: 1.04,
|
hover_scale: 1.04,
|
||||||
hover_lerp_speed: 14.0,
|
hover_lerp_speed: 14.0,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
|||||||
|
//! Card flip animation and drag shadows.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use solitaire_core::Card;
|
||||||
|
|
||||||
|
use crate::animation_plugin::EffectiveSlideDuration;
|
||||||
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
||||||
|
///
|
||||||
|
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
||||||
|
pub(super) fn start_flip_anim(
|
||||||
|
mut events: MessageReader<CardFlippedEvent>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
|
) {
|
||||||
|
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
|
||||||
|
// Instant animation speed — skip the flip effect entirely.
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for CardFlippedEvent(flipped_card) in events.read() {
|
||||||
|
for (entity, marker) in &card_entities {
|
||||||
|
if marker.card == *flipped_card {
|
||||||
|
commands.entity(entity).insert(CardFlipAnim {
|
||||||
|
timer: 0.0,
|
||||||
|
phase: FlipPhase::ScalingDown,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
||||||
|
///
|
||||||
|
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
||||||
|
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
|
||||||
|
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
|
||||||
|
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
|
||||||
|
/// - When complete the component is removed and scale.x is restored to 1.0.
|
||||||
|
pub(super) fn tick_flip_anim(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
||||||
|
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
||||||
|
anim.timer += dt;
|
||||||
|
match anim.phase {
|
||||||
|
FlipPhase::ScalingDown => {
|
||||||
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||||
|
transform.scale.x = 1.0 - t;
|
||||||
|
if t >= 1.0 {
|
||||||
|
anim.phase = FlipPhase::ScalingUp;
|
||||||
|
anim.timer = 0.0;
|
||||||
|
transform.scale.x = 0.0;
|
||||||
|
// Fire the reveal event exactly once, at the phase transition,
|
||||||
|
// so the flip sound is synchronised with the visual face reveal.
|
||||||
|
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlipPhase::ScalingUp => {
|
||||||
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||||
|
transform.scale.x = t;
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale.x = 1.0;
|
||||||
|
commands.entity(entity).remove::<CardFlipAnim>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #38 — Drag-elevation shadow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maintains a single `ShadowEntity` while cards are being dragged.
|
||||||
|
///
|
||||||
|
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
|
||||||
|
/// sprite behind the top dragged card.
|
||||||
|
/// - If no drag is active, despawns the shadow entity.
|
||||||
|
pub(super) fn update_drag_shadow(
|
||||||
|
mut commands: Commands,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
card_entities: Query<(&CardEntity, &Transform)>,
|
||||||
|
card_index: Res<CardEntityIndex>,
|
||||||
|
mut shadow: Local<Option<Entity>>,
|
||||||
|
) {
|
||||||
|
if drag.is_idle() {
|
||||||
|
// No drag in progress — remove shadow if it exists.
|
||||||
|
if let Some(e) = shadow.take() {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let card_w = layout.0.card_size.x;
|
||||||
|
let card_h = layout.0.card_size.y;
|
||||||
|
|
||||||
|
// Find the world position of the first (top) dragged card.
|
||||||
|
let top_pos = drag.cards.first().and_then(|first_card| {
|
||||||
|
card_index
|
||||||
|
.get(first_card)
|
||||||
|
.and_then(|entity| card_entities.get(entity).ok())
|
||||||
|
.map(|(_, t)| t.translation)
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(top_pos) = top_pos else { return };
|
||||||
|
|
||||||
|
// Shadow is slightly larger, offset behind-and-below, at a z slightly
|
||||||
|
// below the dragged cards.
|
||||||
|
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
|
||||||
|
|
||||||
|
match *shadow {
|
||||||
|
Some(e) => {
|
||||||
|
// Reposition the existing shadow.
|
||||||
|
commands
|
||||||
|
.entity(e)
|
||||||
|
.insert(Transform::from_translation(shadow_pos));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Spawn a new shadow sprite. Alpha tracks the per-card
|
||||||
|
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
|
||||||
|
// "no box-shadow" policy disables this stack shadow in
|
||||||
|
// lockstep with the per-card shadows. Re-enabling shadows
|
||||||
|
// is then a one-line change in `ui_theme`, not a hunt
|
||||||
|
// through plugin code.
|
||||||
|
let e = commands
|
||||||
|
.spawn((
|
||||||
|
ShadowEntity,
|
||||||
|
Sprite {
|
||||||
|
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
|
||||||
|
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_translation(shadow_pos),
|
||||||
|
Visibility::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
*shadow = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
|
||||||
|
/// based on whether the parent [`CardEntity`] is currently in
|
||||||
|
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
|
||||||
|
/// (no lerp) — the existing shake / settle feedback already handles motion
|
||||||
|
/// at drag-end, so an additional shadow tween would compete with those cues.
|
||||||
|
///
|
||||||
|
/// The shadow size is rebuilt from the parent card's current `Sprite`
|
||||||
|
/// `custom_size` plus the appropriate padding, so the resize handler does
|
||||||
|
/// not need to pre-tune shadow sizes for the drag state — this system fixes
|
||||||
|
/// the geometry within one frame.
|
||||||
|
pub(super) fn update_card_shadows_on_drag(
|
||||||
|
drag: Res<DragState>,
|
||||||
|
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
|
||||||
|
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
|
||||||
|
) {
|
||||||
|
let dragged: HashSet<&Card> = drag.cards.iter().collect();
|
||||||
|
|
||||||
|
for (card_entity, card_sprite, children) in cards.iter() {
|
||||||
|
let is_dragged = dragged.contains(&card_entity.card);
|
||||||
|
let (offset, padding, alpha) = card_shadow_params(is_dragged);
|
||||||
|
let Some(card_size) = card_sprite.custom_size else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
for child in children.iter() {
|
||||||
|
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
|
||||||
|
shadow_sprite.custom_size = Some(card_size + padding);
|
||||||
|
shadow_transform.translation.x = offset.x;
|
||||||
|
shadow_transform.translation.y = offset.y;
|
||||||
|
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #28 — Hint highlight tick system
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! Hint and right-click highlights, plus cursor hit-testing helpers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::resources::{DragState, GameStateResource};
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||||
|
|
||||||
|
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
|
||||||
|
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
|
||||||
|
/// resets the card sprite to its normal face-up colour.
|
||||||
|
pub(super) fn tick_hint_highlight(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
) {
|
||||||
|
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
|
let use_images = card_images.is_some();
|
||||||
|
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||||
|
hint.remaining -= time.delta_secs();
|
||||||
|
if hint.remaining <= 0.0 {
|
||||||
|
// Restore the normal sprite colour.
|
||||||
|
// When image-based rendering is active, WHITE is the neutral tint;
|
||||||
|
// otherwise restore the solid colour appropriate to the card state.
|
||||||
|
sprite.color = if use_images {
|
||||||
|
Color::WHITE
|
||||||
|
} else {
|
||||||
|
let is_face_up = all_cards(&game.0)
|
||||||
|
.iter()
|
||||||
|
.find(|(c, _face_up)| *c == card_entity.card)
|
||||||
|
.is_some_and(|(_, face_up)| *face_up);
|
||||||
|
if is_face_up {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
} else {
|
||||||
|
card_back_colour(back_idx)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<HintHighlight>()
|
||||||
|
.remove::<HintHighlightTimer>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #46 — Right-click legal destination highlights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Lime tint applied to a `PileMarker` sprite when it is a legal
|
||||||
|
/// destination for the right-clicked card. Same RGB as the design-
|
||||||
|
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
|
||||||
|
/// because `Alpha::with_alpha` is not yet a `const` trait method on
|
||||||
|
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
|
||||||
|
/// so a palette swap can't drift the two apart silently.
|
||||||
|
pub(super) const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
|
||||||
|
|
||||||
|
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
|
||||||
|
/// when the timer expires.
|
||||||
|
///
|
||||||
|
/// This is a fallback expiry: highlights also clear immediately on
|
||||||
|
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
|
||||||
|
/// first. The 1.5 s timer ensures highlights always disappear even if the
|
||||||
|
/// player takes no further action.
|
||||||
|
pub(super) fn tick_right_click_highlights(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut highlights: Query<
|
||||||
|
(Entity, &mut RightClickHighlightTimer, &mut Sprite),
|
||||||
|
With<RightClickHighlight>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut timer, mut sprite) in &mut highlights {
|
||||||
|
timer.0 -= dt;
|
||||||
|
if timer.0 <= 0.0 {
|
||||||
|
// Restore the pile marker to its default colour before removing
|
||||||
|
// the highlight marker component.
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<RightClickHighlight>()
|
||||||
|
.remove::<RightClickHighlightTimer>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the `RightClickHighlight` marker from every highlighted pile and
|
||||||
|
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
||||||
|
///
|
||||||
|
/// Shared by the on-state-change and on-pause clear systems to avoid
|
||||||
|
/// duplicating the removal logic.
|
||||||
|
pub(super) fn clear_right_click_highlights(
|
||||||
|
commands: &mut Commands,
|
||||||
|
highlighted: &Query<Entity, With<RightClickHighlight>>,
|
||||||
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
for entity in highlighted.iter() {
|
||||||
|
commands.entity(entity).remove::<RightClickHighlight>();
|
||||||
|
}
|
||||||
|
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
|
||||||
|
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all right-click destination highlights whenever any game-state
|
||||||
|
/// mutation succeeds (`StateChangedEvent` fires).
|
||||||
|
///
|
||||||
|
/// This ensures stale highlights do not linger after a card is moved.
|
||||||
|
pub(super) fn clear_right_click_highlights_on_state_change(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all right-click destination highlights when the game is paused
|
||||||
|
/// (`PausedResource` changes to `true`).
|
||||||
|
///
|
||||||
|
/// Prevents highlighted pile markers from remaining visible behind the pause
|
||||||
|
/// overlay.
|
||||||
|
pub(super) fn clear_right_click_highlights_on_pause(
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
let Some(paused) = paused else { return };
|
||||||
|
if paused.is_changed() && paused.0 {
|
||||||
|
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles right-click: highlights legal destination piles for the clicked card,
|
||||||
|
/// and clears highlights on any subsequent right- or left-click.
|
||||||
|
///
|
||||||
|
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_right_click(
|
||||||
|
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(buttons) = buttons else { return };
|
||||||
|
let left_pressed = buttons.just_pressed(MouseButton::Left);
|
||||||
|
let right_pressed = buttons.just_pressed(MouseButton::Right);
|
||||||
|
|
||||||
|
// Clear existing highlights on any click.
|
||||||
|
if left_pressed || right_pressed {
|
||||||
|
for entity in &highlighted {
|
||||||
|
commands.entity(entity).remove::<RightClickHighlight>();
|
||||||
|
}
|
||||||
|
for (_entity, _, mut sprite) in &mut pile_markers {
|
||||||
|
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only proceed for right-clicks while not dragging.
|
||||||
|
if !right_pressed || !drag.is_idle() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
|
||||||
|
// Convert cursor to world-space position.
|
||||||
|
let Some(world) = cursor_world_pos(&windows, &cameras) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the topmost face-up card under the cursor.
|
||||||
|
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tint piles that legally accept the card.
|
||||||
|
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
||||||
|
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
|
||||||
|
if legal {
|
||||||
|
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.insert(RightClickHighlight)
|
||||||
|
.insert(RightClickHighlightTimer(1.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts cursor position to 2-D world coordinates.
|
||||||
|
pub(super) fn cursor_world_pos(
|
||||||
|
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
|
) -> Option<Vec2> {
|
||||||
|
let window = windows.single().ok()?;
|
||||||
|
let cursor = window.cursor_position()?;
|
||||||
|
let (camera, camera_transform) = cameras.single().ok()?;
|
||||||
|
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
|
||||||
|
/// bounding rectangles of all card sprites, picking the highest Z.
|
||||||
|
pub(super) fn find_top_card_at(
|
||||||
|
cursor: Vec2,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
) -> Option<Card> {
|
||||||
|
let half = layout.card_size / 2.0;
|
||||||
|
let mut best: Option<(f32, Card)> = None;
|
||||||
|
|
||||||
|
for (_, card_entity, transform) in card_entities.iter() {
|
||||||
|
let pos = transform.translation.truncate();
|
||||||
|
if cursor.x < pos.x - half.x
|
||||||
|
|| cursor.x > pos.x + half.x
|
||||||
|
|| cursor.y < pos.y - half.y
|
||||||
|
|| cursor.y > pos.y + half.y
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let found = all_cards(game)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(c, face_up)| *c == card_entity.card && *face_up);
|
||||||
|
if let Some((card, _)) = found {
|
||||||
|
let z = transform.translation.z;
|
||||||
|
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
||||||
|
best = Some((z, card));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, card)| card)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #28 — Stock-empty visual indicator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
//! Card face labels: desktop text labels and Android corner labels.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use bevy::sprite::Anchor;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
|
||||||
|
use crate::ui_theme::TEXT_PRIMARY_HC;
|
||||||
|
|
||||||
|
pub(super) fn label_for(card: &Card) -> String {
|
||||||
|
let rank = match card.rank() {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
};
|
||||||
|
let suit = match card.suit() {
|
||||||
|
Suit::Clubs => "C",
|
||||||
|
Suit::Diamonds => "D",
|
||||||
|
Suit::Hearts => "H",
|
||||||
|
Suit::Spades => "S",
|
||||||
|
};
|
||||||
|
format!("{rank}{suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suit colour for the rank/suit overlay rendered atop the constant
|
||||||
|
/// fallback sprite (only fires under `MinimalPlugins` — production
|
||||||
|
/// renders the suit glyph baked into the PNG). 2-colour traditional
|
||||||
|
/// pairing — hearts + diamonds share the saturated red, clubs +
|
||||||
|
/// spades share the near-white. Two accessibility flags compose:
|
||||||
|
///
|
||||||
|
/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM`
|
||||||
|
/// (lime) — the "Settings toggle swaps red→lime" half of the
|
||||||
|
/// design system's colour-blind support. CBM is a hue-replacement
|
||||||
|
/// for red, so HC has no further effect on red when CBM is on
|
||||||
|
/// (the lime is itself a high-luminance colour).
|
||||||
|
/// - `high_contrast`: when CBM is off, red suits boost to
|
||||||
|
/// `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from
|
||||||
|
/// `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`).
|
||||||
|
///
|
||||||
|
/// The other half of CBM support (always-on filled-vs-outlined
|
||||||
|
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
|
||||||
|
/// and has no constant-fallback equivalent.
|
||||||
|
pub(super) fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
|
||||||
|
if card.suit().is_red() {
|
||||||
|
if color_blind {
|
||||||
|
// CBM lime wins — the colour-blind swap replaces the
|
||||||
|
// red hue entirely, and the lime is already high-
|
||||||
|
// luminance, so an HC boost on top has nothing to do.
|
||||||
|
RED_SUIT_COLOUR_CBM
|
||||||
|
} else if high_contrast {
|
||||||
|
RED_SUIT_COLOUR_HC
|
||||||
|
} else {
|
||||||
|
RED_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
} else if high_contrast {
|
||||||
|
TEXT_PRIMARY_HC
|
||||||
|
} else {
|
||||||
|
BLACK_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn label_visibility(face_up: bool) -> Visibility {
|
||||||
|
if face_up {
|
||||||
|
Visibility::Inherited
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank+suit string for the readability overlay on touch HUD layouts.
|
||||||
|
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||||
|
pub(super) fn mobile_label_for(card: &Card) -> String {
|
||||||
|
let rank = match card.rank() {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
};
|
||||||
|
let suit = match card.suit() {
|
||||||
|
Suit::Clubs => "♣",
|
||||||
|
Suit::Diamonds => "♦",
|
||||||
|
Suit::Hearts => "♥",
|
||||||
|
Suit::Spades => "♠",
|
||||||
|
};
|
||||||
|
format!("{rank}{suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards. The background sprite covers the card art's own small
|
||||||
|
/// corner text so only the large overlay is visible.
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards using FiraMono (passed via `font_handle`) so that the
|
||||||
|
/// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit
|
||||||
|
/// font handle Bevy falls back to its built-in face which does not include
|
||||||
|
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
||||||
|
/// the text colour — the root cause of the "red square on face-down cards"
|
||||||
|
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
||||||
|
pub(super) fn add_android_corner_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
card_size: Vec2,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
if !face_up {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
// Background covers ~3 monospace chars wide × 1 line tall.
|
||||||
|
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
|
||||||
|
// (3 chars = 1.8× font_size) plus a small margin.
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
|
||||||
|
// Background covers the PNG's baked-in small corner text (top-left).
|
||||||
|
// Classic PNG cards have a white face, so the background must be white too.
|
||||||
|
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
-card_size.x / 2.0 + inset + bg_w / 2.0,
|
||||||
|
card_size.y / 2.0 - inset - bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
// Cover the matching rotated baked-in text at the bottom-right corner.
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
card_size.x / 2.0 - inset - bg_w / 2.0,
|
||||||
|
-card_size.y / 2.0 + inset + bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||||
|
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||||
|
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||||
|
//
|
||||||
|
// Classic PNG cards have a white face: red suits stay the same saturated
|
||||||
|
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
|
||||||
|
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
|
||||||
|
// Terminal theme background.
|
||||||
|
let text_col = if card.suit().is_red() {
|
||||||
|
if color_blind {
|
||||||
|
RED_SUIT_COLOUR_CBM
|
||||||
|
} else if high_contrast {
|
||||||
|
RED_SUIT_COLOUR_HC
|
||||||
|
} else {
|
||||||
|
RED_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
};
|
||||||
|
let label_text = mobile_label_for(card);
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerLabel(label_text.clone()),
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_text),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle.cloned().unwrap_or_default(),
|
||||||
|
font_size,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_col),
|
||||||
|
Anchor::TOP_LEFT,
|
||||||
|
Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #34 — Card-flip animation systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
//! Resize handling: window-resize snapping, in-place card resizing,
|
||||||
|
//! and tableau fan spread.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::animation_plugin::CardAnim;
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::table_plugin::PileMarker;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
|
||||||
|
CARD_SHADOW_PADDING_IDLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||||
|
/// pending size on [`ResizeThrottle`].
|
||||||
|
///
|
||||||
|
/// `WindowResized` fires per pixel of resize drag, so a fast corner drag can
|
||||||
|
/// emit many events per frame. Reading `.last()` keeps only the final size —
|
||||||
|
/// every frame's snap target is the most recent window size, never a stale
|
||||||
|
/// one. Pending stays set across frames until the throttled applier consumes
|
||||||
|
/// it; that's how we still flush the final "release" position when the user
|
||||||
|
/// stops dragging.
|
||||||
|
pub(super) fn collect_resize_events(
|
||||||
|
mut events: MessageReader<WindowResized>,
|
||||||
|
mut throttle: ResMut<ResizeThrottle>,
|
||||||
|
) {
|
||||||
|
if let Some(ev) = events.read().last() {
|
||||||
|
throttle.pending = Some(Vec2::new(ev.width, ev.height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snaps every card sprite to its target position, size, and (in the
|
||||||
|
/// fallback Text2d label path) font size when the window is resized.
|
||||||
|
///
|
||||||
|
/// **In-place mutation only.** Resize is the hot path — events fire per
|
||||||
|
/// pixel of drag, so this system cannot afford the despawn/respawn churn
|
||||||
|
/// `update_card_entity` does. We mutate `Sprite.custom_size`, `Transform`,
|
||||||
|
/// and child `TextFont.font_size` directly, leaving the card image handle,
|
||||||
|
/// suit/rank, and `CardLabel` entity untouched. Cards keep their identity
|
||||||
|
/// across resizes; only their size and position change. The full repaint
|
||||||
|
/// path lives in [`update_card_entity`] and is still used by every non-resize
|
||||||
|
/// caller (deals, moves, flips, settings toggles).
|
||||||
|
///
|
||||||
|
/// **Throttled to ~20 Hz.** [`ResizeThrottle::pending`] is consumed at most
|
||||||
|
/// once per [`RESIZE_THROTTLE_SECS`]. When events stop arriving, the next
|
||||||
|
/// tick past the throttle window flushes the final size and clears
|
||||||
|
/// `pending`, so the steady-state always matches the user's release size.
|
||||||
|
///
|
||||||
|
/// **Cancels in-flight slides.** Any `CardAnim` is removed so a mid-slide
|
||||||
|
/// tween is not retargeted relative to the previous card-size's position.
|
||||||
|
///
|
||||||
|
/// The "↺" stock-empty label's `font_size` is derived from
|
||||||
|
/// `layout.card_size.x`, so this system also reapplies the stock indicator —
|
||||||
|
/// otherwise the label would not rescale on resize.
|
||||||
|
///
|
||||||
|
/// Scheduled after [`collect_resize_events`] (which itself runs after
|
||||||
|
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
|
||||||
|
/// window size before we read it.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
pub(super) fn snap_cards_on_window_resize(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut throttle: ResMut<ResizeThrottle>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
entities: Query<
|
||||||
|
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||||
|
(
|
||||||
|
Without<CardLabel>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||||
|
shadow_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardShadow>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
frame_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardBackFrame>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut pile_markers: Query<
|
||||||
|
(Entity, &PileMarker, &mut Sprite),
|
||||||
|
(
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
if throttle.pending.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let now = time.elapsed_secs();
|
||||||
|
if !should_apply_resize(now, throttle.last_applied_secs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(game) = game else {
|
||||||
|
// Nothing to apply — clear pending so we don't busy-loop.
|
||||||
|
throttle.pending = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout else {
|
||||||
|
throttle.pending = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
resize_cards_in_place(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&layout.0,
|
||||||
|
card_images.as_deref(),
|
||||||
|
entities,
|
||||||
|
label_query,
|
||||||
|
shadow_query,
|
||||||
|
frame_query,
|
||||||
|
);
|
||||||
|
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
|
||||||
|
throttle.last_applied_secs = now;
|
||||||
|
throttle.pending = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place "size-only" sibling of [`sync_cards`]: walks every existing card
|
||||||
|
/// entity, updates `Sprite.custom_size` and the snap-`Transform` to match the
|
||||||
|
/// fresh layout, and (in fallback solid-colour mode) also updates the child
|
||||||
|
/// `TextFont.font_size` of any `CardLabel`. No despawning, no `Sprite`
|
||||||
|
/// replacement, no children rebuild — that's the entire point of this path.
|
||||||
|
///
|
||||||
|
/// Called only from the resize handler. Game-state changes (deals, moves,
|
||||||
|
/// flips, settings toggles) still flow through [`sync_cards`] /
|
||||||
|
/// [`update_card_entity`], which handle add/remove/repaint correctly.
|
||||||
|
///
|
||||||
|
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||||
|
/// retargeted relative to the previous card-size's position.
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
|
pub(super) fn resize_cards_in_place(
|
||||||
|
commands: &mut Commands,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
mut entities: Query<
|
||||||
|
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||||
|
(
|
||||||
|
Without<CardLabel>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||||
|
mut shadow_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardShadow>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut frame_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardBackFrame>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let positions = card_positions(game, layout);
|
||||||
|
let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
|
||||||
|
.into_iter()
|
||||||
|
.map(|((c, _face_up), p, z)| (c, (p, z)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
|
||||||
|
let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
sprite.custom_size = Some(layout.card_size);
|
||||||
|
transform.translation.x = pos.x;
|
||||||
|
transform.translation.y = pos.y;
|
||||||
|
transform.translation.z = z;
|
||||||
|
// Cancel any in-flight slide so it doesn't retarget from a stale
|
||||||
|
// mid-animation position computed against the previous card size.
|
||||||
|
commands.entity(entity).remove::<CardAnim>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize every per-card shadow halo to match the new card size. Both
|
||||||
|
// idle and drag states scale with the card body, so we preserve the
|
||||||
|
// *current* padding (idle vs drag) by keeping the alpha as-is and only
|
||||||
|
// recomputing the geometry. The drag-tracking system runs every frame
|
||||||
|
// and will retune offset / alpha / padding-mode within one frame if the
|
||||||
|
// drag state diverges from the resized geometry.
|
||||||
|
let idle_padding = CARD_SHADOW_PADDING_IDLE;
|
||||||
|
let drag_padding = CARD_SHADOW_PADDING_DRAG;
|
||||||
|
for mut shadow_sprite in shadow_query.iter_mut() {
|
||||||
|
// Choose padding based on the shadow's current alpha — preserves
|
||||||
|
// a lifted shadow's larger halo across resize without needing to
|
||||||
|
// plumb DragState through the resize handler.
|
||||||
|
let alpha = shadow_sprite.color.alpha();
|
||||||
|
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
|
||||||
|
drag_padding
|
||||||
|
} else {
|
||||||
|
idle_padding
|
||||||
|
};
|
||||||
|
shadow_sprite.custom_size = Some(layout.card_size + padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
|
||||||
|
// when PNG faces are loaded the rank/suit are baked into the image and
|
||||||
|
// there is nothing to resize on the label side.
|
||||||
|
if card_images.is_none() {
|
||||||
|
let new_font_size = layout.card_size.x * FONT_SIZE_FRAC;
|
||||||
|
for mut font in label_query.iter_mut() {
|
||||||
|
font.font_size = new_font_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize every face-down border frame to match the new card size.
|
||||||
|
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
|
||||||
|
for mut frame_sprite in frame_query.iter_mut() {
|
||||||
|
frame_sprite.custom_size = Some(frame_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates font size and top-left anchor transform of every
|
||||||
|
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
|
||||||
|
/// change or any window resize). The full despawn/respawn path in
|
||||||
|
/// `update_card_entity` already handles game-state changes; this system
|
||||||
|
/// covers the resize-only path where children are mutated in place.
|
||||||
|
pub(super) fn resize_android_corner_labels(
|
||||||
|
layout: Res<LayoutResource>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
mut text_query: Query<(
|
||||||
|
&AndroidCornerLabel,
|
||||||
|
&mut Text2d,
|
||||||
|
&mut TextFont,
|
||||||
|
&mut Transform,
|
||||||
|
)>,
|
||||||
|
mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>,
|
||||||
|
) {
|
||||||
|
if !layout.is_changed() || card_images.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
||||||
|
let text_y = layout.0.card_size.y / 2.0 - inset;
|
||||||
|
|
||||||
|
for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
|
||||||
|
text2d.0 = label.0.clone();
|
||||||
|
font.font_size = font_size;
|
||||||
|
transform.translation.x = text_x;
|
||||||
|
transform.translation.y = text_y;
|
||||||
|
}
|
||||||
|
for (mut sprite, mut transform) in bg_query.iter_mut() {
|
||||||
|
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
|
||||||
|
transform.translation.x = text_x + bg_w / 2.0;
|
||||||
|
transform.translation.y = text_y - bg_h / 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
|
||||||
|
/// the deepest tableau column fills the available vertical space at every stage
|
||||||
|
/// of play. Runs after every `StateChangedEvent`.
|
||||||
|
///
|
||||||
|
/// Depth is measured across *all* cards in a column, weighting each face-down
|
||||||
|
/// card by the fixed face-down/face-up step ratio. Counting the face-down
|
||||||
|
/// portion — not just the face-up tail — is what fills the lower screen on a
|
||||||
|
/// fresh deal (the deepest column is then six face-down cards under one face-up
|
||||||
|
/// one): the earlier face-up-only depth was 1, so the fan never spread and the
|
||||||
|
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
|
||||||
|
///
|
||||||
|
/// Deeper columns drive the fraction down so everything still fits the window;
|
||||||
|
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
|
||||||
|
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
|
||||||
|
/// cards far apart.
|
||||||
|
pub(super) fn update_tableau_fan_frac(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut layout: Option<ResMut<LayoutResource>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(game) = game else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
|
||||||
|
/// inserted directly as `GameStateResource` at startup without a
|
||||||
|
/// `StateChangedEvent`, so the event-driven system never fires for it. This
|
||||||
|
/// runs once, before [`sync_cards_startup`] renders, so the very first board
|
||||||
|
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
|
||||||
|
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
|
||||||
|
/// and a large empty band below the tableau until the first move.
|
||||||
|
pub(super) fn fill_tableau_fan_on_startup(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut layout: Option<ResMut<LayoutResource>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
//! 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::{KlondikePile, Tableau};
|
||||||
|
use solitaire_core::Card;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// 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]`.
|
||||||
|
///
|
||||||
|
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
||||||
|
/// Rank order: Ace=0, Two=1 … King=12.
|
||||||
|
pub faces: [[Handle<Image>; 13]; 4],
|
||||||
|
/// One handle per unlockable card-back design (indices 0–4). These
|
||||||
|
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
|
||||||
|
/// by `Settings::selected_card_back`. Used as a fallback when the active
|
||||||
|
/// theme does not provide its own back (see [`Self::theme_back`]).
|
||||||
|
pub backs: [Handle<Image>; 5],
|
||||||
|
/// Back image supplied by the currently-active card theme, if any.
|
||||||
|
///
|
||||||
|
/// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
|
||||||
|
/// a `CardTheme` finishes loading. The face-down render path in
|
||||||
|
/// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
|
||||||
|
/// so a theme switch swaps both faces *and* the back without the player
|
||||||
|
/// needing to touch the legacy `selected_card_back` picker. `None` means
|
||||||
|
/// the active theme did not declare a back asset (or no theme has loaded
|
||||||
|
/// yet); in that case [`card_sprite`] falls back to the legacy array.
|
||||||
|
pub theme_back: Option<Handle<Image>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suit-colour swap for red-suit cards in colour-blind mode — Terminal
|
||||||
|
/// `#acc267` (lime). Replaces `RED_SUIT_COLOUR` (pink) when CBM is on,
|
||||||
|
/// providing a hue-distinct alternative that survives the most common
|
||||||
|
/// red/green deficiencies. Pre-Terminal this was a *face tint*; the new
|
||||||
|
/// design moves CBM differentiation into the suit glyph colour itself
|
||||||
|
/// and keeps the face uniformly `CARD_FACE_COLOUR` regardless of CBM.
|
||||||
|
///
|
||||||
|
/// The CBM swap is lime (not the `ACCENT_PRIMARY` brick-red) because
|
||||||
|
/// the primary accent is itself in the red family — using it for
|
||||||
|
/// "the not-red CBM alternative" would defeat the purpose. Lime is
|
||||||
|
/// the next-best non-red base16-eighties accent; deuteranopia and
|
||||||
|
/// protanopia readers see it as visibly distinct from pink.
|
||||||
|
const RED_SUIT_COLOUR_CBM: Color = Color::srgb(0.675, 0.761, 0.404);
|
||||||
|
|
||||||
|
/// Returns the fallback card-back colour for the given unlocked card-back
|
||||||
|
/// index. Production renders backs from PNG artwork; this fallback only
|
||||||
|
/// fires under `MinimalPlugins` (tests). Mirrors the 5 accent colours
|
||||||
|
/// from `card_face_svg::BACK_ACCENTS` so the test-environment back lives
|
||||||
|
/// in the same hue family as the on-disk PNG art for that index.
|
||||||
|
fn card_back_colour(selected_card_back: usize) -> Color {
|
||||||
|
match selected_card_back {
|
||||||
|
0 => Color::srgb(0.647, 0.259, 0.259), // #a54242 brick red (Terminal canonical, ACCENT_PRIMARY)
|
||||||
|
1 => Color::srgb(0.675, 0.761, 0.404), // #acc267 lime
|
||||||
|
2 => Color::srgb(0.882, 0.639, 0.933), // #e1a3ee lavender
|
||||||
|
3 => Color::srgb(0.984, 0.624, 0.694), // #fb9fb1 pink
|
||||||
|
_ => Color::srgb(0.867, 0.698, 0.435), // #ddb26f gold (4+)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker component linking a Bevy entity to its `solitaire_core::Card`.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct CardEntity {
|
||||||
|
pub card: Card,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached signature of the inputs that determine a card entity's *child*
|
||||||
|
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
|
||||||
|
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
|
||||||
|
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
|
||||||
|
/// the appearance changed — the common case during a move, where only the
|
||||||
|
/// card's position changes. Without this guard every `StateChangedEvent`
|
||||||
|
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
|
||||||
|
/// glyph re-layouts) in a single frame, which stutters the slide animation on
|
||||||
|
/// high-resolution devices.
|
||||||
|
///
|
||||||
|
/// The children depend only on these four inputs; the card identity is fixed
|
||||||
|
/// per entity, and the face/back *image* is handled by the always-refreshed
|
||||||
|
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
|
||||||
|
/// rebuild.
|
||||||
|
#[derive(Component, Clone, Copy, PartialEq)]
|
||||||
|
struct CardChildrenKey {
|
||||||
|
face_up: bool,
|
||||||
|
card_size: Vec2,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query data read by the card-sync systems for each live card entity:
|
||||||
|
/// its id, card identity, current transform, any in-flight curve animation,
|
||||||
|
/// and its cached child-appearance key. Factored into an alias to keep the
|
||||||
|
/// system signatures readable (and satisfy clippy's `type_complexity`).
|
||||||
|
type CardSyncData = (
|
||||||
|
Entity,
|
||||||
|
&'static CardEntity,
|
||||||
|
&'static Transform,
|
||||||
|
Option<&'static CardAnimation>,
|
||||||
|
Option<&'static CardChildrenKey>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Render-side index mapping each live board card to its [`CardEntity`].
|
||||||
|
///
|
||||||
|
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
|
||||||
|
/// after the card-sync authority ([`sync_cards_on_change`]) and the resize
|
||||||
|
/// re-snap have flushed their spawn/despawn `Commands`. Consumers treat it as
|
||||||
|
/// read-only and must still call `Query::get(entity)` for components beyond the
|
||||||
|
/// `Entity` id (the map yields only the id).
|
||||||
|
///
|
||||||
|
/// Keyed by `Card`, which is unique across all live board entities in
|
||||||
|
/// single-deck Klondike. Transient entities (drag shadow, labels) carry no
|
||||||
|
/// `CardEntity` component, so the rebuild — which scans `&CardEntity` — never
|
||||||
|
/// records them.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct CardEntityIndex(pub HashMap<Card, Entity>);
|
||||||
|
|
||||||
|
impl CardEntityIndex {
|
||||||
|
/// Resolve a card to its live entity, if one is currently spawned.
|
||||||
|
#[inline]
|
||||||
|
pub fn get(&self, card: &Card) -> Option<Entity> {
|
||||||
|
self.0.get(card).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker for the text child inside a card entity.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CardLabel;
|
||||||
|
|
||||||
|
/// Marker for the large-print rank+suit corner overlay used by touch HUD layouts.
|
||||||
|
///
|
||||||
|
/// Spawned on top of PNG face cards (face-up only) at font size
|
||||||
|
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
|
||||||
|
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||||||
|
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
struct AndroidCornerLabel(pub String);
|
||||||
|
|
||||||
|
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
||||||
|
///
|
||||||
|
/// Covers the card art's own small corner rank/suit text so only the
|
||||||
|
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
|
||||||
|
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
struct AndroidCornerBg;
|
||||||
|
|
||||||
|
type AndroidCornerBgFilter = (With<AndroidCornerBg>, Without<AndroidCornerLabel>);
|
||||||
|
|
||||||
|
/// Marker component indicating the card is currently highlighted as a hint.
|
||||||
|
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||||||
|
/// reaches zero and the card sprite colour is restored to its normal value.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct HintHighlight {
|
||||||
|
/// Seconds remaining before the highlight is cleared.
|
||||||
|
pub remaining: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Countdown (seconds) until the `HintHighlight` on a card entity is removed.
|
||||||
|
///
|
||||||
|
/// Inserted alongside `HintHighlight` by the hint-visual system. When the timer
|
||||||
|
/// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from
|
||||||
|
/// the entity and the sprite colour is restored.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct HintHighlightTimer(pub f32);
|
||||||
|
|
||||||
|
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
|
||||||
|
/// card can legally be placed there.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct RightClickHighlight;
|
||||||
|
|
||||||
|
/// Countdown (seconds) until this right-click destination highlight despawns.
|
||||||
|
///
|
||||||
|
/// Inserted alongside `RightClickHighlight` so that highlights auto-clear after
|
||||||
|
/// 1.5 s even if the player does not make a move or click again. The existing
|
||||||
|
/// clear-on-state-change and clear-on-pause logic still fires early when
|
||||||
|
/// appropriate.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct RightClickHighlightTimer(pub f32);
|
||||||
|
|
||||||
|
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
|
||||||
|
/// marker when the stock pile is empty.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct StockEmptyLabel;
|
||||||
|
|
||||||
|
/// Marker on the chip-background sprite of the stock-pile remaining-count
|
||||||
|
/// badge.
|
||||||
|
///
|
||||||
|
/// The badge is spawned as a *top-level* world entity (not parented to the
|
||||||
|
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
|
||||||
|
/// `LayoutResource` so it tracks the stock pile through window resizes.
|
||||||
|
/// The chip sits in the bottom-right corner of the stock pile and is hidden
|
||||||
|
/// while the stock is empty — the existing `↺` overlay
|
||||||
|
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
|
||||||
|
/// indicators never render simultaneously.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct StockCountBadge;
|
||||||
|
|
||||||
|
/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
|
||||||
|
/// count of cards remaining in the stock pile.
|
||||||
|
///
|
||||||
|
/// Update systems query this component to write the new count in place rather
|
||||||
|
/// than despawning and respawning the text entity each tick.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct StockCountBadgeText;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #34 — Card-flip animation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Phase of the two-stage flip animation.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum FlipPhase {
|
||||||
|
/// Scale X from 1.0 → 0.0 (hiding the back face).
|
||||||
|
ScalingDown,
|
||||||
|
/// Scale X from 0.0 → 1.0 (revealing the front face).
|
||||||
|
ScalingUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
|
||||||
|
///
|
||||||
|
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
|
||||||
|
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct CardFlipAnim {
|
||||||
|
/// Seconds elapsed in the current phase.
|
||||||
|
pub timer: f32,
|
||||||
|
/// Which half of the flip we are in.
|
||||||
|
pub phase: FlipPhase,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration of each half of the flip animation (scale-down or scale-up).
|
||||||
|
const FLIP_HALF_SECS: f32 = 0.08;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #38 — Drag-elevation shadow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker component for the semi-transparent shadow sprite shown while dragging.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ShadowEntity;
|
||||||
|
|
||||||
|
/// Marker component for the per-card drop-shadow child sprite.
|
||||||
|
///
|
||||||
|
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
|
||||||
|
/// neutral-black halo painted slightly down-and-right of the card. Idle state
|
||||||
|
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
|
||||||
|
/// parent card is being dragged the shadow is pushed to the deeper
|
||||||
|
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
|
||||||
|
/// stack reads as "lifted" off the felt.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CardShadow;
|
||||||
|
|
||||||
|
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
|
||||||
|
///
|
||||||
|
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
|
||||||
|
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
|
||||||
|
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
|
||||||
|
/// frame — gives every face-down card a visible perimeter.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CardBackFrame;
|
||||||
|
|
||||||
|
/// Fill colour for the face-down card border frame. Light-medium gray so it
|
||||||
|
/// reads as a clear "edge" without competing with the suit colours on face-up
|
||||||
|
/// cards. Brightened from `0.38` to `0.48` (≈ #7a7a7a) after a Pixel_7 smoke
|
||||||
|
/// test showed face-down `back_0.png` (≈ #1a1a1a) was nearly invisible against
|
||||||
|
/// the very dark `#151515` felt — the old gray was too close to the back fill
|
||||||
|
/// to define a crisp perimeter.
|
||||||
|
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.48, 0.48, 0.48);
|
||||||
|
|
||||||
|
/// Extra width/height (in world units) added to each side of the card to form
|
||||||
|
/// the visible border. Widened from `3.0` to `6.0` so the frame peeks out as a
|
||||||
|
/// clearly readable perimeter at phone density (420 dpi) rather than a hairline.
|
||||||
|
const CARD_BACK_FRAME_PADDING: f32 = 6.0;
|
||||||
|
|
||||||
|
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||||
|
/// shadow given whether its parent card is currently part of the dragged
|
||||||
|
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||||||
|
/// without spinning up a Bevy app.
|
||||||
|
///
|
||||||
|
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
|
||||||
|
/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)`
|
||||||
|
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
|
||||||
|
if is_dragged {
|
||||||
|
(
|
||||||
|
CARD_SHADOW_OFFSET_DRAG,
|
||||||
|
CARD_SHADOW_PADDING_DRAG,
|
||||||
|
CARD_SHADOW_ALPHA_DRAG,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
CARD_SHADOW_OFFSET_IDLE,
|
||||||
|
CARD_SHADOW_PADDING_IDLE,
|
||||||
|
CARD_SHADOW_ALPHA_IDLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the `Sprite` used for a per-card shadow at the resting state. The
|
||||||
|
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
|
||||||
|
/// retunes them at runtime when the parent card joins / leaves the dragged
|
||||||
|
/// stack.
|
||||||
|
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
|
||||||
|
let (_offset, padding, alpha) = card_shadow_params(false);
|
||||||
|
Sprite {
|
||||||
|
color: CARD_SHADOW_COLOR.with_alpha(alpha),
|
||||||
|
custom_size: Some(card_size + padding),
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the `Transform` used for a per-card shadow at the resting state.
|
||||||
|
/// Local — it is parented to the card entity, so positions are relative.
|
||||||
|
fn card_shadow_transform() -> Transform {
|
||||||
|
let (offset, _padding, _alpha) = card_shadow_params(false);
|
||||||
|
Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a single `CardShadow` child under the given card entity builder.
|
||||||
|
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
|
||||||
|
/// exact same shadow recipe — we never want one path to drift from the other.
|
||||||
|
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||||
|
parent.spawn((
|
||||||
|
CardShadow,
|
||||||
|
card_shadow_sprite(card_size),
|
||||||
|
card_shadow_transform(),
|
||||||
|
Visibility::default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
|
||||||
|
/// thin perimeter against the dark felt, regardless of face state.
|
||||||
|
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||||
|
parent.spawn((
|
||||||
|
CardBackFrame,
|
||||||
|
Sprite {
|
||||||
|
color: CARD_BACK_FRAME_COLOR,
|
||||||
|
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||||
|
Visibility::default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throttle interval for resize-driven card snap work, in seconds.
|
||||||
|
///
|
||||||
|
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||||||
|
/// produce dozens of events per frame. Re-running the per-card snap logic
|
||||||
|
/// (52 cards × sprite/transform/font_size touches) for every event is the
|
||||||
|
/// dominant cost of resize lag. We coalesce pending work and apply it at most
|
||||||
|
/// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates
|
||||||
|
/// during a sustained drag, and the layout always catches up to the final
|
||||||
|
/// size when the drag stops because the pending size is held until applied.
|
||||||
|
const RESIZE_THROTTLE_SECS: f32 = 0.05;
|
||||||
|
|
||||||
|
/// Holds the latest pending window size from `WindowResized` events plus a
|
||||||
|
/// timestamp for the last applied snap, so the resize-snap work can be
|
||||||
|
/// rate-limited to ~20 Hz during sustained drags.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct ResizeThrottle {
|
||||||
|
/// Latest unapplied window size from `WindowResized`. `None` when there is
|
||||||
|
/// nothing to apply.
|
||||||
|
pub pending: Option<Vec2>,
|
||||||
|
/// `Time::elapsed_secs()` value at the moment of the most recent applied
|
||||||
|
/// snap. `0.0` until the first apply.
|
||||||
|
pub last_applied_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper used by the throttled resize-snap system: returns `true` when
|
||||||
|
/// a pending resize should be flushed given the current `now_secs` and the
|
||||||
|
/// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`].
|
||||||
|
///
|
||||||
|
/// Extracted so the rate-limit logic can be unit-tested without spinning up
|
||||||
|
/// a full Bevy app.
|
||||||
|
fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
|
||||||
|
(now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
||||||
|
pub struct CardPlugin;
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
update_tableau_fan_frac
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(sync_cards_on_change),
|
||||||
|
sync_cards_on_change.after(GameMutation),
|
||||||
|
resync_cards_on_settings_change.before(sync_cards_on_change),
|
||||||
|
start_flip_anim.after(GameMutation),
|
||||||
|
tick_flip_anim,
|
||||||
|
update_drag_shadow,
|
||||||
|
update_card_shadows_on_drag.after(sync_cards_on_change),
|
||||||
|
tick_hint_highlight,
|
||||||
|
handle_right_click,
|
||||||
|
tick_right_click_highlights,
|
||||||
|
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||||
|
clear_right_click_highlights_on_pause,
|
||||||
|
update_stock_empty_indicator.after(GameMutation),
|
||||||
|
update_stock_count_badge
|
||||||
|
.after(GameMutation)
|
||||||
|
.run_if(resource_changed::<GameStateResource>),
|
||||||
|
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
||||||
|
snap_cards_on_window_resize.after(collect_resize_events),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.add_systems(Update, resize_android_corner_labels);
|
||||||
|
app.add_systems(PostUpdate, rebuild_card_entity_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
//! Stock-pile indicators: the empty-stock recycle hint and count badge.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY,
|
||||||
|
TYPE_BODY, Z_STOCK_BADGE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
||||||
|
/// to signal to the player that there are no more cards to draw. Pure white
|
||||||
|
/// at 0.4 alpha — a deliberate brightness-boost over the default marker so
|
||||||
|
/// the "empty" state is more visible, not less. Not derived from a palette
|
||||||
|
/// token: this is a sprite tint, not chrome colour.
|
||||||
|
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
|
||||||
|
|
||||||
|
/// Sprite colour applied to the stock `PileMarker` when cards remain in
|
||||||
|
/// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest
|
||||||
|
/// of the engine's idle pile-marker tint automatically.
|
||||||
|
const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
|
||||||
|
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
|
||||||
|
///
|
||||||
|
/// If the stock pile is empty the marker sprite is dimmed to
|
||||||
|
/// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is
|
||||||
|
/// spawned (if not already present). When the stock is non-empty the marker is
|
||||||
|
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
|
||||||
|
/// despawned.
|
||||||
|
pub(super) fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||||
|
commands: &mut Commands,
|
||||||
|
game: &GameState,
|
||||||
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
||||||
|
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
layout: &Layout,
|
||||||
|
font: Handle<Font>,
|
||||||
|
) {
|
||||||
|
let stock_empty = game.stock_cards().is_empty();
|
||||||
|
|
||||||
|
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
|
||||||
|
if pile_marker.0 != KlondikePile::Stock {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if stock_empty {
|
||||||
|
// Dim the marker sprite.
|
||||||
|
sprite.color = STOCK_EMPTY_DIM_COLOUR;
|
||||||
|
|
||||||
|
// Spawn the "↺" label only if one does not already exist.
|
||||||
|
let already_has_label = label_children
|
||||||
|
.iter()
|
||||||
|
.any(|(_, parent)| parent.parent() == entity);
|
||||||
|
if !already_has_label {
|
||||||
|
let font_size = layout.card_size.x * 0.4;
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
StockEmptyLabel,
|
||||||
|
Text2d::new("↺"),
|
||||||
|
TextFont {
|
||||||
|
font: font.clone(),
|
||||||
|
font_size,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Restore normal brightness.
|
||||||
|
sprite.color = STOCK_NORMAL_COLOUR;
|
||||||
|
|
||||||
|
// Despawn any existing "↺" label children.
|
||||||
|
for (label_entity, parent) in label_children.iter() {
|
||||||
|
if parent.parent() == entity {
|
||||||
|
commands.entity(label_entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs at `PostStartup` to apply the stock-empty indicator for the initial
|
||||||
|
/// game state (before any `StateChangedEvent` fires).
|
||||||
|
pub(super) fn update_stock_empty_indicator_startup(
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
|
||||||
|
/// stock pile marker dim state and "↺" label in sync with the current stock.
|
||||||
|
pub(super) fn update_stock_empty_indicator(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stock-pile remaining-count badge
|
||||||
|
//
|
||||||
|
// Shows a small "N" chip pinned to the bottom-right corner of the stock pile so
|
||||||
|
// the player can see how many cards remain before the next recycle. The
|
||||||
|
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
|
||||||
|
// the badge hides itself when the stock has zero cards — the two indicators
|
||||||
|
// never render at the same time.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Inset (in pixels) from the bottom-right corner of the stock pile sprite to
|
||||||
|
/// the centre of the count badge. Anchoring to the bottom-right keeps the chip
|
||||||
|
/// clear of the rank/suit pip in the card's top-left corner. Both components
|
||||||
|
/// move the centre *inward* from that corner: `x` is subtracted from the right
|
||||||
|
/// edge, `y` is added to the bottom edge. The `x` magnitude must satisfy
|
||||||
|
/// `x >= STOCK_BADGE_SIZE.x / 2` so the badge right edge stays inside the stock
|
||||||
|
/// pile and never overlaps the adjacent waste pile — critical on Android where
|
||||||
|
/// `H_GAP_DIVISOR = 32` gives an inter-pile gap of only ~4 px.
|
||||||
|
const STOCK_BADGE_INSET: Vec2 = Vec2::new(20.0, 8.0);
|
||||||
|
|
||||||
|
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||||
|
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||||
|
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
|
||||||
|
|
||||||
|
/// Returns the count of cards currently in the stock pile.
|
||||||
|
///
|
||||||
|
/// Pure helper extracted so the count source is identical between the spawn
|
||||||
|
/// system, the update system, and the unit tests.
|
||||||
|
pub(super) fn stock_card_count(game: &GameState) -> usize {
|
||||||
|
game.stock_cards().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
|
||||||
|
/// given the current `Layout`. The badge sits at the bottom-right corner of
|
||||||
|
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`], so it stays clear of
|
||||||
|
/// the rank/suit pip in the card's top-left corner.
|
||||||
|
pub(super) fn stock_badge_translation(layout: &Layout) -> Vec3 {
|
||||||
|
// Empty layouts don't contain a Stock entry — fall back to origin so
|
||||||
|
// the badge stays in a deterministic spot until the layout is filled.
|
||||||
|
let pile_pos = layout
|
||||||
|
.pile_positions
|
||||||
|
.get(&KlondikePile::Stock)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(Vec2::ZERO);
|
||||||
|
let half = layout.card_size * 0.5;
|
||||||
|
// Anchor to the bottom-right corner, then move the centre inward.
|
||||||
|
let x = pile_pos.x + half.x - STOCK_BADGE_INSET.x;
|
||||||
|
let y = pile_pos.y - half.y + STOCK_BADGE_INSET.y;
|
||||||
|
Vec3::new(x, y, Z_STOCK_BADGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the stock-count badge entity (background sprite + child text)
|
||||||
|
/// into the world. Called once, when the badge does not yet exist.
|
||||||
|
pub(super) fn spawn_stock_count_badge(
|
||||||
|
commands: &mut Commands,
|
||||||
|
layout: &Layout,
|
||||||
|
font: Option<&Handle<Font>>,
|
||||||
|
count: usize,
|
||||||
|
) {
|
||||||
|
let translation = stock_badge_translation(layout);
|
||||||
|
let visibility = if count == 0 {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
let text_font = TextFont {
|
||||||
|
font: font.cloned().unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
StockCountBadge,
|
||||||
|
Sprite {
|
||||||
|
color: STOCK_BADGE_BG,
|
||||||
|
custom_size: Some(STOCK_BADGE_SIZE),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_translation(translation),
|
||||||
|
visibility,
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
StockCountBadgeText,
|
||||||
|
Text2d::new(format!("{count}")),
|
||||||
|
text_font,
|
||||||
|
TextColor(STOCK_BADGE_FG),
|
||||||
|
// Slightly above the chip background so the digits aren't
|
||||||
|
// occluded by the sprite they sit on.
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the stock-pile remaining-count badge if it does not yet exist,
|
||||||
|
/// and otherwise updates its text and visibility in place.
|
||||||
|
///
|
||||||
|
/// Visibility rule: hidden when the stock is empty (the existing `↺`
|
||||||
|
/// `StockEmptyLabel` overlay covers that state), shown when one or more
|
||||||
|
/// cards remain.
|
||||||
|
///
|
||||||
|
/// Position is recomputed from `LayoutResource` every tick so the badge
|
||||||
|
/// follows the stock pile across `WindowResized` layout updates without
|
||||||
|
/// needing a dedicated resize handler.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_stock_count_badge(
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font: Option<Res<FontResource>>,
|
||||||
|
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
|
||||||
|
children: Query<&Children, With<StockCountBadge>>,
|
||||||
|
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
|
||||||
|
let count = stock_card_count(&game.0);
|
||||||
|
let translation = stock_badge_translation(&layout.0);
|
||||||
|
let target_visibility = if count == 0 {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
|
||||||
|
if badges.is_empty() {
|
||||||
|
spawn_stock_count_badge(&mut commands, &layout.0, font.as_ref().map(|f| &f.0), count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (entity, mut transform, mut visibility) in badges.iter_mut() {
|
||||||
|
transform.translation = translation;
|
||||||
|
if *visibility != target_visibility {
|
||||||
|
*visibility = target_visibility;
|
||||||
|
}
|
||||||
|
// Update the child text to reflect the latest count. The text node
|
||||||
|
// is created at spawn time, so under normal operation we always
|
||||||
|
// have exactly one child here.
|
||||||
|
if let Ok(badge_children) = children.get(entity) {
|
||||||
|
for child in badge_children.iter() {
|
||||||
|
if let Ok(mut text) = texts.get_mut(child) {
|
||||||
|
let new = format!("{count}");
|
||||||
|
if text.0 != new {
|
||||||
|
text.0 = new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
//! 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::{Foundation, KlondikePile, Tableau};
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
const RANKS: [Rank; 13] = [
|
||||||
|
Rank::Ace,
|
||||||
|
Rank::Two,
|
||||||
|
Rank::Three,
|
||||||
|
Rank::Four,
|
||||||
|
Rank::Five,
|
||||||
|
Rank::Six,
|
||||||
|
Rank::Seven,
|
||||||
|
Rank::Eight,
|
||||||
|
Rank::Nine,
|
||||||
|
Rank::Ten,
|
||||||
|
Rank::Jack,
|
||||||
|
Rank::Queen,
|
||||||
|
Rank::King,
|
||||||
|
];
|
||||||
|
|
||||||
|
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
||||||
|
std::array::from_fn(|ri| asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si])))
|
||||||
|
});
|
||||||
|
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 = match card.suit() {
|
||||||
|
Suit::Clubs => 0,
|
||||||
|
Suit::Diamonds => 1,
|
||||||
|
Suit::Hearts => 2,
|
||||||
|
Suit::Spades => 3,
|
||||||
|
};
|
||||||
|
let rank_idx = match card.rank() {
|
||||||
|
Rank::Ace => 0,
|
||||||
|
Rank::Two => 1,
|
||||||
|
Rank::Three => 2,
|
||||||
|
Rank::Four => 3,
|
||||||
|
Rank::Five => 4,
|
||||||
|
Rank::Six => 5,
|
||||||
|
Rank::Seven => 6,
|
||||||
|
Rank::Eight => 7,
|
||||||
|
Rank::Nine => 8,
|
||||||
|
Rank::Ten => 9,
|
||||||
|
Rank::Jack => 10,
|
||||||
|
Rank::Queen => 11,
|
||||||
|
Rank::King => 12,
|
||||||
|
};
|
||||||
|
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 [
|
||||||
|
Foundation::Foundation1,
|
||||||
|
Foundation::Foundation2,
|
||||||
|
Foundation::Foundation3,
|
||||||
|
Foundation::Foundation4,
|
||||||
|
] {
|
||||||
|
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
|
||||||
|
}
|
||||||
|
for tableau in [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
] {
|
||||||
|
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
|
||||||
|
}
|
||||||
|
cards
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn spawn_card_entity(
|
||||||
|
commands: &mut Commands,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
pos: Vec2,
|
||||||
|
z: f32,
|
||||||
|
layout: &Layout,
|
||||||
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) -> Entity {
|
||||||
|
let sprite = card_sprite(
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
back_colour,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut entity = commands.spawn((
|
||||||
|
CardEntity { card: card.clone() },
|
||||||
|
sprite,
|
||||||
|
Transform::from_xyz(pos.x, pos.y, z),
|
||||||
|
Visibility::default(),
|
||||||
|
));
|
||||||
|
let entity_id = entity.id();
|
||||||
|
// Every card gets a subtle drop-shadow child so the play surface reads
|
||||||
|
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
||||||
|
// system retunes its offset / alpha when this card joins the dragged
|
||||||
|
// stack.
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_card_shadow_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
// Every card gets a thin border frame so it reads as a distinct
|
||||||
|
// rectangle against the dark felt, regardless of face state.
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||||
|
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||||
|
// On Android we additionally spawn a large-print corner label even in
|
||||||
|
// image mode so the rank/suit are legible at phone scale.
|
||||||
|
if card_images.is_none() {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_for(card)),
|
||||||
|
TextFont {
|
||||||
|
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||||
|
label_visibility(face_up),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_android_corner_label(
|
||||||
|
b,
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Record the appearance signature so subsequent `update_card_entity` calls
|
||||||
|
// can skip rebuilding these children until one of the inputs changes.
|
||||||
|
entity.insert(CardChildrenKey {
|
||||||
|
face_up,
|
||||||
|
card_size: layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
});
|
||||||
|
entity_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_card_entity(
|
||||||
|
commands: &mut Commands,
|
||||||
|
entity: Entity,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
pos: Vec2,
|
||||||
|
z: f32,
|
||||||
|
layout: &Layout,
|
||||||
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
cur: Vec3,
|
||||||
|
has_card_animation: bool,
|
||||||
|
existing_children_key: Option<CardChildrenKey>,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
|
|
||||||
|
// Always refresh the visual appearance.
|
||||||
|
commands.entity(entity).insert(card_sprite(
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
back_colour,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
|
||||||
|
// is driving this card (e.g. the drag-rejection return tween). Writing
|
||||||
|
// `Transform` here would race that animation each frame and cause a
|
||||||
|
// visible jump. The animation system snaps the final position itself
|
||||||
|
// when it completes.
|
||||||
|
if !has_card_animation {
|
||||||
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
|
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||||
|
// Lift the card immediately on the first frame of the animation so
|
||||||
|
// it never appears behind a card that is already resting at the
|
||||||
|
// destination slot. `advance_card_anims` will maintain this lift
|
||||||
|
// throughout the tween and snap to `target` (without lift) on
|
||||||
|
// completion.
|
||||||
|
let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.insert(Transform::from_translation(start))
|
||||||
|
.insert(CardAnim {
|
||||||
|
start,
|
||||||
|
target,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: slide_secs,
|
||||||
|
delay: 0.0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<CardAnim>()
|
||||||
|
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the card's child visuals (drop-shadow, border frame, and the
|
||||||
|
// rank/suit label / large-print corner overlay) only when an input that
|
||||||
|
// affects them actually changed. The child set depends solely on
|
||||||
|
// `CardChildrenKey`; the face/back image is carried by the always-refreshed
|
||||||
|
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
|
||||||
|
// this on a position-only move avoids despawning and respawning the child
|
||||||
|
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
|
||||||
|
// `StateChangedEvent` — the spike that stuttered the slide animation on
|
||||||
|
// high-resolution devices.
|
||||||
|
let new_children_key = CardChildrenKey {
|
||||||
|
face_up,
|
||||||
|
card_size: layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
};
|
||||||
|
if existing_children_key != Some(new_children_key) {
|
||||||
|
commands.entity(entity).despawn_related::<Children>();
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_card_shadow_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
if card_images.is_none() {
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_for(card)),
|
||||||
|
TextFont {
|
||||||
|
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||||
|
label_visibility(face_up),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_android_corner_label(
|
||||||
|
b,
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
commands.entity(entity).insert(new_children_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,7 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::progress_plugin::ProgressPlugin;
|
use crate::progress_plugin::ProgressPlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -135,7 +135,7 @@ mod tests {
|
|||||||
fn challenge_win_advances_index() {
|
fn challenge_win_advances_index() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -224,7 +224,7 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.challenge_index = 2;
|
.challenge_index = 2;
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -13,16 +13,18 @@ use crate::platform::{
|
|||||||
default_storage_backend,
|
default_storage_backend,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin,
|
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||||
AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
||||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
||||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
|
||||||
SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider,
|
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
|
||||||
SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||||
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
};
|
||||||
WinSummaryPlugin,
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::{
|
||||||
|
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||||
@@ -45,6 +47,7 @@ impl Plugin for CoreGamePlugin {
|
|||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
};
|
};
|
||||||
|
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
|
||||||
let sync_provider = sync_provider
|
let sync_provider = sync_provider
|
||||||
.take()
|
.take()
|
||||||
.expect("CoreGamePlugin::build called twice");
|
.expect("CoreGamePlugin::build called twice");
|
||||||
@@ -104,21 +107,26 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(HomePlugin::default())
|
.add_plugins(HomePlugin::default())
|
||||||
.add_plugins(AvatarPlugin)
|
|
||||||
.add_plugins(ProfilePlugin)
|
.add_plugins(ProfilePlugin)
|
||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
.add_plugins(AudioPlugin)
|
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
|
||||||
.add_plugins(SyncSetupPlugin)
|
|
||||||
.add_plugins(AnalyticsPlugin)
|
|
||||||
.add_plugins(LeaderboardPlugin)
|
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.add_plugins(UiFocusPlugin)
|
.add_plugins(UiFocusPlugin)
|
||||||
.add_plugins(UiTooltipPlugin)
|
.add_plugins(UiTooltipPlugin)
|
||||||
.add_plugins(SplashPlugin)
|
.add_plugins(SplashPlugin)
|
||||||
.add_plugins(DiagnosticsHudPlugin);
|
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::Card;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
use crate::card_plugin::RightClickHighlight;
|
use crate::card_plugin::RightClickHighlight;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
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
|
/// Marker component on a parent entity that owns one drop-target overlay
|
||||||
/// (a translucent fill plus four outline edges as children). The wrapped
|
/// (a translucent fill plus four outline edges as children). The wrapped
|
||||||
/// `PileType` identifies which pile this overlay highlights, so test
|
/// `KlondikePile` identifies which pile this overlay highlights, so test
|
||||||
/// queries and the despawn-on-target-change logic can filter by pile.
|
/// queries and the despawn-on-target-change logic can filter by pile.
|
||||||
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DropTargetOverlay(pub PileType);
|
pub struct DropTargetOverlay(pub KlondikePile);
|
||||||
|
|
||||||
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
/// 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;
|
pub struct CursorPlugin;
|
||||||
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_cursor_icon,
|
update_cursor_icon,
|
||||||
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
|
update_drop_highlights.run_if(resource_changed::<DragState>),
|
||||||
update_drop_target_overlays,
|
update_drop_target_overlays,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -163,33 +163,34 @@ fn update_cursor_icon(
|
|||||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||||
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
||||||
let piles = [
|
let piles = [
|
||||||
PileType::Waste,
|
KlondikePile::Stock,
|
||||||
PileType::Foundation(0),
|
KlondikePile::Foundation(Foundation::Foundation1),
|
||||||
PileType::Foundation(1),
|
KlondikePile::Foundation(Foundation::Foundation2),
|
||||||
PileType::Foundation(2),
|
KlondikePile::Foundation(Foundation::Foundation3),
|
||||||
PileType::Foundation(3),
|
KlondikePile::Foundation(Foundation::Foundation4),
|
||||||
PileType::Tableau(0),
|
KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
PileType::Tableau(1),
|
KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
PileType::Tableau(2),
|
KlondikePile::Tableau(Tableau::Tableau3),
|
||||||
PileType::Tableau(3),
|
KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
PileType::Tableau(4),
|
KlondikePile::Tableau(Tableau::Tableau5),
|
||||||
PileType::Tableau(5),
|
KlondikePile::Tableau(Tableau::Tableau6),
|
||||||
PileType::Tableau(6),
|
KlondikePile::Tableau(Tableau::Tableau7),
|
||||||
];
|
];
|
||||||
|
|
||||||
for pile in piles {
|
for pile in piles {
|
||||||
let Some(pile_cards) = game.piles.get(&pile) else {
|
let pile_cards = pile_cards(game, &pile);
|
||||||
|
if pile_cards.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
};
|
}
|
||||||
let is_tableau = matches!(pile, PileType::Tableau(_));
|
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
|
||||||
let base = layout.pile_positions[&pile];
|
let base = layout.pile_positions[&pile];
|
||||||
|
|
||||||
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
|
for (i, card) in pile_cards.iter().enumerate().rev() {
|
||||||
if !card.face_up {
|
if !card.1 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Only the topmost card is draggable on non-tableau piles.
|
// Only the topmost card is draggable on non-tableau piles.
|
||||||
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
if !is_tableau && i != pile_cards.len() - 1 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
|
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
|
||||||
@@ -226,38 +227,14 @@ fn update_drop_highlights(
|
|||||||
|
|
||||||
let Some(game) = game else { return };
|
let Some(game) = game else { return };
|
||||||
|
|
||||||
// The first element of drag.cards is the bottom card that lands on the target.
|
|
||||||
let Some(&bottom_id) = drag.cards.first() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let bottom_card = game
|
|
||||||
.0
|
|
||||||
.piles
|
|
||||||
.values()
|
|
||||||
.flat_map(|p| p.cards.iter())
|
|
||||||
.find(|c| c.id == bottom_id)
|
|
||||||
.cloned();
|
|
||||||
let Some(bottom_card) = bottom_card else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let drag_count = drag.cards.len();
|
let drag_count = drag.cards.len();
|
||||||
|
|
||||||
for (marker, mut sprite, _rch) in &mut markers {
|
let Some(origin) = drag.origin_pile.as_ref() else {
|
||||||
let valid = match &marker.0 {
|
return;
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (marker, mut sprite, _rch) in &mut markers {
|
||||||
|
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
|
||||||
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,20 +274,7 @@ fn update_drop_target_overlays(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve the bottom card of the dragged stack — same logic as
|
let Some(origin) = drag.origin_pile.as_ref() else {
|
||||||
// `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;
|
return;
|
||||||
};
|
};
|
||||||
let drag_count = drag.cards.len();
|
let drag_count = drag.cards.len();
|
||||||
@@ -318,44 +282,24 @@ fn update_drop_target_overlays(
|
|||||||
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
||||||
// Waste are excluded because they are never legal drop targets.
|
// Waste are excluded because they are never legal drop targets.
|
||||||
let candidates = [
|
let candidates = [
|
||||||
PileType::Foundation(0),
|
KlondikePile::Foundation(Foundation::Foundation1),
|
||||||
PileType::Foundation(1),
|
KlondikePile::Foundation(Foundation::Foundation2),
|
||||||
PileType::Foundation(2),
|
KlondikePile::Foundation(Foundation::Foundation3),
|
||||||
PileType::Foundation(3),
|
KlondikePile::Foundation(Foundation::Foundation4),
|
||||||
PileType::Tableau(0),
|
KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
PileType::Tableau(1),
|
KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
PileType::Tableau(2),
|
KlondikePile::Tableau(Tableau::Tableau3),
|
||||||
PileType::Tableau(3),
|
KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
PileType::Tableau(4),
|
KlondikePile::Tableau(Tableau::Tableau5),
|
||||||
PileType::Tableau(5),
|
KlondikePile::Tableau(Tableau::Tableau6),
|
||||||
PileType::Tableau(6),
|
KlondikePile::Tableau(Tableau::Tableau7),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Compute the new set of valid piles for this frame.
|
// Compute the new set of valid piles for this frame.
|
||||||
let mut valid: Vec<PileType> = Vec::new();
|
let mut valid: Vec<KlondikePile> = Vec::new();
|
||||||
for pile in &candidates {
|
for pile in &candidates {
|
||||||
let is_valid = match pile {
|
if game.0.can_move_cards(origin, pile, drag_count) {
|
||||||
PileType::Foundation(_) => {
|
valid.push(*pile);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,9 +311,9 @@ fn update_drop_target_overlays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn overlays for piles that are now valid but don't yet have one.
|
// Spawn overlays for piles that are now valid but don't yet have one.
|
||||||
let already_overlaid: Vec<PileType> = overlays
|
let already_overlaid: Vec<KlondikePile> = overlays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, m)| m.0.clone())
|
.map(|(_, m)| m.0)
|
||||||
.filter(|p| valid.contains(p))
|
.filter(|p| valid.contains(p))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -388,10 +332,14 @@ fn update_drop_target_overlays(
|
|||||||
/// for everything else it is card-sized. Replicated here rather than
|
/// for everything else it is card-sized. Replicated here rather than
|
||||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||||
/// this overlay is the only other consumer.
|
/// this overlay is the only other consumer.
|
||||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
|
fn drop_overlay_rect(
|
||||||
|
pile: &KlondikePile,
|
||||||
|
layout: &Layout,
|
||||||
|
game: &GameState,
|
||||||
|
) -> Option<(Vec2, Vec2)> {
|
||||||
let centre = layout.pile_positions.get(pile).copied()?;
|
let centre = layout.pile_positions.get(pile).copied()?;
|
||||||
if matches!(pile, PileType::Tableau(_)) {
|
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
let card_count = game.pile(*pile).len();
|
||||||
if card_count > 1 {
|
if card_count > 1 {
|
||||||
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||||
@@ -412,7 +360,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Opti
|
|||||||
/// the appropriate world position for `pile`.
|
/// the appropriate world position for `pile`.
|
||||||
fn spawn_drop_target_overlay(
|
fn spawn_drop_target_overlay(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
pile: &PileType,
|
pile: &KlondikePile,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
) {
|
) {
|
||||||
@@ -430,7 +378,7 @@ fn spawn_drop_target_overlay(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
||||||
DropTargetOverlay(pile.clone()),
|
DropTargetOverlay(*pile),
|
||||||
))
|
))
|
||||||
.with_children(|parent| {
|
.with_children(|parent| {
|
||||||
// Top edge.
|
// Top edge.
|
||||||
@@ -479,7 +427,7 @@ fn spawn_drop_target_overlay(
|
|||||||
fn tableau_or_stack_pos(
|
fn tableau_or_stack_pos(
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
pile: &PileType,
|
pile: &KlondikePile,
|
||||||
index: usize,
|
index: usize,
|
||||||
base: Vec2,
|
base: Vec2,
|
||||||
is_tableau: bool,
|
is_tableau: bool,
|
||||||
@@ -489,8 +437,8 @@ fn tableau_or_stack_pos(
|
|||||||
base.x,
|
base.x,
|
||||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||||
)
|
)
|
||||||
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
let pile_len = game.waste_cards().len();
|
||||||
let visible_start = pile_len.saturating_sub(3);
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
let slot = index.saturating_sub(visible_start) as f32;
|
let slot = index.saturating_sub(visible_start) as f32;
|
||||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||||
@@ -499,6 +447,14 @@ fn tableau_or_stack_pos(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||||
|
if matches!(pile, KlondikePile::Stock) {
|
||||||
|
game.waste_cards()
|
||||||
|
} else {
|
||||||
|
game.pile(*pile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||||
let half = size / 2.0;
|
let half = size / 2.0;
|
||||||
point.x >= center.x - half.x
|
point.x >= center.x - half.x
|
||||||
@@ -607,9 +563,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// A cursor far off-screen should never hit anything.
|
// A cursor far off-screen should never hit anything.
|
||||||
assert!(!cursor_over_draggable(
|
assert!(!cursor_over_draggable(
|
||||||
@@ -624,8 +580,8 @@ mod tests {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
|
|
||||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||||
/// registered, plus the resources the system needs. Callers
|
/// registered, plus the resources the system needs. Callers
|
||||||
@@ -649,12 +605,8 @@ mod tests {
|
|||||||
/// card. Used to make a specific tableau column accept a chosen
|
/// card. Used to make a specific tableau column accept a chosen
|
||||||
/// drag stack.
|
/// drag stack.
|
||||||
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
||||||
let pile = game
|
let tableau = GameState::tableau_from_index(idx).expect("tableau pile exists");
|
||||||
.piles
|
game.set_test_tableau_cards(tableau, vec![card]);
|
||||||
.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
|
/// Inserts a single face-up dragged card into the waste pile and
|
||||||
@@ -664,148 +616,41 @@ mod tests {
|
|||||||
// Place the dragged card on the waste pile (origin).
|
// Place the dragged card on the waste pile (origin).
|
||||||
{
|
{
|
||||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
let waste = game
|
game.0.set_test_waste_cards(vec![dragged.clone()]);
|
||||||
.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>();
|
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||||
drag.cards = vec![dragged.id];
|
drag.cards = vec![dragged];
|
||||||
drag.origin_pile = Some(PileType::Waste);
|
drag.origin_pile = Some(KlondikePile::Stock);
|
||||||
drag.committed = true;
|
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]
|
#[test]
|
||||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||||
// — same colour family, illegal. Tableau(2) must NOT be
|
// — same colour family, illegal. Tableau(2) must NOT be
|
||||||
// highlighted.
|
// highlighted.
|
||||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
||||||
set_tableau_top(
|
set_tableau_top(
|
||||||
&mut game,
|
&mut game,
|
||||||
2,
|
2,
|
||||||
Card {
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
|
||||||
id: 9101,
|
|
||||||
suit: Suit::Clubs,
|
|
||||||
rank: Rank::Six,
|
|
||||||
face_up: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let dragged = Card {
|
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
||||||
id: 9102,
|
|
||||||
suit: Suit::Spades,
|
|
||||||
rank: Rank::Five,
|
|
||||||
face_up: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut app = overlay_test_app(game);
|
let mut app = overlay_test_app(game);
|
||||||
begin_drag_with(&mut app, dragged);
|
begin_drag_with(&mut app, dragged);
|
||||||
|
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let overlays: Vec<PileType> = app
|
let overlays: Vec<KlondikePile> = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<&DropTargetOverlay>()
|
.query::<&DropTargetOverlay>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.map(|o| o.0.clone())
|
.map(|o| o.0)
|
||||||
.collect();
|
.collect();
|
||||||
assert!(
|
assert!(
|
||||||
!overlays.contains(&PileType::Tableau(2)),
|
!overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)),
|
||||||
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drop_target_overlays_despawn_on_drag_end() {
|
|
||||||
// Set up a scenario that produces at least one valid overlay,
|
|
||||||
// confirm it spawns, then clear the drag and confirm every
|
|
||||||
// overlay is despawned.
|
|
||||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
|
||||||
set_tableau_top(
|
|
||||||
&mut game,
|
|
||||||
2,
|
|
||||||
Card {
|
|
||||||
id: 9201,
|
|
||||||
suit: Suit::Spades,
|
|
||||||
rank: Rank::Six,
|
|
||||||
face_up: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let dragged = Card {
|
|
||||||
id: 9202,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Five,
|
|
||||||
face_up: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut app = overlay_test_app(game);
|
|
||||||
begin_drag_with(&mut app, dragged);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let count_during_drag = app
|
|
||||||
.world_mut()
|
|
||||||
.query::<&DropTargetOverlay>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
count_during_drag >= 1,
|
|
||||||
"expected ≥1 overlay during drag, got {count_during_drag}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// End the drag — every overlay should despawn next frame.
|
|
||||||
app.world_mut().resource_mut::<DragState>().clear();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let count_after_drag = app
|
|
||||||
.world_mut()
|
|
||||||
.query::<&DropTargetOverlay>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert_eq!(
|
|
||||||
count_after_drag, 0,
|
|
||||||
"all overlays must despawn when the drag ends"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
||||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use solitaire_sync::ChallengeGoal;
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -25,6 +27,7 @@ use crate::events::{
|
|||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
|
|
||||||
/// Bonus XP awarded for completing today's daily challenge.
|
/// Bonus XP awarded for completing today's daily challenge.
|
||||||
@@ -77,8 +80,13 @@ pub struct DailyChallengeCompletedEvent {
|
|||||||
/// Holds the in-flight server challenge fetch so the result can be polled
|
/// Holds the in-flight server challenge fetch so the result can be polled
|
||||||
/// each frame without blocking the main thread.
|
/// each frame without blocking the main thread.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
struct DailyChallengeTask;
|
||||||
|
|
||||||
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
|
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
|
||||||
/// already fired for, so the toast spawns at most once per day.
|
/// already fired for, so the toast spawns at most once per day.
|
||||||
///
|
///
|
||||||
@@ -116,17 +124,21 @@ impl Plugin for DailyChallengePlugin {
|
|||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.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
|
// record/award after the base ProgressUpdate so we don't fight
|
||||||
// ProgressPlugin's add_xp on the same frame.
|
// ProgressPlugin's add_xp on the same frame.
|
||||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||||
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
||||||
.add_systems(Update, check_daily_expiry_warning)
|
.add_systems(Update, check_daily_expiry_warning)
|
||||||
.add_systems(Update, check_date_rollover);
|
.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.
|
/// Startup system: spawns an async task to fetch the server's daily challenge.
|
||||||
///
|
///
|
||||||
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
|
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
|
||||||
@@ -142,6 +154,7 @@ fn fetch_server_challenge(
|
|||||||
task_res.0 = Some(task);
|
task_res.0 = Some(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
/// Update system: polls the server-challenge fetch task.
|
/// Update system: polls the server-challenge fetch task.
|
||||||
///
|
///
|
||||||
/// On success, replaces the locally-computed seed in `DailyChallengeResource`
|
/// On success, replaces the locally-computed seed in `DailyChallengeResource`
|
||||||
@@ -341,7 +354,6 @@ fn check_date_rollover(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -350,7 +362,7 @@ mod tests {
|
|||||||
use crate::progress_plugin::ProgressPlugin;
|
use crate::progress_plugin::ProgressPlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -379,7 +391,7 @@ mod tests {
|
|||||||
|
|
||||||
// Replace the GameState with one whose seed matches the daily seed.
|
// Replace the GameState with one whose seed matches the daily seed.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
GameState::new(daily_seed, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -407,7 +419,7 @@ mod tests {
|
|||||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||||
// Use a deliberately different seed.
|
// Use a deliberately different seed.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
GameState::new(daily_seed.wrapping_add(7777), DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -430,7 +442,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
GameState::new(daily_seed, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! because the starting position is effectively random (player-chosen timing
|
//! because the starting position is effectively random (player-chosen timing
|
||||||
//! determines which seed in the 40-entry catalog they start at).
|
//! determines which seed in the 40-entry catalog they start at).
|
||||||
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use chrono::Utc;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
||||||
@@ -104,10 +104,9 @@ fn handle_difficulty_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn seed_from_system_time() -> u64 {
|
fn seed_from_system_time() -> u64 {
|
||||||
SystemTime::now()
|
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
|
||||||
.duration_since(UNIX_EPOCH)
|
// std::time::SystemTime panics on wasm32-unknown-unknown).
|
||||||
.map(|d| d.as_nanos() as u64)
|
Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
|
||||||
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Cross-system events used by the engine's plugins.
|
//! Cross-system events used by the engine's plugins.
|
||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_core::pile::PileType;
|
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
use solitaire_sync::SyncResponse;
|
use solitaire_sync::SyncResponse;
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ use solitaire_sync::SyncResponse;
|
|||||||
/// consumed by `GamePlugin`.
|
/// consumed by `GamePlugin`.
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct MoveRequestEvent {
|
pub struct MoveRequestEvent {
|
||||||
pub from: PileType,
|
pub from: KlondikePile,
|
||||||
pub to: PileType,
|
pub to: KlondikePile,
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ pub struct StateChangedEvent;
|
|||||||
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct MoveRejectedEvent {
|
pub struct MoveRejectedEvent {
|
||||||
pub from: PileType,
|
pub from: KlondikePile,
|
||||||
pub to: PileType,
|
pub to: KlondikePile,
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fired when a card's face-up state changes during gameplay.
|
/// Fired when a card's face-up state changes during gameplay.
|
||||||
#[derive(Message, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct CardFlippedEvent(pub u32);
|
pub struct CardFlippedEvent(pub Card);
|
||||||
|
|
||||||
/// Fired by the flip animation at its midpoint — the instant the card face
|
/// 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).
|
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
|
||||||
@@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub u32);
|
|||||||
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
||||||
/// so the flip sound is synchronised with the visual reveal, not the move
|
/// so the flip sound is synchronised with the visual reveal, not the move
|
||||||
/// that triggered the animation.
|
/// that triggered the animation.
|
||||||
#[derive(Message, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct CardFaceRevealedEvent(pub u32);
|
pub struct CardFaceRevealedEvent(pub Card);
|
||||||
|
|
||||||
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
/// 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).
|
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct HintVisualEvent {
|
pub struct HintVisualEvent {
|
||||||
/// The `Card::id` of the source card to be highlighted.
|
/// The source card to be highlighted.
|
||||||
pub source_card_id: u32,
|
pub source_card: Card,
|
||||||
/// The destination pile whose `PileMarker` should be tinted gold.
|
/// The destination pile whose `PileMarker` should be tinted gold.
|
||||||
pub dest_pile: solitaire_core::pile::PileType,
|
pub dest_pile: KlondikePile,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ use std::f32::consts::PI;
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::pile::PileType;
|
use bevy::window::RequestRedraw;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::klondike_adapter::foundation_from_slot;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
@@ -204,6 +207,7 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
|
.add_message::<RequestRedraw>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -243,18 +247,16 @@ fn start_shake_anim(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let dest_pile = &ev.to;
|
let dest_pile = &ev.to;
|
||||||
// Collect the card ids that belong to the destination pile.
|
// Collect the cards that belong to the destination pile.
|
||||||
let Some(pile) = game.0.piles.get(dest_pile) else {
|
let dest_cards = pile_cards(&game.0, dest_pile);
|
||||||
continue;
|
let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect();
|
||||||
};
|
|
||||||
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
|
|
||||||
|
|
||||||
if dest_card_ids.is_empty() {
|
if dest_card_set.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (entity, card_marker, transform) in card_entities.iter() {
|
for (entity, card_marker, transform) in card_entities.iter() {
|
||||||
if dest_card_ids.contains(&card_marker.card_id) {
|
if dest_card_set.contains(&card_marker.card) {
|
||||||
commands.entity(entity).insert(ShakeAnim {
|
commands.entity(entity).insert(ShakeAnim {
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
origin_x: transform.translation.x,
|
origin_x: transform.translation.x,
|
||||||
@@ -311,27 +313,27 @@ fn start_settle_anim(
|
|||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
// Build the list of card ids that should bounce this frame from every
|
// Build the list of cards that should bounce this frame from every
|
||||||
// queued request; multiple events can fire in the same frame (e.g. a move
|
// queued request; multiple events can fire in the same frame (e.g. a move
|
||||||
// followed by a draw via keyboard accelerators).
|
// followed by a draw via keyboard accelerators).
|
||||||
let mut bounce_ids: Vec<u32> = Vec::new();
|
let mut bounce_ids: Vec<Card> = Vec::new();
|
||||||
|
|
||||||
for ev in moves.read() {
|
for ev in moves.read() {
|
||||||
if let Some(pile) = game.0.piles.get(&ev.to) {
|
let pile = pile_cards(&game.0, &ev.to);
|
||||||
// The moved cards land on top — take the last `count` ids.
|
if !pile.is_empty() {
|
||||||
let n = ev.count.min(pile.cards.len());
|
// The moved cards land on top — take the last `count` cards.
|
||||||
|
let n = ev.count.min(pile.len());
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
let start = pile.cards.len() - n;
|
let start = pile.len() - n;
|
||||||
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
|
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if draws.read().next().is_some()
|
if draws.read().next().is_some()
|
||||||
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
|
&& let Some((top, _)) = game.0.waste_cards().last()
|
||||||
&& let Some(top) = pile.cards.last()
|
|
||||||
{
|
{
|
||||||
bounce_ids.push(top.id);
|
bounce_ids.push(top.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if bounce_ids.is_empty() {
|
if bounce_ids.is_empty() {
|
||||||
@@ -339,7 +341,7 @@ fn start_settle_anim(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (entity, card_marker) in card_entities.iter() {
|
for (entity, card_marker) in card_entities.iter() {
|
||||||
if bounce_ids.contains(&card_marker.card_id) {
|
if bounce_ids.contains(&card_marker.card) {
|
||||||
commands.entity(entity).insert(SettleAnim::default());
|
commands.entity(entity).insert(SettleAnim::default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,11 +395,11 @@ fn start_deal_anim(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Only animate a fresh deal (no moves made yet).
|
// Only animate a fresh deal (no moves made yet).
|
||||||
if game.0.move_count != 0 {
|
if game.0.move_count() != 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(layout) = layout else { return };
|
let Some(layout) = layout else { return };
|
||||||
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
|
let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
||||||
@@ -407,10 +409,14 @@ fn start_deal_anim(
|
|||||||
|
|
||||||
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
||||||
let final_pos = transform.translation;
|
let final_pos = transform.translation;
|
||||||
// ±10 % jitter, deterministic per card id, so the deal feels organic
|
// ±10 % jitter, deterministic per card, so the deal feels organic
|
||||||
// without losing reproducibility (a given seed still produces the
|
// without losing reproducibility (a given deal produces the same
|
||||||
// same per-card stagger pattern across runs).
|
// per-card stagger pattern across runs). The seed is a hash of the
|
||||||
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
|
// card's own identity — no separate numeric id needed.
|
||||||
|
let mut card_hasher = DefaultHasher::new();
|
||||||
|
card_marker.card.hash(&mut card_hasher);
|
||||||
|
let per_card_stagger =
|
||||||
|
stagger_secs * (1.0 + deal_stagger_jitter(card_hasher.finish() as u32));
|
||||||
commands.entity(entity).insert((
|
commands.entity(entity).insert((
|
||||||
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
||||||
CardAnim {
|
CardAnim {
|
||||||
@@ -518,21 +524,19 @@ fn start_foundation_flourish(
|
|||||||
if reduce_motion {
|
if reduce_motion {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let pile_type = PileType::Foundation(ev.slot);
|
let Some(foundation) = foundation_from_slot(ev.slot) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let pile_type = KlondikePile::Foundation(foundation);
|
||||||
// Top card of the completed foundation is the King.
|
// Top card of the completed foundation is the King.
|
||||||
let Some(king_id) = game
|
let cards = game.0.pile(pile_type);
|
||||||
.0
|
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
|
||||||
.piles
|
|
||||||
.get(&pile_type)
|
|
||||||
.and_then(|p| p.cards.last())
|
|
||||||
.map(|c| c.id)
|
|
||||||
else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tag the King's card entity.
|
// Tag the King's card entity.
|
||||||
for (entity, card_marker) in card_entities.iter() {
|
for (entity, card_marker) in card_entities.iter() {
|
||||||
if card_marker.card_id == king_id {
|
if card_marker.card == king_card {
|
||||||
commands.entity(entity).insert(FoundationFlourish {
|
commands.entity(entity).insert(FoundationFlourish {
|
||||||
foundation_slot: ev.slot,
|
foundation_slot: ev.slot,
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
@@ -632,6 +636,16 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pile_cards(
|
||||||
|
game: &solitaire_core::game_state::GameState,
|
||||||
|
pile: &KlondikePile,
|
||||||
|
) -> Vec<(Card, bool)> {
|
||||||
|
match pile {
|
||||||
|
KlondikePile::Stock => game.waste_cards(),
|
||||||
|
_ => game.pile(*pile),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unit tests (pure functions only — no Bevy world required)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -831,13 +845,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn shake_anim_skipped_under_reduce_motion() {
|
fn shake_anim_skipped_under_reduce_motion() {
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::Tableau;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
use solitaire_data::Settings;
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -845,26 +860,25 @@ mod tests {
|
|||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Pick a card from Tableau(0) so the event refers to a real pile.
|
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||||
let dest_pile = PileType::Tableau(0);
|
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
|
||||||
let card_id = app
|
let card = app
|
||||||
.world()
|
.world()
|
||||||
.resource::<GameStateResource>()
|
.resource::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.piles
|
.pile(dest_pile)
|
||||||
.get(&dest_pile)
|
.last()
|
||||||
.and_then(|p| p.cards.last())
|
.map(|(c, _)| c.clone())
|
||||||
.map(|c| c.id)
|
|
||||||
.expect("Tableau(0) should have at least one card in a fresh game");
|
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||||
|
|
||||||
// Spawn a minimal CardEntity matching that id so the system would
|
// Spawn a minimal CardEntity matching that card so the system would
|
||||||
// find it and insert ShakeAnim if the gate were absent.
|
// find it and insert ShakeAnim if the gate were absent.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.spawn((CardEntity { card_id }, Transform::default()));
|
.spawn((CardEntity { card }, Transform::default()));
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<Messages<MoveRejectedEvent>>()
|
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||||
.write(MoveRejectedEvent {
|
.write(MoveRejectedEvent {
|
||||||
from: PileType::Stock,
|
from: KlondikePile::Stock,
|
||||||
to: dest_pile,
|
to: dest_pile,
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
@@ -886,13 +900,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn foundation_flourish_skipped_under_reduce_motion() {
|
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
use solitaire_data::Settings;
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -903,7 +917,7 @@ mod tests {
|
|||||||
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||||
.write(FoundationCompletedEvent {
|
.write(FoundationCompletedEvent {
|
||||||
slot: 0,
|
slot: 0,
|
||||||
suit: solitaire_core::card::Suit::Spades,
|
suit: solitaire_core::Suit::Spades,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Bundling rather than runtime-loading guarantees the canonical UI face is
|
//! Bundling rather than runtime-loading guarantees the canonical UI face is
|
||||||
//! always available regardless of install or platform. The bytes are
|
//! always available regardless of install or platform. The bytes are
|
||||||
//! validated at startup; a parse failure aborts the program with a clear
|
//! validated at startup; a parse failure logs a warning and continues with
|
||||||
//! error because it means the binary is corrupt.
|
//! glyph-less UI rather than aborting, since crashing on a corrupt embed is
|
||||||
|
//! worse than degraded text.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ impl Plugin for HelpPlugin {
|
|||||||
// plugin under `DefaultPlugins`; register them explicitly so
|
// plugin under `DefaultPlugins`; register them explicitly so
|
||||||
// scroll systems run cleanly under `MinimalPlugins` in tests.
|
// scroll systems run cleanly under `MinimalPlugins` in tests.
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
.add_message::<bevy::input::touch::TouchInput>()
|
.add_message::<TouchInput>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||||
use solitaire_data::save_settings_to;
|
use solitaire_data::save_settings_to;
|
||||||
|
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
@@ -432,7 +432,7 @@ fn build_home_context<'a>(
|
|||||||
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||||
daily_today,
|
daily_today,
|
||||||
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
|
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
|
||||||
font_res,
|
font_res,
|
||||||
difficulty_expanded,
|
difficulty_expanded,
|
||||||
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||||
@@ -620,9 +620,9 @@ fn handle_home_draw_mode_buttons(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let target = if want_one {
|
let target = if want_one {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
} else {
|
} else {
|
||||||
DrawMode::DrawThree
|
DrawStockConfig::DrawThree
|
||||||
};
|
};
|
||||||
if settings.0.draw_mode == target {
|
if settings.0.draw_mode == target {
|
||||||
return; // already in this mode — avoid a redundant respawn.
|
return; // already in this mode — avoid a redundant respawn.
|
||||||
@@ -857,7 +857,7 @@ struct HomeContext<'a> {
|
|||||||
challenge_best: u32,
|
challenge_best: u32,
|
||||||
daily_streak: u32,
|
daily_streak: u32,
|
||||||
daily_today: Option<DailyToday>,
|
daily_today: Option<DailyToday>,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawStockConfig,
|
||||||
font_res: Option<&'a FontResource>,
|
font_res: Option<&'a FontResource>,
|
||||||
/// Whether the difficulty section header is currently expanded.
|
/// Whether the difficulty section header is currently expanded.
|
||||||
difficulty_expanded: bool,
|
difficulty_expanded: bool,
|
||||||
@@ -1038,7 +1038,7 @@ fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>)
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
|
let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
parent
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
@@ -1878,7 +1878,7 @@ mod tests {
|
|||||||
|
|
||||||
let states: Vec<(HomeMode, bool)> = app
|
let states: Vec<(HomeMode, bool)> = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
|
.query::<(&HomeModeCard, Has<Disabled>)>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.map(|(c, d)| (c.0, d))
|
.map(|(c, d)| (c.0, d))
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,430 @@
|
|||||||
|
//! 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
//! 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;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
//! 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;
|
||||||
|
#[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::{
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
.add_systems(Update, update_hud.after(GameMutation))
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
||||||
|
)
|
||||||
|
.add_systems(Update, restore_hud_on_modal)
|
||||||
|
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
|
||||||
|
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||||
|
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
update_selection_hud.run_if(
|
||||||
|
resource_exists_and_changed::<SelectionState>
|
||||||
|
.or(resource_exists_and_changed::<GameStateResource>),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_systems(Update, update_hud_typography)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
detect_score_change,
|
||||||
|
advance_score_pulse,
|
||||||
|
advance_score_floater,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(start_streak_flourish, advance_streak_flourish)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_new_game_button,
|
||||||
|
handle_undo_button,
|
||||||
|
handle_pause_button,
|
||||||
|
handle_help_button,
|
||||||
|
handle_hint_button,
|
||||||
|
handle_modes_button,
|
||||||
|
handle_mode_option_click,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// 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;
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
//! 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)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,873 @@
|
|||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
//! Per-frame HUD text/typography/visibility updater systems.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
use solitaire_core::Suit;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
|
||||||
|
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
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user