Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -36,47 +36,12 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Check wasm pkg drift
|
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
|
||||||
run: |
|
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
|
||||||
set -euo pipefail
|
# and commits it back (CI is the single source of truth — the artifacts
|
||||||
BASE_SHA="${{ github.event.before }}"
|
# aren't byte-reproducible on contributor machines). That pkg/ commit then
|
||||||
HEAD_SHA="${{ github.sha }}"
|
# triggers this workflow, so the deployed image always ships fresh wasm.
|
||||||
if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
|
# No drift check is needed here.
|
||||||
RANGE="$BASE_SHA..$HEAD_SHA"
|
|
||||||
else
|
|
||||||
RANGE="HEAD~1..HEAD"
|
|
||||||
fi
|
|
||||||
|
|
||||||
CHANGED="$(git diff --name-only "$RANGE")"
|
|
||||||
echo "Changed files:"
|
|
||||||
echo "$CHANGED"
|
|
||||||
|
|
||||||
if echo "$CHANGED" | grep -Eq '^(solitaire_wasm/|solitaire_core/|Cargo\.toml|Cargo\.lock)$|^(solitaire_wasm/|solitaire_core/)'; then
|
|
||||||
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/solitaire_wasm\.js$|^solitaire_server/web/pkg/solitaire_wasm_bg\.wasm$'; then
|
|
||||||
echo "error: wasm/core/Cargo changed but committed web pkg artifacts are missing."
|
|
||||||
echo "Run: wasm-pack build --target web --out-dir solitaire_server/web/pkg --no-typescript solitaire_wasm"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Hard check: solitaire_web/ is the direct Bevy WASM source — any
|
|
||||||
# change there MUST rebuild canvas_bg.wasm or the binary goes stale.
|
|
||||||
if echo "$CHANGED" | grep -Eq '^solitaire_web/'; then
|
|
||||||
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
|
|
||||||
echo "error: solitaire_web/ changed but canvas_bg.wasm not updated."
|
|
||||||
echo "Run: ./build_wasm.sh (requires wasm-bindgen-cli + wasm32-unknown-unknown target)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Advisory notice: solitaire_engine/ and solitaire_core/ changes often
|
|
||||||
# require a Bevy WASM rebuild but are not enforced (formatting-only
|
|
||||||
# commits should not be blocked).
|
|
||||||
if echo "$CHANGED" | grep -Eq '^(solitaire_engine/|solitaire_core/)' && \
|
|
||||||
! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
|
|
||||||
echo "notice: solitaire_engine/core changed without a canvas_bg.wasm rebuild."
|
|
||||||
echo " If the change affects gameplay run ./build_wasm.sh before pushing."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
- name: Log in to Gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ jobs:
|
|||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
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
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ deploy/*-auth-secret.yaml
|
|||||||
/.agents/
|
/.agents/
|
||||||
/.codex/
|
/.codex/
|
||||||
/AGENTS.md
|
/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.) —
|
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
||||||
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,19 @@ 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
|
### 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,
|
- **Analytics validation runbook.** Documented native Matomo live validation,
|
||||||
expected event payloads, and the current web/WASM analytics split.
|
expected event payloads, and the current web/WASM analytics split.
|
||||||
- **Android smoke-test runbook.** Updated the Android doc with the current
|
- **Android smoke-test runbook.** Updated the Android doc with the current
|
||||||
@@ -26,6 +37,10 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
### Changed
|
### 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
|
- **Core type ownership.** Routed all klondike/card imports through
|
||||||
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
|
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
|
||||||
types.
|
types.
|
||||||
@@ -37,6 +52,24 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- **Android and modal safe-area layout.** Modal cards now center within the
|
||||||
usable area between status and gesture bars, additional modal-spawn guards were
|
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
|
added, and Android build scripts now auto-discover SDK/NDK paths and strip
|
||||||
@@ -47,6 +80,11 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
|
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
|
||||||
clicks, delayed first-run onboarding until splash teardown, and kept dragged
|
clicks, delayed first-run onboarding until splash teardown, and kept dragged
|
||||||
stacks above all piles.
|
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
|
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
|
||||||
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
|
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
|
||||||
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
|
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -7306,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",
|
||||||
|
|||||||
+16
@@ -18,6 +18,22 @@ 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"
|
||||||
|
|||||||
+52
-17
@@ -1,16 +1,40 @@
|
|||||||
# Ferrous Solitaire — Session Handoff
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-06-09 — AVD Android launch smoke passed; physical-device gate remains.
|
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes.
|
- **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.39.0`
|
- **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. Local `scripts/` helpers are excluded through `.git/info/exclude` and intentionally not committed.
|
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
|
||||||
- **Latest verification in this follow-up:** `cargo test -p solitaire_core`; `cargo test -p solitaire_data matomo_client`; `cargo test -p solitaire_engine analytics_plugin`; `cargo test -p solitaire_engine settings_plugin`; `cargo test -p solitaire_engine card_plugin`; `cargo apk build -p solitaire_app --target x86_64-linux-android --lib`; AVD `Pixel_7` install/launch/input smoke.
|
- **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.
|
||||||
- **Full previous gate:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,12 +127,23 @@ Three bugs fixed:
|
|||||||
|
|
||||||
## Open punch list
|
## Open punch list
|
||||||
|
|
||||||
### 1. Android APK launch verification (Option A)
|
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
|
||||||
|
|
||||||
Physical device test: install the latest APK on a real Android device (not AVD),
|
This is the **single outstanding task** for the v0.40.0 Android release. Everything
|
||||||
and run the checklist in `docs/ANDROID.md`. This has never been gated in CI.
|
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
|
||||||
AVD `adb shell input tap` doesn't deliver real touch events, so physical-device
|
clippy, release manifest sanity, a full local signed APK, the published release, and
|
||||||
smoke testing is the only gate.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The signed release APK is published (grab it from the release page, or use the local
|
||||||
|
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
|
||||||
|
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
|
||||||
|
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
|
||||||
|
|
||||||
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
|
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
|
||||||
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
|
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
|
||||||
@@ -117,13 +152,13 @@ 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
|
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
|
||||||
filtered logcat showed no Ferrous panic/fatal/ANR.
|
filtered logcat showed no Ferrous panic/fatal/ANR.
|
||||||
|
|
||||||
### 2. Matomo analytics live validation
|
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
|
||||||
|
|
||||||
`Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine
|
Separate, ongoing task unrelated to the Android release. `Settings` has
|
||||||
consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live
|
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
||||||
validation against the deployed Matomo instance. Use
|
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
|
||||||
`docs/analytics-validation.md` for the native validation checklist and the
|
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
|
||||||
current web/WASM decision notes.
|
validation checklist and the current web/WASM decision notes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ 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
|
||||||
|
|||||||
+7
-2
@@ -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 \
|
||||||
|
|||||||
@@ -213,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
+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"
|
||||||
|
|||||||
@@ -144,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()
|
||||||
@@ -166,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()
|
||||||
@@ -363,16 +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) {
|
||||||
let vm_ptr = android_app.vm_as_ptr().cast();
|
if let Err(e) = init_android_jni(&android_app) {
|
||||||
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) {
|
eprintln!("warn: could not initialise Android JNI bridge ({e})");
|
||||||
eprintln!("warn: could not initialise Android Keystore JNI ({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
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
//! --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::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_core::game_state::GameState;
|
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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
//! --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::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
|
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let draw_mode = DrawMode::DrawOne;
|
let draw_mode = DrawStockConfig::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;
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ serde = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
klondike = { workspace = true }
|
klondike = { workspace = true }
|
||||||
card_game = { workspace = true }
|
card_game = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
pub use card_game::{Card, Deck, Rank, Suit};
|
|
||||||
|
|
||||||
/// Maps a [`Card`] to a stable `0..=51` numeric identity, independent of the
|
|
||||||
/// upstream `card_game::Card` bit-packing.
|
|
||||||
///
|
|
||||||
/// Encoding: `suit_index * 13 + (rank as u32 - 1)`, where `suit_index` is
|
|
||||||
/// Clubs=0, Diamonds=1, Hearts=2, Spades=3 and `rank` is 1 (Ace) ..= 13 (King).
|
|
||||||
/// The deck id is intentionally ignored so the id depends only on the visible
|
|
||||||
/// face.
|
|
||||||
///
|
|
||||||
/// This is the single source of truth shared by `CardEntity` numeric tracking,
|
|
||||||
/// deterministic per-card animation jitter, and the WASM replay layer — those
|
|
||||||
/// must agree byte-for-byte so replay snapshots are identical across the
|
|
||||||
/// desktop and browser builds.
|
|
||||||
pub fn card_to_id(card: &Card) -> u32 {
|
|
||||||
let suit_index: u32 = match card.suit() {
|
|
||||||
Suit::Clubs => 0,
|
|
||||||
Suit::Diamonds => 1,
|
|
||||||
Suit::Hearts => 2,
|
|
||||||
Suit::Spades => 3,
|
|
||||||
};
|
|
||||||
suit_index * 13 + (card.rank() as u32 - 1)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::error::MoveError;
|
use crate::error::MoveError;
|
||||||
use crate::klondike_adapter::{
|
use crate::klondike_adapter::{
|
||||||
DrawMode, KlondikeAdapter, SavedInstruction,
|
KlondikeAdapter, foundation_from_slot as adapter_foundation_from_slot,
|
||||||
foundation_from_slot as adapter_foundation_from_slot,
|
|
||||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||||
tableau_from_index as adapter_tableau_from_index,
|
tableau_from_index as adapter_tableau_from_index,
|
||||||
};
|
};
|
||||||
@@ -19,11 +18,10 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// History:
|
/// History:
|
||||||
/// - v1: `Foundation(Suit)` keys.
|
/// - v1: `Foundation(Suit)` keys.
|
||||||
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
||||||
/// - v3: session-backed save files using local `SavedInstruction` mirror types
|
/// - v3 (rejected): session-backed save files using local mirror types with u8
|
||||||
/// with u8 indices for enum variants.
|
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
||||||
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||||
/// variants (e.g. `"Foundation1"` instead of `0`). v3 files are auto-migrated
|
/// variants (e.g. `"Foundation1"` instead of `0`).
|
||||||
/// on load via `AnyInstruction` transparent deserialization.
|
|
||||||
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
||||||
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||||
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||||
@@ -101,7 +99,7 @@ pub enum GameMode {
|
|||||||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
/// `KlondikeInstruction` serde, which produces named enum variants.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PersistedGameState {
|
struct PersistedGameState {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
@@ -110,31 +108,16 @@ struct PersistedGameState {
|
|||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transparent migration wrapper for deserialisation.
|
/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
|
||||||
///
|
///
|
||||||
/// Tries `KlondikeInstruction` (schema v4, named variants) first; if that
|
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
|
||||||
/// fails (because the value uses u8 indices), falls back to `SavedInstruction`
|
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
|
||||||
/// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via
|
/// intentionally absent: all three are rebuilt by replaying the instruction
|
||||||
/// the existing `TryFrom` impl.
|
/// history through the upstream session stats. Older v4 save files still carry
|
||||||
///
|
/// those keys; serde ignores them.
|
||||||
/// `SavedInstruction` remains `pub` in `klondike_adapter` because
|
|
||||||
/// `solitaire_data::ReplayMove` and the WASM replay layer depend on it.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum AnyInstruction {
|
|
||||||
V4(KlondikeInstruction),
|
|
||||||
V3(SavedInstruction),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats.
|
|
||||||
///
|
|
||||||
/// `score`, `undo_count`, and `recycle_count` are intentionally absent: all
|
|
||||||
/// three are rebuilt by replaying the instruction history through the upstream
|
|
||||||
/// session stats. Older save files (v3/v4) still carry those keys; serde ignores
|
|
||||||
/// them.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct PersistedGameStateIn {
|
struct PersistedGameStateIn {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
@@ -143,7 +126,7 @@ struct PersistedGameStateIn {
|
|||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub saved_moves: Vec<AnyInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -249,10 +232,10 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Accept v3 (legacy u8-index format, auto-migrated), v4 (upstream
|
// Accept v4 (upstream named-variant serde) and v5 (current, derived
|
||||||
// named-variant serde), and v5 (current, derived stats). Reject the rest.
|
// stats). v3 (legacy u8-index format) and all others are rejected.
|
||||||
match persisted.schema_version {
|
match persisted.schema_version {
|
||||||
3..=5 => {}
|
4 | 5 => {}
|
||||||
v => {
|
v => {
|
||||||
return Err(serde::de::Error::custom(format!(
|
return Err(serde::de::Error::custom(format!(
|
||||||
"unsupported GameState schema version {v}"
|
"unsupported GameState schema version {v}"
|
||||||
@@ -276,17 +259,7 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
// to 0 across save/load because undone moves are not part of the saved
|
// to 0 across save/load because undone moves are not part of the saved
|
||||||
// forward history.
|
// forward history.
|
||||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||||
for any in persisted.saved_moves {
|
for instruction in persisted.saved_moves {
|
||||||
// AnyInstruction::V4 arrives directly from upstream serde (schema v4+).
|
|
||||||
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
|
||||||
// converted here via the existing TryFrom impl.
|
|
||||||
let instruction = match any {
|
|
||||||
AnyInstruction::V4(i) => i,
|
|
||||||
AnyInstruction::V3(s) => {
|
|
||||||
KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -306,12 +279,12 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
|
|
||||||
impl GameState {
|
impl GameState {
|
||||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||||
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
|
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
||||||
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
|
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new game with an explicit `GameMode`.
|
/// Creates a new game with an explicit `GameMode`.
|
||||||
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
|
pub fn new_with_mode(seed: u64, draw_mode: DrawStockConfig, mode: GameMode) -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode,
|
mode,
|
||||||
elapsed_seconds: 0,
|
elapsed_seconds: 0,
|
||||||
@@ -325,11 +298,8 @@ impl GameState {
|
|||||||
|
|
||||||
/// Whether the player draws one or three cards from the stock per turn.
|
/// Whether the player draws one or three cards from the stock per turn.
|
||||||
/// Derived from the underlying session config (set once at deal time).
|
/// Derived from the underlying session config (set once at deal time).
|
||||||
pub fn draw_mode(&self) -> DrawMode {
|
pub fn draw_mode(&self) -> DrawStockConfig {
|
||||||
match self.session.config().inner.draw_stock {
|
self.session.config().inner.draw_stock
|
||||||
DrawStockConfig::DrawOne => DrawMode::DrawOne,
|
|
||||||
DrawStockConfig::DrawThree => DrawMode::DrawThree,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current game score, derived from the upstream session stats.
|
/// Current game score, derived from the upstream session stats.
|
||||||
@@ -405,11 +375,11 @@ impl GameState {
|
|||||||
!self.check_win() && self.check_auto_complete()
|
!self.check_win() && self.check_auto_complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_session(seed: u64, draw_mode: DrawMode) -> Session<Klondike> {
|
fn new_session(seed: u64, draw_mode: DrawStockConfig) -> Session<Klondike> {
|
||||||
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
|
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
|
fn session_config(draw_mode: DrawStockConfig) -> SessionConfig<KlondikeConfig> {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
inner: Self::replay_config(draw_mode),
|
inner: Self::replay_config(draw_mode),
|
||||||
// The −15 WXP undo penalty is now applied by the upstream score
|
// The −15 WXP undo penalty is now applied by the upstream score
|
||||||
@@ -419,7 +389,7 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
fn replay_config(draw_mode: DrawStockConfig) -> KlondikeConfig {
|
||||||
// Always allow foundation returns during replay, regardless of the
|
// Always allow foundation returns during replay, regardless of the
|
||||||
// player's current `take_from_foundation` setting. A move recorded
|
// player's current `take_from_foundation` setting. A move recorded
|
||||||
// when the rule was enabled must replay correctly even if the player
|
// when the rule was enabled must replay correctly even if the player
|
||||||
@@ -443,20 +413,17 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the deterministic instruction history for the current deal as
|
/// Returns the deterministic instruction history for the current deal as
|
||||||
/// legacy mirror types.
|
/// upstream [`KlondikeInstruction`] values.
|
||||||
///
|
///
|
||||||
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
||||||
/// sequence is sufficient to replay the game state exactly.
|
/// sequence is sufficient to replay the game state exactly. Consumers
|
||||||
///
|
/// record these directly (they serialise via `KlondikeInstruction`'s
|
||||||
/// Returns [`SavedInstruction`] (u8-index mirror types) for backward
|
/// compact serde) and play them back via [`GameState::apply_instruction`].
|
||||||
/// compatibility with the WASM replay layer and `solitaire_data::ReplayMove`
|
pub fn instruction_history(&self) -> Vec<KlondikeInstruction> {
|
||||||
/// format. New code that does not need serde should prefer
|
|
||||||
/// `session().history()` directly.
|
|
||||||
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
|
|
||||||
self.session
|
self.session
|
||||||
.history()
|
.history()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|snapshot| SavedInstruction::from(*snapshot.instruction()))
|
.map(|snapshot| *snapshot.instruction())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +554,7 @@ impl GameState {
|
|||||||
/// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need
|
/// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need
|
||||||
/// a specific mode use this instead of mutating a field.
|
/// a specific mode use this instead of mutating a field.
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
pub fn set_test_draw_mode(&mut self, draw_mode: DrawMode) {
|
pub fn set_test_draw_mode(&mut self, draw_mode: DrawStockConfig) {
|
||||||
self.session = Self::new_session(self.seed, draw_mode);
|
self.session = Self::new_session(self.seed, draw_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,6 +863,13 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
||||||
|
///
|
||||||
|
/// Recycling is deliberately unlimited in every draw mode: extra passes
|
||||||
|
/// are discouraged through the upstream score penalty, never rejected
|
||||||
|
/// with a [`MoveError`]. The difficulty seed catalog and the
|
||||||
|
/// winnable-deal solver are verified under this rule — see the
|
||||||
|
/// "Rules decisions" note in `ARCHITECTURE.md` (`solitaire_core`
|
||||||
|
/// section) before considering a hard pass limit.
|
||||||
pub fn draw(&mut self) -> Result<(), MoveError> {
|
pub fn draw(&mut self) -> Result<(), MoveError> {
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
@@ -1002,7 +976,7 @@ impl GameState {
|
|||||||
|
|
||||||
/// Returns `true` when all four foundation slots each contain a complete A→K sequence.
|
/// Returns `true` when all four foundation slots each contain a complete A→K sequence.
|
||||||
pub fn check_win(&self) -> bool {
|
pub fn check_win(&self) -> bool {
|
||||||
self.session.state().state().is_win()
|
self.session.is_win()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when the game can be completed without further player input
|
/// Returns `true` when the game can be completed without further player input
|
||||||
@@ -1148,7 +1122,7 @@ impl GameState {
|
|||||||
/// "Winnable deals only" retry loop.
|
/// "Winnable deals only" retry loop.
|
||||||
pub fn solve_fresh_deal(
|
pub fn solve_fresh_deal(
|
||||||
seed: u64,
|
seed: u64,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawStockConfig,
|
||||||
moves_budget: u64,
|
moves_budget: u64,
|
||||||
states_budget: u64,
|
states_budget: u64,
|
||||||
) -> SolveOutcome {
|
) -> SolveOutcome {
|
||||||
@@ -1177,7 +1151,7 @@ mod tests {
|
|||||||
const MAX_STEPS: usize = 160;
|
const MAX_STEPS: usize = 160;
|
||||||
|
|
||||||
for seed in 1..=MAX_SEED {
|
for seed in 1..=MAX_SEED {
|
||||||
let mut game = GameState::new(seed, DrawMode::DrawOne);
|
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||||
game.take_from_foundation = true;
|
game.take_from_foundation = true;
|
||||||
|
|
||||||
for _ in 0..MAX_STEPS {
|
for _ in 0..MAX_STEPS {
|
||||||
@@ -1226,7 +1200,7 @@ mod tests {
|
|||||||
/// iteration limit (shouldn't happen in practice).
|
/// iteration limit (shouldn't happen in practice).
|
||||||
fn game_at_first_recycle() -> Option<GameState> {
|
fn game_at_first_recycle() -> Option<GameState> {
|
||||||
for seed in 1..=256_u64 {
|
for seed in 1..=256_u64 {
|
||||||
let mut game = GameState::new(seed, DrawMode::DrawOne);
|
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||||
for _ in 0..200 {
|
for _ in 0..200 {
|
||||||
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
||||||
// This draw will recycle.
|
// This draw will recycle.
|
||||||
@@ -1255,11 +1229,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_one_recycling_is_unlimited_by_design() {
|
||||||
|
// Rules decision (Gitea #117): stock recycling is unlimited in every
|
||||||
|
// draw mode. Extra passes are discouraged via the upstream score
|
||||||
|
// penalty, never blocked with a MoveError. The difficulty seed
|
||||||
|
// catalog and the winnable-deal solver are verified under this rule,
|
||||||
|
// so a hard pass limit must not be introduced casually — see the
|
||||||
|
// "Rules decisions" note in ARCHITECTURE.md (`solitaire_core`).
|
||||||
|
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
||||||
|
// ~24 draws per Draw-1 pass; 2_000 draws is ample budget for 10 passes.
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
if game.recycle_count() >= 10 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
game.draw()
|
||||||
|
.expect("unlimited recycling: draw must never fail on pass count");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
game.recycle_count() >= 10,
|
||||||
|
"expected at least 10 recycles within the draw budget, got {}",
|
||||||
|
game.recycle_count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undo_applies_minus_15_penalty_via_upstream_score() {
|
fn undo_applies_minus_15_penalty_via_upstream_score() {
|
||||||
// A foundation move scores +10 upstream; undoing it nets the move score
|
// A foundation move scores +10 upstream; undoing it nets the move score
|
||||||
// back to 0 and adds the −15 undo penalty, which `score()` floors at 0.
|
// back to 0 and adds the −15 undo penalty, which `score()` floors at 0.
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
// Find and play any scoring move, then undo it.
|
// Find and play any scoring move, then undo it.
|
||||||
let scoring_move = game
|
let scoring_move = game
|
||||||
.possible_instructions()
|
.possible_instructions()
|
||||||
@@ -1319,13 +1317,13 @@ mod tests {
|
|||||||
fn solve_fresh_deal_is_deterministic() {
|
fn solve_fresh_deal_is_deterministic() {
|
||||||
let a = GameState::solve_fresh_deal(
|
let a = GameState::solve_fresh_deal(
|
||||||
7,
|
7,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
DEFAULT_SOLVE_STATES_BUDGET,
|
DEFAULT_SOLVE_STATES_BUDGET,
|
||||||
);
|
);
|
||||||
let b = GameState::solve_fresh_deal(
|
let b = GameState::solve_fresh_deal(
|
||||||
7,
|
7,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
DEFAULT_SOLVE_STATES_BUDGET,
|
DEFAULT_SOLVE_STATES_BUDGET,
|
||||||
);
|
);
|
||||||
@@ -1335,7 +1333,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn winnable_verdict_carries_a_first_move() {
|
fn winnable_verdict_carries_a_first_move() {
|
||||||
// Contract: a first move is present iff the verdict is winnable.
|
// Contract: a first move is present iff the verdict is winnable.
|
||||||
let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 5_000);
|
let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 5_000);
|
||||||
let winnable = matches!(outcome, Ok(Some(_)));
|
let winnable = matches!(outcome, Ok(Some(_)));
|
||||||
let has_move = outcome.ok().flatten().is_some();
|
let has_move = outcome.ok().flatten().is_some();
|
||||||
assert_eq!(winnable, has_move);
|
assert_eq!(winnable, has_move);
|
||||||
@@ -1343,7 +1341,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn solve_first_move_uses_live_game_state() {
|
fn solve_first_move_uses_live_game_state() {
|
||||||
let mut game = GameState::new(42, DrawMode::DrawOne);
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
game.draw().expect("draw must succeed");
|
game.draw().expect("draw must succeed");
|
||||||
|
|
||||||
let outcome = game.solve_first_move(5_000, 5_000);
|
let outcome = game.solve_first_move(5_000, 5_000);
|
||||||
@@ -1354,7 +1352,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zero_state_budget_is_inconclusive() {
|
fn zero_state_budget_is_inconclusive() {
|
||||||
let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 0);
|
let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 0);
|
||||||
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,9 +1360,9 @@ mod tests {
|
|||||||
fn budget_is_passed_through_not_clamped() {
|
fn budget_is_passed_through_not_clamped() {
|
||||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||||
// budget reaches the solver unchanged.
|
// budget reaches the solver unchanged.
|
||||||
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
|
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
|
||||||
let medium =
|
let medium =
|
||||||
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
|
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
|
||||||
assert!(easy.is_err());
|
assert!(easy.is_err());
|
||||||
assert!(matches!(medium, Ok(Some(_))));
|
assert!(matches!(medium, Ok(Some(_))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,32 @@
|
|||||||
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
||||||
//! - building [`KlondikeConfig`] from Ferrous settings
|
//! - building [`KlondikeConfig`] from Ferrous settings
|
||||||
//! - translating between local and upstream types
|
//! - translating between local and upstream types
|
||||||
//! - applying Ferrous-specific scoring policy on top of upstream defaults
|
//!
|
||||||
|
//! Ferrous-specific scoring policy (the win-time bonus) lives in
|
||||||
|
//! [`crate::scoring`], not here.
|
||||||
//!
|
//!
|
||||||
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
|
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
|
||||||
//! upstream `card_game` / `klondike` types live here so that the product modules
|
//! upstream `card_game` / `klondike` types live here so that the product modules
|
||||||
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
||||||
|
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
|
DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
|
||||||
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
|
SkipCards, Tableau,
|
||||||
TableauStack,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
/// Bridges `solitaire_core` game config to the upstream `klondike` crate.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum DrawMode {
|
|
||||||
/// Draw one card from stock per turn.
|
|
||||||
DrawOne,
|
|
||||||
/// Draw three cards from stock per turn; only the top is playable.
|
|
||||||
DrawThree,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
|
||||||
///
|
///
|
||||||
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
||||||
/// state, and exists only as a namespace for configuration, conversion, and
|
/// state, and exists only as a namespace for configuration and conversion
|
||||||
/// scoring helpers.
|
/// helpers.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct KlondikeAdapter;
|
pub struct KlondikeAdapter;
|
||||||
|
|
||||||
impl KlondikeAdapter {
|
impl KlondikeAdapter {
|
||||||
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
||||||
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
|
pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig {
|
||||||
KlondikeConfig {
|
KlondikeConfig {
|
||||||
draw_stock: match draw_mode {
|
draw_stock: draw_mode,
|
||||||
DrawMode::DrawOne => DrawStockConfig::DrawOne,
|
|
||||||
DrawMode::DrawThree => DrawStockConfig::DrawThree,
|
|
||||||
},
|
|
||||||
move_from_foundation: if take_from_foundation {
|
move_from_foundation: if take_from_foundation {
|
||||||
MoveFromFoundationConfig::Allowed
|
MoveFromFoundationConfig::Allowed
|
||||||
} else {
|
} else {
|
||||||
@@ -95,272 +83,3 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
|
|
||||||
//
|
|
||||||
// These types were introduced when upstream `klondike` had no serde feature.
|
|
||||||
// Mainline `klondike` now provides full serde support (with a hand-written
|
|
||||||
// compact `KlondikeInstruction` impl), and `GameState` serialises
|
|
||||||
// `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
|
|
||||||
//
|
|
||||||
// The mirror types are retained for three reasons:
|
|
||||||
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
|
|
||||||
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
|
|
||||||
// files with u8 indices and replay them.
|
|
||||||
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
|
|
||||||
// type; changing it would break the on-disk replay format (schema v2).
|
|
||||||
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
|
|
||||||
// replay JSON is cross-compatible between the desktop and browser builds.
|
|
||||||
//
|
|
||||||
// These types should not be used for new serialisation concerns. If the
|
|
||||||
// ReplayMove format is ever bumped to a new schema, migrate those callers to
|
|
||||||
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedTableau(pub u8);
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedFoundation(pub u8);
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedSkipCards(pub u8);
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum SavedKlondikePile {
|
|
||||||
Tableau(SavedTableau),
|
|
||||||
Stock,
|
|
||||||
Foundation(SavedFoundation),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedTableauStack {
|
|
||||||
pub tableau: SavedTableau,
|
|
||||||
pub skip_cards: SavedSkipCards,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum SavedKlondikePileStack {
|
|
||||||
Tableau(SavedTableauStack),
|
|
||||||
Stock,
|
|
||||||
Foundation(SavedFoundation),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedDstFoundation {
|
|
||||||
pub src: SavedKlondikePile,
|
|
||||||
pub foundation: SavedFoundation,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedDstTableau {
|
|
||||||
pub src: SavedKlondikePileStack,
|
|
||||||
pub tableau: SavedTableau,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
|
|
||||||
///
|
|
||||||
/// Convert to/from the upstream type with:
|
|
||||||
/// ```ignore
|
|
||||||
/// let saved = SavedInstruction::from(instruction);
|
|
||||||
/// let instruction = KlondikeInstruction::try_from(saved)?;
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum SavedInstruction {
|
|
||||||
DstFoundation(SavedDstFoundation),
|
|
||||||
DstTableau(SavedDstTableau),
|
|
||||||
RotateStock,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
|
|
||||||
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
||||||
pub enum InvalidSavedInstruction {
|
|
||||||
#[error("invalid tableau index {0} (expected 0–6)")]
|
|
||||||
Tableau(u8),
|
|
||||||
#[error("invalid foundation index {0} (expected 0–3)")]
|
|
||||||
Foundation(u8),
|
|
||||||
#[error("invalid skip_cards value {0} (expected 0–12)")]
|
|
||||||
SkipCards(u8),
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
|
|
||||||
|
|
||||||
impl From<Tableau> for SavedTableau {
|
|
||||||
fn from(t: Tableau) -> Self {
|
|
||||||
Self(t as u8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Foundation> for SavedFoundation {
|
|
||||||
fn from(f: Foundation) -> Self {
|
|
||||||
Self(f as u8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SkipCards> for SavedSkipCards {
|
|
||||||
fn from(s: SkipCards) -> Self {
|
|
||||||
Self(s as u8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<KlondikePile> for SavedKlondikePile {
|
|
||||||
fn from(p: KlondikePile) -> Self {
|
|
||||||
match p {
|
|
||||||
KlondikePile::Tableau(t) => Self::Tableau(t.into()),
|
|
||||||
KlondikePile::Stock => Self::Stock,
|
|
||||||
KlondikePile::Foundation(f) => Self::Foundation(f.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TableauStack> for SavedTableauStack {
|
|
||||||
fn from(ts: TableauStack) -> Self {
|
|
||||||
Self {
|
|
||||||
tableau: ts.tableau.into(),
|
|
||||||
skip_cards: ts.skip_cards.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<KlondikePileStack> for SavedKlondikePileStack {
|
|
||||||
fn from(ps: KlondikePileStack) -> Self {
|
|
||||||
match ps {
|
|
||||||
KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()),
|
|
||||||
KlondikePileStack::Stock => Self::Stock,
|
|
||||||
KlondikePileStack::Foundation(f) => Self::Foundation(f.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DstFoundation> for SavedDstFoundation {
|
|
||||||
fn from(df: DstFoundation) -> Self {
|
|
||||||
Self {
|
|
||||||
src: df.src.into(),
|
|
||||||
foundation: df.foundation.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DstTableau> for SavedDstTableau {
|
|
||||||
fn from(dt: DstTableau) -> Self {
|
|
||||||
Self {
|
|
||||||
src: dt.src.into(),
|
|
||||||
tableau: dt.tableau.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<KlondikeInstruction> for SavedInstruction {
|
|
||||||
fn from(i: KlondikeInstruction) -> Self {
|
|
||||||
match i {
|
|
||||||
KlondikeInstruction::RotateStock => Self::RotateStock,
|
|
||||||
KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()),
|
|
||||||
KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TryFrom impls: Saved* → KlondikeInstruction ──────────────────────────────
|
|
||||||
|
|
||||||
impl TryFrom<SavedTableau> for Tableau {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
|
|
||||||
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedFoundation> for Foundation {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
|
||||||
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedSkipCards> for SkipCards {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
|
||||||
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedKlondikePile> for KlondikePile {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
|
|
||||||
Ok(match s {
|
|
||||||
SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?),
|
|
||||||
SavedKlondikePile::Stock => KlondikePile::Stock,
|
|
||||||
SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedTableauStack> for TableauStack {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
|
|
||||||
Ok(TableauStack {
|
|
||||||
tableau: s.tableau.try_into()?,
|
|
||||||
skip_cards: s.skip_cards.try_into()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
|
|
||||||
Ok(match s {
|
|
||||||
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
|
|
||||||
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
|
|
||||||
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedDstFoundation> for DstFoundation {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
|
|
||||||
Ok(DstFoundation {
|
|
||||||
src: s.src.try_into()?,
|
|
||||||
foundation: s.foundation.try_into()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedDstTableau> for DstTableau {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
|
|
||||||
Ok(DstTableau {
|
|
||||||
src: s.src.try_into()?,
|
|
||||||
tableau: s.tableau.try_into()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedInstruction> for KlondikeInstruction {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
|
|
||||||
Ok(match s {
|
|
||||||
SavedInstruction::RotateStock => KlondikeInstruction::RotateStock,
|
|
||||||
SavedInstruction::DstFoundation(df) => {
|
|
||||||
KlondikeInstruction::DstFoundation(df.try_into()?)
|
|
||||||
}
|
|
||||||
SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
|
||||||
if elapsed_seconds == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
pub mod achievement;
|
pub mod achievement;
|
||||||
pub mod card;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod game_state;
|
pub mod game_state;
|
||||||
pub mod klondike_adapter;
|
pub mod klondike_adapter;
|
||||||
|
pub mod scoring;
|
||||||
|
|
||||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
// 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
|
// downstream crates (engine, wasm) can import from one place without a direct
|
||||||
@@ -12,9 +12,8 @@ pub mod klondike_adapter;
|
|||||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||||
// appear in any public method signature.
|
// appear in any public method signature.
|
||||||
pub use card_game::{Card, Session, SolveError};
|
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||||
pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
pub use klondike_adapter::DrawMode;
|
|
||||||
|
|
||||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||||
// former `solitaire_data::solver` wrapper module.
|
// former `solitaire_data::solver` wrapper module.
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
use card_game::{Card, Game};
|
use card_game::{Card, Game};
|
||||||
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
|
|
||||||
use crate::game_state::GameState;
|
use crate::game_state::GameState;
|
||||||
use crate::klondike_adapter::DrawMode;
|
|
||||||
use crate::klondike_adapter::{
|
|
||||||
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
|
|
||||||
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
|
|
||||||
SavedTableauStack,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared helpers
|
// Shared helpers
|
||||||
@@ -52,8 +46,8 @@ fn all_cards(game: &GameState) -> Vec<Card> {
|
|||||||
cards
|
cards
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
|
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||||
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
|
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||||
@@ -262,116 +256,4 @@ proptest! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// SavedInstruction ↔ KlondikeInstruction round-trip
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Every valid `SavedInstruction` survives a round-trip through
|
|
||||||
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
|
|
||||||
///
|
|
||||||
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
|
|
||||||
/// and all legal sub-field ranges:
|
|
||||||
/// - `SavedTableau`: 0–6
|
|
||||||
/// - `SavedFoundation`: 0–3
|
|
||||||
/// - `SavedSkipCards`: 0–12
|
|
||||||
#[test]
|
|
||||||
fn saved_instruction_round_trip(
|
|
||||||
instruction in saved_instruction_strategy(),
|
|
||||||
) {
|
|
||||||
let klondike = KlondikeInstruction::try_from(instruction);
|
|
||||||
prop_assert!(
|
|
||||||
klondike.is_ok(),
|
|
||||||
"TryFrom failed for valid SavedInstruction {instruction:?}: {:?}",
|
|
||||||
klondike.err(),
|
|
||||||
);
|
|
||||||
let saved_again = SavedInstruction::from(klondike.expect("checked above"));
|
|
||||||
prop_assert_eq!(
|
|
||||||
saved_again,
|
|
||||||
instruction,
|
|
||||||
"round-trip produced a different SavedInstruction",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Proptest strategies for SavedInstruction and its sub-types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn saved_tableau_strategy() -> impl Strategy<Value = SavedTableau> {
|
|
||||||
(0u8..=6).prop_map(SavedTableau)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn saved_foundation_strategy() -> impl Strategy<Value = SavedFoundation> {
|
|
||||||
(0u8..=3).prop_map(SavedFoundation)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn saved_skip_cards_strategy() -> impl Strategy<Value = SavedSkipCards> {
|
|
||||||
(0u8..=12).prop_map(SavedSkipCards)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn saved_klondike_pile_strategy() -> impl Strategy<Value = SavedKlondikePile> {
|
|
||||||
prop_oneof![
|
|
||||||
saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau),
|
|
||||||
Just(SavedKlondikePile::Stock),
|
|
||||||
saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn saved_klondike_pile_stack_strategy() -> impl Strategy<Value = SavedKlondikePileStack> {
|
|
||||||
prop_oneof![
|
|
||||||
(saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| {
|
|
||||||
SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards })
|
|
||||||
}),
|
|
||||||
Just(SavedKlondikePileStack::Stock),
|
|
||||||
saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn saved_instruction_strategy() -> impl Strategy<Value = SavedInstruction> {
|
|
||||||
prop_oneof![
|
|
||||||
Just(SavedInstruction::RotateStock),
|
|
||||||
(saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map(
|
|
||||||
|(src, foundation)| {
|
|
||||||
SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation })
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map(
|
|
||||||
|(src, tableau)| {
|
|
||||||
SavedInstruction::DstTableau(SavedDstTableau { src, tableau })
|
|
||||||
}
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Boundary error unit tests (exact out-of-range values)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod saved_instruction_boundary_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn saved_tableau_7_is_invalid() {
|
|
||||||
let result = Tableau::try_from(SavedTableau(7));
|
|
||||||
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn saved_tableau_255_is_invalid() {
|
|
||||||
let result = Tableau::try_from(SavedTableau(255));
|
|
||||||
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn saved_foundation_4_is_invalid() {
|
|
||||||
let result = Foundation::try_from(SavedFoundation(4));
|
|
||||||
assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn saved_skip_cards_13_is_invalid() {
|
|
||||||
let result = SkipCards::try_from(SavedSkipCards(13));
|
|
||||||
assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Ferrous-specific scoring policy layered on top of upstream `klondike`.
|
||||||
|
//!
|
||||||
|
//! Upstream [`klondike::KlondikeStats::score`] owns the per-move point values
|
||||||
|
//! (move-to-foundation, flip-up bonus, recycle penalty, etc.). The functions
|
||||||
|
//! here are the Ferrous Solitaire house rules that upstream has no opinion on —
|
||||||
|
//! currently just the win-time bonus shown in the win modal.
|
||||||
|
|
||||||
|
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||||
|
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||||
|
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||||
|
if elapsed_seconds == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
||||||
|
}
|
||||||
@@ -47,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,19 +14,16 @@
|
|||||||
///
|
///
|
||||||
/// 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};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::ffi::c_void;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use crate::auth_tokens::TokenError;
|
use crate::auth_tokens::TokenError;
|
||||||
|
|
||||||
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
|
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
|
||||||
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct TokenBlob {
|
struct TokenBlob {
|
||||||
@@ -39,43 +36,15 @@ struct TokenBlob {
|
|||||||
// JVM helper
|
// JVM helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Initialise Android Keystore access with the process-wide `JavaVM*`.
|
/// Run `f` with an attached `JNIEnv`, delegating thread attach and the
|
||||||
///
|
/// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
|
||||||
/// This is called by `solitaire_app` from Android startup code. Keeping the
|
/// initialised once from Android startup, so the keystore never touches a raw
|
||||||
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or
|
/// pointer and this module stays `forbid(unsafe_code)`.
|
||||||
/// engine layer just to reach platform startup state.
|
|
||||||
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
|
|
||||||
if vm_ptr.is_null() {
|
|
||||||
return Err(TokenError::KeychainUnavailable(
|
|
||||||
"JavaVM pointer is null".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if ANDROID_JVM.get().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
|
|
||||||
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
|
|
||||||
// the process lifetime.
|
|
||||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
|
|
||||||
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
|
|
||||||
let _ = ANDROID_JVM.set(vm);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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 vm = ANDROID_JVM
|
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
|
||||||
.get()
|
|
||||||
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?;
|
|
||||||
|
|
||||||
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}")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -230,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)?;
|
||||||
@@ -240,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());
|
||||||
@@ -292,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,29 +348,29 @@ 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 {
|
{
|
||||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||||
other => other,
|
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||||
|
other => other,
|
||||||
|
})?;
|
||||||
|
if data.len() >= 12 {
|
||||||
|
let plaintext = with_jvm(|env| {
|
||||||
|
let key = load_or_create_key(env)?;
|
||||||
|
decrypt_gcm(env, &key, &data)
|
||||||
})?;
|
})?;
|
||||||
if data.len() >= 12 {
|
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
||||||
let plaintext = with_jvm(|env| {
|
let mut map = HashMap::new();
|
||||||
let key = load_or_create_key(env)?;
|
map.insert(blob.username.clone(), blob);
|
||||||
decrypt_gcm(env, &key, &data)
|
// Write to the new location, then remove the legacy file.
|
||||||
})?;
|
if write_map_inner(&map).is_ok() {
|
||||||
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
let _ = std::fs::remove_file(lpath);
|
||||||
let mut map = HashMap::new();
|
|
||||||
map.insert(blob.username.clone(), blob);
|
|
||||||
// Write to the new location, then remove the legacy file.
|
|
||||||
if write_map_inner(&map).is_ok() {
|
|
||||||
let _ = std::fs::remove_file(lpath);
|
|
||||||
}
|
|
||||||
return Ok(map);
|
|
||||||
}
|
}
|
||||||
|
return Ok(map);
|
||||||
}
|
}
|
||||||
// Legacy file corrupt or unrecognised — treat as empty.
|
|
||||||
}
|
}
|
||||||
|
// Legacy file corrupt or unrecognised — treat as empty.
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3. No file found ---
|
// --- 3. No file found ---
|
||||||
@@ -491,11 +459,11 @@ 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)
|
{
|
||||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
std::fs::remove_file(&path)
|
||||||
}
|
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
//! `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 this module delegates to an Android Keystore
|
//! doesn't expose). On Android this module delegates to an Android Keystore
|
||||||
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm`
|
//! JNI backend. `solitaire_app` must initialise the safe
|
||||||
//! from Android startup before token operations can succeed.
|
//! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
|
||||||
|
//! startup before token operations can succeed.
|
||||||
//!
|
//!
|
||||||
//! # Note: no unit tests — requires live OS keychain.
|
//! # Note: no unit tests — requires live OS keychain.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,9 +144,10 @@ pub use settings::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod android_keystore;
|
pub mod android_jni;
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub use android_keystore::init_android_jvm;
|
mod android_keystore;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
@@ -163,7 +164,7 @@ 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
|
// `latest_replay_path` is still consumed by the engine's one-shot legacy
|
||||||
|
|||||||
@@ -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::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
|
||||||
|
|
||||||
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: SavedKlondikePile,
|
|
||||||
/// Destination pile.
|
|
||||||
to: SavedKlondikePile,
|
|
||||||
/// 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,7 +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 solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
|
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 {
|
||||||
@@ -453,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: SavedKlondikePile::Stock,
|
src: KlondikePileStack::Stock,
|
||||||
to: SavedKlondikePile::Tableau(SavedTableau(3)),
|
tableau: Tableau::Tableau4,
|
||||||
count: 1,
|
}),
|
||||||
},
|
KlondikeInstruction::RotateStock,
|
||||||
ReplayMove::StockClick,
|
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
ReplayMove::Move {
|
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
from: SavedKlondikePile::Tableau(SavedTableau(3)),
|
foundation: Foundation::Foundation1,
|
||||||
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
|
}),
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -596,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],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,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",
|
||||||
@@ -847,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::{DrawMode, game_state::DifficultyLevel};
|
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,
|
||||||
@@ -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 {
|
||||||
@@ -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::{DrawMode, game_state::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,
|
||||||
|
|||||||
@@ -279,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::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
@@ -292,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);
|
||||||
@@ -381,7 +381,7 @@ mod tests {
|
|||||||
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");
|
||||||
@@ -410,7 +410,7 @@ mod tests {
|
|||||||
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.set_test_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!(
|
||||||
@@ -423,7 +423,7 @@ mod tests {
|
|||||||
fn delete_game_state_removes_file() {
|
fn delete_game_state_removes_file() {
|
||||||
use solitaire_core::game_state::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");
|
||||||
@@ -441,7 +441,7 @@ mod tests {
|
|||||||
fn save_game_state_is_atomic() {
|
fn save_game_state_is_atomic() {
|
||||||
use solitaire_core::game_state::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");
|
||||||
@@ -512,7 +512,7 @@ mod tests {
|
|||||||
let path = gs_path("v4_mid_game");
|
let path = gs_path("v4_mid_game");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
// Draw several times to populate the instruction history with
|
// Draw several times to populate the instruction history with
|
||||||
// RotateStock entries and expose waste cards for further moves.
|
// RotateStock entries and expose waste cards for further moves.
|
||||||
@@ -583,24 +583,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A schema v3 save (instruction history using u8 indices) must load
|
/// A schema v3 save (instruction history using the old u8-index mirror
|
||||||
/// successfully and be transparently migrated to schema v4.
|
/// types) is no longer loadable. The legacy migration path was dropped,
|
||||||
///
|
/// so any file claiming `schema_version: 3` must be rejected and the
|
||||||
/// This verifies the `AnyInstruction` untagged deserialization migration
|
/// player started on a fresh game.
|
||||||
/// path. v3 files with `RotateStock` (unit variant, format-identical in
|
|
||||||
/// v3 and v4) load correctly and report `schema_version == 4` after load.
|
|
||||||
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
|
|
||||||
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_v3_migrates_to_v4() {
|
fn game_state_v3_is_rejected() {
|
||||||
use solitaire_core::game_state::GameState;
|
let path = gs_path("v3_reject");
|
||||||
|
|
||||||
let path = gs_path("v3_migrate");
|
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
|
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
|
||||||
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
|
|
||||||
// so this exercises the schema version acceptance code path.
|
|
||||||
let v3_json = r#"{
|
let v3_json = r#"{
|
||||||
"draw_mode": "DrawOne",
|
"draw_mode": "DrawOne",
|
||||||
"mode": "Classic",
|
"mode": "Classic",
|
||||||
@@ -615,13 +607,12 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
fs::write(&path, v3_json).expect("write v3 fixture");
|
fs::write(&path, v3_json).expect("write v3 fixture");
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path)
|
assert!(
|
||||||
.expect("schema v3 must be accepted and migrated to v4");
|
load_game_state_from(&path).is_none(),
|
||||||
|
"schema v3 must be rejected (no migration path)",
|
||||||
|
);
|
||||||
|
|
||||||
// The loaded game should match a fresh game that had one draw applied.
|
let _ = fs::remove_file(&path);
|
||||||
let mut expected = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
expected.draw().expect("draw must succeed on a fresh game");
|
|
||||||
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
||||||
|
|||||||
@@ -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::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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,3 +52,6 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
solitaire_core = { workspace = true, features = ["test-support"] }
|
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).
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -819,7 +819,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.set_test_draw_mode(solitaire_core::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
|
||||||
.set_test_draw_mode(solitaire_core::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::{DrawMode, game_state::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],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -115,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"),
|
||||||
@@ -172,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"),
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Deck, Rank, Suit};
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::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();
|
||||||
@@ -177,13 +177,13 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
||||||
let mut g = GameState::new(1, DrawMode::DrawOne);
|
let mut g = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
g.set_test_stock_cards(Vec::new());
|
g.set_test_stock_cards(Vec::new());
|
||||||
g.set_test_waste_cards(Vec::new());
|
g.set_test_waste_cards(Vec::new());
|
||||||
for foundation in [
|
for foundation in [
|
||||||
@@ -207,7 +207,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
g.set_test_tableau_cards(
|
g.set_test_tableau_cards(
|
||||||
Tableau::Tableau1,
|
Tableau::Tableau1,
|
||||||
vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||||
);
|
);
|
||||||
g.set_test_auto_completable(true);
|
g.set_test_auto_completable(true);
|
||||||
let expected = (
|
let expected = (
|
||||||
@@ -227,7 +227,7 @@ 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();
|
||||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
g.set_test_auto_completable(true);
|
g.set_test_auto_completable(true);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
||||||
app.world_mut().write_message(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
|
|||||||
@@ -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,7 +33,7 @@ use std::collections::VecDeque;
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
|
|
||||||
use super::animation::CardAnimation;
|
use super::animation::CardAnimation;
|
||||||
use super::tuning::AnimationTuning;
|
use super::tuning::AnimationTuning;
|
||||||
@@ -73,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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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::{DrawMode, game_state::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,
|
||||||
|
|||||||
@@ -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::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
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};
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -437,7 +437,7 @@ fn tableau_or_stack_pos(
|
|||||||
base.x,
|
base.x,
|
||||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||||
)
|
)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
|
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||||
let pile_len = game.waste_cards().len();
|
let pile_len = game.waste_cards().len();
|
||||||
let visible_start = pile_len.saturating_sub(3);
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
let slot = index.saturating_sub(visible_start) as f32;
|
let slot = index.saturating_sub(visible_start) as f32;
|
||||||
@@ -563,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::{DrawMode, game_state::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(
|
||||||
@@ -580,8 +580,8 @@ mod tests {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
|
|
||||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||||
/// registered, plus the resources the system needs. Callers
|
/// registered, plus the resources the system needs. Callers
|
||||||
@@ -629,7 +629,7 @@ mod tests {
|
|||||||
// 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,
|
||||||
|
|||||||
@@ -362,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::{DrawMode, game_state::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();
|
||||||
@@ -391,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,
|
||||||
@@ -419,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,
|
||||||
@@ -442,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,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::card::{Card, Suit};
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
use solitaire_sync::SyncResponse;
|
use solitaire_sync::SyncResponse;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ use std::hash::{Hash, Hasher};
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
use bevy::window::RequestRedraw;
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::klondike_adapter::foundation_from_slot;
|
use solitaire_core::klondike_adapter::foundation_from_slot;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
@@ -189,10 +189,6 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
|||||||
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
|
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-card jitter keys off the shared stable card id so it matches the
|
|
||||||
// numeric identity used elsewhere (and on the WASM replay side).
|
|
||||||
use solitaire_core::card::card_to_id;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -413,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_to_id(&card_marker.card)));
|
// 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 {
|
||||||
@@ -639,7 +639,7 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
|||||||
fn pile_cards(
|
fn pile_cards(
|
||||||
game: &solitaire_core::game_state::GameState,
|
game: &solitaire_core::game_state::GameState,
|
||||||
pile: &KlondikePile,
|
pile: &KlondikePile,
|
||||||
) -> Vec<(solitaire_core::card::Card, bool)> {
|
) -> Vec<(Card, bool)> {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => game.waste_cards(),
|
KlondikePile::Stock => game.waste_cards(),
|
||||||
_ => game.pile(*pile),
|
_ => game.pile(*pile),
|
||||||
@@ -846,13 +846,13 @@ mod tests {
|
|||||||
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::Tableau;
|
use solitaire_core::Tableau;
|
||||||
use solitaire_core::{DrawMode, game_state::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()
|
||||||
@@ -900,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::{DrawMode, game_state::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()
|
||||||
@@ -917,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
@@ -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::{DrawMode, game_state::DifficultyLevel};
|
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();
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::Suit;
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -36,7 +36,6 @@ use crate::game_plugin::GameMutation;
|
|||||||
use crate::input_plugin::TouchDragSet;
|
use crate::input_plugin::TouchDragSet;
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
use crate::layout::LayoutSystem;
|
use crate::layout::LayoutSystem;
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
|
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -174,7 +173,7 @@ pub enum HudVisibility {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[derive(Resource, Debug, Default)]
|
#[derive(Resource, Debug, Default)]
|
||||||
struct HudTapTracker {
|
struct HudTapTracker {
|
||||||
start_pos: Option<bevy::math::Vec2>,
|
start_pos: Option<Vec2>,
|
||||||
/// Set `true` when the finger-down hit an action button so the
|
/// Set `true` when the finger-down hit an action button so the
|
||||||
/// finger-up never toggles bar visibility.
|
/// finger-up never toggles bar visibility.
|
||||||
started_on_button: bool,
|
started_on_button: bool,
|
||||||
@@ -529,7 +528,7 @@ impl Plugin for HudPlugin {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
app.init_resource::<HudTapTracker>()
|
app.init_resource::<HudTapTracker>()
|
||||||
.add_message::<bevy::input::touch::TouchInput>()
|
.add_message::<TouchInput>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
toggle_hud_on_tap
|
toggle_hud_on_tap
|
||||||
@@ -1140,7 +1139,7 @@ fn handle_help_button(
|
|||||||
|
|
||||||
fn handle_hint_button(
|
fn handle_hint_button(
|
||||||
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
|
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
|
||||||
paused: Option<Res<crate::PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
|
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
|
||||||
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
|
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
|
||||||
@@ -2284,8 +2283,8 @@ fn update_hud(
|
|||||||
if let Ok(mut t) = mode_q.single_mut() {
|
if let Ok(mut t) = mode_q.single_mut() {
|
||||||
**t = match g.mode {
|
**t = match g.mode {
|
||||||
GameMode::Classic => match g.draw_mode() {
|
GameMode::Classic => match g.draw_mode() {
|
||||||
DrawMode::DrawOne => String::new(),
|
DrawStockConfig::DrawOne => String::new(),
|
||||||
DrawMode::DrawThree => "Draw 3".to_string(),
|
DrawStockConfig::DrawThree => "Draw 3".to_string(),
|
||||||
},
|
},
|
||||||
GameMode::Zen => "ZEN".to_string(),
|
GameMode::Zen => "ZEN".to_string(),
|
||||||
GameMode::Challenge => "CHALLENGE".to_string(),
|
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||||
@@ -2334,7 +2333,7 @@ fn update_hud(
|
|||||||
|
|
||||||
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
||||||
if let Ok(mut t) = draw_cycle_q.single_mut() {
|
if let Ok(mut t) = draw_cycle_q.single_mut() {
|
||||||
**t = if g.is_won() || g.draw_mode() != DrawMode::DrawThree {
|
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
|
||||||
// Hide when not in Draw-Three or after the game is won.
|
// Hide when not in Draw-Three or after the game is won.
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
@@ -2658,8 +2657,9 @@ fn resize_action_bar_labels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn toggle_hud_on_tap(
|
fn toggle_hud_on_tap(
|
||||||
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
drag: Res<DragState>,
|
drag: Res<DragState>,
|
||||||
scrims: Query<(), With<ModalScrim>>,
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
@@ -2697,13 +2697,14 @@ fn toggle_hud_on_tap(
|
|||||||
// regardless of whether we toggle.
|
// regardless of whether we toggle.
|
||||||
let on_button = tracker.started_on_button || game_consumed.0;
|
let on_button = tracker.started_on_button || game_consumed.0;
|
||||||
game_consumed.0 = false;
|
game_consumed.0 = false;
|
||||||
if let Some(start) = tracker.start_pos.take() {
|
if let Some(start) = tracker.start_pos.take()
|
||||||
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX {
|
&& !on_button
|
||||||
*hud_vis = match *hud_vis {
|
&& (event.position - start).length() < HUD_TAP_SLOP_PX
|
||||||
HudVisibility::Visible => HudVisibility::Hidden,
|
{
|
||||||
HudVisibility::Hidden => HudVisibility::Visible,
|
*hud_vis = match *hud_vis {
|
||||||
};
|
HudVisibility::Visible => HudVisibility::Hidden,
|
||||||
}
|
HudVisibility::Hidden => HudVisibility::Visible,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
tracker.started_on_button = false;
|
tracker.started_on_button = false;
|
||||||
}
|
}
|
||||||
@@ -2721,877 +2722,4 @@ fn toggle_hud_on_tap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use crate::game_plugin::GamePlugin;
|
|
||||||
use crate::table_plugin::TablePlugin;
|
|
||||||
use chrono::Local;
|
|
||||||
use solitaire_core::{DrawMode, 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, DrawMode::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, DrawMode::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, DrawMode::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, DrawMode::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, DrawMode::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, DrawMode::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, DrawMode::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,872 @@
|
|||||||
|
use super::*;
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
|
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
|
||||||
//!
|
//!
|
||||||
//! Mouse:
|
//! Mouse:
|
||||||
//! - Left-click on the stock pile (face-down deck) or waste slot → `DrawRequestEvent`
|
//! - Left-click on the stock pile (face-down deck) → `DrawRequestEvent`
|
||||||
|
//! (the waste card is left free to play: double-click to auto-move, or drag)
|
||||||
//! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between
|
//! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between
|
||||||
//! the origin pile and whatever pile the cursor is over at release.
|
//! the origin pile and whatever pile the cursor is over at release.
|
||||||
//! On rejection, the drag cards snap back to their origin via a
|
//! On rejection, the drag cards snap back to their origin via a
|
||||||
@@ -27,14 +28,14 @@ use bevy::window::PrimaryWindow;
|
|||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::window::{MonitorSelection, WindowMode};
|
use bevy::window::{MonitorSelection, WindowMode};
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Suit};
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::card_animation::tuning::AnimationTuning;
|
use crate::card_animation::tuning::AnimationTuning;
|
||||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::{
|
use crate::card_plugin::{
|
||||||
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC,
|
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, waste_fan_step,
|
||||||
};
|
};
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -54,7 +55,7 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
use crate::touch_selection_plugin::TouchSelectionState;
|
use crate::touch_selection_plugin::TouchSelectionState;
|
||||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
|
|
||||||
/// System-set labels used to anchor external systems relative to the touch
|
/// System-set labels used to anchor external systems relative to the touch
|
||||||
/// drag pipeline without duplicating the internal chain ordering.
|
/// drag pipeline without duplicating the internal chain ordering.
|
||||||
@@ -536,8 +537,9 @@ fn handle_stock_click(
|
|||||||
|
|
||||||
// `pile_positions[Stock]` is the waste column (col_x(1)). card_plugin renders the
|
// `pile_positions[Stock]` is the waste column (col_x(1)). card_plugin renders the
|
||||||
// face-down deck one column to the left via `base.x -= tableau_col_step`, placing it
|
// face-down deck one column to the left via `base.x -= tableau_col_step`, placing it
|
||||||
// at Tableau1's x (col_x(0)). Hit-test both the deck AND the waste slot: in standard
|
// at Tableau1's x (col_x(0)). Only the deck draws — clicking the waste card must
|
||||||
// Klondike UX clicking either card draws from the deck.
|
// leave it free to be played (double-click to auto-move, or drag); hit-testing the
|
||||||
|
// waste slot here would intercept that click and draw the next card instead.
|
||||||
let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
|
let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -549,9 +551,7 @@ fn handle_stock_click(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
|
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
|
||||||
if point_in_rect(world, deck_pos, layout.0.card_size)
|
if point_in_rect(world, deck_pos, layout.0.card_size) {
|
||||||
|| point_in_rect(world, waste_pos, layout.0.card_size)
|
|
||||||
{
|
|
||||||
draw.write(DrawRequestEvent);
|
draw.write(DrawRequestEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -596,9 +596,9 @@ fn handle_touch_stock_tap(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
|
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
|
||||||
if point_in_rect(world, deck_pos, layout.0.card_size)
|
// Only the face-down deck draws; tapping the waste card leaves it free to
|
||||||
|| point_in_rect(world, waste_pos, layout.0.card_size)
|
// play (double-tap to auto-move, or drag).
|
||||||
{
|
if point_in_rect(world, deck_pos, layout.0.card_size) {
|
||||||
draw.write(DrawRequestEvent);
|
draw.write(DrawRequestEvent);
|
||||||
game_consumed.0 = true;
|
game_consumed.0 = true;
|
||||||
break; // one draw per tap frame
|
break; // one draw per tap frame
|
||||||
@@ -1173,14 +1173,17 @@ fn card_position(
|
|||||||
y_offset -= layout.card_size.y * step;
|
y_offset -= layout.card_size.y * step;
|
||||||
}
|
}
|
||||||
Vec2::new(base.x, base.y + y_offset)
|
Vec2::new(base.x, base.y + y_offset)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
|
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||||
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||||
// card_plugin::card_positions(). Hit-testing must use the same offsets
|
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
||||||
// so clicking the visually rightmost (top) card actually registers.
|
// so clicking the visually rightmost (top) card actually registers — a
|
||||||
|
// fixed `card_size.x * 0.28` matched the renderer on desktop but drifted
|
||||||
|
// on Android (tighter column spacing), shifting the top card's hit target
|
||||||
|
// onto the card beneath it.
|
||||||
let pile_len = game.waste_cards().len();
|
let pile_len = game.waste_cards().len();
|
||||||
let visible_start = pile_len.saturating_sub(3);
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
let slot = stack_index.saturating_sub(visible_start) as f32;
|
let slot = stack_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 * waste_fan_step(layout), base.y)
|
||||||
} else {
|
} else {
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
@@ -1826,628 +1829,4 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
|||||||
const _VEC3_REFERENCED: Option<Vec3> = None;
|
const _VEC3_REFERENCED: Option<Vec3> = None;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use crate::layout::compute_layout;
|
|
||||||
use solitaire_core::{Foundation, Tableau};
|
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
|
||||||
|
|
||||||
fn clear_test_piles(game: &mut GameState) {
|
|
||||||
game.set_test_stock_cards(Vec::new());
|
|
||||||
game.set_test_waste_cards(Vec::new());
|
|
||||||
for foundation in [
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
] {
|
|
||||||
game.set_test_foundation_cards(foundation, Vec::new());
|
|
||||||
}
|
|
||||||
for tableau in [
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
] {
|
|
||||||
game.set_test_tableau_cards(tableau, Vec::new());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dragged_card_z_matches_resting_stack_step() {
|
|
||||||
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
|
||||||
let step = dragged_card_z(1) - dragged_card_z(0);
|
|
||||||
assert!(
|
|
||||||
step > 0.02,
|
|
||||||
"drag step must exceed Android overlay local_z, got {step}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
step + 1e-4 >= STACK_FAN_FRAC,
|
|
||||||
"drag step must stay aligned with resting stack spacing, got {step}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn point_in_rect_inside_returns_true() {
|
|
||||||
let center = Vec2::new(10.0, 20.0);
|
|
||||||
let size = Vec2::new(40.0, 60.0);
|
|
||||||
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
|
||||||
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
|
||||||
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn point_in_rect_on_edge_returns_true() {
|
|
||||||
let center = Vec2::ZERO;
|
|
||||||
let size = Vec2::new(10.0, 10.0);
|
|
||||||
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
|
||||||
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn point_in_rect_outside_returns_false() {
|
|
||||||
let center = Vec2::ZERO;
|
|
||||||
let size = Vec2::new(10.0, 10.0);
|
|
||||||
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
|
||||||
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
|
||||||
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_picks_top_of_tableau() {
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
|
|
||||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
|
||||||
// Its position: base.y + fan * 6.
|
|
||||||
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
|
||||||
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
|
||||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
|
||||||
assert_eq!(result.1, 6);
|
|
||||||
assert_eq!(result.2.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_skips_face_down_cards() {
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
|
|
||||||
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
|
||||||
// the bottom (index 6). Click at the topmost face-down card's
|
|
||||||
// position — its full body is partly visible above the fanned
|
|
||||||
// face-up card, but the iterator should skip face-down cards and
|
|
||||||
// the cursor sits above the face-up card's AABB, so the result
|
|
||||||
// is None.
|
|
||||||
let face_down_pos =
|
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
|
||||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
|
||||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
|
||||||
// Regression test for the bug where input_plugin's hit-testing used
|
|
||||||
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
|
||||||
// at 0.12 — so for any column with face-down cards above the
|
|
||||||
// face-up bottom card, clicking the visible card face missed the
|
|
||||||
// hit-test box and only the bottom strip of the card responded.
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
|
|
||||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
|
||||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
|
||||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
|
||||||
let face_up_pos =
|
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
|
||||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
|
||||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
|
||||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
|
||||||
assert_eq!(result.1, 6);
|
|
||||||
assert_eq!(result.2.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_returns_run_when_picking_mid_stack() {
|
|
||||||
// Manually construct a tableau with three face-up cards all stacked.
|
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
|
||||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
|
||||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
|
||||||
game.set_test_tableau_cards(
|
|
||||||
Tableau::Tableau1,
|
|
||||||
vec![king, queen.clone(), jack.clone()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
|
||||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
|
||||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
|
||||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
|
||||||
let queen_center =
|
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
|
||||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
|
||||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
|
||||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
|
||||||
assert_eq!(start, 1);
|
|
||||||
assert_eq!(ids, vec![queen, jack]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_skips_non_top_waste_card() {
|
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
|
||||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
|
||||||
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
|
||||||
// the visually top card (three_hearts), with count = 1.
|
|
||||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
|
||||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
|
||||||
assert_eq!(pile, KlondikePile::Stock);
|
|
||||||
assert_eq!(start, 1);
|
|
||||||
assert_eq!(ids, vec![three_hearts]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// Move all cards out of tableau 0 so its marker is the only drop area.
|
|
||||||
let mut game = game;
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
|
||||||
let target = find_drop_target(
|
|
||||||
pos,
|
|
||||||
&game,
|
|
||||||
&layout,
|
|
||||||
&KlondikePile::Tableau(Tableau::Tableau7),
|
|
||||||
);
|
|
||||||
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_drop_target_returns_none_for_origin() {
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
|
||||||
let target = find_drop_target(
|
|
||||||
pos,
|
|
||||||
&game,
|
|
||||||
&layout,
|
|
||||||
&KlondikePile::Tableau(Tableau::Tableau4),
|
|
||||||
);
|
|
||||||
assert_eq!(target, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// Tableau 6 has 7 cards.
|
|
||||||
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
|
||||||
// Expected: card_height + 6 fan steps.
|
|
||||||
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
|
||||||
assert!(
|
|
||||||
(size.y - expected).abs() < 1e-3,
|
|
||||||
"expected {expected}, got {}",
|
|
||||||
size.y
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
|
||||||
let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic);
|
|
||||||
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
|
||||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
|
||||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
|
||||||
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
|
||||||
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
|
||||||
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
|
||||||
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
|
||||||
let cursor = Vec2::new(top_card_x, waste_base.y);
|
|
||||||
|
|
||||||
let result = find_draggable_at(cursor, &game, &layout);
|
|
||||||
assert!(
|
|
||||||
result.is_some(),
|
|
||||||
"top fanned waste card must be hittable at its visual X position"
|
|
||||||
);
|
|
||||||
let (pile, _start, ids) = result.unwrap();
|
|
||||||
assert_eq!(pile, KlondikePile::Stock);
|
|
||||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
|
||||||
let mut game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
// Clear tableau 0 so it's an empty slot.
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
|
||||||
let result = find_draggable_at(pos, &game, &layout);
|
|
||||||
assert!(
|
|
||||||
result.is_none(),
|
|
||||||
"clicking an empty pile must not produce a draggable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
||||||
for pile in [
|
|
||||||
KlondikePile::Stock,
|
|
||||||
KlondikePile::Foundation(Foundation::Foundation3),
|
|
||||||
] {
|
|
||||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
|
||||||
assert_eq!(size, layout.card_size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #27 — best_destination pure-function tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_destination_returns_none_when_no_legal_move() {
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
|
||||||
|
|
||||||
// Clear everything except one card that has nowhere to go.
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
|
|
||||||
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
|
||||||
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
|
||||||
assert!(best_destination(&card, &game).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// best_tableau_destination_for_stack pure-function tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
|
||||||
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
|
|
||||||
// Only tableau 0 has anything; every other column is empty.
|
|
||||||
// A King is the only card that can go on an empty tableau column.
|
|
||||||
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
|
||||||
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
|
||||||
|
|
||||||
let result = best_tableau_destination_for_stack(
|
|
||||||
&king,
|
|
||||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
|
||||||
&game,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
// Result must be some other empty tableau column, never the source.
|
|
||||||
if let Some((dest, _)) = result {
|
|
||||||
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
|
||||||
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
|
|
||||||
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
|
||||||
// All other piles are empty — no legal tableau target.
|
|
||||||
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
|
||||||
|
|
||||||
let result = best_tableau_destination_for_stack(
|
|
||||||
&two_clubs,
|
|
||||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
|
||||||
&game,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.is_none(),
|
|
||||||
"Two of Clubs has no legal tableau destination on empty piles"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #28 — find_hint pure-function tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_hint_finds_ace_to_foundation() {
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
|
||||||
|
|
||||||
// Place Ace of Clubs on top of tableau 0.
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
|
||||||
|
|
||||||
let hint = find_hint(&game);
|
|
||||||
assert!(hint.is_some(), "should find a hint");
|
|
||||||
let (from, to) = hint.unwrap();
|
|
||||||
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
|
||||||
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
|
||||||
/// the "is there actually a game?" gating lives in
|
|
||||||
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
|
||||||
/// "No game to forfeit" toast instead of failing silently.
|
|
||||||
#[test]
|
|
||||||
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
|
||||||
// Build the system param state by hand so we don't rely on a
|
|
||||||
// full Bevy app: the assertion is that the function returns
|
|
||||||
// early on the paused branch without calling write_message.
|
|
||||||
// This is verified by the plain `if paused { return; }` shape;
|
|
||||||
// the body is small enough to inspect by reading.
|
|
||||||
// (Higher-level integration coverage lives in the pause-plugin
|
|
||||||
// tests where `forfeit_app` simulates the full flow.)
|
|
||||||
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// all_hints / new-game window — pure-function tests added during refactor
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
|
||||||
/// are no other moves and the stock is non-empty.
|
|
||||||
#[test]
|
|
||||||
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
|
||||||
|
|
||||||
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
|
||||||
// move exists. Leave one card in the stock.
|
|
||||||
clear_test_piles(&mut game);
|
|
||||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
|
||||||
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
|
||||||
|
|
||||||
let hints = all_hints(&game);
|
|
||||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
|
||||||
let (from, to) = &hints[0];
|
|
||||||
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
|
||||||
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
|
||||||
}
|
|
||||||
|
|
||||||
// `all_hints` must be empty when both stock and waste are empty and no
|
|
||||||
// pile-to-pile move exists — the game is truly stuck.
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
|
||||||
// `ShakeAnim` on the dragged cards. The audio cue
|
|
||||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
|
||||||
// visual response on the dragged cards swapped from a horizontal wiggle
|
|
||||||
// to a smooth ease-out glide back to the origin pile.
|
|
||||||
//
|
|
||||||
// These tests build the component values exactly as `end_drag` and
|
|
||||||
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
|
||||||
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
|
||||||
// and mouse-button input, so we exercise the data path the same way the
|
|
||||||
// legacy `ShakeAnim` tests did.
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Helper: build the `CardAnimation` the rejection paths construct for
|
|
||||||
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
|
||||||
/// `touch_end_drag` so the tests stay in sync with the production code.
|
|
||||||
fn build_drag_reject_animation(
|
|
||||||
drag_pos: Vec2,
|
|
||||||
drag_z: f32,
|
|
||||||
target_pos: Vec2,
|
|
||||||
stack_index: usize,
|
|
||||||
) -> CardAnimation {
|
|
||||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
|
||||||
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
|
||||||
.with_duration(MOTION_DRAG_REJECT_SECS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
|
||||||
/// rejection. With the shake → tween migration, the assertion changes
|
|
||||||
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
|
||||||
/// gets a CardAnimation" — same coverage, new component.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
|
||||||
use solitaire_core::card::Deck as D;
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
// Simulate a stack drag of two cards.
|
|
||||||
let dragged_cards: Vec<Card> = vec![
|
|
||||||
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
|
||||||
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut animated: Vec<Card> = Vec::new();
|
|
||||||
for card in &dragged_cards {
|
|
||||||
// In `end_drag` we iterate `drag.cards` and look up each card in
|
|
||||||
// `card_entities`. The cards we would insert a `CardAnimation` on
|
|
||||||
// must exactly match the dragged set.
|
|
||||||
animated.push(card.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
animated, dragged_cards,
|
|
||||||
"every card in drag.cards must receive a CardAnimation on rejection"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `end` field of the inserted tween must equal the card's resting
|
|
||||||
/// slot in its origin pile — the position the card belongs at after a
|
|
||||||
/// rejected drop. Without this, the tween would glide to the wrong spot
|
|
||||||
/// and `sync_cards` would have to fight it back.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
|
||||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
|
||||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
|
||||||
let anim =
|
|
||||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
(anim.end - target_pos).length() < 1e-6,
|
|
||||||
"CardAnimation.end must match the origin slot's resting position. \
|
|
||||||
Expected {target_pos:?}, got {:?}",
|
|
||||||
anim.end
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `start` field of the inserted tween must equal the card's
|
|
||||||
/// drop-time transform position — i.e. wherever the cursor or finger
|
|
||||||
/// released the card. This is what makes the glide feel like a
|
|
||||||
/// continuous return rather than a teleport-then-shake.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_starts_from_drag_position() {
|
|
||||||
let drag_pos = Vec2::new(640.0, 200.0);
|
|
||||||
let target_pos = Vec2::new(80.0, -120.0);
|
|
||||||
let anim =
|
|
||||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
(anim.start - drag_pos).length() < 1e-6,
|
|
||||||
"CardAnimation.start must match the drop-time transform position \
|
|
||||||
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
|
||||||
anim.start
|
|
||||||
);
|
|
||||||
// And the start must be visibly distinct from the origin slot — the
|
|
||||||
// whole point of the tween is that it visibly travels.
|
|
||||||
assert!(
|
|
||||||
(anim.start - anim.end).length() > 1.0,
|
|
||||||
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
|
||||||
anim.start,
|
|
||||||
anim.end
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The tween duration is taken from the project-wide motion token so
|
|
||||||
/// designers can retune the feel from one place. Keeps the constant and
|
|
||||||
/// the call site honest.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_uses_correct_duration() {
|
|
||||||
let anim = build_drag_reject_animation(
|
|
||||||
Vec2::new(640.0, 200.0),
|
|
||||||
DRAG_Z,
|
|
||||||
Vec2::new(80.0, -120.0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
|
||||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
|
||||||
({MOTION_DRAG_REJECT_SECS}), got {}",
|
|
||||||
anim.duration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The curve must be a no-overshoot ease-out so the card decelerates
|
|
||||||
/// cleanly into its rest position — overshoot on a rejection feels
|
|
||||||
/// jittery rather than forgiving.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_uses_responsive_curve() {
|
|
||||||
let anim = build_drag_reject_animation(
|
|
||||||
Vec2::new(640.0, 200.0),
|
|
||||||
DRAG_Z,
|
|
||||||
Vec2::new(80.0, -120.0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
anim.curve,
|
|
||||||
MotionCurve::Responsive,
|
|
||||||
"drag-rejection tween must use Responsive (quintic ease-out) \
|
|
||||||
so the card snaps back without bouncing past the slot"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `start_z` of the tween must equal the card's drop-time z
|
|
||||||
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
|
||||||
/// travels home, then settles at the correct resting z.
|
|
||||||
#[test]
|
|
||||||
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
|
||||||
let stack_index = 2_usize;
|
|
||||||
let anim = build_drag_reject_animation(
|
|
||||||
Vec2::new(640.0, 200.0),
|
|
||||||
DRAG_Z,
|
|
||||||
Vec2::new(80.0, -120.0),
|
|
||||||
stack_index,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
|
||||||
"tween must start at DRAG_Z so the card stays on top during the glide"
|
|
||||||
);
|
|
||||||
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
|
||||||
assert!(
|
|
||||||
(anim.end_z - expected_end_z).abs() < 1e-6,
|
|
||||||
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
|
||||||
anim.end_z
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Hint system — async port (v0.18.0+)
|
|
||||||
//
|
|
||||||
// `handle_keyboard_hint` no longer runs the solver inline; it
|
|
||||||
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
|
||||||
// system in `pending_hint` turns into hint visuals one frame
|
|
||||||
// later. The behaviour contract this section pins is "pressing H
|
|
||||||
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
|
||||||
// covered end-to-end in `pending_hint::tests`.
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Pressing H on a non-paused, non-won game with a live
|
|
||||||
/// `GameStateResource` + `LayoutResource` must populate
|
|
||||||
/// `PendingHintTask`. The polling system, exercised in
|
|
||||||
/// `pending_hint::tests`, drives the result to a visual event.
|
|
||||||
#[test]
|
|
||||||
fn pressing_h_spawns_pending_hint_task() {
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins);
|
|
||||||
app.add_message::<InfoToastEvent>();
|
|
||||||
app.add_message::<HintVisualEvent>();
|
|
||||||
app.init_resource::<HintCycleIndex>();
|
|
||||||
app.init_resource::<HintSolverConfig>();
|
|
||||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
|
||||||
app.insert_resource(crate::layout::LayoutResource(
|
|
||||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
|
||||||
));
|
|
||||||
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
|
|
||||||
app.add_systems(Update, handle_keyboard_hint);
|
|
||||||
|
|
||||||
// Simulate the H key being pressed this frame.
|
|
||||||
{
|
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
||||||
input.release(KeyCode::KeyH);
|
|
||||||
input.clear();
|
|
||||||
input.press(KeyCode::KeyH);
|
|
||||||
}
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
app.world()
|
|
||||||
.resource::<crate::pending_hint::PendingHintTask>()
|
|
||||||
.is_pending(),
|
|
||||||
"pressing H must spawn an async hint task",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::layout::compute_layout;
|
||||||
|
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
|
fn clear_test_piles(game: &mut GameState) {
|
||||||
|
game.set_test_stock_cards(Vec::new());
|
||||||
|
game.set_test_waste_cards(Vec::new());
|
||||||
|
for foundation in [
|
||||||
|
Foundation::Foundation1,
|
||||||
|
Foundation::Foundation2,
|
||||||
|
Foundation::Foundation3,
|
||||||
|
Foundation::Foundation4,
|
||||||
|
] {
|
||||||
|
game.set_test_foundation_cards(foundation, Vec::new());
|
||||||
|
}
|
||||||
|
for tableau in [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
] {
|
||||||
|
game.set_test_tableau_cards(tableau, Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dragged_card_z_matches_resting_stack_step() {
|
||||||
|
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
||||||
|
let step = dragged_card_z(1) - dragged_card_z(0);
|
||||||
|
assert!(
|
||||||
|
step > 0.02,
|
||||||
|
"drag step must exceed Android overlay local_z, got {step}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
step + 1e-4 >= STACK_FAN_FRAC,
|
||||||
|
"drag step must stay aligned with resting stack spacing, got {step}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_inside_returns_true() {
|
||||||
|
let center = Vec2::new(10.0, 20.0);
|
||||||
|
let size = Vec2::new(40.0, 60.0);
|
||||||
|
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_on_edge_returns_true() {
|
||||||
|
let center = Vec2::ZERO;
|
||||||
|
let size = Vec2::new(10.0, 10.0);
|
||||||
|
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_outside_returns_false() {
|
||||||
|
let center = Vec2::ZERO;
|
||||||
|
let size = Vec2::new(10.0, 10.0);
|
||||||
|
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
||||||
|
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
||||||
|
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_top_of_tableau() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||||
|
// Its position: base.y + fan * 6.
|
||||||
|
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
|
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
|
assert_eq!(result.1, 6);
|
||||||
|
assert_eq!(result.2.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||||
|
// Reproduces the reported "drags the wrong waste card" bug: with several
|
||||||
|
// cards in the waste, clicking the visible top must pick the actual top
|
||||||
|
// (last index), not the buffer card underneath it.
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||||
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||||
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||||
|
game.set_test_waste_cards(waste.clone());
|
||||||
|
|
||||||
|
let top_index = waste.len() - 1; // 2 = the visible top
|
||||||
|
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||||
|
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||||
|
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||||
|
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||||
|
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_lone_waste_card() {
|
||||||
|
// "can't play the first card in the stock" — a waste of one card must
|
||||||
|
// still be draggable.
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
||||||
|
game.set_test_waste_cards(vec![card.clone()]);
|
||||||
|
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||||
|
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
||||||
|
assert_eq!(result.0, KlondikePile::Stock);
|
||||||
|
assert_eq!(result.1, 0);
|
||||||
|
assert_eq!(result.2, vec![card]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
||||||
|
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
||||||
|
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
||||||
|
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
||||||
|
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
||||||
|
// fanned card's click target lands on the card beneath it — so dragging
|
||||||
|
// the visible top card plays the wrong one.
|
||||||
|
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||||
|
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
||||||
|
let cw = layout.card_size.x;
|
||||||
|
let base = layout.pile_positions[&KlondikePile::Stock];
|
||||||
|
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
layout.pile_positions.insert(
|
||||||
|
KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
Vec2::new(t1.x + cw * 1.03, t1.y),
|
||||||
|
);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let waste = vec![
|
||||||
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||||
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||||
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||||
|
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
||||||
|
];
|
||||||
|
game.set_test_waste_cards(waste.clone());
|
||||||
|
|
||||||
|
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
||||||
|
let top_index = waste.len() - 1;
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||||
|
|
||||||
|
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
||||||
|
assert!(
|
||||||
|
(pos.x - expected).abs() < 1e-3,
|
||||||
|
"hit-test must use the shared waste fan step"
|
||||||
|
);
|
||||||
|
// The old fixed constant would have drifted from the renderer here.
|
||||||
|
let old = base.x + 2.0 * cw * 0.28;
|
||||||
|
assert!(
|
||||||
|
(pos.x - old).abs() > 1.0,
|
||||||
|
"shared step must differ from the old fixed step under tight spacing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_skips_face_down_cards() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
||||||
|
// the bottom (index 6). Click at the topmost face-down card's
|
||||||
|
// position — its full body is partly visible above the fanned
|
||||||
|
// face-up card, but the iterator should skip face-down cards and
|
||||||
|
// the cursor sits above the face-up card's AABB, so the result
|
||||||
|
// is None.
|
||||||
|
let face_down_pos =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||||
|
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||||
|
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
||||||
|
// Regression test for the bug where input_plugin's hit-testing used
|
||||||
|
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
||||||
|
// at 0.12 — so for any column with face-down cards above the
|
||||||
|
// face-up bottom card, clicking the visible card face missed the
|
||||||
|
// hit-test box and only the bottom strip of the card responded.
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||||
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||||
|
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||||
|
let face_up_pos =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
|
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||||
|
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||||
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
|
assert_eq!(result.1, 6);
|
||||||
|
assert_eq!(result.2.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||||
|
// Manually construct a tableau with three face-up cards all stacked.
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||||
|
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||||
|
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||||
|
game.set_test_tableau_cards(
|
||||||
|
Tableau::Tableau1,
|
||||||
|
vec![king, queen.clone(), jack.clone()],
|
||||||
|
);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||||
|
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||||
|
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||||
|
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||||
|
let queen_center =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||||
|
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||||
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
assert_eq!(start, 1);
|
||||||
|
assert_eq!(ids, vec![queen, jack]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_skips_non_top_waste_card() {
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||||
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||||
|
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||||
|
// the visually top card (three_hearts), with count = 1.
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||||
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(pile, KlondikePile::Stock);
|
||||||
|
assert_eq!(start, 1);
|
||||||
|
assert_eq!(ids, vec![three_hearts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||||
|
let mut game = game;
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
let target = find_drop_target(
|
||||||
|
pos,
|
||||||
|
&game,
|
||||||
|
&layout,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau7),
|
||||||
|
);
|
||||||
|
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_drop_target_returns_none_for_origin() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||||
|
let target = find_drop_target(
|
||||||
|
pos,
|
||||||
|
&game,
|
||||||
|
&layout,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
|
);
|
||||||
|
assert_eq!(target, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Tableau 6 has 7 cards.
|
||||||
|
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
||||||
|
// Expected: card_height + 6 fan steps.
|
||||||
|
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||||
|
assert!(
|
||||||
|
(size.y - expected).abs() < 1e-3,
|
||||||
|
"expected {expected}, got {}",
|
||||||
|
size.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||||
|
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
||||||
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||||
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||||
|
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
||||||
|
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
||||||
|
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
||||||
|
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||||
|
let cursor = Vec2::new(top_card_x, waste_base.y);
|
||||||
|
|
||||||
|
let result = find_draggable_at(cursor, &game, &layout);
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"top fanned waste card must be hittable at its visual X position"
|
||||||
|
);
|
||||||
|
let (pile, _start, ids) = result.unwrap();
|
||||||
|
assert_eq!(pile, KlondikePile::Stock);
|
||||||
|
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Clear tableau 0 so it's an empty slot.
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
let result = find_draggable_at(pos, &game, &layout);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"clicking an empty pile must not produce a draggable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
for pile in [
|
||||||
|
KlondikePile::Stock,
|
||||||
|
KlondikePile::Foundation(Foundation::Foundation3),
|
||||||
|
] {
|
||||||
|
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||||
|
assert_eq!(size, layout.card_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Task #27 — best_destination pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_destination_returns_none_when_no_legal_move() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Clear everything except one card that has nowhere to go.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
||||||
|
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||||
|
assert!(best_destination(&card, &game).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// best_tableau_destination_for_stack pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// Only tableau 0 has anything; every other column is empty.
|
||||||
|
// A King is the only card that can go on an empty tableau column.
|
||||||
|
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
||||||
|
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
||||||
|
|
||||||
|
let result = best_tableau_destination_for_stack(
|
||||||
|
&king,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Result must be some other empty tableau column, never the source.
|
||||||
|
if let Some((dest, _)) = result {
|
||||||
|
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
||||||
|
// All other piles are empty — no legal tableau target.
|
||||||
|
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
||||||
|
|
||||||
|
let result = best_tableau_destination_for_stack(
|
||||||
|
&two_clubs,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"Two of Clubs has no legal tableau destination on empty piles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Task #28 — find_hint pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_hint_finds_ace_to_foundation() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Place Ace of Clubs on top of tableau 0.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
||||||
|
|
||||||
|
let hint = find_hint(&game);
|
||||||
|
assert!(hint.is_some(), "should find a hint");
|
||||||
|
let (from, to) = hint.unwrap();
|
||||||
|
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
||||||
|
/// the "is there actually a game?" gating lives in
|
||||||
|
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
||||||
|
/// "No game to forfeit" toast instead of failing silently.
|
||||||
|
#[test]
|
||||||
|
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
||||||
|
// Build the system param state by hand so we don't rely on a
|
||||||
|
// full Bevy app: the assertion is that the function returns
|
||||||
|
// early on the paused branch without calling write_message.
|
||||||
|
// This is verified by the plain `if paused { return; }` shape;
|
||||||
|
// the body is small enough to inspect by reading.
|
||||||
|
// (Higher-level integration coverage lives in the pause-plugin
|
||||||
|
// tests where `forfeit_app` simulates the full flow.)
|
||||||
|
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// all_hints / new-game window — pure-function tests added during refactor
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
||||||
|
/// are no other moves and the stock is non-empty.
|
||||||
|
#[test]
|
||||||
|
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||||
|
// move exists. Leave one card in the stock.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||||
|
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
||||||
|
|
||||||
|
let hints = all_hints(&game);
|
||||||
|
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||||
|
let (from, to) = &hints[0];
|
||||||
|
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
||||||
|
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
||||||
|
}
|
||||||
|
|
||||||
|
// `all_hints` must be empty when both stock and waste are empty and no
|
||||||
|
// pile-to-pile move exists — the game is truly stuck.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
|
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||||
|
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||||
|
// to a smooth ease-out glide back to the origin pile.
|
||||||
|
//
|
||||||
|
// These tests build the component values exactly as `end_drag` and
|
||||||
|
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||||
|
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||||
|
// and mouse-button input, so we exercise the data path the same way the
|
||||||
|
// legacy `ShakeAnim` tests did.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||||
|
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||||
|
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||||
|
fn build_drag_reject_animation(
|
||||||
|
drag_pos: Vec2,
|
||||||
|
drag_z: f32,
|
||||||
|
target_pos: Vec2,
|
||||||
|
stack_index: usize,
|
||||||
|
) -> CardAnimation {
|
||||||
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
|
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||||
|
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||||
|
/// rejection. With the shake → tween migration, the assertion changes
|
||||||
|
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||||
|
/// gets a CardAnimation" — same coverage, new component.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
// Simulate a stack drag of two cards.
|
||||||
|
let dragged_cards: Vec<Card> = vec![
|
||||||
|
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
||||||
|
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut animated: Vec<Card> = Vec::new();
|
||||||
|
for card in &dragged_cards {
|
||||||
|
// In `end_drag` we iterate `drag.cards` and look up each card in
|
||||||
|
// `card_entities`. The cards we would insert a `CardAnimation` on
|
||||||
|
// must exactly match the dragged set.
|
||||||
|
animated.push(card.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
animated, dragged_cards,
|
||||||
|
"every card in drag.cards must receive a CardAnimation on rejection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `end` field of the inserted tween must equal the card's resting
|
||||||
|
/// slot in its origin pile — the position the card belongs at after a
|
||||||
|
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||||
|
/// and `sync_cards` would have to fight it back.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||||
|
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||||
|
let anim =
|
||||||
|
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.end - target_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.end must match the origin slot's resting position. \
|
||||||
|
Expected {target_pos:?}, got {:?}",
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start` field of the inserted tween must equal the card's
|
||||||
|
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||||
|
/// released the card. This is what makes the glide feel like a
|
||||||
|
/// continuous return rather than a teleport-then-shake.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_starts_from_drag_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0);
|
||||||
|
let target_pos = Vec2::new(80.0, -120.0);
|
||||||
|
let anim =
|
||||||
|
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.start - drag_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.start must match the drop-time transform position \
|
||||||
|
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||||
|
anim.start
|
||||||
|
);
|
||||||
|
// And the start must be visibly distinct from the origin slot — the
|
||||||
|
// whole point of the tween is that it visibly travels.
|
||||||
|
assert!(
|
||||||
|
(anim.start - anim.end).length() > 1.0,
|
||||||
|
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||||
|
anim.start,
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tween duration is taken from the project-wide motion token so
|
||||||
|
/// designers can retune the feel from one place. Keeps the constant and
|
||||||
|
/// the call site honest.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_correct_duration() {
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||||
|
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||||
|
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||||
|
anim.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||||
|
/// cleanly into its rest position — overshoot on a rejection feels
|
||||||
|
/// jittery rather than forgiving.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_responsive_curve() {
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
anim.curve,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||||
|
so the card snaps back without bouncing past the slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start_z` of the tween must equal the card's drop-time z
|
||||||
|
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||||
|
/// travels home, then settles at the correct resting z.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||||
|
let stack_index = 2_usize;
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
stack_index,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||||
|
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||||
|
);
|
||||||
|
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
|
assert!(
|
||||||
|
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||||
|
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||||
|
anim.end_z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hint system — async port (v0.18.0+)
|
||||||
|
//
|
||||||
|
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||||
|
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||||
|
// system in `pending_hint` turns into hint visuals one frame
|
||||||
|
// later. The behaviour contract this section pins is "pressing H
|
||||||
|
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||||
|
// covered end-to-end in `pending_hint::tests`.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pressing H on a non-paused, non-won game with a live
|
||||||
|
/// `GameStateResource` + `LayoutResource` must populate
|
||||||
|
/// `PendingHintTask`. The polling system, exercised in
|
||||||
|
/// `pending_hint::tests`, drives the result to a visual event.
|
||||||
|
#[test]
|
||||||
|
fn pressing_h_spawns_pending_hint_task() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<HintVisualEvent>();
|
||||||
|
app.init_resource::<HintCycleIndex>();
|
||||||
|
app.init_resource::<HintSolverConfig>();
|
||||||
|
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.insert_resource(LayoutResource(
|
||||||
|
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||||
|
));
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||||
|
app.add_systems(Update, handle_keyboard_hint);
|
||||||
|
|
||||||
|
// Simulate the H key being pressed this frame.
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::KeyH);
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::KeyH);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<crate::pending_hint::PendingHintTask>()
|
||||||
|
.is_pending(),
|
||||||
|
"pressing H must spawn an async hint task",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::{Resource, SystemSet};
|
use bevy::prelude::{Resource, SystemSet};
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
/// Schedule labels for layout-related systems so cross-plugin ordering is
|
/// Schedule labels for layout-related systems so cross-plugin ordering is
|
||||||
@@ -91,6 +92,15 @@ const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
|||||||
/// this column inside the visible window.
|
/// this column inside the visible window.
|
||||||
const MAX_TABLEAU_CARDS: f32 = 13.0;
|
const MAX_TABLEAU_CARDS: f32 = 13.0;
|
||||||
|
|
||||||
|
/// Upper bound for the dynamic tableau fan step (fraction of card height) chosen
|
||||||
|
/// by [`apply_dynamic_tableau_fan`]. The fan is spread to fill the available
|
||||||
|
/// height, but a near-empty column has tiny demand, so without a cap its few
|
||||||
|
/// cards would fling far apart on a tall viewport. At 0.6 the face-up cards keep
|
||||||
|
/// clear overlap (a readable stack) while still filling most of a near-square /
|
||||||
|
/// unfolded-foldable screen. Tunable purely for feel — no effect on correctness
|
||||||
|
/// or hit-testing.
|
||||||
|
pub(crate) const MAX_DYNAMIC_FAN_FRAC: f32 = 0.9;
|
||||||
|
|
||||||
/// Vertical pixel band reserved at the top of the play area for the HUD
|
/// Vertical pixel band reserved at the top of the play area for the HUD
|
||||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||||
/// below this band so the HUD doesn't bleed into the play surface.
|
/// below this band so the HUD doesn't bleed into the play surface.
|
||||||
@@ -275,13 +285,12 @@ pub fn compute_layout(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
|
// Adaptive tableau fan fraction. On height-limited windows the height-based
|
||||||
// height-based sizing already ensures a worst-case 13-card column fits at
|
// sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
|
||||||
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
|
// so the formula returns the minimum and the clamp keeps it there. On
|
||||||
// keeps it there — no change from prior behaviour. On width-limited
|
// width-limited (portrait phone) windows card_size is small and lots of
|
||||||
// (portrait phone) windows card_size is small and lots of vertical space
|
// vertical space is unused; solve for the fraction that fills the available
|
||||||
// is unused; we solve for the fraction that exactly fills the available
|
// space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
|
||||||
// space to the bottom margin.
|
|
||||||
//
|
//
|
||||||
// avail = distance from the top of the first tableau card to the bottom
|
// avail = distance from the top of the first tableau card to the bottom
|
||||||
// margin — i.e. the space available for 12 fan steps.
|
// margin — i.e. the space available for 12 fan steps.
|
||||||
@@ -292,20 +301,87 @@ pub fn compute_layout(
|
|||||||
} else {
|
} else {
|
||||||
TABLEAU_FAN_FRAC
|
TABLEAU_FAN_FRAC
|
||||||
};
|
};
|
||||||
// Never go below the desktop minimum — avoids shrinking the fan on
|
|
||||||
// degenerate near-square windows where the formula might undershoot.
|
|
||||||
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
|
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
|
||||||
// Scale the face-down fraction proportionally so rendering and hit-testing
|
|
||||||
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
|
|
||||||
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
|
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
|
||||||
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
|
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
|
||||||
|
let available_tableau_height = avail;
|
||||||
|
|
||||||
Layout {
|
Layout {
|
||||||
card_size,
|
card_size,
|
||||||
pile_positions,
|
pile_positions,
|
||||||
tableau_fan_frac,
|
tableau_fan_frac,
|
||||||
tableau_facedown_fan_frac,
|
tableau_facedown_fan_frac,
|
||||||
available_tableau_height: avail,
|
available_tableau_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spread the tableau fan so the deepest column fills the available vertical
|
||||||
|
/// height for the *current* deal, mutating `layout.tableau_fan_frac` and its
|
||||||
|
/// face-down companion in place.
|
||||||
|
///
|
||||||
|
/// `compute_layout` is pure geometry and sizes the fan for a worst-case 13-card
|
||||||
|
/// column, so early in a game (shallow columns) a tall or near-square viewport
|
||||||
|
/// — e.g. an unfolded foldable — is left with a large empty band below the
|
||||||
|
/// tableau. This refines the fan once the actual deal is known.
|
||||||
|
///
|
||||||
|
/// Depth is measured across *all* cards in a column, each face-down card
|
||||||
|
/// weighted 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, where the deepest column is several face-down cards under one face-up
|
||||||
|
/// one. Deeper columns drive the fraction down so everything still fits;
|
||||||
|
/// [`TABLEAU_FAN_FRAC`] floors it and [`MAX_DYNAMIC_FAN_FRAC`] caps it.
|
||||||
|
///
|
||||||
|
/// Called from card-sync at startup and on every `StateChangedEvent`, and from
|
||||||
|
/// the resize pipeline after `compute_layout`, so the cold-start deal, ongoing
|
||||||
|
/// play, and fold/unfold all stay filled. `card_position` / `card_positions`
|
||||||
|
/// read the same fractions, so rendering and hit-testing remain in sync.
|
||||||
|
pub(crate) fn apply_dynamic_tableau_fan(game: &GameState, layout: &mut Layout) {
|
||||||
|
let card_h = layout.card_size.y;
|
||||||
|
let avail = layout.available_tableau_height;
|
||||||
|
if card_h <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
|
||||||
|
|
||||||
|
// "Step demand" of a column: the vertical offset of its bottom card from its
|
||||||
|
// top card, in units of the face-up fan step. Every card except the last
|
||||||
|
// contributes one step, weighted down to `facedown_ratio` while face-down.
|
||||||
|
let max_demand = [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|tableau| {
|
||||||
|
let pile = game.pile(KlondikePile::Tableau(tableau));
|
||||||
|
let steps = pile.len().saturating_sub(1);
|
||||||
|
pile.iter()
|
||||||
|
.take(steps)
|
||||||
|
.map(|(_, face_up)| if *face_up { 1.0 } else { facedown_ratio })
|
||||||
|
.sum::<f32>()
|
||||||
|
})
|
||||||
|
.fold(0.0_f32, f32::max);
|
||||||
|
|
||||||
|
// No fannable column (every tableau pile has ≤ 1 card) — leave the fractions
|
||||||
|
// at the values compute_layout set.
|
||||||
|
if max_demand <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ideal = avail / (max_demand * card_h);
|
||||||
|
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, MAX_DYNAMIC_FAN_FRAC);
|
||||||
|
let new_facedown_frac = new_frac * facedown_ratio;
|
||||||
|
|
||||||
|
if (layout.tableau_fan_frac - new_frac).abs() > 1e-4 {
|
||||||
|
layout.tableau_fan_frac = new_frac;
|
||||||
|
}
|
||||||
|
if (layout.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
|
||||||
|
layout.tableau_facedown_fan_frac = new_facedown_frac;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,7 +821,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// The HUD band top clearance (distance from window top to card top)
|
// The HUD band top clearance (distance from window top to card top)
|
||||||
// must match as well — this is the quantity directly visible in Bug 2.
|
// must match as well — this is the quantity directly visible in Bug 2.
|
||||||
let card_top = |layout: &super::Layout| {
|
let card_top = |layout: &Layout| {
|
||||||
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
|
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -862,7 +862,7 @@ fn handle_display_name_confirm(
|
|||||||
.leaderboard_display_name
|
.leaderboard_display_name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
if let solitaire_data::settings::SyncBackend::SolitaireServer {
|
if let SyncBackend::SolitaireServer {
|
||||||
ref username,
|
ref username,
|
||||||
..
|
..
|
||||||
} = settings.0.sync_backend
|
} = settings.0.sync_backend
|
||||||
@@ -1091,7 +1091,7 @@ mod tests {
|
|||||||
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
||||||
.add_plugins(SyncPlugin::new(NoOpProvider))
|
.add_plugins(SyncPlugin::new(NoOpProvider))
|
||||||
.add_plugins(LeaderboardPlugin);
|
.add_plugins(LeaderboardPlugin);
|
||||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
//! active opens the overlay as normal.
|
//! active opens the overlay as normal.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_data::save_game_state_to;
|
use solitaire_data::save_game_state_to;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -86,10 +86,10 @@ struct ForfeitConfirmButton;
|
|||||||
/// Returns the human-readable label for a draw mode.
|
/// Returns the human-readable label for a draw mode.
|
||||||
///
|
///
|
||||||
/// Used on the pause overlay draw-mode toggle button.
|
/// Used on the pause overlay draw-mode toggle button.
|
||||||
pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
|
||||||
match mode {
|
match mode {
|
||||||
DrawMode::DrawOne => "Draw 1",
|
DrawStockConfig::DrawOne => "Draw 1",
|
||||||
DrawMode::DrawThree => "Draw 3",
|
DrawStockConfig::DrawThree => "Draw 3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,9 +273,9 @@ fn handle_pause_draw_buttons(
|
|||||||
}
|
}
|
||||||
let Some(mut settings) = settings else { return };
|
let Some(mut settings) = settings else { return };
|
||||||
let new_mode = if pressed_one {
|
let new_mode = if pressed_one {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
} else {
|
} else {
|
||||||
DrawMode::DrawThree
|
DrawStockConfig::DrawThree
|
||||||
};
|
};
|
||||||
if settings.0.draw_mode == new_mode {
|
if settings.0.draw_mode == new_mode {
|
||||||
return;
|
return;
|
||||||
@@ -477,7 +477,7 @@ fn spawn_pause_screen(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
level: Option<u32>,
|
level: Option<u32>,
|
||||||
streak: Option<u32>,
|
streak: Option<u32>,
|
||||||
draw_mode: Option<DrawMode>,
|
draw_mode: Option<DrawStockConfig>,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| {
|
spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| {
|
||||||
@@ -516,7 +516,7 @@ fn spawn_pause_screen(
|
|||||||
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
|
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
|
||||||
fn spawn_draw_mode_row(
|
fn spawn_draw_mode_row(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
mode: DrawMode,
|
mode: DrawStockConfig,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
let label_font = TextFont {
|
let label_font = TextFont {
|
||||||
@@ -530,8 +530,8 @@ fn spawn_draw_mode_row(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let (one_variant, three_variant) = match mode {
|
let (one_variant, three_variant) = match mode {
|
||||||
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
DrawStockConfig::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
||||||
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
DrawStockConfig::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
||||||
};
|
};
|
||||||
parent
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
@@ -800,20 +800,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draw_mode_label_draw_one() {
|
fn draw_mode_label_draw_one() {
|
||||||
assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1");
|
assert_eq!(draw_mode_label(DrawStockConfig::DrawOne), "Draw 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draw_mode_label_draw_three() {
|
fn draw_mode_label_draw_three() {
|
||||||
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
|
assert_eq!(draw_mode_label(DrawStockConfig::DrawThree), "Draw 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Both variants are covered so the match is exhaustive — this test would
|
/// Both variants are covered so the match is exhaustive — this test would
|
||||||
/// fail to compile if a new DrawMode variant were added without updating
|
/// fail to compile if a new DrawStockConfig variant were added without updating
|
||||||
/// `draw_mode_label`.
|
/// `draw_mode_label`.
|
||||||
#[test]
|
#[test]
|
||||||
fn draw_mode_label_covers_all_variants() {
|
fn draw_mode_label_covers_all_variants() {
|
||||||
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
|
for mode in [DrawStockConfig::DrawOne, DrawStockConfig::DrawThree] {
|
||||||
let label = draw_mode_label(mode);
|
let label = draw_mode_label(mode);
|
||||||
assert!(
|
assert!(
|
||||||
!label.is_empty(),
|
!label.is_empty(),
|
||||||
@@ -842,7 +842,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<SettingsResource>()
|
.resource_mut::<SettingsResource>()
|
||||||
.0
|
.0
|
||||||
.draw_mode = DrawMode::DrawOne;
|
.draw_mode = DrawStockConfig::DrawOne;
|
||||||
|
|
||||||
// Set paused so handle_pause_draw_toggle acts.
|
// Set paused so handle_pause_draw_toggle acts.
|
||||||
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
||||||
@@ -856,7 +856,7 @@ mod tests {
|
|||||||
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
|
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*mode,
|
*mode,
|
||||||
DrawMode::DrawThree,
|
DrawStockConfig::DrawThree,
|
||||||
"pressing Draw 3 must set mode to DrawThree"
|
"pressing Draw 3 must set mode to DrawThree"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -869,7 +869,7 @@ mod tests {
|
|||||||
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
|
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*mode2,
|
*mode2,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
"pressing Draw 1 must set mode to DrawOne"
|
"pressing Draw 1 must set mode to DrawOne"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -965,11 +965,11 @@ mod tests {
|
|||||||
/// Provides a fresh `GameStateResource` (not won) so the modal can
|
/// Provides a fresh `GameStateResource` (not won) so the modal can
|
||||||
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
|
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
|
||||||
fn forfeit_app() -> App {
|
fn forfeit_app() -> App {
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -1020,11 +1020,11 @@ mod tests {
|
|||||||
/// hotkey was received but is currently a no-op.
|
/// hotkey was received but is currently a no-op.
|
||||||
#[test]
|
#[test]
|
||||||
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
|
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
game.set_test_won(true);
|
game.set_test_won(true);
|
||||||
app.insert_resource(GameStateResource(game));
|
app.insert_resource(GameStateResource(game));
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ mod tests {
|
|||||||
use crate::events::HintVisualEvent;
|
use crate::events::HintVisualEvent;
|
||||||
use crate::input_plugin::HintSolverConfig;
|
use crate::input_plugin::HintSolverConfig;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
/// Build a minimal Bevy app exercising only the polling system
|
/// Build a minimal Bevy app exercising only the polling system
|
||||||
/// and the resources/messages it touches.
|
/// and the resources/messages it touches.
|
||||||
@@ -209,7 +209,7 @@ mod tests {
|
|||||||
/// foundations hold A..Q for each suit, four Kings sit on
|
/// foundations hold A..Q for each suit, four Kings sit on
|
||||||
/// tableau columns 0..3, stock and waste empty.
|
/// tableau columns 0..3, stock and waste empty.
|
||||||
fn near_finished_state() -> GameState {
|
fn near_finished_state() -> GameState {
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
game.set_test_stock_cards(Vec::new());
|
game.set_test_stock_cards(Vec::new());
|
||||||
game.set_test_waste_cards(Vec::new());
|
game.set_test_waste_cards(Vec::new());
|
||||||
for foundation in [
|
for foundation in [
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||||
|
|
||||||
@@ -340,7 +340,7 @@ fn tick_debounce_and_spawn_solver_task(
|
|||||||
|
|
||||||
let draw_mode = settings
|
let draw_mode = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
|
.map_or(DrawStockConfig::DrawOne, |s| s.0.draw_mode);
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
GameState::solve_fresh_deal(
|
GameState::solve_fresh_deal(
|
||||||
seed,
|
seed,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ use bevy::math::Vec2;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||||
@@ -113,9 +113,9 @@ pub enum RightClickRadialState {
|
|||||||
/// radial is built around single-card foundation/tableau
|
/// radial is built around single-card foundation/tableau
|
||||||
/// shortcuts and that matches the right-click highlight set).
|
/// shortcuts and that matches the right-click highlight set).
|
||||||
count: usize,
|
count: usize,
|
||||||
/// Card ids that would be moved (bottom-to-top order). Length
|
/// Cards that would be moved (bottom-to-top order). Length
|
||||||
/// always equals `count`. Currently always one element.
|
/// always equals `count`. Currently always one element.
|
||||||
cards: Vec<u32>,
|
cards: Vec<Card>,
|
||||||
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
|
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
|
||||||
///
|
///
|
||||||
/// Anchors are evenly spaced around a ring of radius
|
/// Anchors are evenly spaced around a ring of radius
|
||||||
@@ -359,7 +359,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use solitaire_core::card::card_to_id;
|
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
const fn foundations() -> [Foundation; 4] {
|
||||||
[
|
[
|
||||||
@@ -500,7 +499,7 @@ fn radial_open_on_right_click(
|
|||||||
*state = RightClickRadialState::Active {
|
*state = RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
count: 1,
|
count: 1,
|
||||||
cards: vec![card_to_id(&card)],
|
cards: vec![card.clone()],
|
||||||
legal_destinations,
|
legal_destinations,
|
||||||
centre: world,
|
centre: world,
|
||||||
hovered_index: None,
|
hovered_index: None,
|
||||||
@@ -573,7 +572,7 @@ fn radial_open_on_long_press(
|
|||||||
*state = RightClickRadialState::Active {
|
*state = RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
count: 1,
|
count: 1,
|
||||||
cards: vec![card_to_id(&card)],
|
cards: vec![card.clone()],
|
||||||
legal_destinations,
|
legal_destinations,
|
||||||
centre: world,
|
centre: world,
|
||||||
hovered_index: None,
|
hovered_index: None,
|
||||||
@@ -796,8 +795,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit};
|
use solitaire_core::{Card as CoreCard, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
|
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
|
||||||
/// resources / messages it depends on. No window, no camera — the
|
/// resources / messages it depends on. No window, no camera — the
|
||||||
@@ -820,7 +819,7 @@ mod tests {
|
|||||||
/// destination — Foundation(0) — under the standard rules
|
/// destination — Foundation(0) — under the standard rules
|
||||||
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
|
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
|
||||||
fn ace_only_state() -> GameState {
|
fn ace_only_state() -> GameState {
|
||||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||||
// Wipe everything.
|
// Wipe everything.
|
||||||
g.set_test_stock_cards(Vec::new());
|
g.set_test_stock_cards(Vec::new());
|
||||||
g.set_test_waste_cards(Vec::new());
|
g.set_test_waste_cards(Vec::new());
|
||||||
@@ -854,7 +853,7 @@ mod tests {
|
|||||||
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
|
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
|
||||||
/// must skip it.
|
/// must skip it.
|
||||||
fn face_down_only_state() -> GameState {
|
fn face_down_only_state() -> GameState {
|
||||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||||
g.set_test_stock_cards(Vec::new());
|
g.set_test_stock_cards(Vec::new());
|
||||||
g.set_test_waste_cards(Vec::new());
|
g.set_test_waste_cards(Vec::new());
|
||||||
for foundation in [
|
for foundation in [
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use super::ReplayPlaybackState;
|
use super::ReplayPlaybackState;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
use solitaire_data::ReplayMove;
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
|
|
||||||
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
|
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
|
||||||
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
|
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
|
||||||
@@ -60,12 +58,6 @@ pub(crate) fn format_pile(p: &KlondikePile) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
|
|
||||||
KlondikePile::try_from(*p)
|
|
||||||
.map(|pile| format_pile(&pile))
|
|
||||||
.unwrap_or_else(|_| "unknown pile".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn foundation_number(foundation: Foundation) -> u8 {
|
fn foundation_number(foundation: Foundation) -> u8 {
|
||||||
match foundation {
|
match foundation {
|
||||||
Foundation::Foundation1 => 1,
|
Foundation::Foundation1 => 1,
|
||||||
@@ -87,20 +79,36 @@ fn tableau_number(tableau: Tableau) -> u8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — formats a [`ReplayMove`] as the body of a
|
/// Pure helper — formats a [`KlondikeInstruction`] as the body of a
|
||||||
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
|
/// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*`
|
||||||
/// reads as `"{from} → {to}"` using [`format_pile`] for both
|
/// instruction reads as `"{from} → {to}"` using [`format_pile`] for
|
||||||
/// endpoints. The `count` field is omitted from the row body —
|
/// each nameable endpoint. The card count is omitted from the row
|
||||||
/// at row scale it adds visual noise without meaningful
|
/// body — at row scale it adds visual noise without meaningful
|
||||||
/// information for the typical 1-card moves.
|
/// information for the typical 1-card moves.
|
||||||
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
|
///
|
||||||
match m {
|
/// The destination pile is always recoverable directly from the
|
||||||
ReplayMove::StockClick => "stock cycle".to_string(),
|
/// instruction. The source pile is shown when it is statically
|
||||||
ReplayMove::Move { from, to, .. } => {
|
/// nameable (a `DstFoundation` carries a [`KlondikePile`] source);
|
||||||
|
/// a `DstTableau`'s source is the runtime-only `KlondikePileStack`
|
||||||
|
/// type — not re-exported across the `solitaire_core` boundary and so
|
||||||
|
/// not pattern-matchable here — so its row renders `"→ {to}"` without
|
||||||
|
/// a leading source label. Faithful full-coordinate decoding lives in
|
||||||
|
/// [`GameState::instruction_to_piles`] on the playback path; the
|
||||||
|
/// move-log is a display-only digest.
|
||||||
|
pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
||||||
|
match instruction {
|
||||||
|
KlondikeInstruction::RotateStock => "stock cycle".to_string(),
|
||||||
|
KlondikeInstruction::DstFoundation(dst) => {
|
||||||
format!(
|
format!(
|
||||||
"{} \u{2192} {}",
|
"{} \u{2192} {}",
|
||||||
format_saved_pile(from),
|
format_pile(&dst.src),
|
||||||
format_saved_pile(to)
|
format_pile(&KlondikePile::Foundation(dst.foundation))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
KlondikeInstruction::DstTableau(dst) => {
|
||||||
|
format!(
|
||||||
|
"\u{2192} {}",
|
||||||
|
format_pile(&KlondikePile::Tableau(dst.tableau))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::replay_playback::{
|
|||||||
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
|
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
|
||||||
stop_replay_playback, toggle_pause_replay_playback,
|
stop_replay_playback, toggle_pause_replay_playback,
|
||||||
};
|
};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
/// Per-arrow-key time-since-last-fire accumulators that drive the
|
/// Per-arrow-key time-since-last-fire accumulators that drive the
|
||||||
/// continuous-scrub repeat behaviour for held arrow keys. Each
|
/// continuous-scrub repeat behaviour for held arrow keys. Each
|
||||||
@@ -1033,6 +1034,7 @@ pub(crate) fn handle_pause_button(
|
|||||||
/// guard lives inside `step_replay_playback`.
|
/// guard lives inside `step_replay_playback`.
|
||||||
pub(crate) fn handle_step_button(
|
pub(crate) fn handle_step_button(
|
||||||
mut state: ResMut<ReplayPlaybackState>,
|
mut state: ResMut<ReplayPlaybackState>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||||
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
|
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
|
||||||
@@ -1040,7 +1042,12 @@ pub(crate) fn handle_step_button(
|
|||||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
step_replay_playback(
|
||||||
|
&mut state,
|
||||||
|
game.as_deref(),
|
||||||
|
&mut moves_writer,
|
||||||
|
&mut draws_writer,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repaints the Pause / Resume button's label whenever
|
/// Repaints the Pause / Resume button's label whenever
|
||||||
@@ -1112,6 +1119,7 @@ pub(crate) fn handle_pause_keyboard(
|
|||||||
pub(crate) fn handle_arrow_keyboard(
|
pub(crate) fn handle_arrow_keyboard(
|
||||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
mut hold: ResMut<ReplayScrubKeyHold>,
|
mut hold: ResMut<ReplayScrubKeyHold>,
|
||||||
mut state: ResMut<ReplayPlaybackState>,
|
mut state: ResMut<ReplayPlaybackState>,
|
||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
@@ -1136,12 +1144,22 @@ pub(crate) fn handle_arrow_keyboard(
|
|||||||
// Right (forward step) — initial press fires immediately;
|
// Right (forward step) — initial press fires immediately;
|
||||||
// held repeats fire when the accumulator crosses the interval.
|
// held repeats fire when the accumulator crosses the interval.
|
||||||
if keys.just_pressed(KeyCode::ArrowRight) {
|
if keys.just_pressed(KeyCode::ArrowRight) {
|
||||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
step_replay_playback(
|
||||||
|
&mut state,
|
||||||
|
game.as_deref(),
|
||||||
|
&mut moves_writer,
|
||||||
|
&mut draws_writer,
|
||||||
|
);
|
||||||
hold.right_held_secs = 0.0;
|
hold.right_held_secs = 0.0;
|
||||||
} else if keys.pressed(KeyCode::ArrowRight) {
|
} else if keys.pressed(KeyCode::ArrowRight) {
|
||||||
hold.right_held_secs += dt;
|
hold.right_held_secs += dt;
|
||||||
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
||||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
step_replay_playback(
|
||||||
|
&mut state,
|
||||||
|
game.as_deref(),
|
||||||
|
&mut moves_writer,
|
||||||
|
&mut draws_writer,
|
||||||
|
);
|
||||||
hold.right_held_secs = 0.0;
|
hold.right_held_secs = 0.0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{Rank, Suit};
|
||||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
use solitaire_data::Replay;
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
|
||||||
|
|
||||||
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
|
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
|
||||||
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
|
/// `RotateStock` entries. Tests only ever read `replay.moves.len()`
|
||||||
/// (denominator of the progress indicator), so the move kind is
|
/// (denominator of the progress indicator), so the move kind is
|
||||||
/// irrelevant beyond producing the right count.
|
/// irrelevant beyond producing the right count.
|
||||||
fn synthetic_replay(move_count: usize) -> Replay {
|
fn synthetic_replay(move_count: usize) -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
42,
|
42,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
120,
|
120,
|
||||||
1_000,
|
1_000,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||||
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
|
(0..move_count)
|
||||||
|
.map(|_| KlondikeInstruction::RotateStock)
|
||||||
|
.collect(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,20 +1124,17 @@ fn format_pile_uses_one_indexed_lowercase_names() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move-body formatter renders `StockClick` as a label and
|
/// Move-body formatter renders `RotateStock` as a label. The
|
||||||
/// `Move` as a `from → to` arrow. The `count` field is
|
/// `Dst*` variants render as a `→ to` arrow, but their pile-stack
|
||||||
/// deliberately omitted — at row scale it adds noise.
|
/// source types are runtime-only and not constructible from this
|
||||||
|
/// crate, so only the stock-cycle label is asserted here; the
|
||||||
|
/// arrow path is exercised end-to-end through the move-log
|
||||||
|
/// integration tests.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_move_body_handles_both_variants() {
|
fn format_move_body_handles_stock_cycle() {
|
||||||
assert_eq!(format_move_body(&ReplayMove::StockClick), "stock cycle");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_move_body(&ReplayMove::Move {
|
format_move_body(&KlondikeInstruction::RotateStock),
|
||||||
from: SavedKlondikePile::Stock,
|
"stock cycle"
|
||||||
to: SavedKlondikePile::Tableau(SavedTableau(4)),
|
|
||||||
count: 1,
|
|
||||||
}),
|
|
||||||
"waste \u{2192} tableau 5",
|
|
||||||
"Move variant must render as `{{from}} → {{to}}` with 1-indexed pile numbers",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2314,8 +2312,8 @@ fn format_suit_glyph_all_suits() {
|
|||||||
fn format_foundations_row_empty_board() {
|
fn format_foundations_row_empty_board() {
|
||||||
let game = solitaire_core::game_state::GameState::new_with_mode(
|
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||||
42,
|
42,
|
||||||
solitaire_core::DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
solitaire_core::game_state::GameMode::Classic,
|
GameMode::Classic,
|
||||||
);
|
);
|
||||||
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
|
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
|
||||||
}
|
}
|
||||||
@@ -2326,8 +2324,8 @@ fn format_foundations_row_empty_board() {
|
|||||||
fn format_stock_waste_row_initial_state() {
|
fn format_stock_waste_row_initial_state() {
|
||||||
let game = solitaire_core::game_state::GameState::new_with_mode(
|
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||||
42,
|
42,
|
||||||
solitaire_core::DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
solitaire_core::game_state::GameMode::Classic,
|
GameMode::Classic,
|
||||||
);
|
);
|
||||||
let text = format_stock_waste_row(&game);
|
let text = format_stock_waste_row(&game);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use super::*;
|
|||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||||
use solitaire_data::ReplayMove;
|
|
||||||
|
|
||||||
/// Overwrites the banner label whenever the resource changes — covers the
|
/// Overwrites the banner label whenever the resource changes — covers the
|
||||||
/// `Playing → Completed` transition by swapping "▌ replay" for
|
/// `Playing → Completed` transition by swapping "▌ replay" for
|
||||||
@@ -85,9 +84,15 @@ pub(crate) fn update_floating_progress_chip(
|
|||||||
// the most-recently-applied move sits at `cursor - 1`.
|
// the most-recently-applied move sits at `cursor - 1`.
|
||||||
let dest_pile = match state.as_ref() {
|
let dest_pile = match state.as_ref() {
|
||||||
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
||||||
|
// The destination pile is recoverable directly from the
|
||||||
|
// instruction — no live state needed. `RotateStock` has no
|
||||||
|
// destination (the chip hides over the stock pile).
|
||||||
match &replay.moves[cursor - 1] {
|
match &replay.moves[cursor - 1] {
|
||||||
ReplayMove::Move { to, .. } => Some(*to),
|
KlondikeInstruction::DstFoundation(dst) => {
|
||||||
ReplayMove::StockClick => None,
|
Some(KlondikePile::Foundation(dst.foundation))
|
||||||
|
}
|
||||||
|
KlondikeInstruction::DstTableau(dst) => Some(KlondikePile::Tableau(dst.tableau)),
|
||||||
|
KlondikeInstruction::RotateStock => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -95,8 +100,7 @@ pub(crate) fn update_floating_progress_chip(
|
|||||||
|
|
||||||
let Some(world_pos) = dest_pile
|
let Some(world_pos) = dest_pile
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| KlondikePile::try_from(*p).ok())
|
.and_then(|p| layout.0.pile_positions.get(p).copied())
|
||||||
.and_then(|p| layout.0.pile_positions.get(&p).copied())
|
|
||||||
else {
|
else {
|
||||||
// Nothing to point at — hide every chip and exit.
|
// Nothing to point at — hide every chip and exit.
|
||||||
for (_, mut visibility, _) in chips.iter_mut() {
|
for (_, mut visibility, _) in chips.iter_mut() {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
//! flag is threaded through, no every-callsite gate is added.
|
//! flag is threaded through, no every-callsite gate is added.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||||
@@ -94,7 +94,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
|||||||
/// replay's recorded deal.
|
/// replay's recorded deal.
|
||||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||||
/// for each [`ReplayMove`].
|
/// for each [`KlondikeInstruction`].
|
||||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||||
/// [`Completed`](Self::Completed). It lingers for
|
/// [`Completed`](Self::Completed). It lingers for
|
||||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||||
@@ -251,6 +251,7 @@ pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) ->
|
|||||||
/// normal advance loop takes.
|
/// normal advance loop takes.
|
||||||
pub fn step_replay_playback(
|
pub fn step_replay_playback(
|
||||||
state: &mut ResMut<ReplayPlaybackState>,
|
state: &mut ResMut<ReplayPlaybackState>,
|
||||||
|
game: Option<&GameStateResource>,
|
||||||
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
||||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
@@ -266,31 +267,49 @@ pub fn step_replay_playback(
|
|||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= replay.moves.len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match &replay.moves[*cursor] {
|
let instruction = replay.moves[*cursor];
|
||||||
ReplayMove::Move { from, to, count } => {
|
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||||
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
|
||||||
else {
|
|
||||||
warn!(
|
|
||||||
"skipping replay move with invalid pile encoding at cursor {}",
|
|
||||||
*cursor
|
|
||||||
);
|
|
||||||
*cursor += 1;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
moves_writer.write(MoveRequestEvent {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
count: *count,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ReplayMove::StockClick => {
|
|
||||||
draws_writer.write(DrawRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Translates one recorded [`KlondikeInstruction`] into the canonical
|
||||||
|
/// engine event that drives the live animation pipeline.
|
||||||
|
///
|
||||||
|
/// `RotateStock` fires a [`DrawRequestEvent`]; a `Dst*` instruction is
|
||||||
|
/// decoded back to its runtime `(from, to, count)` pile coordinates via
|
||||||
|
/// [`GameState::instruction_to_piles`] against the *current* live state
|
||||||
|
/// (decoded before the event mutates it, so the source pile's face-up
|
||||||
|
/// run length is the one in effect when the move applies) and fires a
|
||||||
|
/// [`MoveRequestEvent`]. A decode that returns `None` (e.g. a malformed
|
||||||
|
/// instruction loaded from disk) is skipped with a warning rather than
|
||||||
|
/// panicking — the cursor still advances so playback never stalls.
|
||||||
|
///
|
||||||
|
/// `game` is `None` only in headless fixtures that install no
|
||||||
|
/// [`GameStateResource`]; in that case only `RotateStock` (which needs
|
||||||
|
/// no live state) is dispatched and `Dst*` instructions are skipped.
|
||||||
|
fn dispatch_instruction(
|
||||||
|
instruction: KlondikeInstruction,
|
||||||
|
cursor: usize,
|
||||||
|
game: Option<&GameStateResource>,
|
||||||
|
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
||||||
|
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||||
|
) {
|
||||||
|
match instruction {
|
||||||
|
KlondikeInstruction::RotateStock => {
|
||||||
|
draws_writer.write(DrawRequestEvent);
|
||||||
|
}
|
||||||
|
_ => match game.and_then(|g| g.0.instruction_to_piles(instruction)) {
|
||||||
|
Some((from, to, count)) => {
|
||||||
|
moves_writer.write(MoveRequestEvent { from, to, count });
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
warn!("skipping replay move that did not decode to piles at cursor {cursor}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Steps the replay **backwards** by exactly one move while paused.
|
/// Steps the replay **backwards** by exactly one move while paused.
|
||||||
///
|
///
|
||||||
/// Strategy: the live game's undo system is the source of truth for
|
/// Strategy: the live game's undo system is the source of truth for
|
||||||
@@ -355,6 +374,7 @@ pub fn step_backwards_replay_playback(
|
|||||||
fn tick_replay_playback(
|
fn tick_replay_playback(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
mut state: ResMut<ReplayPlaybackState>,
|
mut state: ResMut<ReplayPlaybackState>,
|
||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||||
@@ -378,27 +398,14 @@ fn tick_replay_playback(
|
|||||||
if !*paused {
|
if !*paused {
|
||||||
*secs_to_next -= dt;
|
*secs_to_next -= dt;
|
||||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||||
match &replay.moves[*cursor] {
|
let instruction = replay.moves[*cursor];
|
||||||
ReplayMove::Move { from, to, count } => {
|
dispatch_instruction(
|
||||||
if let (Ok(from), Ok(to)) =
|
instruction,
|
||||||
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
*cursor,
|
||||||
{
|
game.as_deref(),
|
||||||
moves_writer.write(MoveRequestEvent {
|
&mut moves_writer,
|
||||||
from,
|
&mut draws_writer,
|
||||||
to,
|
);
|
||||||
count: *count,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
warn!(
|
|
||||||
"skipping replay move with invalid pile encoding at cursor {}",
|
|
||||||
*cursor
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ReplayMove::StockClick => {
|
|
||||||
draws_writer.write(DrawRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
*secs_to_next += interval;
|
*secs_to_next += interval;
|
||||||
}
|
}
|
||||||
@@ -555,9 +562,8 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use bevy::time::TimeUpdateStrategy;
|
use bevy::time::TimeUpdateStrategy;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{KlondikePile, Tableau};
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||||
@@ -592,25 +598,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A 3-move replay covering both `Move` and `StockClick` variants.
|
/// A 3-move replay of `RotateStock` inputs. Pile-position types are
|
||||||
/// Seed 12345 is arbitrary — the test asserts on event counts and
|
/// runtime-only and intentionally not constructible from the engine
|
||||||
/// move shapes, not on board positions.
|
/// crate, so a `Dst*` fixture can't be hand-built here; `RotateStock`
|
||||||
|
/// exercises the dispatch path (it fires a `DrawRequestEvent` without
|
||||||
|
/// needing a live state to decode piles). Seed 12345 is arbitrary —
|
||||||
|
/// the test asserts on event counts, not board positions.
|
||||||
fn sample_replay_three_moves() -> Replay {
|
fn sample_replay_three_moves() -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
12345,
|
12345,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![
|
vec![
|
||||||
ReplayMove::StockClick,
|
KlondikeInstruction::RotateStock,
|
||||||
ReplayMove::Move {
|
KlondikeInstruction::RotateStock,
|
||||||
from: SavedKlondikePile::Stock,
|
KlondikeInstruction::RotateStock,
|
||||||
to: SavedKlondikePile::Tableau(SavedTableau(3)),
|
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
ReplayMove::StockClick,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -748,20 +753,17 @@ mod tests {
|
|||||||
let captured_moves = app.world().resource::<CapturedMoves>();
|
let captured_moves = app.world().resource::<CapturedMoves>();
|
||||||
let captured_draws = app.world().resource::<CapturedDraws>();
|
let captured_draws = app.world().resource::<CapturedDraws>();
|
||||||
|
|
||||||
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
|
// Sample replay: three `RotateStock` inputs — each dispatches a
|
||||||
|
// `DrawRequestEvent` and never a `MoveRequestEvent`.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
captured_draws.0, 2,
|
captured_draws.0, 3,
|
||||||
"expected 2 DrawRequestEvent (two StockClicks)",
|
"expected 3 DrawRequestEvent (one per RotateStock)",
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
captured_moves.0.len(),
|
captured_moves.0.len(),
|
||||||
1,
|
0,
|
||||||
"expected 1 MoveRequestEvent (the single Move variant)",
|
"RotateStock inputs must not produce MoveRequestEvent",
|
||||||
);
|
);
|
||||||
let m = &captured_moves.0[0];
|
|
||||||
assert!(matches!(m.from, KlondikePile::Stock));
|
|
||||||
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
|
|
||||||
assert_eq!(m.count, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Driving past one interval on a single-move replay must
|
/// Driving past one interval on a single-move replay must
|
||||||
@@ -771,12 +773,12 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let one_move = Replay::new(
|
let one_move = Replay::new(
|
||||||
42,
|
42,
|
||||||
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],
|
||||||
);
|
);
|
||||||
start_playback(&mut app, one_move);
|
start_playback(&mut app, one_move);
|
||||||
app.update();
|
app.update();
|
||||||
@@ -822,7 +824,7 @@ mod tests {
|
|||||||
// Replay — their in-flight recording must not get clobbered.
|
// Replay — their in-flight recording must not get clobbered.
|
||||||
{
|
{
|
||||||
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
|
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
|
||||||
rec.moves.push(ReplayMove::StockClick);
|
rec.moves.push(KlondikeInstruction::RotateStock);
|
||||||
}
|
}
|
||||||
start_playback(&mut app, sample_replay_three_moves());
|
start_playback(&mut app, sample_replay_three_moves());
|
||||||
app.update();
|
app.update();
|
||||||
@@ -880,12 +882,12 @@ mod tests {
|
|||||||
fn ten_draws_replay() -> Replay {
|
fn ten_draws_replay() -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
7,
|
7,
|
||||||
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; 10],
|
vec![KlondikeInstruction::RotateStock; 10],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use bevy::math::Vec2;
|
|||||||
use bevy::prelude::Resource;
|
use bevy::prelude::Resource;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||||
|
|||||||
@@ -191,10 +191,12 @@ fn apply_safe_area_to_modal_scrims(
|
|||||||
/// whatever `SafeAreaInsets` are current at that moment.
|
/// whatever `SafeAreaInsets` are current at that moment.
|
||||||
///
|
///
|
||||||
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||||||
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
|
/// and resets `SafeAreaPollTries` (the cached `SafeAreaInsets` keep their
|
||||||
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
|
/// last-known values), causing `refresh_insets` to re-poll JNI over the next
|
||||||
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
|
/// few frames. If the insets changed while backgrounded, `on_safe_area_changed`
|
||||||
/// synthetic `WindowResized` and the layout converges to the right position.
|
/// in `table_plugin` emits a second synthetic `WindowResized` and the layout
|
||||||
|
/// converges to the right position; if they didn't, nothing is rewritten and
|
||||||
|
/// the layout stays put.
|
||||||
///
|
///
|
||||||
/// On non-Android targets this handler still fires — it ensures that a resume
|
/// On non-Android targets this handler still fires — it ensures that a resume
|
||||||
/// event always refreshes the layout (e.g., after a minimise/restore on
|
/// event always refreshes the layout (e.g., after a minimise/restore on
|
||||||
@@ -232,9 +234,14 @@ mod android {
|
|||||||
pub(super) struct SafeAreaPollTries(pub u32);
|
pub(super) struct SafeAreaPollTries(pub u32);
|
||||||
|
|
||||||
/// Polls Android for safe-area insets until we get a non-zero
|
/// Polls Android for safe-area insets until we get a non-zero
|
||||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
/// reading, then settles until [`rearm_on_resumed`] re-arms it on the
|
||||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
/// next foreground resume — insets can change while backgrounded
|
||||||
/// is typically frame 1–3 of a fresh launch.
|
/// (rotation, fold/unfold, gesture ↔ 3-button nav). The poll counter
|
||||||
|
/// (not `insets.is_populated()`) gates the loop, so a re-armed cycle
|
||||||
|
/// re-queries JNI even though cached values are already populated.
|
||||||
|
/// `getRootWindowInsets()` returns `null` (or all-zero `Insets`)
|
||||||
|
/// until the decor view has been laid out, which is typically frame
|
||||||
|
/// 1–3 of a fresh launch.
|
||||||
pub(super) fn refresh_insets(
|
pub(super) fn refresh_insets(
|
||||||
mut insets: ResMut<SafeAreaInsets>,
|
mut insets: ResMut<SafeAreaInsets>,
|
||||||
mut poll: ResMut<SafeAreaPollTries>,
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
@@ -243,18 +250,25 @@ mod android {
|
|||||||
// devices that genuinely report zero insets.
|
// devices that genuinely report zero insets.
|
||||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||||
|
|
||||||
if poll.0 >= MAX_TRIES || insets.is_populated() {
|
if poll.0 >= MAX_TRIES {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
poll.0 += 1;
|
poll.0 += 1;
|
||||||
|
|
||||||
match query_insets() {
|
match query_insets() {
|
||||||
Ok(v) if v.is_populated() => {
|
Ok(v) if v.is_populated() => {
|
||||||
info!(
|
if *insets != v {
|
||||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
info!(
|
||||||
v.top, v.bottom, v.left, v.right, poll.0
|
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||||
);
|
v.top, v.bottom, v.left, v.right, poll.0
|
||||||
*insets = v;
|
);
|
||||||
|
*insets = v;
|
||||||
|
}
|
||||||
|
// Settled for this poll cycle; `rearm_on_resumed` re-arms on
|
||||||
|
// the next resume. Writing `insets` only on an actual change
|
||||||
|
// keeps resource change detection (and the relayout it
|
||||||
|
// triggers) quiet on resumes where nothing moved.
|
||||||
|
poll.0 = MAX_TRIES;
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Layout not ready yet; try again next frame.
|
// Layout not ready yet; try again next frame.
|
||||||
@@ -291,30 +305,12 @@ mod android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use bevy::android::ANDROID_APP;
|
use solitaire_data::android_jni;
|
||||||
use jni::{JavaVM, objects::JObject};
|
|
||||||
|
|
||||||
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 JavaVM* set up by the Android
|
|
||||||
// runtime; valid for the lifetime of the process.
|
|
||||||
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()` returns 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<SafeAreaInsets> {
|
|
||||||
// Window window = activity.getWindow();
|
// Window window = activity.getWindow();
|
||||||
let window = env
|
let window = env
|
||||||
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
|
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||||
.l()?;
|
.l()?;
|
||||||
|
|
||||||
// View decor = window.getDecorView();
|
// View decor = window.getDecorView();
|
||||||
@@ -366,8 +362,7 @@ mod android {
|
|||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
})
|
})
|
||||||
})()
|
})
|
||||||
.map_err(|e| format!("safe-area JNI: {e}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntityIndex;
|
use crate::card_plugin::CardEntityIndex;
|
||||||
@@ -163,7 +163,7 @@ impl Plugin for SelectionPlugin {
|
|||||||
update_selection_highlight.after(GameMutation).run_if(
|
update_selection_highlight.after(GameMutation).run_if(
|
||||||
resource_changed::<SelectionState>
|
resource_changed::<SelectionState>
|
||||||
.or(resource_changed::<KeyboardDragState>)
|
.or(resource_changed::<KeyboardDragState>)
|
||||||
.or(resource_changed::<crate::GameStateResource>),
|
.or(resource_changed::<GameStateResource>),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -534,7 +534,7 @@ fn handle_selection_keys(
|
|||||||
/// destination after a lift. Players who want a different column simply
|
/// destination after a lift. Players who want a different column simply
|
||||||
/// press the right-arrow key once or twice.
|
/// press the right-arrow key once or twice.
|
||||||
pub(crate) fn legal_destinations_for(
|
pub(crate) fn legal_destinations_for(
|
||||||
_bottom: &solitaire_core::card::Card,
|
_bottom: &Card,
|
||||||
source: &KlondikePile,
|
source: &KlondikePile,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
stack_count: usize,
|
stack_count: usize,
|
||||||
@@ -579,7 +579,7 @@ pub(crate) fn legal_destinations_for(
|
|||||||
/// Walks backwards from the last element and stops at the first face-down card
|
/// Walks backwards from the last element and stops at the first face-down card
|
||||||
/// (or when the slice is exhausted). Returns at least `1` when the top card is
|
/// (or when the slice is exhausted). Returns at least `1` when the top card is
|
||||||
/// face-up; returns `0` for an empty slice or when the top card is face-down.
|
/// face-up; returns `0` for an empty slice or when the top card is face-down.
|
||||||
fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
|
fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for (_, face_up) in cards.iter().rev() {
|
for (_, face_up) in cards.iter().rev() {
|
||||||
if *face_up {
|
if *face_up {
|
||||||
@@ -598,8 +598,8 @@ fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
|
|||||||
/// handler can attempt a foundation move first and fall through to a
|
/// handler can attempt a foundation move first and fall through to a
|
||||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||||
fn try_foundation_dest(
|
fn try_foundation_dest(
|
||||||
card: &solitaire_core::card::Card,
|
card: &Card,
|
||||||
game: &solitaire_core::game_state::GameState,
|
game: &GameState,
|
||||||
) -> Option<KlondikePile> {
|
) -> Option<KlondikePile> {
|
||||||
let source = game.pile_containing_card(card.clone())?;
|
let source = game.pile_containing_card(card.clone())?;
|
||||||
for foundation in [
|
for foundation in [
|
||||||
@@ -886,7 +886,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn face_up_run_len_all_face_up() {
|
fn face_up_run_len_all_face_up() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let cards = vec![
|
let cards = vec![
|
||||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
||||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
|
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
|
||||||
@@ -897,7 +897,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn face_up_run_len_mixed_stops_at_face_down() {
|
fn face_up_run_len_mixed_stops_at_face_down() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let cards = vec![
|
let cards = vec![
|
||||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
|
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
|
||||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
||||||
@@ -910,7 +910,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn face_up_run_len_top_card_face_down_is_zero() {
|
fn face_up_run_len_top_card_face_down_is_zero() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let cards = vec![
|
let cards = vec![
|
||||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
||||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
||||||
@@ -920,7 +920,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn face_up_run_len_single_face_up_card() {
|
fn face_up_run_len_single_face_up_card() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
|
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
|
||||||
assert_eq!(face_up_run_len(&cards), 1);
|
assert_eq!(face_up_run_len(&cards), 1);
|
||||||
}
|
}
|
||||||
@@ -934,8 +934,8 @@ mod tests {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
|
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
|
||||||
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
|
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
|
||||||
@@ -968,7 +968,7 @@ mod tests {
|
|||||||
/// Ace first). It cannot go to an empty tableau (only Kings).
|
/// Ace first). It cannot go to an empty tableau (only Kings).
|
||||||
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
|
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
|
||||||
fn deterministic_state() -> GameState {
|
fn deterministic_state() -> GameState {
|
||||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||||
// Clear stock, waste, all tableaus.
|
// Clear stock, waste, all tableaus.
|
||||||
g.set_test_stock_cards(Vec::new());
|
g.set_test_stock_cards(Vec::new());
|
||||||
g.set_test_waste_cards(Vec::new());
|
g.set_test_waste_cards(Vec::new());
|
||||||
|
|||||||
+10
-665
@@ -15,7 +15,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
use bevy::window::{WindowMoved, WindowResized};
|
use bevy::window::{WindowMoved, WindowResized};
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
|
||||||
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
|
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
|
||||||
@@ -379,8 +379,8 @@ impl Plugin for SettingsPlugin {
|
|||||||
.add_message::<DeleteAccountRequestEvent>()
|
.add_message::<DeleteAccountRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
.add_message::<bevy::input::touch::TouchInput>()
|
.add_message::<TouchInput>()
|
||||||
// `WindowResized` / `WindowMoved` are real Bevy window events
|
// `WindowResized` / `WindowMoved` are real Bevy window events
|
||||||
// and emitted by the windowing backend under `DefaultPlugins`,
|
// and emitted by the windowing backend under `DefaultPlugins`,
|
||||||
// but we register them explicitly here so the geometry watcher
|
// but we register them explicitly here so the geometry watcher
|
||||||
@@ -1086,8 +1086,8 @@ fn handle_settings_buttons(
|
|||||||
}
|
}
|
||||||
SettingsButton::ToggleDrawMode => {
|
SettingsButton::ToggleDrawMode => {
|
||||||
settings.0.draw_mode = match settings.0.draw_mode {
|
settings.0.draw_mode = match settings.0.draw_mode {
|
||||||
DrawMode::DrawOne => DrawMode::DrawThree,
|
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||||||
DrawMode::DrawThree => DrawMode::DrawOne,
|
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
|
||||||
};
|
};
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
@@ -1310,10 +1310,10 @@ fn handle_sync_buttons(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_mode_label(mode: &DrawMode) -> String {
|
fn draw_mode_label(mode: &DrawStockConfig) -> String {
|
||||||
match mode {
|
match mode {
|
||||||
DrawMode::DrawOne => "Draw 1".into(),
|
DrawStockConfig::DrawOne => "Draw 1".into(),
|
||||||
DrawMode::DrawThree => "Draw 3".into(),
|
DrawStockConfig::DrawThree => "Draw 3".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2662,7 +2662,7 @@ fn handle_scan_themes(
|
|||||||
|
|
||||||
let themes_dir = user_theme_dir();
|
let themes_dir = user_theme_dir();
|
||||||
|
|
||||||
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) {
|
let zips: Vec<PathBuf> = match std::fs::read_dir(&themes_dir) {
|
||||||
Ok(entries) => entries
|
Ok(entries) => entries
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|e| e.path())
|
.map(|e| e.path())
|
||||||
@@ -2854,659 +2854,4 @@ fn icon_button(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn headless_app() -> App {
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(SettingsPlugin::headless());
|
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
|
||||||
app.update();
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
fn press(app: &mut App, key: KeyCode) {
|
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
||||||
input.release(key);
|
|
||||||
input.clear();
|
|
||||||
input.press(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_are_loaded() {
|
|
||||||
let app = headless_app();
|
|
||||||
assert_eq!(
|
|
||||||
app.world().resource::<SettingsResource>().0,
|
|
||||||
Settings::default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketLeft);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
|
||||||
assert!(after < before);
|
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
|
||||||
let mut cursor = events.get_cursor();
|
|
||||||
assert_eq!(cursor.read(events).count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pressing_right_bracket_increases_volume() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.sfx_volume = 0.5;
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketRight);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
|
||||||
assert!((after - 0.6).abs() < 1e-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clamped_change_does_not_emit_event() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.sfx_volume = 1.0;
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketRight);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
|
||||||
let mut cursor = events.get_cursor();
|
|
||||||
assert_eq!(cursor.read(events).count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn volume_clamped_at_zero_does_not_emit_event() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.sfx_volume = 0.0;
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketLeft);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
|
||||||
assert!(after >= 0.0, "volume must not go below zero");
|
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
|
||||||
let mut cursor = events.get_cursor();
|
|
||||||
assert_eq!(
|
|
||||||
cursor.read(events).count(),
|
|
||||||
0,
|
|
||||||
"no event when clamped at floor"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pressing_o_toggles_settings_screen_flag() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
assert!(
|
|
||||||
!app.world().resource::<SettingsScreen>().0,
|
|
||||||
"screen is closed initially"
|
|
||||||
);
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::KeyO);
|
|
||||||
app.update();
|
|
||||||
assert!(
|
|
||||||
app.world().resource::<SettingsScreen>().0,
|
|
||||||
"O opens settings"
|
|
||||||
);
|
|
||||||
|
|
||||||
press(&mut app, KeyCode::KeyO);
|
|
||||||
app.update();
|
|
||||||
assert!(
|
|
||||||
!app.world().resource::<SettingsScreen>().0,
|
|
||||||
"second O closes settings"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// cycle_unlocked pure-function tests
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_wraps_at_end() {
|
|
||||||
// [0, 1, 2] → cycling from 2 wraps to 0
|
|
||||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_advances_normally() {
|
|
||||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
|
||||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_single_element_stays() {
|
|
||||||
// Only one unlockable — cycling always returns it.
|
|
||||||
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
|
||||||
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
|
||||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_unlocked_empty_returns_zero() {
|
|
||||||
assert_eq!(cycle_unlocked(&[], 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scroll_is_noop_when_settings_panel_closed() {
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
|
||||||
let entity = app
|
|
||||||
.world_mut()
|
|
||||||
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
|
||||||
.id();
|
|
||||||
// Send a downward scroll event while the panel is closed.
|
|
||||||
app.world_mut().write_message(MouseWheel {
|
|
||||||
unit: MouseScrollUnit::Line,
|
|
||||||
x: 0.0,
|
|
||||||
y: -3.0,
|
|
||||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
|
||||||
});
|
|
||||||
app.update();
|
|
||||||
// ScrollPosition must remain at 0.0 — panel was closed.
|
|
||||||
let offset = app
|
|
||||||
.world()
|
|
||||||
.entity(entity)
|
|
||||||
.get::<ScrollPosition>()
|
|
||||||
.unwrap()
|
|
||||||
.0
|
|
||||||
.y;
|
|
||||||
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scroll_moves_offset_when_panel_open() {
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Open the panel.
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
|
||||||
let entity = app
|
|
||||||
.world_mut()
|
|
||||||
.spawn((
|
|
||||||
SettingsPanelScrollable,
|
|
||||||
ScrollPosition(Vec2::new(0.0, 100.0)),
|
|
||||||
))
|
|
||||||
.id();
|
|
||||||
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
|
||||||
app.world_mut().write_message(MouseWheel {
|
|
||||||
unit: MouseScrollUnit::Line,
|
|
||||||
x: 0.0,
|
|
||||||
y: -2.0,
|
|
||||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
|
||||||
});
|
|
||||||
app.update();
|
|
||||||
let offset = app
|
|
||||||
.world()
|
|
||||||
.entity(entity)
|
|
||||||
.get::<ScrollPosition>()
|
|
||||||
.unwrap()
|
|
||||||
.0
|
|
||||||
.y;
|
|
||||||
assert!(
|
|
||||||
(offset - 200.0).abs() < 1e-3,
|
|
||||||
"scrolling down should increase offset_y; got {offset}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
|
||||||
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
|
||||||
/// auto-tag systems fire end-to-end without writing to disk.
|
|
||||||
fn headless_app_with_focus() -> App {
|
|
||||||
use crate::ui_focus::UiFocusPlugin;
|
|
||||||
use crate::ui_modal::UiModalPlugin;
|
|
||||||
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(UiModalPlugin)
|
|
||||||
.add_plugins(UiFocusPlugin)
|
|
||||||
.add_plugins(SettingsPlugin {
|
|
||||||
// No persistence — keep the test isolated.
|
|
||||||
storage_path: None,
|
|
||||||
ui_enabled: true,
|
|
||||||
});
|
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
|
||||||
app.update();
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_buttons_get_focusable_marker() {
|
|
||||||
let mut app = headless_app_with_focus();
|
|
||||||
|
|
||||||
// Open the panel.
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
app.update();
|
|
||||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
|
||||||
// and queues the spawn commands; the second flushes them and
|
|
||||||
// runs `attach_focusable_to_settings_buttons`.
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
|
||||||
// `ModalButton`) must carry a `Focusable`.
|
|
||||||
let untagged: Vec<&SettingsButton> = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
|
||||||
.iter(app.world())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
untagged.is_empty(),
|
|
||||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
|
||||||
untagged
|
|
||||||
);
|
|
||||||
|
|
||||||
// And there must be at least one tagged `SettingsButton` so the
|
|
||||||
// assertion above isn't vacuously true (the panel really did
|
|
||||||
// spawn).
|
|
||||||
let tagged_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
tagged_count >= 6,
|
|
||||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
|
||||||
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
|
||||||
/// indexed swatches carry hover-reveal context. Mirrors
|
|
||||||
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
|
||||||
/// the invariant — every interactive Settings element except the
|
|
||||||
/// `Done` modal button has a tooltip — is asserted consistently.
|
|
||||||
#[test]
|
|
||||||
fn settings_buttons_carry_tooltip() {
|
|
||||||
let mut app = headless_app_with_focus();
|
|
||||||
|
|
||||||
// Open the panel and let spawn + child-flush run.
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
|
||||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
|
||||||
let untipped: Vec<&SettingsButton> = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
|
||||||
.iter(app.world())
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
untipped.is_empty(),
|
|
||||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
|
||||||
untipped
|
|
||||||
);
|
|
||||||
|
|
||||||
// And there must be at least 6 tipped buttons so the assertion
|
|
||||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
|
||||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
|
||||||
// card-back and one background swatch — well over the floor.
|
|
||||||
let tipped_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
tipped_count >= 6,
|
|
||||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spot-check: with default (Local) settings the Connect button
|
|
||||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
|
||||||
let connect_tip = app
|
|
||||||
.world_mut()
|
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
|
||||||
.iter(app.world())
|
|
||||||
.find_map(|(btn, tip)| {
|
|
||||||
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
|
||||||
})
|
|
||||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
|
||||||
assert_eq!(
|
|
||||||
connect_tip.as_ref(),
|
|
||||||
"Connect to a self-hosted Ferrous Solitaire sync server.",
|
|
||||||
"ConnectSync tooltip must use the canonical microcopy"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_picker_rows_get_focus_row_marker() {
|
|
||||||
let mut app = headless_app_with_focus();
|
|
||||||
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Two picker rows are spawned (card-back + background); each
|
|
||||||
// must carry the FocusRow marker.
|
|
||||||
let row_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<Entity, With<FocusRow>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
row_count >= 2,
|
|
||||||
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
|
||||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
|
||||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
|
||||||
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
|
|
||||||
/// `ImageNode` for each preview slot.
|
|
||||||
#[test]
|
|
||||||
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
|
||||||
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
|
|
||||||
|
|
||||||
let mut app = headless_app_with_focus();
|
|
||||||
// Prime an Assets<Image> resource so we can mint stable handles
|
|
||||||
// for the synthetic thumbnail pair.
|
|
||||||
app.init_resource::<Assets<Image>>();
|
|
||||||
let (ace_handle, back_handle) = {
|
|
||||||
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
|
|
||||||
let ace = images.add(Image::default());
|
|
||||||
let back = images.add(Image::default());
|
|
||||||
(ace, back)
|
|
||||||
};
|
|
||||||
// Inject one theme entry + a matching thumbnail pair.
|
|
||||||
app.insert_resource(ThemeRegistry {
|
|
||||||
entries: vec![ThemeEntry {
|
|
||||||
id: "test_theme".into(),
|
|
||||||
display_name: "Test Theme".into(),
|
|
||||||
manifest_url: "themes://test_theme/theme.ron".into(),
|
|
||||||
meta: crate::theme::ThemeMeta {
|
|
||||||
id: "test_theme".into(),
|
|
||||||
name: "Test Theme".into(),
|
|
||||||
author: "x".into(),
|
|
||||||
version: "x".into(),
|
|
||||||
card_aspect: (2, 3),
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
let mut cache = ThemeThumbnailCache::default();
|
|
||||||
cache.entries.insert(
|
|
||||||
"test_theme".into(),
|
|
||||||
ThemeThumbnailPair {
|
|
||||||
ace: ace_handle.clone(),
|
|
||||||
back: back_handle.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
app.insert_resource(cache);
|
|
||||||
|
|
||||||
// Open the panel and let the spawn + child-flush systems run.
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
|
||||||
// theme picker chip for "test_theme" must contribute exactly
|
|
||||||
// two of them (ace + back).
|
|
||||||
let thumbnail_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
thumbnail_count >= 2,
|
|
||||||
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spot-check: at least one thumbnail's image handle matches one
|
|
||||||
// of the ones we inserted into the cache. This guards against a
|
|
||||||
// future refactor that accidentally clones the wrong handle.
|
|
||||||
let any_matches = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.any(|node| node.image == ace_handle || node.image == back_handle);
|
|
||||||
assert!(
|
|
||||||
any_matches,
|
|
||||||
"at least one rendered thumbnail must reuse the cached handle"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Window geometry persistence
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn should_persist_geometry_respects_debounce_window() {
|
|
||||||
// Within the debounce window: not yet.
|
|
||||||
assert!(!should_persist_geometry(10.0, 9.7));
|
|
||||||
assert!(!should_persist_geometry(
|
|
||||||
10.0,
|
|
||||||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
|
||||||
));
|
|
||||||
// Exactly the debounce window: allowed (>= comparison).
|
|
||||||
assert!(should_persist_geometry(
|
|
||||||
10.0,
|
|
||||||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
|
||||||
));
|
|
||||||
// Well past the debounce window: allowed.
|
|
||||||
assert!(should_persist_geometry(20.0, 10.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_geometry_uses_existing_when_event_components_missing() {
|
|
||||||
let existing = WindowGeometry {
|
|
||||||
width: 1280,
|
|
||||||
height: 800,
|
|
||||||
x: 100,
|
|
||||||
y: 50,
|
|
||||||
};
|
|
||||||
// Position-only event keeps existing size.
|
|
||||||
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
|
||||||
assert_eq!(merged.width, 1280);
|
|
||||||
assert_eq!(merged.height, 800);
|
|
||||||
assert_eq!(merged.x, 200);
|
|
||||||
assert_eq!(merged.y, 75);
|
|
||||||
// Size-only event keeps existing position.
|
|
||||||
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
|
||||||
assert_eq!(merged.width, 1024);
|
|
||||||
assert_eq!(merged.height, 768);
|
|
||||||
assert_eq!(merged.x, 100);
|
|
||||||
assert_eq!(merged.y, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_geometry_returns_none_when_size_unknown() {
|
|
||||||
// No existing geometry, no size in the event → can't fabricate one.
|
|
||||||
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
|
||||||
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
|
||||||
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
|
||||||
/// slices and run enough ticks to comfortably exceed the debounce
|
|
||||||
/// window after the first record tick has set `last_changed_secs`.
|
|
||||||
fn advance_past_geometry_debounce(app: &mut App) {
|
|
||||||
use bevy::time::TimeUpdateStrategy;
|
|
||||||
use std::time::Duration;
|
|
||||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
|
||||||
0.15,
|
|
||||||
)));
|
|
||||||
// Tick 1 sets last_changed_secs from any pending events. Each
|
|
||||||
// subsequent tick advances the clock by 150 ms; five ticks total
|
|
||||||
// buys 0.75 s of elapsed time relative to the record tick — well
|
|
||||||
// past the 0.5 s debounce window.
|
|
||||||
for _ in 0..5 {
|
|
||||||
app.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
|
||||||
app.world_mut().write_message(WindowResized {
|
|
||||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fire_move(app: &mut App, x: i32, y: i32) {
|
|
||||||
app.world_mut().write_message(WindowMoved {
|
|
||||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
|
||||||
position: IVec2::new(x, y),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resize_event_then_quiet_persists_window_geometry() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Sanity: geometry starts unset (default).
|
|
||||||
assert!(
|
|
||||||
app.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fire a resize, then go quiet for past the debounce.
|
|
||||||
fire_resize(&mut app, 1500.0, 950.0);
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
|
|
||||||
let geom = app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.expect("geometry should be persisted after debounce");
|
|
||||||
assert_eq!(geom.width, 1500);
|
|
||||||
assert_eq!(geom.height, 950);
|
|
||||||
// Position not yet observed → defaults to 0, 0 since there was
|
|
||||||
// no existing geometry to fall back on.
|
|
||||||
assert_eq!(geom.x, 0);
|
|
||||||
assert_eq!(geom.y, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn move_event_after_resize_updates_position_only() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
|
|
||||||
// First, establish a baseline geometry via a resize event.
|
|
||||||
fire_resize(&mut app, 1280.0, 800.0);
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
let baseline = app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(baseline.width, 1280);
|
|
||||||
|
|
||||||
// Now fire a move-only event — size must be preserved from the
|
|
||||||
// existing geometry.
|
|
||||||
fire_move(&mut app, 250, 175);
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
|
|
||||||
let geom = app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
geom.width, 1280,
|
|
||||||
"size must be preserved across a move-only update"
|
|
||||||
);
|
|
||||||
assert_eq!(geom.height, 800);
|
|
||||||
assert_eq!(geom.x, 250);
|
|
||||||
assert_eq!(geom.y, 175);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rapid_resize_storm_only_persists_final_size() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
|
|
||||||
// Burst of resize events on a single frame — only the last one
|
|
||||||
// should be the eventually-persisted size.
|
|
||||||
fire_resize(&mut app, 900.0, 600.0);
|
|
||||||
fire_resize(&mut app, 1100.0, 700.0);
|
|
||||||
fire_resize(&mut app, 1400.0, 850.0);
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
|
|
||||||
let geom = app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!((geom.width, geom.height), (1400, 850));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_window_events_no_geometry_change() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Just advance time — without any events, settings must stay clean.
|
|
||||||
advance_past_geometry_debounce(&mut app);
|
|
||||||
assert!(
|
|
||||||
app.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.window_geometry
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scroll_clamps_offset_to_zero_at_top() {
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
|
||||||
// Entity starts at 10 px offset.
|
|
||||||
let entity = app
|
|
||||||
.world_mut()
|
|
||||||
.spawn((
|
|
||||||
SettingsPanelScrollable,
|
|
||||||
ScrollPosition(Vec2::new(0.0, 10.0)),
|
|
||||||
))
|
|
||||||
.id();
|
|
||||||
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
|
||||||
app.world_mut().write_message(MouseWheel {
|
|
||||||
unit: MouseScrollUnit::Line,
|
|
||||||
x: 0.0,
|
|
||||||
y: 5.0,
|
|
||||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
|
||||||
});
|
|
||||||
app.update();
|
|
||||||
let offset = app
|
|
||||||
.world()
|
|
||||||
.entity(entity)
|
|
||||||
.get::<ScrollPosition>()
|
|
||||||
.unwrap()
|
|
||||||
.0
|
|
||||||
.y;
|
|
||||||
assert_eq!(
|
|
||||||
offset, 0.0,
|
|
||||||
"scrolling past top must clamp to 0, got {offset}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(SettingsPlugin::headless());
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_loaded() {
|
||||||
|
let app = headless_app();
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<SettingsResource>().0,
|
||||||
|
Settings::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketLeft);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!(after < before);
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(cursor.read(events).count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_right_bracket_increases_volume() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 0.5;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketRight);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!((after - 0.6).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamped_change_does_not_emit_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 1.0;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketRight);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(cursor.read(events).count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume_clamped_at_zero_does_not_emit_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 0.0;
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::BracketLeft);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!(after >= 0.0, "volume must not go below zero");
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(
|
||||||
|
cursor.read(events).count(),
|
||||||
|
0,
|
||||||
|
"no event when clamped at floor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_o_toggles_settings_screen_flag() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<SettingsScreen>().0,
|
||||||
|
"screen is closed initially"
|
||||||
|
);
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyO);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SettingsScreen>().0,
|
||||||
|
"O opens settings"
|
||||||
|
);
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyO);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<SettingsScreen>().0,
|
||||||
|
"second O closes settings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycle_unlocked pure-function tests
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_wraps_at_end() {
|
||||||
|
// [0, 1, 2] → cycling from 2 wraps to 0
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_advances_normally() {
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_single_element_stays() {
|
||||||
|
// Only one unlockable — cycling always returns it.
|
||||||
|
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
||||||
|
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
||||||
|
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_unlocked_empty_returns_zero() {
|
||||||
|
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_is_noop_when_settings_panel_closed() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||||||
|
.id();
|
||||||
|
// Send a downward scroll event while the panel is closed.
|
||||||
|
app.world_mut().write_message(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: -3.0,
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
// ScrollPosition must remain at 0.0 — panel was closed.
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.y;
|
||||||
|
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_moves_offset_when_panel_open() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Open the panel.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
SettingsPanelScrollable,
|
||||||
|
ScrollPosition(Vec2::new(0.0, 100.0)),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
||||||
|
app.world_mut().write_message(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: -2.0,
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.y;
|
||||||
|
assert!(
|
||||||
|
(offset - 200.0).abs() < 1e-3,
|
||||||
|
"scrolling down should increase offset_y; got {offset}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
||||||
|
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
||||||
|
/// auto-tag systems fire end-to-end without writing to disk.
|
||||||
|
fn headless_app_with_focus() -> App {
|
||||||
|
use crate::ui_focus::UiFocusPlugin;
|
||||||
|
use crate::ui_modal::UiModalPlugin;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(UiModalPlugin)
|
||||||
|
.add_plugins(UiFocusPlugin)
|
||||||
|
.add_plugins(SettingsPlugin {
|
||||||
|
// No persistence — keep the test isolated.
|
||||||
|
storage_path: None,
|
||||||
|
ui_enabled: true,
|
||||||
|
});
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_buttons_get_focusable_marker() {
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
|
// Open the panel.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||||
|
// and queues the spawn commands; the second flushes them and
|
||||||
|
// runs `attach_focusable_to_settings_buttons`.
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||||
|
// `ModalButton`) must carry a `Focusable`.
|
||||||
|
let untagged: Vec<&SettingsButton> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
untagged.is_empty(),
|
||||||
|
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||||||
|
untagged
|
||||||
|
);
|
||||||
|
|
||||||
|
// And there must be at least one tagged `SettingsButton` so the
|
||||||
|
// assertion above isn't vacuously true (the panel really did
|
||||||
|
// spawn).
|
||||||
|
let tagged_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
tagged_count >= 6,
|
||||||
|
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
||||||
|
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
||||||
|
/// indexed swatches carry hover-reveal context. Mirrors
|
||||||
|
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
||||||
|
/// the invariant — every interactive Settings element except the
|
||||||
|
/// `Done` modal button has a tooltip — is asserted consistently.
|
||||||
|
#[test]
|
||||||
|
fn settings_buttons_carry_tooltip() {
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
|
// Open the panel and let spawn + child-flush run.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||||||
|
// also a `ModalButton`) may be missing a `Tooltip`.
|
||||||
|
let untipped: Vec<&SettingsButton> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
untipped.is_empty(),
|
||||||
|
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||||
|
untipped
|
||||||
|
);
|
||||||
|
|
||||||
|
// And there must be at least 6 tipped buttons so the assertion
|
||||||
|
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
||||||
|
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
||||||
|
// card-back and one background swatch — well over the floor.
|
||||||
|
let tipped_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
tipped_count >= 6,
|
||||||
|
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spot-check: with default (Local) settings the Connect button
|
||||||
|
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||||
|
let connect_tip = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
|
.iter(app.world())
|
||||||
|
.find_map(|(btn, tip)| {
|
||||||
|
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
||||||
|
})
|
||||||
|
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||||
|
assert_eq!(
|
||||||
|
connect_tip.as_ref(),
|
||||||
|
"Connect to a self-hosted Ferrous Solitaire sync server.",
|
||||||
|
"ConnectSync tooltip must use the canonical microcopy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_picker_rows_get_focus_row_marker() {
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Two picker rows are spawned (card-back + background); each
|
||||||
|
// must carry the FocusRow marker.
|
||||||
|
let row_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<FocusRow>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
row_count >= 2,
|
||||||
|
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||||
|
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||||
|
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||||
|
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
|
||||||
|
/// `ImageNode` for each preview slot.
|
||||||
|
#[test]
|
||||||
|
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||||
|
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
|
||||||
|
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
// Prime an Assets<Image> resource so we can mint stable handles
|
||||||
|
// for the synthetic thumbnail pair.
|
||||||
|
app.init_resource::<Assets<Image>>();
|
||||||
|
let (ace_handle, back_handle) = {
|
||||||
|
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
|
||||||
|
let ace = images.add(Image::default());
|
||||||
|
let back = images.add(Image::default());
|
||||||
|
(ace, back)
|
||||||
|
};
|
||||||
|
// Inject one theme entry + a matching thumbnail pair.
|
||||||
|
app.insert_resource(ThemeRegistry {
|
||||||
|
entries: vec![ThemeEntry {
|
||||||
|
id: "test_theme".into(),
|
||||||
|
display_name: "Test Theme".into(),
|
||||||
|
manifest_url: "themes://test_theme/theme.ron".into(),
|
||||||
|
meta: crate::theme::ThemeMeta {
|
||||||
|
id: "test_theme".into(),
|
||||||
|
name: "Test Theme".into(),
|
||||||
|
author: "x".into(),
|
||||||
|
version: "x".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
let mut cache = ThemeThumbnailCache::default();
|
||||||
|
cache.entries.insert(
|
||||||
|
"test_theme".into(),
|
||||||
|
ThemeThumbnailPair {
|
||||||
|
ace: ace_handle.clone(),
|
||||||
|
back: back_handle.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.insert_resource(cache);
|
||||||
|
|
||||||
|
// Open the panel and let the spawn + child-flush systems run.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||||
|
// theme picker chip for "test_theme" must contribute exactly
|
||||||
|
// two of them (ace + back).
|
||||||
|
let thumbnail_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
thumbnail_count >= 2,
|
||||||
|
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spot-check: at least one thumbnail's image handle matches one
|
||||||
|
// of the ones we inserted into the cache. This guards against a
|
||||||
|
// future refactor that accidentally clones the wrong handle.
|
||||||
|
let any_matches = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.any(|node| node.image == ace_handle || node.image == back_handle);
|
||||||
|
assert!(
|
||||||
|
any_matches,
|
||||||
|
"at least one rendered thumbnail must reuse the cached handle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Window geometry persistence
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_persist_geometry_respects_debounce_window() {
|
||||||
|
// Within the debounce window: not yet.
|
||||||
|
assert!(!should_persist_geometry(10.0, 9.7));
|
||||||
|
assert!(!should_persist_geometry(
|
||||||
|
10.0,
|
||||||
|
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
||||||
|
));
|
||||||
|
// Exactly the debounce window: allowed (>= comparison).
|
||||||
|
assert!(should_persist_geometry(
|
||||||
|
10.0,
|
||||||
|
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||||||
|
));
|
||||||
|
// Well past the debounce window: allowed.
|
||||||
|
assert!(should_persist_geometry(20.0, 10.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_geometry_uses_existing_when_event_components_missing() {
|
||||||
|
let existing = WindowGeometry {
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
x: 100,
|
||||||
|
y: 50,
|
||||||
|
};
|
||||||
|
// Position-only event keeps existing size.
|
||||||
|
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
||||||
|
assert_eq!(merged.width, 1280);
|
||||||
|
assert_eq!(merged.height, 800);
|
||||||
|
assert_eq!(merged.x, 200);
|
||||||
|
assert_eq!(merged.y, 75);
|
||||||
|
// Size-only event keeps existing position.
|
||||||
|
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
||||||
|
assert_eq!(merged.width, 1024);
|
||||||
|
assert_eq!(merged.height, 768);
|
||||||
|
assert_eq!(merged.x, 100);
|
||||||
|
assert_eq!(merged.y, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_geometry_returns_none_when_size_unknown() {
|
||||||
|
// No existing geometry, no size in the event → can't fabricate one.
|
||||||
|
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
||||||
|
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
||||||
|
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
||||||
|
/// slices and run enough ticks to comfortably exceed the debounce
|
||||||
|
/// window after the first record tick has set `last_changed_secs`.
|
||||||
|
fn advance_past_geometry_debounce(app: &mut App) {
|
||||||
|
use bevy::time::TimeUpdateStrategy;
|
||||||
|
use std::time::Duration;
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||||
|
0.15,
|
||||||
|
)));
|
||||||
|
// Tick 1 sets last_changed_secs from any pending events. Each
|
||||||
|
// subsequent tick advances the clock by 150 ms; five ticks total
|
||||||
|
// buys 0.75 s of elapsed time relative to the record tick — well
|
||||||
|
// past the 0.5 s debounce window.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
||||||
|
app.world_mut().write_message(WindowResized {
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_move(app: &mut App, x: i32, y: i32) {
|
||||||
|
app.world_mut().write_message(WindowMoved {
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
position: IVec2::new(x, y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_event_then_quiet_persists_window_geometry() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Sanity: geometry starts unset (default).
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire a resize, then go quiet for past the debounce.
|
||||||
|
fire_resize(&mut app, 1500.0, 950.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.expect("geometry should be persisted after debounce");
|
||||||
|
assert_eq!(geom.width, 1500);
|
||||||
|
assert_eq!(geom.height, 950);
|
||||||
|
// Position not yet observed → defaults to 0, 0 since there was
|
||||||
|
// no existing geometry to fall back on.
|
||||||
|
assert_eq!(geom.x, 0);
|
||||||
|
assert_eq!(geom.y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_event_after_resize_updates_position_only() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// First, establish a baseline geometry via a resize event.
|
||||||
|
fire_resize(&mut app, 1280.0, 800.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
let baseline = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(baseline.width, 1280);
|
||||||
|
|
||||||
|
// Now fire a move-only event — size must be preserved from the
|
||||||
|
// existing geometry.
|
||||||
|
fire_move(&mut app, 250, 175);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
geom.width, 1280,
|
||||||
|
"size must be preserved across a move-only update"
|
||||||
|
);
|
||||||
|
assert_eq!(geom.height, 800);
|
||||||
|
assert_eq!(geom.x, 250);
|
||||||
|
assert_eq!(geom.y, 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rapid_resize_storm_only_persists_final_size() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// Burst of resize events on a single frame — only the last one
|
||||||
|
// should be the eventually-persisted size.
|
||||||
|
fire_resize(&mut app, 900.0, 600.0);
|
||||||
|
fire_resize(&mut app, 1100.0, 700.0);
|
||||||
|
fire_resize(&mut app, 1400.0, 850.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((geom.width, geom.height), (1400, 850));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_window_events_no_geometry_change() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Just advance time — without any events, settings must stay clean.
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_clamps_offset_to_zero_at_top() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
// Entity starts at 10 px offset.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
SettingsPanelScrollable,
|
||||||
|
ScrollPosition(Vec2::new(0.0, 10.0)),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
||||||
|
app.world_mut().write_message(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: 5.0,
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.y;
|
||||||
|
assert_eq!(
|
||||||
|
offset, 0.0,
|
||||||
|
"scrolling past top must clamp to 0, got {offset}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1010,8 +1010,8 @@ mod tests {
|
|||||||
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(bevy::asset::AssetPlugin::default())
|
.add_plugins(AssetPlugin::default())
|
||||||
.init_asset::<bevy::image::Image>()
|
.init_asset::<Image>()
|
||||||
.add_plugins(SplashPlugin);
|
.add_plugins(SplashPlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.init_resource::<ButtonInput<MouseButton>>();
|
app.init_resource::<ButtonInput<MouseButton>>();
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ impl Plugin for StatsPlugin {
|
|||||||
// `DefaultPlugins`; register it explicitly so the stats-scroll
|
// `DefaultPlugins`; register it explicitly so the stats-scroll
|
||||||
// system also runs cleanly under `MinimalPlugins` in tests.
|
// system also runs cleanly under `MinimalPlugins` in tests.
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
.add_message::<bevy::input::touch::TouchInput>()
|
.add_message::<TouchInput>()
|
||||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||||
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
||||||
// StatsUpdate (as a set) is ordered after GameMutation by external
|
// StatsUpdate (as a set) is ordered after GameMutation by external
|
||||||
@@ -464,7 +464,7 @@ fn repaint_replay_selector_detail(
|
|||||||
/// Pure helper: render the detail line for the selected replay. Returns
|
/// Pure helper: render the detail line for the selected replay. Returns
|
||||||
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
|
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
|
||||||
/// when a share URL is present. Empty when the history slice is empty.
|
/// when a share URL is present. Empty when the history slice is empty.
|
||||||
pub fn replay_selector_detail(replays: &[solitaire_data::Replay], index: usize) -> String {
|
pub fn replay_selector_detail(replays: &[Replay], index: usize) -> String {
|
||||||
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
|
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
@@ -1325,9 +1325,9 @@ mod tests {
|
|||||||
fn draw_three_win_increments_draw_three_wins_only() {
|
fn draw_three_win_increments_draw_three_wins_only() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<crate::resources::GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
|
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -1371,7 +1371,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<crate::resources::GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.set_test_move_count(3);
|
.set_test_move_count(3);
|
||||||
|
|
||||||
@@ -1501,7 +1501,7 @@ mod tests {
|
|||||||
fn zen_win_event_updates_zen_best_score_only() {
|
fn zen_win_event_updates_zen_best_score_only() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<crate::resources::GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
.mode = solitaire_core::game_state::GameMode::Zen;
|
||||||
|
|
||||||
@@ -1697,7 +1697,7 @@ mod tests {
|
|||||||
stats.0.win_streak_current = 3;
|
stats.0.win_streak_current = 3;
|
||||||
}
|
}
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<crate::resources::GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.set_test_move_count(1);
|
.set_test_move_count(1);
|
||||||
|
|
||||||
@@ -1723,7 +1723,7 @@ mod tests {
|
|||||||
stats.0.win_streak_current = 1;
|
stats.0.win_streak_current = 1;
|
||||||
}
|
}
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<crate::resources::GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.set_test_move_count(1);
|
.set_test_move_count(1);
|
||||||
|
|
||||||
@@ -1948,11 +1948,11 @@ mod tests {
|
|||||||
///
|
///
|
||||||
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
|
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
|
||||||
/// `time_seconds` and `share_url` are the only varying fields across tests.
|
/// `time_seconds` and `share_url` are the only varying fields across tests.
|
||||||
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay {
|
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> Replay {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
|
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
|
||||||
let mut r = solitaire_data::Replay::new(
|
let mut r = Replay::new(
|
||||||
1,
|
1,
|
||||||
solitaire_core::DrawMode::DrawOne,
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
solitaire_core::game_state::GameMode::Classic,
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
time_seconds,
|
time_seconds,
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ mod tests {
|
|||||||
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
||||||
.add_plugins(SyncPlugin::new(provider));
|
.add_plugins(SyncPlugin::new(provider));
|
||||||
// MinimalPlugins does not register keyboard input.
|
// MinimalPlugins does not register keyboard input.
|
||||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -604,7 +604,7 @@ mod tests {
|
|||||||
/// would silently drop the link.
|
/// would silently drop the link.
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_result_writes_share_url_into_replay_and_persists() {
|
fn upload_result_writes_share_url_into_replay_and_persists() {
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
|
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
|
||||||
};
|
};
|
||||||
@@ -617,7 +617,7 @@ mod tests {
|
|||||||
// share_url — the upload-poll path must populate it.
|
// share_url — the upload-poll path must populate it.
|
||||||
let initial = Replay::new(
|
let initial = Replay::new(
|
||||||
42,
|
42,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
@@ -629,8 +629,8 @@ mod tests {
|
|||||||
replays: vec![initial],
|
replays: vec![initial],
|
||||||
};
|
};
|
||||||
save_replay_history_to(&path, &history).expect("seed history on disk");
|
save_replay_history_to(&path, &history).expect("seed history on disk");
|
||||||
app.insert_resource(crate::stats_plugin::ReplayHistoryResource(history));
|
app.insert_resource(ReplayHistoryResource(history));
|
||||||
app.insert_resource(crate::stats_plugin::LatestReplayPath(Some(path.clone())));
|
app.insert_resource(LatestReplayPath(Some(path.clone())));
|
||||||
|
|
||||||
// Pre-resolved task carrying the URL the production path would
|
// Pre-resolved task carrying the URL the production path would
|
||||||
// get back from the server.
|
// get back from the server.
|
||||||
@@ -659,7 +659,7 @@ mod tests {
|
|||||||
// In-memory contract: replays[0].share_url is now Some(url).
|
// In-memory contract: replays[0].share_url is now Some(url).
|
||||||
let live = app
|
let live = app
|
||||||
.world()
|
.world()
|
||||||
.resource::<crate::stats_plugin::ReplayHistoryResource>();
|
.resource::<ReplayHistoryResource>();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
live.0.replays.first().and_then(|r| r.share_url.clone()),
|
live.0.replays.first().and_then(|r| r.share_url.clone()),
|
||||||
Some(url.clone()),
|
Some(url.clone()),
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::Suit;
|
||||||
|
|
||||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||||
use crate::hud_plugin::HudVisibility;
|
use crate::hud_plugin::HudVisibility;
|
||||||
#[cfg(test)]
|
use crate::layout::{
|
||||||
use crate::layout::TABLE_COLOUR;
|
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
||||||
use crate::layout::{Layout, LayoutResource, LayoutSystem, compute_layout};
|
};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::safe_area::SafeAreaInsets;
|
use crate::safe_area::SafeAreaInsets;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
@@ -177,9 +177,9 @@ fn setup_table(
|
|||||||
Camera2d,
|
Camera2d,
|
||||||
Camera {
|
Camera {
|
||||||
clear_color: ClearColorConfig::Custom(Color::srgb(
|
clear_color: ClearColorConfig::Custom(Color::srgb(
|
||||||
crate::layout::TABLE_COLOUR[0],
|
TABLE_COLOUR[0],
|
||||||
crate::layout::TABLE_COLOUR[1],
|
TABLE_COLOUR[1],
|
||||||
crate::layout::TABLE_COLOUR[2],
|
TABLE_COLOUR[2],
|
||||||
)),
|
)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
@@ -350,12 +350,13 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
fn on_window_resized(
|
fn on_window_resized(
|
||||||
mut events: MessageReader<WindowResized>,
|
mut events: MessageReader<WindowResized>,
|
||||||
safe_area: Option<Res<SafeAreaInsets>>,
|
safe_area: Option<Res<SafeAreaInsets>>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
hud_vis: Option<Res<HudVisibility>>,
|
hud_vis: Option<Res<HudVisibility>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
mut layout_res: Option<ResMut<LayoutResource>>,
|
mut layout_res: Option<ResMut<LayoutResource>>,
|
||||||
mut backgrounds: Query<
|
mut backgrounds: Query<
|
||||||
(&mut Sprite, &mut Transform),
|
(&mut Sprite, &mut Transform),
|
||||||
@@ -372,7 +373,16 @@ fn on_window_resized(
|
|||||||
let safe_area_top = insets.top / scale;
|
let safe_area_top = insets.top / scale;
|
||||||
let safe_area_bottom = insets.bottom / scale;
|
let safe_area_bottom = insets.bottom / scale;
|
||||||
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
|
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
|
||||||
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
|
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
|
||||||
|
|
||||||
|
// compute_layout sizes the fan for a worst-case column; refine it to the
|
||||||
|
// current deal so a resize (incl. the Android safe-area-inset resize that
|
||||||
|
// fires in the first few frames, and fold/unfold on foldables) keeps the
|
||||||
|
// tableau filling the viewport instead of snapping back to the sparse
|
||||||
|
// worst-case fan.
|
||||||
|
if let Some(game) = game.as_ref() {
|
||||||
|
apply_dynamic_tableau_fan(&game.0, &mut new_layout);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(layout_res) = layout_res.as_deref_mut() {
|
if let Some(layout_res) = layout_res.as_deref_mut() {
|
||||||
layout_res.0 = new_layout.clone();
|
layout_res.0 = new_layout.clone();
|
||||||
@@ -520,7 +530,7 @@ fn sync_pile_marker_visibility(
|
|||||||
fn pile_cards(
|
fn pile_cards(
|
||||||
game: &solitaire_core::game_state::GameState,
|
game: &solitaire_core::game_state::GameState,
|
||||||
pile: &KlondikePile,
|
pile: &KlondikePile,
|
||||||
) -> Vec<(solitaire_core::card::Card, bool)> {
|
) -> Vec<(solitaire_core::Card, bool)> {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => {
|
KlondikePile::Stock => {
|
||||||
let stock = game.stock_cards();
|
let stock = game.stock_cards();
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId,
|
|||||||
/// Sums every entry's declared uncompressed size and rejects archives
|
/// Sums every entry's declared uncompressed size and rejects archives
|
||||||
/// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central
|
/// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central
|
||||||
/// directory only — does not actually decompress anything.
|
/// directory only — does not actually decompress anything.
|
||||||
fn enforce_archive_size_limit<R: io::Read + io::Seek>(
|
fn enforce_archive_size_limit<R: Read + io::Seek>(
|
||||||
archive: &mut zip::ZipArchive<R>,
|
archive: &mut zip::ZipArchive<R>,
|
||||||
) -> Result<(), ImportError> {
|
) -> Result<(), ImportError> {
|
||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
@@ -257,7 +257,7 @@ fn enforce_archive_size_limit<R: io::Read + io::Seek>(
|
|||||||
/// (after normalisation) escapes its root. Catches `..`, absolute
|
/// (after normalisation) escapes its root. Catches `..`, absolute
|
||||||
/// paths, drive prefixes on Windows, and the awkward case where
|
/// paths, drive prefixes on Windows, and the awkward case where
|
||||||
/// `enclosed_name` returns `None` because the entry is suspicious.
|
/// `enclosed_name` returns `None` because the entry is suspicious.
|
||||||
fn enforce_zip_slip_safe<R: io::Read + io::Seek>(
|
fn enforce_zip_slip_safe<R: Read + io::Seek>(
|
||||||
archive: &mut zip::ZipArchive<R>,
|
archive: &mut zip::ZipArchive<R>,
|
||||||
) -> Result<(), ImportError> {
|
) -> Result<(), ImportError> {
|
||||||
for i in 0..archive.len() {
|
for i in 0..archive.len() {
|
||||||
@@ -291,7 +291,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
|
|||||||
/// `theme.ron` entry at its root.
|
/// `theme.ron` entry at its root.
|
||||||
/// - [`ImportError::ManifestParse`] when the bytes don't form valid
|
/// - [`ImportError::ManifestParse`] when the bytes don't form valid
|
||||||
/// RON for `ThemeManifest`.
|
/// RON for `ThemeManifest`.
|
||||||
fn read_manifest<R: io::Read + io::Seek>(
|
fn read_manifest<R: Read + io::Seek>(
|
||||||
archive: &mut zip::ZipArchive<R>,
|
archive: &mut zip::ZipArchive<R>,
|
||||||
) -> Result<ThemeManifest, ImportError> {
|
) -> Result<ThemeManifest, ImportError> {
|
||||||
// We can't use `?` directly across `by_name` because a missing
|
// We can't use `?` directly across `by_name` because a missing
|
||||||
@@ -318,7 +318,7 @@ fn read_manifest<R: io::Read + io::Seek>(
|
|||||||
///
|
///
|
||||||
/// Returns [`ImportError::MissingFile`] when the archive has no entry
|
/// Returns [`ImportError::MissingFile`] when the archive has no entry
|
||||||
/// matching the path.
|
/// matching the path.
|
||||||
fn read_archive_entry<R: io::Read + io::Seek>(
|
fn read_archive_entry<R: Read + io::Seek>(
|
||||||
archive: &mut zip::ZipArchive<R>,
|
archive: &mut zip::ZipArchive<R>,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
) -> Result<Vec<u8>, ImportError> {
|
) -> Result<Vec<u8>, ImportError> {
|
||||||
@@ -351,7 +351,7 @@ fn archive_key(path: &Path) -> String {
|
|||||||
/// parent directories as needed. The destination path is rebuilt from
|
/// parent directories as needed. The destination path is rebuilt from
|
||||||
/// the safe components we already vetted in
|
/// the safe components we already vetted in
|
||||||
/// [`enforce_zip_slip_safe`], not from the raw entry name.
|
/// [`enforce_zip_slip_safe`], not from the raw entry name.
|
||||||
fn write_archive_entry<R: io::Read + io::Seek>(
|
fn write_archive_entry<R: Read + io::Seek>(
|
||||||
archive: &mut zip::ZipArchive<R>,
|
archive: &mut zip::ZipArchive<R>,
|
||||||
name: &str,
|
name: &str,
|
||||||
staging: &Path,
|
staging: &Path,
|
||||||
@@ -384,7 +384,7 @@ fn write_archive_entry<R: io::Read + io::Seek>(
|
|||||||
|
|
||||||
/// Variant of [`write_archive_entry`] keyed by `Path` for the
|
/// Variant of [`write_archive_entry`] keyed by `Path` for the
|
||||||
/// manifest-declared face/back paths.
|
/// manifest-declared face/back paths.
|
||||||
fn write_archive_entry_pathbuf<R: io::Read + io::Seek>(
|
fn write_archive_entry_pathbuf<R: Read + io::Seek>(
|
||||||
archive: &mut zip::ZipArchive<R>,
|
archive: &mut zip::ZipArchive<R>,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
staging: &Path,
|
staging: &Path,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use bevy::reflect::TypePath;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
|
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use bevy::asset::AssetEvent;
|
|||||||
use bevy::ecs::message::MessageReader;
|
use bevy::ecs::message::MessageReader;
|
||||||
use bevy::math::UVec2;
|
use bevy::math::UVec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
|
|
||||||
use crate::assets::{
|
use crate::assets::{
|
||||||
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
|
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
|
||||||
@@ -484,7 +484,7 @@ mod tests {
|
|||||||
let mut image_set = empty_card_image_set();
|
let mut image_set = empty_card_image_set();
|
||||||
// Snapshot the legacy back ids so we can prove they don't
|
// Snapshot the legacy back ids so we can prove they don't
|
||||||
// change when a theme is applied.
|
// change when a theme is applied.
|
||||||
let legacy_ids_before: [bevy::asset::AssetId<bevy::image::Image>; 5] =
|
let legacy_ids_before: [AssetId<Image>; 5] =
|
||||||
std::array::from_fn(|i| image_set.backs[i].id());
|
std::array::from_fn(|i| image_set.backs[i].id());
|
||||||
let theme = empty_theme();
|
let theme = empty_theme();
|
||||||
assert!(image_set.theme_back.is_none(), "theme_back starts empty");
|
assert!(image_set.theme_back.is_none(), "theme_back starts empty");
|
||||||
|
|||||||
@@ -299,7 +299,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::{DrawMode, game_state::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();
|
||||||
@@ -430,7 +430,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
// The current game must be in TimeAttack mode for auto-deal to fire.
|
// The current game must be in TimeAttack mode for auto-deal to fire.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -454,7 +454,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Default session is inactive. Game is TimeAttack mode — still no count.
|
// Default session is inactive. Game is TimeAttack mode — still no count.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -521,7 +521,7 @@ mod tests {
|
|||||||
// the session timer or the running win count.
|
// the session timer or the running win count.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
fn tmp_ta_path(name: &str) -> std::path::PathBuf {
|
fn tmp_ta_path(name: &str) -> PathBuf {
|
||||||
std::env::temp_dir().join(format!("engine_test_ta_{name}.json"))
|
std::env::temp_dir().join(format!("engine_test_ta_{name}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
use bevy::ecs::message::MessageReader;
|
use bevy::ecs::message::MessageReader;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::StateChangedEvent;
|
||||||
@@ -194,7 +194,7 @@ fn spawn_touch_highlight(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use solitaire_core::Tableau;
|
use solitaire_core::Tableau;
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
|
|
||||||
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
|
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
|
||||||
/// numeric ids. Identity is now the `Card` value.
|
/// numeric ids. Identity is now the `Card` value.
|
||||||
|
|||||||
@@ -1324,7 +1324,7 @@ mod tests {
|
|||||||
let row = world.spawn((FocusRow, Node::default())).id();
|
let row = world.spawn((FocusRow, Node::default())).id();
|
||||||
world.entity_mut(scrim).add_child(row);
|
world.entity_mut(scrim).add_child(row);
|
||||||
|
|
||||||
let make_swatch = |w: &mut World, marker: fn(&mut bevy::ecs::world::EntityWorldMut)| {
|
let make_swatch = |w: &mut World, marker: fn(&mut EntityWorldMut)| {
|
||||||
let mut e = w.spawn((
|
let mut e = w.spawn((
|
||||||
Button,
|
Button,
|
||||||
Node::default(),
|
Node::default(),
|
||||||
|
|||||||
@@ -982,9 +982,9 @@ mod tests {
|
|||||||
outline_width: 0.0,
|
outline_width: 0.0,
|
||||||
outline_offset: 0.0,
|
outline_offset: 0.0,
|
||||||
unrounded_size: card_size,
|
unrounded_size: card_size,
|
||||||
border: bevy::sprite::BorderRect::default(),
|
border: BorderRect::default(),
|
||||||
border_radius: bevy::ui::ResolvedBorderRadius::default(),
|
border_radius: ResolvedBorderRadius::default(),
|
||||||
padding: bevy::sprite::BorderRect::default(),
|
padding: BorderRect::default(),
|
||||||
inverse_scale_factor: 1.0,
|
inverse_scale_factor: 1.0,
|
||||||
};
|
};
|
||||||
// `is_empty` guard inside Bevy treats zero-size
|
// `is_empty` guard inside Bevy treats zero-size
|
||||||
|
|||||||
@@ -249,12 +249,12 @@ pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0);
|
|||||||
pub struct HighContrastBorder {
|
pub struct HighContrastBorder {
|
||||||
/// Border colour to use when high-contrast mode is *off* — the
|
/// Border colour to use when high-contrast mode is *off* — the
|
||||||
/// site's normal idle / active-state colour.
|
/// site's normal idle / active-state colour.
|
||||||
pub default_color: bevy::prelude::Color,
|
pub default_color: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HighContrastBorder {
|
impl HighContrastBorder {
|
||||||
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
|
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
|
||||||
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
pub const fn with_default(default_color: Color) -> Self {
|
||||||
Self { default_color }
|
Self { default_color }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,18 +282,18 @@ impl HighContrastBorder {
|
|||||||
pub struct HighContrastBackground {
|
pub struct HighContrastBackground {
|
||||||
/// Background colour to use when high-contrast mode is *off* —
|
/// Background colour to use when high-contrast mode is *off* —
|
||||||
/// the site's normal idle / active-state colour.
|
/// the site's normal idle / active-state colour.
|
||||||
pub default_color: bevy::prelude::Color,
|
pub default_color: Color,
|
||||||
/// Background colour to use when high-contrast mode is *on*.
|
/// Background colour to use when high-contrast mode is *on*.
|
||||||
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
|
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
|
||||||
///
|
///
|
||||||
/// [`with_default`]: HighContrastBackground::with_default
|
/// [`with_default`]: HighContrastBackground::with_default
|
||||||
pub hc_color: bevy::prelude::Color,
|
pub hc_color: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HighContrastBackground {
|
impl HighContrastBackground {
|
||||||
/// Convenience constructor — HC colour defaults to
|
/// Convenience constructor — HC colour defaults to
|
||||||
/// [`BORDER_SUBTLE_HC`].
|
/// [`BORDER_SUBTLE_HC`].
|
||||||
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
pub const fn with_default(default_color: Color) -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_color,
|
default_color,
|
||||||
hc_color: BORDER_SUBTLE_HC,
|
hc_color: BORDER_SUBTLE_HC,
|
||||||
@@ -305,8 +305,8 @@ impl HighContrastBackground {
|
|||||||
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
|
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
|
||||||
/// than to a neutral gray.
|
/// than to a neutral gray.
|
||||||
pub const fn with_hc(
|
pub const fn with_hc(
|
||||||
default_color: bevy::prelude::Color,
|
default_color: Color,
|
||||||
hc_color: bevy::prelude::Color,
|
hc_color: Color,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_color,
|
default_color,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_core::klondike_adapter::compute_time_bonus;
|
use solitaire_core::scoring::compute_time_bonus;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
@@ -551,7 +551,7 @@ fn spawn_win_summary_after_delay(
|
|||||||
// speed the duration is zero anyway, suppressing the shake.
|
// speed the duration is zero anyway, suppressing the shake.
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(solitaire_data::AnimSpeed::Normal, |s| s.0.animation_speed);
|
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
|
||||||
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
|
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
|
||||||
shake.remaining = scaled;
|
shake.remaining = scaled;
|
||||||
shake.total = scaled;
|
shake.total = scaled;
|
||||||
@@ -1205,7 +1205,7 @@ mod tests {
|
|||||||
.insert_resource(StatsResource(StatsSnapshot::default()))
|
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||||
.insert_resource(GameStateResource(GameState::new(
|
.insert_resource(GameStateResource(GameState::new(
|
||||||
0,
|
0,
|
||||||
solitaire_core::DrawMode::DrawOne,
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
)))
|
)))
|
||||||
.insert_resource(ProgressResource(PlayerProgress::default()));
|
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1534,9 +1534,9 @@ mod tests {
|
|||||||
.challenge_index = 4;
|
.challenge_index = 4;
|
||||||
// Switch game mode to Challenge.
|
// Switch game mode to Challenge.
|
||||||
{
|
{
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
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 {
|
||||||
@@ -1580,13 +1580,13 @@ mod tests {
|
|||||||
/// mode-multiplier rows.
|
/// mode-multiplier rows.
|
||||||
#[test]
|
#[test]
|
||||||
fn cache_win_data_captures_undo_count_and_mode() {
|
fn cache_win_data_captures_undo_count_and_mode() {
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
|
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
// Set up a Zen-mode game with 2 undos used.
|
// Set up a Zen-mode game with 2 undos used.
|
||||||
{
|
{
|
||||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
|
game.0 = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Zen);
|
||||||
game.0.force_test_undos(2);
|
game.0.force_test_undos(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,6 @@ dotenvy = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ module.exports = defineConfig({
|
|||||||
`cargo run -p solitaire_server --quiet`,
|
`cargo run -p solitaire_server --quiet`,
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
url: `http://127.0.0.1:${serverPort}/health`,
|
url: `http://127.0.0.1:${serverPort}/health`,
|
||||||
timeout: 120_000,
|
// CI prebuilds the server (see web-e2e.yml) so `cargo run` reuses the
|
||||||
|
// compiled binary and starts in seconds. The generous timeout is a
|
||||||
|
// safety margin for a cold cargo cache (e.g. first run after a deps
|
||||||
|
// bump) where `cargo run` may still recompile.
|
||||||
|
timeout: 300_000,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,10 +142,32 @@ async function main() {
|
|||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
|
// Load the page once, then reset each game in place via the bridge's
|
||||||
|
// newGame(). A fresh page.goto() per game (hundreds of navigations in a
|
||||||
|
// single browser context) accumulates resources and eventually makes
|
||||||
|
// waitForFunction time out around game ~100. One load stays fast.
|
||||||
|
await page.goto(`${baseUrl}/${route}`, { waitUntil: "domcontentloaded" });
|
||||||
|
if (route === "play-classic") {
|
||||||
|
const resumeVisible = await page
|
||||||
|
.locator("#resume-overlay:not(.hidden)")
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
if (resumeVisible) {
|
||||||
|
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||||
|
typeof window.__FERROUS_DEBUG__.newGame === "function",
|
||||||
|
null,
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < games; i++) {
|
for (let i = 0; i < games; i++) {
|
||||||
const seed = i;
|
const seed = i;
|
||||||
const draw3 = i % 2 === 1;
|
const draw3 = i % 2 === 1;
|
||||||
const suffix = draw3 ? "&draw3=" : "";
|
|
||||||
|
|
||||||
const pageErrors = [];
|
const pageErrors = [];
|
||||||
const consoleErrors = [];
|
const consoleErrors = [];
|
||||||
@@ -158,27 +180,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`${baseUrl}/${route}?seed=${seed}${suffix}`, {
|
// Reset to a fresh seeded game without navigating.
|
||||||
waitUntil: "domcontentloaded",
|
await page.evaluate(
|
||||||
});
|
({ seed, draw3 }) => window.__FERROUS_DEBUG__.newGame(seed, draw3),
|
||||||
|
{ seed, draw3 }
|
||||||
if (route === "play-classic") {
|
|
||||||
const resumeVisible = await page
|
|
||||||
.locator("#resume-overlay:not(.hidden)")
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
if (resumeVisible) {
|
|
||||||
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForFunction(
|
|
||||||
() =>
|
|
||||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
|
||||||
window.__FERROUS_DEBUG__.seed() !== null,
|
|
||||||
null,
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const run = await page.evaluate(({ stepCap, policyName, maxVisits }) => {
|
const run = await page.evaluate(({ stepCap, policyName, maxVisits }) => {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ test("new game button resets move history and score", async ({ page }) => {
|
|||||||
|
|
||||||
test("timer stops accumulating while tab is hidden", async ({ page }) => {
|
test("timer stops accumulating while tab is hidden", async ({ page }) => {
|
||||||
// Install the fake clock before navigation so the game's setInterval is
|
// Install the fake clock before navigation so the game's setInterval is
|
||||||
// controlled by page.clock.tick() and won't fire on real wall-clock time.
|
// controlled by page.clock.runFor() and won't fire on real wall-clock time.
|
||||||
await page.clock.install();
|
await page.clock.install();
|
||||||
|
|
||||||
await page.goto("/play-classic?seed=42");
|
await page.goto("/play-classic?seed=42");
|
||||||
@@ -144,7 +144,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
|
|||||||
await waitForBridge(page);
|
await waitForBridge(page);
|
||||||
|
|
||||||
// Advance 3 fake seconds to get a non-zero timer reading.
|
// Advance 3 fake seconds to get a non-zero timer reading.
|
||||||
await page.clock.tick(3_000);
|
await page.clock.runFor(3_000);
|
||||||
const timerAfter3s = await page.locator("#hud-timer").textContent();
|
const timerAfter3s = await page.locator("#hud-timer").textContent();
|
||||||
expect(timerAfter3s).toBe("0:03");
|
expect(timerAfter3s).toBe("0:03");
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
|
|||||||
await setTabHidden(page, true);
|
await setTabHidden(page, true);
|
||||||
|
|
||||||
// Advance 10 fake seconds while hidden.
|
// Advance 10 fake seconds while hidden.
|
||||||
await page.clock.tick(10_000);
|
await page.clock.runFor(10_000);
|
||||||
const timerWhileHidden = await page.locator("#hud-timer").textContent();
|
const timerWhileHidden = await page.locator("#hud-timer").textContent();
|
||||||
expect(timerWhileHidden).toBe("0:03"); // must not have advanced
|
expect(timerWhileHidden).toBe("0:03"); // must not have advanced
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
|
|||||||
await setTabHidden(page, false);
|
await setTabHidden(page, false);
|
||||||
|
|
||||||
// Advance 2 more fake seconds.
|
// Advance 2 more fake seconds.
|
||||||
await page.clock.tick(2_000);
|
await page.clock.runFor(2_000);
|
||||||
const timerAfterResume = await page.locator("#hud-timer").textContent();
|
const timerAfterResume = await page.locator("#hud-timer").textContent();
|
||||||
expect(timerAfterResume).toBe("0:05"); // only 3 + 2 visible seconds counted
|
expect(timerAfterResume).toBe("0:05"); // only 3 + 2 visible seconds counted
|
||||||
});
|
});
|
||||||
@@ -182,11 +182,11 @@ test("timer does not restart while tab is visible during an auto-complete or won
|
|||||||
// the snap state correctly gates the restart.
|
// the snap state correctly gates the restart.
|
||||||
//
|
//
|
||||||
// Advance 2 s, then hide+show — timer should continue normally.
|
// Advance 2 s, then hide+show — timer should continue normally.
|
||||||
await page.clock.tick(2_000);
|
await page.clock.runFor(2_000);
|
||||||
await setTabHidden(page, true);
|
await setTabHidden(page, true);
|
||||||
await page.clock.tick(5_000);
|
await page.clock.runFor(5_000);
|
||||||
await setTabHidden(page, false);
|
await setTabHidden(page, false);
|
||||||
await page.clock.tick(2_000);
|
await page.clock.runFor(2_000);
|
||||||
|
|
||||||
const timerText = await page.locator("#hud-timer").textContent();
|
const timerText = await page.locator("#hud-timer").textContent();
|
||||||
// 2 visible + 0 hidden + 2 visible = 4 total
|
// 2 visible + 0 hidden + 2 visible = 4 total
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ struct UserIdKeyExtractor {
|
|||||||
impl KeyExtractor for UserIdKeyExtractor {
|
impl KeyExtractor for UserIdKeyExtractor {
|
||||||
type Key = String;
|
type Key = String;
|
||||||
|
|
||||||
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> {
|
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
|
||||||
if let Some(user_id) = self.try_extract_user_id(req.headers()) {
|
if let Some(user_id) = self.try_extract_user_id(req.headers()) {
|
||||||
return Ok(user_id);
|
return Ok(user_id);
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,12 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
// and the wasm-bindgen-generated `web/pkg/`). The HTML page is the
|
// and the wasm-bindgen-generated `web/pkg/`). The HTML page is the
|
||||||
// same regardless of `:id` — it reads the path from `location` in JS
|
// same regardless of `:id` — it reads the path from `location` in JS
|
||||||
// and fetches the replay JSON from `/api/replays/:id`.
|
// and fetches the replay JSON from `/api/replays/:id`.
|
||||||
let web = Router::new()
|
// HTML pages are `include_str!`'d into the binary and change on every
|
||||||
|
// deploy, so they get `Cache-Control: no-cache` (always revalidate). The
|
||||||
|
// `/web` + `/assets` static files keep ServeDir's default Last-Modified
|
||||||
|
// caching — applying no-cache to *those* too made the e2e cycle gate's 240
|
||||||
|
// page reloads recompile the wasm each time and time out.
|
||||||
|
let html_pages = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
get(|| async { Html(include_str!("../web/home.html")) }),
|
get(|| async { Html(include_str!("../web/home.html")) }),
|
||||||
@@ -233,6 +238,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
"/replays",
|
"/replays",
|
||||||
get(|| async { Html(include_str!("../web/replays.html")) }),
|
get(|| async { Html(include_str!("../web/replays.html")) }),
|
||||||
)
|
)
|
||||||
|
.layer(axum_middleware::from_fn(no_cache_headers));
|
||||||
|
|
||||||
|
let web = Router::new()
|
||||||
|
.merge(html_pages)
|
||||||
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
||||||
.nest_service("/assets", ServeDir::new("assets"))
|
.nest_service("/assets", ServeDir::new("assets"))
|
||||||
.layer(axum_middleware::from_fn(security_headers));
|
.layer(axum_middleware::from_fn(security_headers));
|
||||||
@@ -270,6 +279,18 @@ async fn security_headers(req: Request<axum::body::Body>, next: axum_middleware:
|
|||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds `Cache-Control: no-cache` so the browser always revalidates before
|
||||||
|
/// using a cached copy. Scoped to the `include_str!` HTML pages (which change
|
||||||
|
/// on every deploy and have no validators) — not the ServeDir static assets,
|
||||||
|
/// which keep normal Last-Modified caching so repeated page loads can reuse the
|
||||||
|
/// already-downloaded/compiled wasm.
|
||||||
|
async fn no_cache_headers(req: Request<axum::body::Body>, next: axum_middleware::Next) -> Response {
|
||||||
|
let mut res = next.run(req).await;
|
||||||
|
res.headers_mut()
|
||||||
|
.insert("Cache-Control", HeaderValue::from_static("no-cache"));
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET /health` — simple liveness probe, no auth required.
|
/// `GET /health` — simple liveness probe, no auth required.
|
||||||
async fn health() -> axum::Json<serde_json::Value> {
|
async fn health() -> axum::Json<serde_json::Value> {
|
||||||
axum::Json(serde_json::json!({
|
axum::Json(serde_json::json!({
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ async fn register_login_push_pull_full_roundtrip() {
|
|||||||
},
|
},
|
||||||
achievements: vec![],
|
achievements: vec![],
|
||||||
progress: PlayerProgress::default(),
|
progress: PlayerProgress::default(),
|
||||||
last_modified: chrono::Utc::now(),
|
last_modified: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let push_resp = post_authed(
|
let push_resp = post_authed(
|
||||||
@@ -1299,7 +1299,7 @@ async fn expired_access_token_returns_401() {
|
|||||||
exp: usize,
|
exp: usize,
|
||||||
kind: String,
|
kind: String,
|
||||||
}
|
}
|
||||||
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||||
let expired_token = encode(
|
let expired_token = encode(
|
||||||
&Header::default(),
|
&Header::default(),
|
||||||
&ExpiredClaims {
|
&ExpiredClaims {
|
||||||
@@ -1375,7 +1375,7 @@ async fn refresh_with_expired_refresh_token_returns_401() {
|
|||||||
exp: usize,
|
exp: usize,
|
||||||
kind: String,
|
kind: String,
|
||||||
}
|
}
|
||||||
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||||
let expired_token = encode(
|
let expired_token = encode(
|
||||||
&Header::default(),
|
&Header::default(),
|
||||||
&ExpiredRefreshClaims {
|
&ExpiredRefreshClaims {
|
||||||
|
|||||||
@@ -270,6 +270,11 @@ function startGame(seed) {
|
|||||||
|
|
||||||
// ── Timer ────────────────────────────────────────────────────────────────────
|
// ── Timer ────────────────────────────────────────────────────────────────────
|
||||||
function startTimer() {
|
function startTimer() {
|
||||||
|
// Idempotent: never stack a second interval. The visibilitychange handler
|
||||||
|
// and startGame can both call this, and a stray call (e.g. a load-time
|
||||||
|
// visibilitychange while a timer is already running) would otherwise leak
|
||||||
|
// the old interval and make elapsedSecs increment twice per second.
|
||||||
|
if (timerInterval) return;
|
||||||
timerInterval = setInterval(() => {
|
timerInterval = setInterval(() => {
|
||||||
elapsedSecs++;
|
elapsedSecs++;
|
||||||
updateTimerDisplay();
|
updateTimerDisplay();
|
||||||
@@ -986,6 +991,17 @@ window.__FERROUS_DEBUG__ = {
|
|||||||
snapshot() {
|
snapshot() {
|
||||||
return game ? game.debug_snapshot() : null;
|
return game ? game.debug_snapshot() : null;
|
||||||
},
|
},
|
||||||
|
serialize() {
|
||||||
|
return game ? game.serialize() : null;
|
||||||
|
},
|
||||||
|
// Reset to a fresh seeded game in place (no page reload). Lets the cycle
|
||||||
|
// regression harness reuse one page across hundreds of games instead of
|
||||||
|
// navigating per game.
|
||||||
|
newGame(seed, drawThreeMode) {
|
||||||
|
drawThree = !!drawThreeMode;
|
||||||
|
startGame(seed ?? randomSeed());
|
||||||
|
return game ? game.state() : null;
|
||||||
|
},
|
||||||
applyLegalMove(index) {
|
applyLegalMove(index) {
|
||||||
if (!game) return { ok: false, error: "game_not_ready" };
|
if (!game) return { ok: false, error: "game_not_ready" };
|
||||||
const result = game.debug_apply_legal_move(index);
|
const result = game.debug_apply_legal_move(index);
|
||||||
|
|||||||
@@ -1649,63 +1649,63 @@ function __wbg_get_imports() {
|
|||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114621, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114857, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf0188236128725a8);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9767, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9843, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h876550298b312ff8);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9775, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9840, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9769, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9834, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h545edb23183e448a);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000d: function(arg0) {
|
__wbindgen_cast_000000000000000d: function(arg0) {
|
||||||
@@ -1769,55 +1769,55 @@ function __wbg_get_imports() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871(arg0, arg1) {
|
function wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871(arg0, arg1);
|
wasm.wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__hf0188236128725a8(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2) {
|
||||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf0188236128725a8(arg0, arg1, arg2);
|
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2);
|
||||||
if (ret[1]) {
|
if (ret[1]) {
|
||||||
throw takeFromExternrefTable0(ret[0]);
|
throw takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1(arg0, arg1, arg2, arg3) {
|
function wasm_bindgen__convert__closures_____invoke__h876550298b312ff8(arg0, arg1, arg2, arg3) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1(arg0, arg1, arg2, arg3);
|
wasm.wasm_bindgen__convert__closures_____invoke__h876550298b312ff8(arg0, arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
|
wasm.wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user