Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aebb401c44 | |||
| a550a0cdf9 | |||
| 8f5193035b | |||
| c21c0ebf99 | |||
| ccccdd2b40 |
@@ -1,5 +0,0 @@
|
|||||||
[registries.Quaternions]
|
|
||||||
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
|
||||||
|
|
||||||
[target.wasm32-unknown-unknown]
|
|
||||||
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
|
|
||||||
@@ -6,14 +6,10 @@ on:
|
|||||||
branches: [master]
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'solitaire_server/**'
|
- 'solitaire_server/**'
|
||||||
- 'solitaire_wasm/**'
|
|
||||||
- 'solitaire_web/**'
|
|
||||||
- 'solitaire_sync/**'
|
- 'solitaire_sync/**'
|
||||||
- 'solitaire_core/**'
|
- 'solitaire_core/**'
|
||||||
- 'solitaire_engine/**'
|
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'solitaire_server/Dockerfile'
|
|
||||||
- '.gitea/workflows/docker-build.yml'
|
- '.gitea/workflows/docker-build.yml'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -36,13 +32,6 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
|
|
||||||
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
|
|
||||||
# and commits it back (CI is the single source of truth — the artifacts
|
|
||||||
# aren't byte-reproducible on contributor machines). That pkg/ commit then
|
|
||||||
# triggers this workflow, so the deployed image always ships fresh wasm.
|
|
||||||
# No drift check is needed here.
|
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
- name: Log in to Gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
name: Web E2E
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
paths:
|
|
||||||
- 'solitaire_server/web/**'
|
|
||||||
- 'solitaire_server/src/**'
|
|
||||||
- 'solitaire_server/e2e/**'
|
|
||||||
- 'solitaire_wasm/**'
|
|
||||||
- 'solitaire_core/**'
|
|
||||||
- 'Cargo.toml'
|
|
||||||
- 'Cargo.lock'
|
|
||||||
- '.gitea/workflows/web-e2e.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
web-e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Cache cargo build
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
|
|
||||||
# Prebuild the server so Playwright's `webServer` (which runs
|
|
||||||
# `cargo run -p solitaire_server`) starts from a compiled binary instead
|
|
||||||
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
|
|
||||||
# its 120s startup window — the timeout that was failing every run.
|
|
||||||
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB).
|
|
||||||
- name: Prebuild server
|
|
||||||
env:
|
|
||||||
SQLX_OFFLINE: 'true'
|
|
||||||
run: cargo build -p solitaire_server --quiet
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: solitaire_server/e2e/package-lock.json
|
|
||||||
|
|
||||||
- name: Install e2e dependencies
|
|
||||||
working-directory: solitaire_server/e2e
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Install Playwright browser
|
|
||||||
working-directory: solitaire_server/e2e
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run web e2e tests
|
|
||||||
working-directory: solitaire_server/e2e
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Run cycle regression gate
|
|
||||||
working-directory: solitaire_server/e2e
|
|
||||||
run: npm run review:cycles:regression
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
name: Web WASM Rebuild
|
|
||||||
|
|
||||||
# CI is the single source of truth for solitaire_server/web/pkg/.
|
|
||||||
#
|
|
||||||
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
|
|
||||||
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
|
|
||||||
# flags, the same Cargo.lock and remapped source paths, the output still differs
|
|
||||||
# by host environment. So rather than police freshness with a rebuild-and-diff
|
|
||||||
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
|
|
||||||
# itself on every master change to a wasm-feeding crate and commits them back.
|
|
||||||
#
|
|
||||||
# Result: the deployed pkg/ can't silently rot, and contributors never need to
|
|
||||||
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
|
|
||||||
# workflow's trigger paths (so it does not re-trigger here) but does match
|
|
||||||
# docker-build's, so the refreshed wasm deploys.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
paths:
|
|
||||||
- 'solitaire_core/**'
|
|
||||||
- 'solitaire_engine/**'
|
|
||||||
- 'solitaire_data/**'
|
|
||||||
- 'solitaire_sync/**'
|
|
||||||
- 'solitaire_wasm/**'
|
|
||||||
- 'solitaire_web/**'
|
|
||||||
- 'Cargo.toml'
|
|
||||||
- 'Cargo.lock'
|
|
||||||
- 'build_wasm.sh'
|
|
||||||
- '.gitea/workflows/web-wasm-rebuild.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: web-wasm-rebuild
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rebuild:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.CI_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install Rust 1.95.0
|
|
||||||
uses: dtolnay/rust-toolchain@master
|
|
||||||
with:
|
|
||||||
toolchain: 1.95.0
|
|
||||||
targets: wasm32-unknown-unknown
|
|
||||||
|
|
||||||
- name: Cache cargo build
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
|
|
||||||
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
|
|
||||||
uses: taiki-e/install-action@v2
|
|
||||||
with:
|
|
||||||
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
|
|
||||||
|
|
||||||
- name: Install binaryen 130 (wasm-opt, pinned)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl -sSL \
|
|
||||||
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
|
||||||
| tar xz
|
|
||||||
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Rebuild WASM artifacts
|
|
||||||
run: ./build_wasm.sh
|
|
||||||
|
|
||||||
- name: Commit refreshed artifacts if changed
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if git diff --quiet -- solitaire_server/web/pkg/; then
|
|
||||||
echo "pkg/ already up to date — nothing to commit."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
git config user.email "ci@gitea.local"
|
|
||||||
git config user.name "Gitea CI"
|
|
||||||
git add solitaire_server/web/pkg/
|
|
||||||
git commit -m "chore(web): regenerate wasm artifacts"
|
|
||||||
# master is unprotected; retry once if the tip moved under us.
|
|
||||||
git push origin HEAD:master || {
|
|
||||||
git fetch origin master
|
|
||||||
git rebase origin/master
|
|
||||||
git push origin HEAD:master
|
|
||||||
}
|
|
||||||
-16
@@ -15,11 +15,6 @@ agentdb.rvf.lock
|
|||||||
# IDE project files
|
# IDE project files
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Browser e2e harness artifacts
|
|
||||||
solitaire_server/e2e/node_modules/
|
|
||||||
solitaire_server/e2e/playwright-report/
|
|
||||||
solitaire_server/e2e/test-results/
|
|
||||||
|
|
||||||
# Android signing keystores — never commit
|
# Android signing keystores — never commit
|
||||||
*.jks
|
*.jks
|
||||||
*.jks.bak
|
*.jks.bak
|
||||||
@@ -30,14 +25,3 @@ solitaire_server/e2e/test-results/
|
|||||||
deploy/matomo-secret.yaml
|
deploy/matomo-secret.yaml
|
||||||
deploy/*-secret.yaml
|
deploy/*-secret.yaml
|
||||||
deploy/*-auth-secret.yaml
|
deploy/*-auth-secret.yaml
|
||||||
|
|
||||||
# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo
|
|
||||||
/.agents/
|
|
||||||
/.codex/
|
|
||||||
/AGENTS.md
|
|
||||||
# claude-flow scratch dirs, anywhere in the tree (e.g. solitaire_engine/src/)
|
|
||||||
.claude-flow/
|
|
||||||
|
|
||||||
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
|
||||||
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
|
||||||
scripts/*.go
|
|
||||||
|
|||||||
@@ -109,18 +109,6 @@ Owns:
|
|||||||
- Achievement unlock condition evaluation
|
- Achievement unlock condition evaluation
|
||||||
- Seeded RNG for reproducible deals
|
- Seeded RNG for reproducible deals
|
||||||
|
|
||||||
**Rules decisions:**
|
|
||||||
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
|
||||||
passes through the stock are discouraged via the upstream score penalty
|
|
||||||
(applied by the `card_game`/`klondike` session), never blocked with a
|
|
||||||
`MoveError`. This matches mainstream digital solitaire (unlimited redeals
|
|
||||||
in Draw-1) rather than strict tournament rules (3-pass cap). It is load-
|
|
||||||
bearing: the difficulty seed catalog and the winnable-deal solver are
|
|
||||||
verified under unlimited recycling, so introducing a hard pass limit would
|
|
||||||
invalidate both. Locked in by the
|
|
||||||
`draw_one_recycling_is_unlimited_by_design` test in
|
|
||||||
`solitaire_core/src/game_state.rs`. (Decision record: Gitea issue #117.)
|
|
||||||
|
|
||||||
### `solitaire_sync`
|
### `solitaire_sync`
|
||||||
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
||||||
|
|
||||||
|
|||||||
-314
@@ -6,320 +6,6 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.41.0] — 2026-07-06
|
|
||||||
|
|
||||||
> Consolidates everything shipped since v0.39.0, including the v0.40.0–v0.40.3
|
|
||||||
> patch tags (which were cut from this section without renaming it at the time).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Rules decision record: unlimited stock recycling.** Documented in
|
|
||||||
`ARCHITECTURE.md` that unlimited recycling with score penalties (matching
|
|
||||||
mainstream digital solitaire) is intentional, and locked it in with a core
|
|
||||||
test — the difficulty seed catalog and winnable-deal solver are verified
|
|
||||||
under this rule. Resolves the last open finding from the June 500-game
|
|
||||||
audit (issue #117).
|
|
||||||
- **Analytics validation runbook.** Documented native Matomo live validation,
|
|
||||||
expected event payloads, and the current web/WASM analytics split.
|
|
||||||
- **Android smoke-test runbook.** Updated the Android doc with the current
|
|
||||||
platform status, support matrix, and a physical-device
|
|
||||||
launch/touch/safe-area checklist.
|
|
||||||
- **Browser Bevy canvas route and automation support.** Added the `solitaire_web`
|
|
||||||
Bevy WASM build, wired `/play` to the Bevy canvas, added a
|
|
||||||
`window.__FERROUS_DEBUG__` bridge, and introduced Playwright coverage for the
|
|
||||||
web routes and interactive canvas behavior.
|
|
||||||
- **Card-game / klondike integration.** Began replacing in-house card and pile
|
|
||||||
internals with upstream `card_game` / `klondike` types, including adapter
|
|
||||||
work, GameMode-aware scoring, upstream instruction serde, `KlondikePile`
|
|
||||||
migration, and documentation for the in-place rewrite phases.
|
|
||||||
- **Android keystore integration.** Added Android Keystore JNI wiring via
|
|
||||||
`OnceLock` and improved Android token handling around the app directory.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Engine plugin modules restructured.** The five oversized plugin files
|
|
||||||
(card, hud, settings, game, input) are now module directories with their
|
|
||||||
test suites in sibling `tests.rs` files — no behaviour change; first phase
|
|
||||||
of the module-split plan (#118).
|
|
||||||
- **Core type ownership.** Routed all klondike/card imports through
|
|
||||||
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
|
|
||||||
types.
|
|
||||||
- **Web/WASM build reliability.** Rebuilt WASM packages, cleaned up wasm32 build
|
|
||||||
warnings, added a Binaryen `wasm-opt` pass, pinned upstream git dependencies,
|
|
||||||
and added a CI guard for canvas WASM drift.
|
|
||||||
- **Difficulty seed catalog.** Regenerated the difficulty seed list for the
|
|
||||||
latest verified catalog.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Safe-area insets now re-polled after app resume.** The inset poller settled
|
|
||||||
permanently once insets first resolved, so inset changes while backgrounded
|
|
||||||
(fold/unfold, rotation, gesture ↔ 3-button nav switch) kept stale layout until
|
|
||||||
the app was killed. Each resume now re-arms a fresh poll window; the cached
|
|
||||||
value is rewritten only when it actually changed, so unchanged resumes still
|
|
||||||
cause no relayout flash. (#116)
|
|
||||||
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
|
|
||||||
each column's total depth (face-down cards included) and refills on the
|
|
||||||
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
|
|
||||||
resize and fold/unfold), so a near-square viewport such as an unfolded Galaxy
|
|
||||||
Fold no longer leaves the bottom of the screen empty. The fan spread cap was
|
|
||||||
raised so very tall / narrow viewports (e.g. a foldable cover screen) fill
|
|
||||||
further.
|
|
||||||
- **Card-move animation jank.** A move now rebuilds a card's child visuals only
|
|
||||||
when its appearance changes (flip / resize / accessibility) instead of
|
|
||||||
despawning and respawning every card's children each `StateChangedEvent`,
|
|
||||||
removing the per-move spike that stuttered the slide animation on
|
|
||||||
high-resolution devices.
|
|
||||||
- **Android and modal safe-area layout.** Modal cards now center within the
|
|
||||||
usable area between status and gesture bars, additional modal-spawn guards were
|
|
||||||
added, and Android build scripts now auto-discover SDK/NDK paths and strip
|
|
||||||
native libraries.
|
|
||||||
- **Core scoring and undo correctness.** Fixed recycle-count drift, undo score
|
|
||||||
compounding, foundation-to-tableau instruction coverage, and several
|
|
||||||
illegal-move paths discovered during the card-game migration.
|
|
||||||
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
|
|
||||||
clicks, delayed first-run onboarding until splash teardown, and kept dragged
|
|
||||||
stacks above all piles.
|
|
||||||
- **Draw-Three waste fan hit testing on Android.** The renderer and the click
|
|
||||||
hit-test now share a single `waste_fan_step` / `tableau_col_step` source. They
|
|
||||||
previously diverged under Android's tighter column spacing, shifting the top
|
|
||||||
fanned waste card's hit target onto the card beneath it, so dragging the visible
|
|
||||||
card played the wrong one.
|
|
||||||
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
|
|
||||||
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
|
|
||||||
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
|
|
||||||
file paths to async I/O where needed, and validated `JWT_SECRET` at startup.
|
|
||||||
- **CI and deployment workflow.** Fixed deploy-branch handling, Docker registry
|
|
||||||
secret usage, and related release automation issues.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Ran an Android AVD `Pixel_7` launch smoke for the x86_64 debug APK,
|
|
||||||
including install, NativeActivity launch, safe-area log validation, screenshot
|
|
||||||
render check, onboarding input, and crash-log review.
|
|
||||||
- Added direct coverage for Android/touch card corner labels using Unicode suit
|
|
||||||
glyphs.
|
|
||||||
- Added schema-v3 persistence round-trip coverage, foundation-to-tableau
|
|
||||||
instruction coverage, expanded WASM unit tests, and Playwright E2E specs for
|
|
||||||
browser routes and game-canvas behavior.
|
|
||||||
|
|
||||||
## [0.39.0] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **No-legal-moves detection and banner.** Corrected no-move detection across
|
|
||||||
engine, WASM, and web paths, then surfaced the state to players with an
|
|
||||||
in-game banner instead of silently leaving the board stuck.
|
|
||||||
- **Release/deploy automation.** Updated deployment automation so kustomization
|
|
||||||
changes are pushed to the deploy branch instead of the main development
|
|
||||||
branch.
|
|
||||||
|
|
||||||
## [0.38.0] — 2026-05-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Klondike scoring parity.** Added tableau flip bonuses and stock recycle
|
|
||||||
penalties to align scoring with standard Klondike expectations.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Core rule enforcement.** Auto-complete now requires an empty waste pile,
|
|
||||||
waste-origin moves reject multi-card transfers, foundation-to-foundation moves
|
|
||||||
are blocked, and undo restores score from the snapshot baseline.
|
|
||||||
- **Modal lifecycle guards.** Added missing `ModalScrim` guards to New Game,
|
|
||||||
restore prompt, and no-moves modal spawn sites.
|
|
||||||
- **Runtime and server robustness.** Tokio runtime setup degrades gracefully
|
|
||||||
instead of panicking; web replay submission casing/date formatting now matches
|
|
||||||
server expectations; avatar routes are publicly reachable when intended.
|
|
||||||
- **Android token and sync merge correctness.** Android tokens are namespaced
|
|
||||||
under the application directory, stored per user, and migrated safely; sync
|
|
||||||
merges preserve draw-one / draw-three win invariants.
|
|
||||||
|
|
||||||
## [0.37.0] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Foundation-to-tableau default.** Made `take_from_foundation` default to true
|
|
||||||
across clients so restored, startup, and web games use the same supported move
|
|
||||||
rules.
|
|
||||||
|
|
||||||
## [0.36.12] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Foundation-to-tableau default.** Set `take_from_foundation` true by default
|
|
||||||
in core so every client inherits the intended house rule without special-case
|
|
||||||
setup.
|
|
||||||
|
|
||||||
## [0.36.11] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Web foundation moves.** Enabled take-from-foundation moves in the web game
|
|
||||||
client.
|
|
||||||
|
|
||||||
## [0.36.10] — 2026-05-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Web resume flow.** Browser games now persist state across page refreshes and
|
|
||||||
can resume through a dialog instead of starting over.
|
|
||||||
|
|
||||||
## [0.36.9] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Settings sync connection flow.** Clicking Connect from Settings now opens the
|
|
||||||
sync-setup modal.
|
|
||||||
|
|
||||||
## [0.36.8] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Restored/startup foundation moves.** Enabled take-from-foundation behavior
|
|
||||||
for restored and startup games, not only newly-created sessions.
|
|
||||||
|
|
||||||
## [0.36.7] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Remaining Android UI issues.** Resolved the final Android UI defects from
|
|
||||||
the review pass, including action-bar/tableau interaction and safe visual
|
|
||||||
spacing.
|
|
||||||
|
|
||||||
## [0.36.6] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Action-bar layout reservation.** Reserved action-bar height in layout so
|
|
||||||
tableau columns do not extend behind bottom controls.
|
|
||||||
|
|
||||||
## [0.36.5] — 2026-05-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Responsive Android action-bar glyphs.** Action-bar glyph font size now scales
|
|
||||||
dynamically on Android to fit available space.
|
|
||||||
|
|
||||||
## [0.36.4] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Classic card labels and HUD overlap.** Corrected classic-card corner-label
|
|
||||||
colors and fixed HUD-band overlap in the Android layout.
|
|
||||||
|
|
||||||
## [0.36.3] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Core, animation, and modal review fixes.** Added the foundation-to-tableau
|
|
||||||
score penalty, hardened solver win validation, guarded zero-duration card
|
|
||||||
animations, aligned initial and dynamic tableau fan spacing, and added missing
|
|
||||||
modal guards for play-by-seed and win-summary paths.
|
|
||||||
- **Pause, messages, credentials, and server validation.** Auto-complete respects
|
|
||||||
pause state, standalone plugins register their events, sync passwords are
|
|
||||||
cleared from ECS buffers after auth task spawn, and avatar MIME validation uses
|
|
||||||
exact matches.
|
|
||||||
- **Foundation pile rendering.** Raised stack fan z-order above corner labels to
|
|
||||||
prevent bleed-through.
|
|
||||||
- **Android release workflow.** Added a manual `workflow_dispatch` trigger to
|
|
||||||
the Android release workflow.
|
|
||||||
|
|
||||||
## [0.36.2] — 2026-05-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Comprehensive review fixes.** Addressed 26 issues across core rules, replay
|
|
||||||
controls, modal guards, sync payload timing, server replay casing, time-attack
|
|
||||||
overlays, theme refresh, auth overlays, stats ordering, animations, cursor
|
|
||||||
fallbacks, achievements, server temp-file cleanup, and runtime fallback paths.
|
|
||||||
- **Animation and Android label polish.** Cancelled stale win-cascade animations
|
|
||||||
on new game, refreshed Android corner labels on resize, lifted animating cards
|
|
||||||
above lower z-layers, and froze the web timer when auto-complete starts.
|
|
||||||
- **Web package and tooling updates.** Rebuilt the WASM package for
|
|
||||||
foundation-to-tableau moves, added ruflo scaffolding, and ignored ruflo runtime
|
|
||||||
state files.
|
|
||||||
- **Leaderboard test stability.** Made opt-in / opt-out tests robust under
|
|
||||||
parallel test execution.
|
|
||||||
|
|
||||||
## [0.36.1] — 2026-05-18
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Android HUD gesture conflict.** Stock taps no longer toggle HUD visibility on
|
|
||||||
Android.
|
|
||||||
|
|
||||||
## [0.36.0] — 2026-05-18
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Rank model cleanup.** `Rank` now uses explicit discriminants and checked
|
|
||||||
arithmetic, making rank conversions and sequencing more robust.
|
|
||||||
- **Instruction generation.** Refined `possible_instructions` alongside the rank
|
|
||||||
arithmetic cleanup.
|
|
||||||
- **Session handoff.** Recreated `SESSION_HANDOFF.md` to reflect the `0.35.1`
|
|
||||||
state.
|
|
||||||
|
|
||||||
## [0.35.1] — 2026-05-17
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Leaderboard profile sync.** Fixed three leaderboard/profile issues: wrong
|
|
||||||
toast type for failures, stale display-name label after update, and display
|
|
||||||
name not syncing to the server.
|
|
||||||
|
|
||||||
## [0.35.0] — 2026-05-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Reduced-motion support.** Decorative motion animations are now gated behind
|
|
||||||
`reduce_motion_mode`.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Performance and runtime cleanup.** Shared a single Tokio runtime across
|
|
||||||
network tasks and gated frame-hot ECS systems on resource changes.
|
|
||||||
- **Core/data refactors.** Consolidated the application directory name, added
|
|
||||||
`#[must_use]` to pure helpers, derived `Copy` for `DrawMode`, removed
|
|
||||||
redundant clones, added missing derives to `AchievementContext`, and used
|
|
||||||
saturating move-count arithmetic.
|
|
||||||
- **HUD z-layer naming.** Replaced raw HUD popover z-index arithmetic with named
|
|
||||||
layer constants.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Android UI and font safety.** Wired FiraMono to stock-empty labels, removed
|
|
||||||
raw physical safe-area pixels from HUD spawns, replaced unsupported chevrons,
|
|
||||||
corrected the Android help hint label, and fixed touch/drop-zone behavior.
|
|
||||||
- **Engine modal and panic hardening.** Eliminated several runtime panics, added
|
|
||||||
required transforms to modal scrims, constrained dismiss hit-tests, and guarded
|
|
||||||
home overlay respawns.
|
|
||||||
- **Sync/data/server correctness.** Deterministic pile serialization, undo skip
|
|
||||||
handling, byte URL encoding, merge timestamp handling, auth-guarded avatar
|
|
||||||
serving, atomic server writes, and user-id assertions were corrected.
|
|
||||||
- **Display-name and token-file boundaries.** Enforced the 32-character display
|
|
||||||
name limit in the sync client and aligned Android keystore temp-file cleanup
|
|
||||||
with the cleanup glob.
|
|
||||||
- **WASM error reporting.** `state()` and `step()` now return `Result` so errors
|
|
||||||
surface as JavaScript exceptions.
|
|
||||||
- **Sync and leaderboard toasts.** Pull failures and leaderboard opt-in /
|
|
||||||
opt-out failures now produce the intended warning/error feedback.
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Corrected stale focus-ring color documentation.
|
|
||||||
|
|
||||||
## [0.34.0] — 2026-05-17
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Android waste fan and resume layout.** Corrected Android waste-pile fan
|
|
||||||
overlap and a layout desynchronization after resume.
|
|
||||||
- **Card-face artwork.** Fixed the wrong bottom-right suit symbol on the jack,
|
|
||||||
queen, and king of spades.
|
|
||||||
- **Android corner-label font coverage.** Wired FiraMono into Android corner
|
|
||||||
labels and added `CardImageSet` tests to guard the asset path behavior.
|
|
||||||
|
|
||||||
## [0.33.0] — 2026-05-16
|
## [0.33.0] — 2026-05-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -208,14 +208,9 @@ 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 or background images — these are loaded via
|
Do NOT embed card face PNGs, background images, or user fonts —
|
||||||
`AssetServer` so art can be swapped without recompile. User-supplied fonts
|
these are loaded via `AssetServer` so art can be swapped without recompile.
|
||||||
(if ever added) likewise go through `AssetServer`; only the bundled FiraMono
|
|
||||||
face above is embedded.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -435,11 +430,9 @@ explicitly replacing the current one (despawn first, then spawn).
|
|||||||
|
|
||||||
## 14.3 Safe area
|
## 14.3 Safe area
|
||||||
|
|
||||||
Every `ModalScrim` automatically receives `padding.top` equal to the logical
|
Every `ModalScrim` automatically receives `padding.bottom` equal to the
|
||||||
status-bar height and `padding.bottom` equal to the logical gesture-bar height
|
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
|
||||||
via `apply_safe_area_to_modal_scrims` in `SafeAreaInsetsPlugin`. This centres
|
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
|
||||||
the modal card within the usable area between both system bars. Do not manually
|
|
||||||
add top or bottom padding to scrim nodes.
|
|
||||||
|
|
||||||
## 14.4 Z-ordering
|
## 14.4 Z-ordering
|
||||||
|
|
||||||
|
|||||||
Generated
+15
-419
@@ -364,12 +364,6 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arrayvec"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
|
||||||
checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "as-raw-xcb-connection"
|
name = "as-raw-xcb-connection"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -723,28 +717,6 @@ dependencies = [
|
|||||||
"android-activity",
|
"android-activity",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_anti_alias"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928"
|
|
||||||
dependencies = [
|
|
||||||
"bevy_app",
|
|
||||||
"bevy_asset",
|
|
||||||
"bevy_camera",
|
|
||||||
"bevy_core_pipeline",
|
|
||||||
"bevy_derive",
|
|
||||||
"bevy_diagnostic",
|
|
||||||
"bevy_ecs",
|
|
||||||
"bevy_image",
|
|
||||||
"bevy_math",
|
|
||||||
"bevy_reflect",
|
|
||||||
"bevy_render",
|
|
||||||
"bevy_shader",
|
|
||||||
"bevy_utils",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_app"
|
name = "bevy_app"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -906,35 +878,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_dev_tools"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e"
|
|
||||||
dependencies = [
|
|
||||||
"bevy_app",
|
|
||||||
"bevy_asset",
|
|
||||||
"bevy_camera",
|
|
||||||
"bevy_color",
|
|
||||||
"bevy_diagnostic",
|
|
||||||
"bevy_ecs",
|
|
||||||
"bevy_image",
|
|
||||||
"bevy_input",
|
|
||||||
"bevy_math",
|
|
||||||
"bevy_picking",
|
|
||||||
"bevy_reflect",
|
|
||||||
"bevy_render",
|
|
||||||
"bevy_shader",
|
|
||||||
"bevy_state",
|
|
||||||
"bevy_text",
|
|
||||||
"bevy_time",
|
|
||||||
"bevy_transform",
|
|
||||||
"bevy_ui",
|
|
||||||
"bevy_ui_render",
|
|
||||||
"bevy_window",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_diagnostic"
|
name = "bevy_diagnostic"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -958,7 +901,7 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
|
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"bevy_ecs_macros",
|
"bevy_ecs_macros",
|
||||||
"bevy_platform",
|
"bevy_platform",
|
||||||
"bevy_ptr",
|
"bevy_ptr",
|
||||||
@@ -1002,36 +945,6 @@ dependencies = [
|
|||||||
"encase_derive_impl",
|
"encase_derive_impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_feathers"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6"
|
|
||||||
dependencies = [
|
|
||||||
"accesskit",
|
|
||||||
"bevy_a11y",
|
|
||||||
"bevy_app",
|
|
||||||
"bevy_asset",
|
|
||||||
"bevy_camera",
|
|
||||||
"bevy_color",
|
|
||||||
"bevy_derive",
|
|
||||||
"bevy_ecs",
|
|
||||||
"bevy_input_focus",
|
|
||||||
"bevy_log",
|
|
||||||
"bevy_math",
|
|
||||||
"bevy_picking",
|
|
||||||
"bevy_platform",
|
|
||||||
"bevy_reflect",
|
|
||||||
"bevy_render",
|
|
||||||
"bevy_shader",
|
|
||||||
"bevy_text",
|
|
||||||
"bevy_ui",
|
|
||||||
"bevy_ui_render",
|
|
||||||
"bevy_ui_widgets",
|
|
||||||
"bevy_window",
|
|
||||||
"smol_str",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_gizmos"
|
name = "bevy_gizmos"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1154,17 +1067,14 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy_a11y",
|
"bevy_a11y",
|
||||||
"bevy_android",
|
"bevy_android",
|
||||||
"bevy_anti_alias",
|
|
||||||
"bevy_app",
|
"bevy_app",
|
||||||
"bevy_asset",
|
"bevy_asset",
|
||||||
"bevy_camera",
|
"bevy_camera",
|
||||||
"bevy_color",
|
"bevy_color",
|
||||||
"bevy_core_pipeline",
|
"bevy_core_pipeline",
|
||||||
"bevy_derive",
|
"bevy_derive",
|
||||||
"bevy_dev_tools",
|
|
||||||
"bevy_diagnostic",
|
"bevy_diagnostic",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
"bevy_feathers",
|
|
||||||
"bevy_gizmos_render",
|
"bevy_gizmos_render",
|
||||||
"bevy_image",
|
"bevy_image",
|
||||||
"bevy_input",
|
"bevy_input",
|
||||||
@@ -1172,7 +1082,6 @@ dependencies = [
|
|||||||
"bevy_log",
|
"bevy_log",
|
||||||
"bevy_math",
|
"bevy_math",
|
||||||
"bevy_mesh",
|
"bevy_mesh",
|
||||||
"bevy_pbr",
|
|
||||||
"bevy_platform",
|
"bevy_platform",
|
||||||
"bevy_ptr",
|
"bevy_ptr",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
@@ -1192,27 +1101,6 @@ dependencies = [
|
|||||||
"bevy_winit",
|
"bevy_winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_light"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc"
|
|
||||||
dependencies = [
|
|
||||||
"bevy_app",
|
|
||||||
"bevy_asset",
|
|
||||||
"bevy_camera",
|
|
||||||
"bevy_color",
|
|
||||||
"bevy_ecs",
|
|
||||||
"bevy_image",
|
|
||||||
"bevy_math",
|
|
||||||
"bevy_mesh",
|
|
||||||
"bevy_platform",
|
|
||||||
"bevy_reflect",
|
|
||||||
"bevy_transform",
|
|
||||||
"bevy_utils",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_log"
|
name = "bevy_log"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1250,7 +1138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
|
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"glam 0.30.10",
|
"glam 0.30.10",
|
||||||
@@ -1273,9 +1161,7 @@ dependencies = [
|
|||||||
"bevy_asset",
|
"bevy_asset",
|
||||||
"bevy_derive",
|
"bevy_derive",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
"bevy_image",
|
|
||||||
"bevy_math",
|
"bevy_math",
|
||||||
"bevy_mikktspace",
|
|
||||||
"bevy_platform",
|
"bevy_platform",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"bevy_transform",
|
"bevy_transform",
|
||||||
@@ -1288,71 +1174,6 @@ dependencies = [
|
|||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_mikktspace"
|
|
||||||
version = "0.17.0-dev"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_pbr"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0"
|
|
||||||
dependencies = [
|
|
||||||
"bevy_app",
|
|
||||||
"bevy_asset",
|
|
||||||
"bevy_camera",
|
|
||||||
"bevy_color",
|
|
||||||
"bevy_core_pipeline",
|
|
||||||
"bevy_derive",
|
|
||||||
"bevy_diagnostic",
|
|
||||||
"bevy_ecs",
|
|
||||||
"bevy_image",
|
|
||||||
"bevy_light",
|
|
||||||
"bevy_log",
|
|
||||||
"bevy_math",
|
|
||||||
"bevy_mesh",
|
|
||||||
"bevy_platform",
|
|
||||||
"bevy_reflect",
|
|
||||||
"bevy_render",
|
|
||||||
"bevy_shader",
|
|
||||||
"bevy_transform",
|
|
||||||
"bevy_utils",
|
|
||||||
"bitflags 2.11.1",
|
|
||||||
"bytemuck",
|
|
||||||
"derive_more",
|
|
||||||
"fixedbitset",
|
|
||||||
"nonmax",
|
|
||||||
"offset-allocator",
|
|
||||||
"smallvec",
|
|
||||||
"static_assertions",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_picking"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10"
|
|
||||||
dependencies = [
|
|
||||||
"bevy_app",
|
|
||||||
"bevy_asset",
|
|
||||||
"bevy_camera",
|
|
||||||
"bevy_derive",
|
|
||||||
"bevy_ecs",
|
|
||||||
"bevy_input",
|
|
||||||
"bevy_math",
|
|
||||||
"bevy_platform",
|
|
||||||
"bevy_reflect",
|
|
||||||
"bevy_time",
|
|
||||||
"bevy_transform",
|
|
||||||
"bevy_window",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_platform"
|
name = "bevy_platform"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1679,7 +1500,6 @@ dependencies = [
|
|||||||
"bevy_input",
|
"bevy_input",
|
||||||
"bevy_input_focus",
|
"bevy_input_focus",
|
||||||
"bevy_math",
|
"bevy_math",
|
||||||
"bevy_picking",
|
|
||||||
"bevy_platform",
|
"bevy_platform",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"bevy_sprite",
|
"bevy_sprite",
|
||||||
@@ -1692,7 +1512,6 @@ dependencies = [
|
|||||||
"taffy",
|
"taffy",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1726,26 +1545,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_ui_widgets"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712"
|
|
||||||
dependencies = [
|
|
||||||
"accesskit",
|
|
||||||
"bevy_a11y",
|
|
||||||
"bevy_app",
|
|
||||||
"bevy_camera",
|
|
||||||
"bevy_ecs",
|
|
||||||
"bevy_input",
|
|
||||||
"bevy_input_focus",
|
|
||||||
"bevy_log",
|
|
||||||
"bevy_math",
|
|
||||||
"bevy_picking",
|
|
||||||
"bevy_reflect",
|
|
||||||
"bevy_ui",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_utils"
|
name = "bevy_utils"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1873,7 +1672,6 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1905,7 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
@@ -2081,17 +1879,6 @@ dependencies = [
|
|||||||
"wayland-client",
|
"wayland-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "card_game"
|
|
||||||
version = "0.4.1"
|
|
||||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
|
||||||
checksum = "983728ead19f51d96931725706e62293bd133ac3d836097dd7d745e929f7811b"
|
|
||||||
dependencies = [
|
|
||||||
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbc"
|
name = "cbc"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -2152,17 +1939,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487"
|
checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures 0.3.0",
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -3681,17 +3457,6 @@ dependencies = [
|
|||||||
"weezl",
|
"weezl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gl_generator"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
|
|
||||||
dependencies = [
|
|
||||||
"khronos_api",
|
|
||||||
"log",
|
|
||||||
"xml-rs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glam"
|
name = "glam"
|
||||||
version = "0.30.10"
|
version = "0.30.10"
|
||||||
@@ -3720,27 +3485,6 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "glow"
|
|
||||||
version = "0.16.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"slotmap",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "glutin_wgl_sys"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
|
|
||||||
dependencies = [
|
|
||||||
"gl_generator",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "governor"
|
name = "governor"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -4307,7 +4051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
"quick-error 2.0.1",
|
"quick-error",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4565,23 +4309,6 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "khronos-egl"
|
|
||||||
version = "6.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"libloading",
|
|
||||||
"pkg-config",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "khronos_api"
|
|
||||||
version = "3.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kira"
|
name = "kira"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -4599,25 +4326,13 @@ dependencies = [
|
|||||||
"triple_buffer",
|
"triple_buffer",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "klondike"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
|
||||||
checksum = "d5c82b0c3abd7da07b4a1c4221a809e6e2ffd475ae0e67180fbfef35a9cfe769"
|
|
||||||
dependencies = [
|
|
||||||
"card_game",
|
|
||||||
"rand 0.10.1",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kurbo"
|
name = "kurbo"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"euclid",
|
"euclid",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
@@ -5025,7 +4740,7 @@ version = "27.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
|
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -6063,25 +5778,6 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proptest"
|
|
||||||
version = "1.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
|
||||||
dependencies = [
|
|
||||||
"bit-set",
|
|
||||||
"bit-vec",
|
|
||||||
"bitflags 2.11.1",
|
|
||||||
"num-traits",
|
|
||||||
"rand 0.9.4",
|
|
||||||
"rand_chacha 0.9.0",
|
|
||||||
"rand_xorshift",
|
|
||||||
"regex-syntax",
|
|
||||||
"rusty-fork",
|
|
||||||
"tempfile",
|
|
||||||
"unarray",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prost"
|
name = "prost"
|
||||||
version = "0.14.3"
|
version = "0.14.3"
|
||||||
@@ -6126,12 +5822,6 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quick-error"
|
|
||||||
version = "1.2.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -6257,16 +5947,6 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
|
||||||
dependencies = [
|
|
||||||
"chacha20",
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -6305,12 +5985,6 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_distr"
|
name = "rand_distr"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -6330,15 +6004,6 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_xorshift"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.9.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "range-alloc"
|
name = "range-alloc"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -6828,18 +6493,6 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rusty-fork"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
|
||||||
dependencies = [
|
|
||||||
"fnv",
|
|
||||||
"quick-error 1.2.3",
|
|
||||||
"tempfile",
|
|
||||||
"wait-timeout",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustybuzz"
|
name = "rustybuzz"
|
||||||
version = "0.20.1"
|
version = "0.20.1"
|
||||||
@@ -7306,7 +6959,6 @@ 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",
|
||||||
@@ -7328,9 +6980,7 @@ dependencies = [
|
|||||||
name = "solitaire_core"
|
name = "solitaire_core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"card_game",
|
"rand 0.9.4",
|
||||||
"klondike",
|
|
||||||
"proptest",
|
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
@@ -7341,13 +6991,12 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"card_game",
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"klondike",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -7441,18 +7090,6 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "solitaire_web"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"bevy",
|
|
||||||
"console_error_panic_hook",
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
"solitaire_data",
|
|
||||||
"solitaire_engine",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
@@ -7865,7 +7502,7 @@ version = "0.5.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
@@ -7964,7 +7601,7 @@ version = "0.9.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
|
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"grid",
|
"grid",
|
||||||
"serde",
|
"serde",
|
||||||
"slotmap",
|
"slotmap",
|
||||||
@@ -8233,7 +7870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"log",
|
"log",
|
||||||
@@ -8247,7 +7884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"log",
|
"log",
|
||||||
@@ -8896,12 +8533,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unarray"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uncased"
|
name = "uncased"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -9108,15 +8739,6 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wait-timeout"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -9422,13 +9044,12 @@ version = "27.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
|
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"js-sys",
|
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
@@ -9436,8 +9057,6 @@ dependencies = [
|
|||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"wasm-bindgen",
|
|
||||||
"web-sys",
|
|
||||||
"wgpu-core",
|
"wgpu-core",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
@@ -9449,7 +9068,7 @@ version = "27.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
|
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
@@ -9469,7 +9088,6 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"wgpu-core-deps-apple",
|
"wgpu-core-deps-apple",
|
||||||
"wgpu-core-deps-wasm",
|
|
||||||
"wgpu-core-deps-windows-linux-android",
|
"wgpu-core-deps-windows-linux-android",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
@@ -9484,15 +9102,6 @@ dependencies = [
|
|||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wgpu-core-deps-wasm"
|
|
||||||
version = "27.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7"
|
|
||||||
dependencies = [
|
|
||||||
"wgpu-hal",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-windows-linux-android"
|
name = "wgpu-core-deps-windows-linux-android"
|
||||||
version = "27.0.0"
|
version = "27.0.0"
|
||||||
@@ -9509,7 +9118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
|
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
"arrayvec",
|
||||||
"ash",
|
"ash",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
@@ -9518,20 +9127,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics-types 0.2.0",
|
"core-graphics-types 0.2.0",
|
||||||
"glow",
|
|
||||||
"glutin_wgl_sys",
|
|
||||||
"gpu-alloc",
|
"gpu-alloc",
|
||||||
"gpu-allocator",
|
"gpu-allocator",
|
||||||
"gpu-descriptor",
|
"gpu-descriptor",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"js-sys",
|
|
||||||
"khronos-egl",
|
|
||||||
"libc",
|
"libc",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
"metal",
|
"metal",
|
||||||
"naga",
|
"naga",
|
||||||
"ndk-sys",
|
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
@@ -9544,8 +9148,6 @@ dependencies = [
|
|||||||
"renderdoc-sys",
|
"renderdoc-sys",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"wasm-bindgen",
|
|
||||||
"web-sys",
|
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
"windows 0.58.0",
|
"windows 0.58.0",
|
||||||
"windows-core 0.58.0",
|
"windows-core 0.58.0",
|
||||||
@@ -10428,12 +10030,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "xml-rs"
|
|
||||||
version = "0.8.28"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xmlwriter"
|
name = "xmlwriter"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+1
-20
@@ -8,7 +8,6 @@ members = [
|
|||||||
"solitaire_app",
|
"solitaire_app",
|
||||||
"solitaire_assetgen",
|
"solitaire_assetgen",
|
||||||
"solitaire_wasm",
|
"solitaire_wasm",
|
||||||
"solitaire_web",
|
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -18,27 +17,11 @@ version = "0.1.0"
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
|
||||||
# Pedantic correctness lints applied across every member crate via
|
|
||||||
# `[lints] workspace = true`.
|
|
||||||
[workspace.lints.rust]
|
|
||||||
# Workspace-wide ban on `unsafe`. The sole exception is `solitaire_app`,
|
|
||||||
# which sets its own `deny`-level lints (see its Cargo.toml) because the
|
|
||||||
# Android cdylib entry point must reconstruct raw JNI handles. Every other
|
|
||||||
# crate reaches Android JNI through the safe `solitaire_data::android_jni`
|
|
||||||
# bridge and stays fully unsafe-free.
|
|
||||||
unsafe_code = "forbid"
|
|
||||||
single_use_lifetimes = "warn"
|
|
||||||
trivial_casts = "warn"
|
|
||||||
unused_lifetimes = "warn"
|
|
||||||
unused_qualifications = "warn"
|
|
||||||
variant_size_differences = "warn"
|
|
||||||
unexpected_cfgs = "warn"
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
@@ -54,8 +37,6 @@ solitaire_core = { path = "solitaire_core" }
|
|||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
solitaire_data = { path = "solitaire_data" }
|
solitaire_data = { path = "solitaire_data" }
|
||||||
solitaire_engine = { path = "solitaire_engine" }
|
solitaire_engine = { path = "solitaire_engine" }
|
||||||
klondike = { version = "0.4.0", registry = "Quaternions", features = ["serde"] }
|
|
||||||
card_game = { version = "0.4.1", registry = "Quaternions", features = ["serde"] }
|
|
||||||
|
|
||||||
# Bevy with `default-features = false` to avoid the unused
|
# Bevy with `default-features = false` to avoid the unused
|
||||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||||
|
|||||||
@@ -118,28 +118,8 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
|
|||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
# Browser e2e smoke (starts solitaire_server automatically)
|
|
||||||
cd solitaire_server/e2e
|
|
||||||
npm ci
|
|
||||||
npx playwright install chromium
|
|
||||||
npm test
|
|
||||||
|
|
||||||
# Seed-batch cycle regression gate (thresholded)
|
|
||||||
npm run review:cycles:regression
|
|
||||||
|
|
||||||
# Loop-aware candidate benchmark (writes test-results/cycle-candidate.json)
|
|
||||||
npm run review:cycles:candidate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For layered engine-vs-UI automation design (Rust unit tests, wasm debug-API
|
|
||||||
integration tests, and Playwright UI validation), see
|
|
||||||
[docs/testing-architecture.md](docs/testing-architecture.md).
|
|
||||||
|
|
||||||
For Quaternions (`klondike` / `card_game`) dependency upgrades, use
|
|
||||||
[`scripts/update_quaternions_deps.sh`](scripts/update_quaternions_deps.sh) and
|
|
||||||
the runbook in [docs/card-game-integration.md](docs/card-game-integration.md).
|
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||||
|
|||||||
+26
-93
@@ -1,62 +1,16 @@
|
|||||||
# Ferrous Solitaire — Session Handoff
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
|
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105–#108).
|
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
|
||||||
- **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.
|
- **Latest tag:** `v0.35.1`
|
||||||
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
|
- **Working tree:** clean
|
||||||
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
|
- **Build:** `cargo clippy --workspace -- -D warnings` clean
|
||||||
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
|
- **Tests:** 1277 passing / 0 failing across the workspace
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.40.0 release (2026-06-25)
|
|
||||||
|
|
||||||
Released via tag push → `.gitea/workflows/android-release.yml` built and signed the
|
|
||||||
arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`,
|
|
||||||
29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo
|
|
||||||
pick it up automatically.
|
|
||||||
|
|
||||||
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
|
|
||||||
|
|
||||||
| PR | Summary |
|
|
||||||
|----|---------|
|
|
||||||
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
|
|
||||||
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
|
|
||||||
| #108 | **docs(android):** NDK reference updated `26.3.11579264` → `30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
|
|
||||||
|
|
||||||
Pre-release validation performed locally this session: workspace clippy/test/build
|
|
||||||
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
|
|
||||||
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
|
|
||||||
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
|
|
||||||
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
|
|
||||||
proving the `build_android_apk.sh` packaging pipeline end-to-end.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What shipped since v0.39.0
|
|
||||||
|
|
||||||
- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`.
|
|
||||||
- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up:
|
|
||||||
- `5e87358` integrates upstream deps cleanly.
|
|
||||||
- `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types.
|
|
||||||
- `d864d98` routes klondike/card imports through `solitaire_core`.
|
|
||||||
- `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs.
|
|
||||||
- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed.
|
|
||||||
- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`.
|
|
||||||
- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels.
|
|
||||||
- Native analytics and Android physical-device validation now have runbooks in
|
|
||||||
`docs/analytics-validation.md` and `docs/ANDROID.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Historical notes before v0.39.0
|
|
||||||
|
|
||||||
See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,38 +81,32 @@ Three bugs fixed:
|
|||||||
|
|
||||||
## Open punch list
|
## Open punch list
|
||||||
|
|
||||||
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
|
### 1. CHANGELOG documentation debt
|
||||||
|
|
||||||
This is the **single outstanding task** for the v0.40.0 Android release. Everything
|
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
|
||||||
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
|
are missing. Low priority (git log is authoritative) but worth closing before the
|
||||||
clippy, release manifest sanity, a full local signed APK, the published release, and
|
next release.
|
||||||
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
|
### 2. Android APK launch verification (Option A)
|
||||||
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
|
Physical device test: install the latest APK on a real Android device (not AVD),
|
||||||
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
|
confirm:
|
||||||
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
|
- App launches without crash
|
||||||
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
|
- Safe area insets arrive and shift HUD correctly after ~3 frames
|
||||||
|
- All modal Done buttons are above the gesture bar
|
||||||
|
- Drag-and-drop works on all pile types
|
||||||
|
- Leaderboard panel opens and the "Public name" label updates correctly after
|
||||||
|
using "Set Name"
|
||||||
|
|
||||||
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
|
This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real
|
||||||
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
|
touch events, so physical-device smoke testing is the only gate.
|
||||||
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
|
|
||||||
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
|
|
||||||
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
|
|
||||||
filtered logcat showed no Ferrous panic/fatal/ANR.
|
|
||||||
|
|
||||||
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
|
### 3. Matomo analytics wiring
|
||||||
|
|
||||||
Separate, ongoing task unrelated to the Android release. `Settings` has
|
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
|
||||||
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
engine code consumes them — the analytics toggle in Settings is a no-op. If
|
||||||
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
|
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
|
||||||
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
|
and wired to `GameStateResource` events.
|
||||||
validation checklist and the current web/WASM decision notes.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,18 +128,3 @@ validation checklist and the current web/WASM decision notes.
|
|||||||
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
||||||
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
||||||
with `input.release(key); input.clear()` between updates.
|
with `input.release(key); input.clear()` between updates.
|
||||||
|
|
||||||
- **`/play` debug bridge design:** `play.html` runs two independent WASM instances in
|
|
||||||
`Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__`
|
|
||||||
(logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge
|
|
||||||
operates its own `SolitaireGame` — moves applied through the bridge do NOT affect
|
|
||||||
the Bevy visual game. This is intentional for automation/invariant checking.
|
|
||||||
|
|
||||||
- **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)`
|
|
||||||
is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit
|
|
||||||
on HiDPI displays, causing an immediate wgpu panic on the first resize event.
|
|
||||||
|
|
||||||
- **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js`
|
|
||||||
target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play`
|
|
||||||
using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js`
|
|
||||||
supports both via `--route play-classic|play`.
|
|
||||||
|
|||||||
+7
-64
@@ -1,43 +1,24 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Rebuild WASM artifacts and install them into solitaire_server/web/pkg/.
|
# Rebuild the solitaire_wasm crate and install the output into
|
||||||
#
|
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||||
# Two artifacts are produced:
|
|
||||||
# solitaire_wasm.* — thin replay-viewer + interactive JS API (wasm-pack)
|
|
||||||
# canvas.* — full Bevy WASM app for play.html (cargo + wasm-bindgen)
|
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# cargo install wasm-pack wasm-bindgen-cli
|
# cargo install wasm-pack
|
||||||
# rustup target add wasm32-unknown-unknown
|
# rustup target add wasm32-unknown-unknown
|
||||||
# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm
|
|
||||||
#
|
#
|
||||||
# Run from the repo root:
|
# Run from the repo root:
|
||||||
# ./build_wasm.sh
|
# ./build_wasm.sh
|
||||||
#
|
#
|
||||||
# The generated pkg/ files are committed to git so self-hosters who don't
|
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||||
# touch the WASM crates can skip this step. Regenerate after any change to
|
# committed to git so self-hosters who don't touch the WASM crate can
|
||||||
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
|
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||||
|
# solitaire_core/.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||||
|
|
||||||
# Reproducible builds. The wasm artifacts otherwise bake in machine-specific
|
|
||||||
# absolute source paths (the cargo registry, the rustup std sources, and this
|
|
||||||
# checkout), so a rebuild on a different machine produces different bytes and
|
|
||||||
# the CI freshness gate (rebuild-and-diff) false-positives. Remap all three
|
|
||||||
# prefixes to fixed names so the output is byte-identical anywhere.
|
|
||||||
#
|
|
||||||
# We must use CARGO_ENCODED_RUSTFLAGS (not RUSTFLAGS) and re-state the
|
|
||||||
# getrandom backend cfg here: a `*_RUSTFLAGS` env var *replaces* — does not
|
|
||||||
# merge with — the `[target.wasm32-unknown-unknown] rustflags` in
|
|
||||||
# .cargo/config.toml, so dropping that cfg would break the wasm getrandom build.
|
|
||||||
# Keep this `--cfg` in sync with .cargo/config.toml.
|
|
||||||
CARGO_HOME_DIR="${CARGO_HOME:-$HOME/.cargo}"
|
|
||||||
RUSTUP_HOME_DIR="${RUSTUP_HOME:-$HOME/.rustup}"
|
|
||||||
US=$'\x1f' # unit separator: CARGO_ENCODED_RUSTFLAGS arg delimiter
|
|
||||||
export CARGO_ENCODED_RUSTFLAGS="--cfg${US}getrandom_backend=\"wasm_js\"${US}--remap-path-prefix=${CARGO_HOME_DIR}=/cargo${US}--remap-path-prefix=${RUSTUP_HOME_DIR}=/rustup${US}--remap-path-prefix=${REPO_ROOT}=/build"
|
|
||||||
|
|
||||||
if ! command -v wasm-pack &> /dev/null; then
|
if ! command -v wasm-pack &> /dev/null; then
|
||||||
echo "error: wasm-pack not found." >&2
|
echo "error: wasm-pack not found." >&2
|
||||||
echo " Install with: cargo install wasm-pack" >&2
|
echo " Install with: cargo install wasm-pack" >&2
|
||||||
@@ -55,43 +36,5 @@ wasm-pack build \
|
|||||||
# Remove them — we manage the output directory ourselves.
|
# Remove them — we manage the output directory ourselves.
|
||||||
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Bevy WASM app (solitaire_web → canvas.js + canvas_bg.wasm)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if ! command -v wasm-bindgen &> /dev/null; then
|
|
||||||
echo "error: wasm-bindgen not found." >&2
|
|
||||||
echo " Install with: cargo install wasm-bindgen-cli" >&2
|
|
||||||
echo " The CLI version must match the wasm-bindgen crate dep." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building solitaire_web (Bevy WASM app)..."
|
|
||||||
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
|
|
||||||
|
|
||||||
echo "Running wasm-bindgen for solitaire_web..."
|
|
||||||
wasm-bindgen \
|
|
||||||
--out-dir "$OUT_DIR" \
|
|
||||||
--out-name canvas \
|
|
||||||
--target web \
|
|
||||||
--no-typescript \
|
|
||||||
"$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm"
|
|
||||||
|
|
||||||
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
|
||||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
|
||||||
if command -v wasm-opt &> /dev/null; then
|
|
||||||
echo "Running wasm-opt on canvas_bg.wasm..."
|
|
||||||
# Use -O2 (not -Oz): Bevy's render pipeline uses deep call stacks and
|
|
||||||
# complex memory patterns that wasm-opt -Oz can miscompile, resulting
|
|
||||||
# in a grey screen on first load. -O2 is speed-optimised and avoids
|
|
||||||
# the size-focused transforms that trigger the regression.
|
|
||||||
wasm-opt -O2 \
|
|
||||||
-o "$OUT_DIR/canvas_bg.wasm" \
|
|
||||||
"$OUT_DIR/canvas_bg.wasm"
|
|
||||||
else
|
|
||||||
echo "note: wasm-opt not found; skipping size optimisation."
|
|
||||||
echo " Install with: cargo install wasm-opt (or via binaryen)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Done. Output:"
|
echo "Done. Output:"
|
||||||
ls -lh "$OUT_DIR"
|
ls -lh "$OUT_DIR"
|
||||||
|
|||||||
+22
-57
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
This doc captures the toolchain install + build invocation for the
|
This doc captures the toolchain install + build invocation for the
|
||||||
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
|
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
|
||||||
later sections document physical-device validation, supported platform
|
later sections document what's known to compile, what's stubbed, and
|
||||||
surfaces, and remaining Android follow-ups.
|
the next milestones.
|
||||||
|
|
||||||
> **Status (2026-06-09):** Android build plumbing, app-directory storage,
|
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
||||||
> JNI keystore wiring, and safe-area layout fixes have landed. The remaining
|
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
|
||||||
> release gate is a physical-device smoke test; AVD tap injection does not
|
> NOT yet been verified to launch on a device or emulator — that's
|
||||||
> exercise the real touch path reliably enough for launch verification.
|
> the next milestone.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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/30.0.14904198"'
|
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
|
||||||
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
|
echo 'export 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,15 +49,10 @@ sdkmanager \
|
|||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-34" \
|
"platforms;android-34" \
|
||||||
"build-tools;34.0.0" \
|
"build-tools;34.0.0" \
|
||||||
"ndk;30.0.14904198" \
|
"ndk;26.3.11579264" \
|
||||||
"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 \
|
||||||
@@ -168,8 +163,8 @@ accepted workaround.
|
|||||||
Physical device:
|
Physical device:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
adb devices # confirm connection
|
adb devices # confirm connection
|
||||||
adb install -r target/debug/apk/ferrous-solitaire.apk
|
adb install target/debug/apk/ferrous-solitaire.apk
|
||||||
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
||||||
```
|
```
|
||||||
@@ -190,65 +185,35 @@ AVD.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Physical-device smoke test
|
## 4. What's wired vs. what's stubbed
|
||||||
|
|
||||||
Run this on a real phone, preferably a modern 64-bit ARM device with gesture
|
The first build pass (commit `fb8b2ac`) gates four desktop-only
|
||||||
navigation enabled.
|
crates / call sites so the workspace cross-compiles. Each gate is
|
||||||
|
documented at its call site.
|
||||||
Build and install:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo apk build -p solitaire_app --target aarch64-linux-android --lib
|
|
||||||
adb install -r target/debug/apk/ferrous-solitaire.apk
|
|
||||||
adb logcat -c
|
|
||||||
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
|
||||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic|WindowInsets"
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass criteria:
|
|
||||||
|
|
||||||
- App launches without panic or ANR.
|
|
||||||
- Safe-area insets arrive after the first few frames and shift HUD/modal
|
|
||||||
content away from the status and gesture bars.
|
|
||||||
- Every modal's Done button remains above the gesture bar:
|
|
||||||
Settings, Help, Pause, Win Summary, and Leaderboard-related dialogs.
|
|
||||||
- Drag-and-drop works on tableau, waste, foundation, and stock/recycle paths.
|
|
||||||
- Tap-to-select and one-tap modes both respond correctly on card stacks.
|
|
||||||
- Leaderboard panel opens, "Set Name" saves, and the "Public name" label updates
|
|
||||||
while the panel remains open.
|
|
||||||
- Rotate the device once, then repeat one modal and one drag operation.
|
|
||||||
- Close and relaunch the app; settings/progress still load.
|
|
||||||
|
|
||||||
Record the device model, Android version, APK commit, and pass/fail notes in the
|
|
||||||
release notes or session handoff. If a failure occurs, keep the filtered logcat
|
|
||||||
and note the exact screen/control path that reproduced it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Platform support matrix
|
|
||||||
|
|
||||||
Desktop-only crates and call sites are gated so the workspace cross-compiles.
|
|
||||||
Each gate is documented at its call site.
|
|
||||||
|
|
||||||
| Surface | Desktop | Android |
|
| Surface | Desktop | Android |
|
||||||
|---------|---------|---------|
|
|---------|---------|---------|
|
||||||
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
|
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
|
||||||
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
|
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
|
||||||
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Android Keystore via JNI |
|
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
|
||||||
| Data directory | Platform data dir | Android app files dir |
|
|
||||||
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
|
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
|
||||||
|
|
||||||
Remaining Android follow-ups:
|
What's NOT yet ported / not yet measured:
|
||||||
|
|
||||||
|
- `dirs::data_dir()` returns `None` on Android. Callers in
|
||||||
|
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
||||||
|
`achievements.rs`, `settings.rs` all need an Android-aware
|
||||||
|
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
|
||||||
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
||||||
app lifecycle (suspend / resume), font scaling.
|
app lifecycle (suspend / resume), font scaling.
|
||||||
|
- Android Keystore via JNI for `auth_tokens`.
|
||||||
- JNI ClipboardManager for share links.
|
- JNI ClipboardManager for share links.
|
||||||
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
|
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
|
||||||
in older docs doesn't yet exist).
|
in older docs doesn't yet exist).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Iteration loop
|
## 5. Iteration loop
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Edit code…
|
# Edit code…
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
# Analytics Validation Runbook
|
|
||||||
|
|
||||||
Ferrous Solitaire currently has two analytics paths:
|
|
||||||
|
|
||||||
- Native desktop/Android gameplay events use `solitaire_engine::AnalyticsPlugin`
|
|
||||||
and `solitaire_data::MatomoClient`.
|
|
||||||
- Hosted web pages include Matomo page-view snippets in
|
|
||||||
`solitaire_server/web/*.html`.
|
|
||||||
|
|
||||||
The Bevy `/play` WASM canvas does not emit the native gameplay events because
|
|
||||||
`AnalyticsPlugin` is intentionally gated out on `wasm32`; it depends on the
|
|
||||||
native Tokio/reqwest stack.
|
|
||||||
|
|
||||||
## Native Matomo Validation
|
|
||||||
|
|
||||||
Use this when a deployed Matomo instance and a native build are available.
|
|
||||||
|
|
||||||
1. Configure `settings.json` with a Matomo URL and site ID:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"analytics_enabled": true,
|
|
||||||
"matomo_url": "https://analytics.example.com",
|
|
||||||
"matomo_site_id": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Launch the native app and open Settings.
|
|
||||||
3. Confirm the Privacy section appears and "Share usage data" is `ON`.
|
|
||||||
4. Start a new confirmed game.
|
|
||||||
5. Win or forfeit the game.
|
|
||||||
6. Unlock an achievement if practical, or use an existing achievement path that
|
|
||||||
is easy to trigger in a test profile.
|
|
||||||
7. Wait at least 60 seconds, or close after the win/forfeit path has fired its
|
|
||||||
immediate flush.
|
|
||||||
8. In Matomo, confirm the following custom events arrived:
|
|
||||||
|
|
||||||
| Category | Action | Name |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `Game` | `Start` | `classic`, `zen`, `challenge`, `time_attack`, or `difficulty` |
|
|
||||||
| `Game` | `Won` | empty |
|
|
||||||
| `Game` | `Forfeit` | empty |
|
|
||||||
| `Achievement` | `Unlocked` | achievement id |
|
|
||||||
|
|
||||||
## Web/WASM Decision
|
|
||||||
|
|
||||||
Keep the current split unless the project explicitly needs in-canvas gameplay
|
|
||||||
events for `/play`.
|
|
||||||
|
|
||||||
Current behavior:
|
|
||||||
|
|
||||||
- `/`, `/play-classic`, `/account`, `/leaderboard`, and `/replays` emit Matomo
|
|
||||||
page views through the hosted HTML snippets.
|
|
||||||
- `/play` hosts the Bevy canvas but does not emit gameplay events from the
|
|
||||||
engine.
|
|
||||||
- The browser Content-Security-Policy already allows the deployed Matomo host
|
|
||||||
for scripts, images, and connections.
|
|
||||||
|
|
||||||
If gameplay events are needed on `/play`, add a small `wasm32`-only analytics
|
|
||||||
bridge instead of trying to compile the native plugin:
|
|
||||||
|
|
||||||
- keep the same event contract as native (`Game / Start`, `Game / Won`,
|
|
||||||
`Game / Forfeit`, `Achievement / Unlocked`);
|
|
||||||
- read `Settings::analytics_enabled`, `matomo_url`, and `matomo_site_id`;
|
|
||||||
- send through browser APIs or the existing `_paq` queue;
|
|
||||||
- keep the Settings opt-in behavior identical to native;
|
|
||||||
- add Playwright coverage that stubs Matomo and verifies emitted payloads.
|
|
||||||
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
|
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
|
||||||
|
|
||||||
**Approach:** Integration is complete. Upstream `card_game` / `klondike` now owns
|
**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
|
||||||
authoritative Klondike rules, session history, undo snapshots, and solving.
|
|
||||||
Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes,
|
|
||||||
and typed UI errors in `solitaire_core`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -45,12 +42,10 @@ and typed UI errors in `solitaire_core`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What Ferrous Solitaire's `solitaire_core` Still Owns
|
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
|
||||||
|
|
||||||
### 1. Scoring — remaining adapter responsibilities
|
### 1. Scoring — remaining adapter responsibilities
|
||||||
Ferrous uses **Windows XP Standard** scoring. The upstream library handles the
|
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
|
||||||
per-move counters and configurable deltas; Ferrous adds the product-specific
|
|
||||||
parts in `GameState` / `KlondikeAdapter`.
|
|
||||||
|
|
||||||
| Event | Delta | Handled by |
|
| Event | Delta | Handled by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -66,13 +61,11 @@ parts in `GameState` / `KlondikeAdapter`.
|
|||||||
|
|
||||||
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
||||||
|
|
||||||
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty.
|
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The −15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
|
||||||
|
|
||||||
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
||||||
|
|
||||||
**In our wrapper:** `KlondikeAdapter::config_for` configures the upstream rules
|
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
|
||||||
and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor,
|
|
||||||
time bonus, game-mode suppression, and undo score restoration.
|
|
||||||
|
|
||||||
### 2. Game Modes
|
### 2. Game Modes
|
||||||
Ferrous has three modes that alter scoring and undo behaviour:
|
Ferrous has three modes that alter scoring and undo behaviour:
|
||||||
@@ -85,9 +78,7 @@ Ferrous has three modes that alter scoring and undo behaviour:
|
|||||||
|
|
||||||
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
|
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
|
||||||
|
|
||||||
**In our wrapper:** `GameMode` lives on `solitaire_core::GameState`; undo and
|
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
|
||||||
scoring behavior are applied before/after delegating legal moves to the upstream
|
|
||||||
session.
|
|
||||||
|
|
||||||
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
||||||
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
||||||
@@ -96,33 +87,23 @@ session.
|
|||||||
|
|
||||||
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
|
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
|
||||||
|
|
||||||
The old local DFS has been replaced. `solitaire_core::solver` is now a small
|
Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
|
||||||
adapter around `Session::solve()` that preserves the engine-facing
|
|
||||||
`SolverResult`, `SolverConfig`, and first-move payload contract.
|
|
||||||
|
|
||||||
**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested
|
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
|
||||||
budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget
|
|
||||||
errors → Inconclusive.
|
|
||||||
|
|
||||||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
||||||
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
||||||
|
|
||||||
**Default behaviour:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire **also defaults to Allowed** (`take_from_foundation: true` in `GameState`, `Settings`). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (`take_from_foundation = false`), which maps to `Disallowed`.
|
**Important:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in. Our adapter explicitly sets `Disallowed` in the default `KlondikeConfig` and switches to `Allowed` only when the user toggles the house-rule option.
|
||||||
|
|
||||||
**In our wrapper:** `KlondikeAdapter::config_for(draw_mode, take_from_foundation)` constructs `KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }`. No custom intercept needed — `klondike` enforces the rule automatically.
|
**In our wrapper:** Construct `KlondikeConfig { move_from_foundation: MoveFromFoundationConfig::Disallowed, .. }` by default; mirror the user's settings toggle to `Allowed`. No custom intercept needed — `klondike` enforces the rule automatically.
|
||||||
|
|
||||||
### 5. JSON Serialisation / Persistence
|
### 5. JSON Serialisation / Persistence
|
||||||
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch.
|
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
|
||||||
|
|
||||||
**Upstream serde status (rev 99b49e62):** At this revision, `klondike` and `card_game` both enable a `serde` feature. All nine instruction/pile types (`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`, `TableauStack`, `Foundation`, `Tableau`, `SkipCards`) derive `serde::Serialize` + `serde::Deserialize` under that feature. The workspace `Cargo.toml` enables `features = ["serde"]`.
|
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
|
||||||
|
|
||||||
**Schema v4 (current):** `saved_moves` serialises as `Vec<KlondikeInstruction>` using upstream named-variant serde. Example: `{"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}`.
|
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
|
||||||
|
|
||||||
**Schema v3 (legacy, auto-migrated):** `saved_moves` used local `SavedInstruction` mirror types with u8 indices. Example: `{"DstFoundation": {"src": "Stock", "foundation": 0}}`. On load, an `AnyInstruction` untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The `SavedInstruction` bridge types are retained in `solitaire_core::klondike_adapter` for this migration path and for backward-compatible `solitaire_data::ReplayMove` / WASM replay formats.
|
|
||||||
|
|
||||||
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
|
|
||||||
|
|
||||||
**In our wrapper:** `GameState::Serialize` emits schema v4 (upstream instruction types). `GameState::Deserialize` accepts v3 (auto-migrates) and v4 (direct). Schema version field lives on our wrapper.
|
|
||||||
|
|
||||||
### 6. Typed Move Errors
|
### 6. Typed Move Errors
|
||||||
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
|
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
|
||||||
@@ -148,9 +129,7 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
|
|||||||
### 8. Undo Stack Approach *(resolved — not an issue)*
|
### 8. Undo Stack Approach *(resolved — not an issue)*
|
||||||
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
|
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
|
||||||
|
|
||||||
**Resolution:** `GameState` uses `Session`'s built-in snapshot history. Ferrous
|
**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
|
||||||
keeps parallel score/recycle metadata so undo can restore product-specific score
|
|
||||||
state that upstream snapshots do not own.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -159,35 +138,12 @@ state that upstream snapshots do not own.
|
|||||||
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
||||||
|
|
||||||
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
|
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
|
||||||
2. ✅ **Map pile types** — project `klondike`'s stock face-up half as the engine's waste pile and expose renderer-facing pile snapshots.
|
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
|
||||||
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (gap 4, upstream).
|
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
|
||||||
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
|
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
|
||||||
5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
|
5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
|
||||||
6. ✅ **Replace solver** — call `session.solve()` with budgets from `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
||||||
7. ✅ **Implement `serde`** — serialise schema v4 with upstream `KlondikeInstruction`; auto-migrate schema v3 via `SavedInstruction` compatibility types.
|
7. **Implement `serde`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quaternions Upgrade Runbook
|
|
||||||
|
|
||||||
Use this sequence whenever upgrading `klondike` / `card_game` from the
|
|
||||||
Quaternions registry:
|
|
||||||
|
|
||||||
1. Review upstream changes/releases:
|
|
||||||
- <https://git.aleshym.co/Quaternions/card_game>
|
|
||||||
- <https://git.aleshym.co/Quaternions/klondike>
|
|
||||||
2. Run:
|
|
||||||
```bash
|
|
||||||
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
|
|
||||||
```
|
|
||||||
3. If the script passes, inspect the resulting `Cargo.lock` diff and land the
|
|
||||||
upgrade with the normal PR flow.
|
|
||||||
|
|
||||||
The script enforces:
|
|
||||||
- lockfile update to requested versions
|
|
||||||
- `cargo test --workspace`
|
|
||||||
- `cargo clippy --workspace -- -D warnings`
|
|
||||||
- deterministic replay/debug-API smoke tests in `solitaire_wasm`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -207,5 +163,5 @@ The script enforces:
|
|||||||
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
||||||
- Upstream solver PR: #14
|
- Upstream solver PR: #14
|
||||||
- `solitaire_core` source: `solitaire_core/src/`
|
- `solitaire_core` source: `solitaire_core/src/`
|
||||||
- Scoring implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs`
|
- Scoring spec: `solitaire_core/src/scoring.rs`
|
||||||
- Architecture overview: `ARCHITECTURE.md`
|
- Architecture overview: `ARCHITECTURE.md`
|
||||||
|
|||||||
@@ -1,408 +0,0 @@
|
|||||||
# In-Place card_game / klondike Rewrite Plan
|
|
||||||
|
|
||||||
**Date:** 2026-06-08
|
|
||||||
**Upstream rev:** `99b49e62`
|
|
||||||
**Status:** All phases complete (0–3). recycle_count drift and score compound error on undo fixed in `56e3b62`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What Is Already Integrated
|
|
||||||
|
|
||||||
The integration is substantially complete. `solitaire_core` already delegates all
|
|
||||||
authoritative Klondike logic to the upstream crates.
|
|
||||||
|
|
||||||
| Area | Status | Location |
|
|
||||||
|---|---|---|
|
|
||||||
| `Session<Klondike>` ownership | ✅ complete | `GameState.session` |
|
|
||||||
| `draw()` → `session.process_instruction(RotateStock)` | ✅ complete | `game_state.rs` |
|
|
||||||
| `move_cards()` → `session.process_instruction(KlondikeInstruction)` | ✅ complete | `game_state.rs` |
|
|
||||||
| `undo()` → `session.undo()` | ✅ complete | `game_state.rs` |
|
|
||||||
| `possible_instructions()` → `session.state().state().get_sorted_moves()` | ✅ complete | `game_state.rs` |
|
|
||||||
| `can_move_cards()` → `session.state().state().is_instruction_valid()` | ✅ complete | `game_state.rs` |
|
|
||||||
| `solver.rs` → `session.solve()` | ✅ complete | `solver.rs` |
|
|
||||||
| `Suit`, `Rank` → re-export from `card_game` | ✅ complete | `card.rs` |
|
|
||||||
| `Foundation`, `Klondike`, `KlondikePile`, `Session`, `Tableau` → `solitaire_core::lib` | ✅ complete | `lib.rs` |
|
|
||||||
| Move legality enforcement | ✅ upstream (`is_instruction_valid`) | `klondike/src/lib.rs` |
|
|
||||||
| Foundation placement rules (Ace start, suit match) | ✅ upstream | `klondike/src/lib.rs` |
|
|
||||||
| Tableau placement rules (alternating colour, King on empty) | ✅ upstream | `klondike/src/lib.rs` |
|
|
||||||
| Multi-card stack moves via `SkipCards` | ✅ upstream | `klondike/src/lib.rs` |
|
|
||||||
| Session history / snapshot undo | ✅ upstream | `card_game/src/lib.rs` |
|
|
||||||
| DFS solver with budget limits | ✅ upstream | `card_game/src/lib.rs` |
|
|
||||||
| Instruction history → `SavedInstruction` serde mirrors | ✅ in adapter | `klondike_adapter.rs` |
|
|
||||||
| Schema v3 save/load (instruction replay) | ✅ complete | `game_state.rs`, `storage.rs` |
|
|
||||||
| `take_from_foundation` house rule → `MoveFromFoundationConfig` | ✅ complete | `klondike_adapter.rs` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Duplicated / Replaceable Logic
|
|
||||||
|
|
||||||
These are local implementations that either replicate upstream or could be removed.
|
|
||||||
|
|
||||||
### 2a. `SavedInstruction` mirror types (~300 lines, `klondike_adapter.rs`)
|
|
||||||
|
|
||||||
**What:** A full hand-written serde mirror for every upstream klondike instruction type
|
|
||||||
(`SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
|
|
||||||
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
|
|
||||||
`SavedSkipCards`, `InvalidSavedInstruction`) plus ~20 `From`/`TryFrom` conversion impls.
|
|
||||||
|
|
||||||
**Why written:** At the time, upstream klondike had no serde feature.
|
|
||||||
|
|
||||||
**Current upstream status:** At rev `99b49e62`, the `serde` feature is present and active.
|
|
||||||
`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`,
|
|
||||||
`TableauStack`, `Tableau`, `Foundation`, `SkipCards` all derive
|
|
||||||
`#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]`.
|
|
||||||
|
|
||||||
**Blocker — JSON format incompatibility:**
|
|
||||||
| Field | Local `SavedInstruction` JSON | Upstream `KlondikeInstruction` JSON |
|
|
||||||
|---|---|---|
|
|
||||||
| Tableau index | `{ "Tableau": 0 }` (u8) | `{ "Tableau": "Tableau1" }` (named) |
|
|
||||||
| Foundation slot | `{ "Foundation": 0 }` (u8) | `{ "Foundation": "Foundation1" }` (named) |
|
|
||||||
| Skip count | `{ "skip_cards": 0 }` (u8) | `{ "skip_cards": "Skip0" }` (named) |
|
|
||||||
|
|
||||||
Switching to direct upstream serde **changes the `saved_moves` JSON shape** stored in
|
|
||||||
`game_state.json`. Any existing v3 save file would fail to deserialize after the switch.
|
|
||||||
This requires either:
|
|
||||||
- A schema bump to v4 **with a migration** (deserialize v3 manually then re-save as v4), or
|
|
||||||
- A schema bump to v4 **with graceful fallback** (v3 files rejected → fresh game).
|
|
||||||
|
|
||||||
**Recommendation:** Schema v4 with graceful fallback (v3 saves start fresh). Migration
|
|
||||||
is feasible but adds ~100 lines of throwaway code; the in-progress game loss is modest
|
|
||||||
since schema v3 was never shipped to users (it landed in the current dev branch, not a
|
|
||||||
release).
|
|
||||||
|
|
||||||
### 2b. `GameState::check_win()` (~15 lines)
|
|
||||||
|
|
||||||
**What:** Iterates all four foundation slots checking 13-card A→K sequences.
|
|
||||||
**Upstream equivalent:** `session.state().state().is_win()` on `Klondike`.
|
|
||||||
**Status:** Local check is correct but redundant. Trivially replaceable with no format change.
|
|
||||||
**Risk:** None — only affects `is_won` flag update path.
|
|
||||||
|
|
||||||
### 2c. `GameState::check_auto_complete()` (~15 lines)
|
|
||||||
|
|
||||||
**What:** Checks stock empty, waste empty, all tableau cards face-up.
|
|
||||||
**Upstream equivalent:** `session.state().state().is_win_trivial()` on `Klondike`.
|
|
||||||
**Semantic difference:** Upstream `is_win_trivial` checks `stock.is_empty()` (both faces)
|
|
||||||
and all `tableau.face_down().is_empty()`. Ferrous additionally checks `waste.is_empty()`.
|
|
||||||
These are logically equivalent for a valid game state (waste = stock face-up half).
|
|
||||||
**Risk:** Low — validated by existing auto-complete engine tests.
|
|
||||||
|
|
||||||
### 2c. `recycle_count` drift on undo (existing bug, not new)
|
|
||||||
|
|
||||||
**What:** `GameState.recycle_count` is incremented in `draw()` when stock is empty.
|
|
||||||
`undo()` does not decrement it. After undoing a recycle, `recycle_count` is stale and
|
|
||||||
may cause incorrect future penalty application.
|
|
||||||
**Upstream:** `KlondikeStats.recycle_count()` has the same problem — it is cumulative
|
|
||||||
and not restored on undo (stats are not part of the session snapshot, only game state is).
|
|
||||||
**Fix approach:** After each undo, recompute `recycle_count` by scanning
|
|
||||||
`session.history()` for `RotateStock` instructions that caused recycling.
|
|
||||||
**Priority:** Medium — affects scoring correctness in rare paths. File as a separate bug.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. What Must Remain Ferrous-Specific
|
|
||||||
|
|
||||||
These responsibilities are product-layer, not Klondike-rules-layer, and must stay in `solitaire_core`.
|
|
||||||
|
|
||||||
| Responsibility | Why upstream cannot own it |
|
|
||||||
|---|---|
|
|
||||||
| WXP recycle penalties (free allowance + -100/-20) | `ScoringConfig::recycle` is a flat delta; no free-allowance concept exists upstream |
|
|
||||||
| Score floor (`score.max(0)`) | Not modelled upstream |
|
|
||||||
| Time bonus (`700_000 / elapsed_seconds`) | Not modelled upstream |
|
|
||||||
| `DrawMode` / `GameMode` enums | Product concept; not in upstream |
|
|
||||||
| Challenge mode undo block | Product rule |
|
|
||||||
| Zen mode scoring suppression | Product rule |
|
|
||||||
| `MoveError` variants for UI feedback | Upstream returns `bool`; Ferrous needs typed errors |
|
|
||||||
| `card::Card` projection (adds `id`, `face_up`) | Renderer requires stable `id` and face orientation |
|
|
||||||
| `Pile` DTO for engine sync | Renderer-facing snapshot type |
|
|
||||||
| `stock_cards()` / `waste_cards()` distinction | Engine models waste as a separate pile; upstream uses stock face-up half |
|
|
||||||
| `recycle_count` tracking | Needed for free-allowance penalty calculation |
|
|
||||||
| Persistence format + schema versioning | Product concern |
|
|
||||||
| `SavedInstruction` (currently) or upstream serde (after migration) | Either way, Ferrous owns the save contract |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Key Audit Findings
|
|
||||||
|
|
||||||
### Finding 1 — Upstream serde claim in docs is stale
|
|
||||||
|
|
||||||
`docs/card-game-integration.md` (last section "JSON Serialisation") states:
|
|
||||||
|
|
||||||
> Current verification (2026-06-01): klondike v0.3.0 and card_game v0.4.0 crate manifests
|
|
||||||
> expose no serde dependency/feature.
|
|
||||||
|
|
||||||
**This is wrong at rev 99b49e62.** The `serde` feature is present and active. All nine
|
|
||||||
instruction/pile types have `#[cfg_attr(feature = "serde", derive(...))]`. The doc must
|
|
||||||
be updated.
|
|
||||||
|
|
||||||
### Finding 2 — `take_from_foundation` default: docs vs code
|
|
||||||
|
|
||||||
`docs/card-game-integration.md` says:
|
|
||||||
> Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the
|
|
||||||
> default, with the house rule as an opt-in.
|
|
||||||
|
|
||||||
**The code and settings say the opposite:** `Settings::take_from_foundation` defaults to
|
|
||||||
`true` (Allowed); `GameState.take_from_foundation` also initializes to `true`. Multiple
|
|
||||||
tests assert this is the intended behavior. The upstream default is also `Allowed`.
|
|
||||||
|
|
||||||
**Resolution:** The docs are wrong. Default = Allowed (house rule on by default for
|
|
||||||
beginner-friendliness) is intentional. Update the docs; do not change the code.
|
|
||||||
|
|
||||||
### Finding 3 — `KlondikeStats` cumulative vs session-history-aware counts
|
|
||||||
|
|
||||||
`KlondikeStats.moves()` and `KlondikeStats.recycle_count()` accumulate monotonically.
|
|
||||||
They are NOT restored when `Session::undo()` is called (only `Klondike` game state is
|
|
||||||
restored from the snapshot, not the stats). Ferrous correctly uses
|
|
||||||
`session.history().len()` for `move_count` (history-aware). But `recycle_count` is
|
|
||||||
stored separately in `GameState` and also not decremented on undo — making them
|
|
||||||
equivalent in this one bug.
|
|
||||||
|
|
||||||
### Finding 4 — `SkipCards as usize` cast is correct
|
|
||||||
|
|
||||||
Upstream `SkipCards` has no explicit discriminants, so `Skip0 = 0 .. Skip12 = 12`.
|
|
||||||
`skip_cards as usize` in `solver.rs` and `game_state.rs` is correct.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Staged Migration
|
|
||||||
|
|
||||||
### Phase 0 — Doc fixes only (no code change)
|
|
||||||
|
|
||||||
Files: `docs/card-game-integration.md`
|
|
||||||
|
|
||||||
- Correct the serde claim (upstream has serde at rev 99b49e62).
|
|
||||||
- Correct the `take_from_foundation` default description.
|
|
||||||
- Update integration status table.
|
|
||||||
|
|
||||||
### Phase 1 — Delegate `is_win` / `is_win_trivial` (safe, no format change)
|
|
||||||
|
|
||||||
Files: `solitaire_core/src/game_state.rs`
|
|
||||||
|
|
||||||
Replace local `check_win()` and `check_auto_complete()` with upstream delegation:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before
|
|
||||||
pub fn check_win(&self) -> bool { ... 40 lines ... }
|
|
||||||
|
|
||||||
// after
|
|
||||||
pub fn check_win(&self) -> bool {
|
|
||||||
self.session.state().state().is_win()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before
|
|
||||||
pub fn check_auto_complete(&self) -> bool { ... 15 lines ... }
|
|
||||||
|
|
||||||
// after
|
|
||||||
pub fn check_auto_complete(&self) -> bool {
|
|
||||||
self.session.state().state().is_win_trivial()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Risk:** Very low. Both methods are tested by existing integration tests. The semantic
|
|
||||||
difference in `check_auto_complete` (upstream vs Ferrous definition) is equivalent for
|
|
||||||
valid game states.
|
|
||||||
|
|
||||||
### Phase 2 — Replace `SavedInstruction` with upstream serde (schema v4)
|
|
||||||
|
|
||||||
Files:
|
|
||||||
- `solitaire_core/src/klondike_adapter.rs` (remove ~300 lines)
|
|
||||||
- `solitaire_core/src/game_state.rs` (update `Serialize`/`Deserialize` impls)
|
|
||||||
- `solitaire_core/src/proptest_tests.rs` (remove now-redundant SavedInstruction tests)
|
|
||||||
- `solitaire_data/src/storage.rs` (add schema v4 rejection test)
|
|
||||||
- `solitaire_data/src/replay.rs` (no change — uses `SavedKlondikePile` independently)
|
|
||||||
- `solitaire_wasm/src/lib.rs` (uses `SavedKlondikePileStack` in its own mirror — evaluate)
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. In `game_state.rs`, change `PersistedGameState.saved_moves` from
|
|
||||||
`Vec<SavedInstruction>` to `Vec<KlondikeInstruction>` (upstream serde now works).
|
|
||||||
2. Update `GameState::Serialize` to emit `KlondikeInstruction` directly.
|
|
||||||
3. Update `GameState::Deserialize` to parse `KlondikeInstruction` directly.
|
|
||||||
4. Increment `GAME_STATE_SCHEMA_VERSION` to 4.
|
|
||||||
5. In `GameState::Deserialize`, reject schema != 4 with graceful fallback (already
|
|
||||||
handled by `load_game_state_from` returning `None` on serde error or wrong version).
|
|
||||||
6. Delete `SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
|
|
||||||
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
|
|
||||||
`SavedSkipCards`, `InvalidSavedInstruction` from `klondike_adapter.rs`.
|
|
||||||
7. Delete the 20 `From`/`TryFrom` impls.
|
|
||||||
8. Remove `SavedInstruction` proptest and boundary tests (no longer needed).
|
|
||||||
9. Add schema v4 round-trip test and v3 rejection test.
|
|
||||||
|
|
||||||
**Note on `solitaire_data::replay.rs`:**
|
|
||||||
`replay.rs` uses `SavedKlondikePile` independently (for `ReplayMove`). This is a
|
|
||||||
separate type from the game-state save format and is NOT changed by this phase.
|
|
||||||
`ReplayMove` has its own schema (`REPLAY_SCHEMA_VERSION`) and can keep using the local
|
|
||||||
mirror types.
|
|
||||||
|
|
||||||
**Note on `solitaire_wasm/src/lib.rs`:**
|
|
||||||
Uses `SavedKlondikePileStack` in its own `ReplayMove` mirror. Same as above — separate
|
|
||||||
type, not affected.
|
|
||||||
|
|
||||||
### Pre-Phase 3 — Undo Field Audit (completed 2026-06-08)
|
|
||||||
|
|
||||||
Full audit of every Ferrous-owned field in `GameState` for undo correctness.
|
|
||||||
|
|
||||||
| Field | Correctly updated by `undo()`? | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `score` | ✅ By design | −15 WXP undo penalty applied; Zen: stays 0 |
|
|
||||||
| `move_count` | ✅ Correct | Recomputed from `session.history().len()` |
|
|
||||||
| `is_won` | ✅ Correct | Recomputed; undo blocked on won game |
|
|
||||||
| `is_auto_completable` | ✅ Correct | Recomputed |
|
|
||||||
| `undo_count` | ✅ By design | Total undos ever, intentionally non-reversible |
|
|
||||||
| `elapsed_seconds` | ✅ Intentional | Timer is independent of moves |
|
|
||||||
| `seed` / `draw_mode` / `mode` / `take_from_foundation` | ✅ Immutable | |
|
|
||||||
| **`recycle_count`** | ❌ **Bug** | Not decremented — see below |
|
|
||||||
|
|
||||||
**`recycle_count` drift bug:**
|
|
||||||
|
|
||||||
`draw()` increments `recycle_count` when `stock.face_down().is_empty()` (the rotation
|
|
||||||
is a recycle, not just a draw). `undo()` calls `session.undo()` which restores the
|
|
||||||
`Klondike` card state, but does NOT decrement `recycle_count`.
|
|
||||||
|
|
||||||
Consequence: if the player recycles, undoes it, then recycles again, `recycle_count`
|
|
||||||
is `2` instead of `1` — the free-recycle allowance is consumed even though the first
|
|
||||||
recycle was undone. On Draw-1, the 2nd recycle costs −100; after the undo-and-replay
|
|
||||||
bug the player pays −100 for what should be their still-free recycle.
|
|
||||||
|
|
||||||
**Score compound effect:** When `undo()` is applied to a recycle that incurred a
|
|
||||||
penalty, the penalty amount (`score_after_recycle - 100`) is already in `self.score`.
|
|
||||||
`apply_undo_score` then adds `−15` on top. The recycle penalty is never reversed.
|
|
||||||
|
|
||||||
**Fix approach for Phase 3:**
|
|
||||||
- After `session.undo()`, recompute `recycle_count` by scanning the new
|
|
||||||
`session.history()` for `RotateStock` snapshots where
|
|
||||||
`snapshot.state().state().stock().face_down().is_empty()` (indicating the rotation
|
|
||||||
was a recycle, not a draw from a populated stock).
|
|
||||||
- Restore `score` to `snapshot_score` **before** the undone move, then apply only
|
|
||||||
the −15 undo penalty. This requires reading the score stored in `StateSnapshot`
|
|
||||||
or keeping a pre-move score stack alongside the session history.
|
|
||||||
|
|
||||||
**Simpler alternative:** Store `(score_before, recycle_count_before)` in `GameState`
|
|
||||||
alongside each `session.process_instruction` call, mirroring the snapshot stack.
|
|
||||||
Undo pops this alongside the session undo.
|
|
||||||
|
|
||||||
### Phase 3 — Fix `recycle_count` drift on undo (optional, post-approval)
|
|
||||||
|
|
||||||
Files: `solitaire_core/src/game_state.rs`
|
|
||||||
|
|
||||||
After `session.undo()`, recompute `recycle_count` by scanning `session.history()` for
|
|
||||||
`RotateStock` snapshots where the pre-instruction stock face-down was empty (indicating
|
|
||||||
a recycle). Also correct the score: restore to the pre-undone-move score and apply only
|
|
||||||
the −15 undo penalty.
|
|
||||||
|
|
||||||
**Tests to add:**
|
|
||||||
- `recycle_count_decrements_when_recycle_is_undone`
|
|
||||||
- `score_recycle_penalty_is_reversed_on_undo`
|
|
||||||
|
|
||||||
**Risk:** Medium — changes observable scoring behavior. The fix is strictly more
|
|
||||||
correct, but any golden-file or regression test that recorded the old (buggy) score
|
|
||||||
after undo-of-recycle will need updating.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Files Likely to Change Per Phase
|
|
||||||
|
|
||||||
| Phase | Files |
|
|
||||||
|---|---|
|
|
||||||
| Phase 0 | `docs/card-game-integration.md` |
|
|
||||||
| Phase 1 | `solitaire_core/src/game_state.rs` |
|
|
||||||
| Phase 2 | `solitaire_core/src/klondike_adapter.rs`, `solitaire_core/src/game_state.rs`, `solitaire_core/src/proptest_tests.rs`, `solitaire_data/src/storage.rs` |
|
|
||||||
| Phase 3 | `solitaire_core/src/game_state.rs`, new test module |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Risks
|
|
||||||
|
|
||||||
### R1 — Save file format break (Phase 2, HIGH)
|
|
||||||
Users with v3 saves lose their in-progress game. Mitigated by the fact that v3 is
|
|
||||||
not in any shipped release (dev branch only). Graceful fallback (start fresh) is
|
|
||||||
acceptable; a migration shim is possible but not required.
|
|
||||||
|
|
||||||
### R2 — `solitaire_wasm` / `solitaire_data::replay` breakage (Phase 2, MEDIUM)
|
|
||||||
`SavedKlondikePile` and `SavedKlondikePileStack` are also used in `replay.rs` and
|
|
||||||
`wasm/src/lib.rs`. These are separate from the game-state save format and must be
|
|
||||||
left in place. Plan is to keep them in `klondike_adapter.rs` (or relocate to
|
|
||||||
`replay.rs`) after the game-state mirror types are deleted.
|
|
||||||
|
|
||||||
### R3 — `check_auto_complete` semantic drift (Phase 1, LOW)
|
|
||||||
Upstream `is_win_trivial` checks `stock.is_empty()` (no cards at all in stock)
|
|
||||||
whereas Ferrous also checks waste. These are equivalent for a valid game state but
|
|
||||||
could differ under test-support pile overrides. Existing auto-complete tests will
|
|
||||||
catch any regression.
|
|
||||||
|
|
||||||
### R4 — `SkipCards as usize` cast correctness
|
|
||||||
Already verified: enums have implicit 0..12 discriminants. No risk.
|
|
||||||
|
|
||||||
### R5 — Upstream changes after rev pin
|
|
||||||
The workspace is pinned to `rev = "99b49e62"`. No upstream drift risk until explicitly
|
|
||||||
re-pinned.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Test Plan
|
|
||||||
|
|
||||||
### Phase 1 tests (all currently pass)
|
|
||||||
- `game_state::tests::take_from_foundation_allows_legal_return_move`
|
|
||||||
- `game_state::tests::take_from_foundation_disabled_blocks_return_move_everywhere`
|
|
||||||
- `proptest_tests::*` (card conservation, deal determinism, undo invariant, legal moves)
|
|
||||||
|
|
||||||
### Phase 2 tests to add
|
|
||||||
- `storage::tests::game_state_v4_mid_game_round_trip` — verify upstream serde round-trip
|
|
||||||
after migrating to `KlondikeInstruction` directly
|
|
||||||
- `storage::tests::save_format_v3_is_rejected` — v3 files must return `None`
|
|
||||||
- Update `game_state::tests::*` — all existing tests must continue to pass
|
|
||||||
|
|
||||||
### Phase 2 tests to remove
|
|
||||||
- `proptest_tests::saved_instruction_round_trip` — no longer needed (no mirror types)
|
|
||||||
- `proptest_tests::saved_instruction_boundary_tests::*` — no longer needed
|
|
||||||
|
|
||||||
### Phase 3 tests to add
|
|
||||||
- `game_state::tests::recycle_count_decrements_on_undo` — after recycling and undoing,
|
|
||||||
`recycle_count` must reflect the correct post-undo count
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Validation Commands
|
|
||||||
|
|
||||||
Run after each phase:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Targeted (fast)
|
|
||||||
cargo test -p solitaire_core
|
|
||||||
cargo clippy -p solitaire_core -- -D warnings
|
|
||||||
|
|
||||||
# Broader
|
|
||||||
cargo test -p solitaire_wasm
|
|
||||||
cargo test -p solitaire_data
|
|
||||||
|
|
||||||
# Full workspace (run before declaring phase complete)
|
|
||||||
cargo test --workspace
|
|
||||||
cargo clippy --workspace -- -D warnings
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary: What Would Be Removed vs Kept
|
|
||||||
|
|
||||||
### Removed after all phases complete
|
|
||||||
| Code | Lines est. | Reason |
|
|
||||||
|---|---|---|
|
|
||||||
| `SavedInstruction` + 8 mirror types | ~150 | Upstream serde now available |
|
|
||||||
| 20 `From`/`TryFrom` impls | ~150 | Upstream serde now available |
|
|
||||||
| `InvalidSavedInstruction` error type | ~10 | Upstream serde now available |
|
|
||||||
| `check_win()` local impl | ~20 | Replaced by `is_win()` delegation |
|
|
||||||
| `check_auto_complete()` local impl | ~15 | Replaced by `is_win_trivial()` delegation |
|
|
||||||
| `SavedInstruction` proptest + boundary tests | ~60 | Mirror types removed |
|
|
||||||
|
|
||||||
**Total: ~400 lines removed from `solitaire_core`**
|
|
||||||
|
|
||||||
### Remains Ferrous-specific
|
|
||||||
- `KlondikeAdapter` scoring helpers (recycle penalties, score floor, time bonus, Zen/mode suppression)
|
|
||||||
- `DrawMode`, `GameMode`, `DifficultyLevel`
|
|
||||||
- `MoveError` and all boundary-checking logic
|
|
||||||
- `card::Card` (id + face_up projection)
|
|
||||||
- `Pile` DTO
|
|
||||||
- `stock_cards()` / `waste_cards()` projections
|
|
||||||
- Persistence format (`GameState` serde, schema version, `PersistedGameState`)
|
|
||||||
- `solitaire_data::replay` types (`ReplayMove`, `SavedKlondikePile` mirror — unchanged)
|
|
||||||
- `solitaire_wasm` replay mirror types (unchanged)
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# Testing Architecture — Engine-first Validation
|
|
||||||
|
|
||||||
Ferrous Solitaire validation is split into three layers with clear ownership:
|
|
||||||
|
|
||||||
1. **Rust unit tests (`solitaire_core`)**
|
|
||||||
- move generation and legality
|
|
||||||
- deal generation determinism
|
|
||||||
- scoring and penalties
|
|
||||||
- undo semantics
|
|
||||||
- win detection
|
|
||||||
|
|
||||||
2. **Engine integration tests (`solitaire_wasm` debug API)**
|
|
||||||
- autonomous game execution without UI/pointer simulation
|
|
||||||
- invariant checks after every move
|
|
||||||
- deterministic seed replay
|
|
||||||
- high-volume seeded runs (including long-running soak tests)
|
|
||||||
|
|
||||||
3. **Playwright UI tests**
|
|
||||||
- verify rendering vs engine state
|
|
||||||
- drag/drop and keyboard UX behavior
|
|
||||||
- responsive layout behavior
|
|
||||||
- browser-compatibility checks
|
|
||||||
|
|
||||||
## Source of truth
|
|
||||||
|
|
||||||
The Rust engine is authoritative. Browser tests must interact with the game via
|
|
||||||
debug API hooks, not via pixel/OCR solving or hardcoded screen coordinates.
|
|
||||||
|
|
||||||
## Debug API surfaces
|
|
||||||
|
|
||||||
Two automation surfaces are exposed:
|
|
||||||
|
|
||||||
- `solitaire_wasm::SolitaireGame` methods:
|
|
||||||
- `debug_snapshot()`
|
|
||||||
- `debug_legal_moves()`
|
|
||||||
- `debug_move_history()`
|
|
||||||
- `debug_apply_legal_move(index)`
|
|
||||||
- `debug_apply_move_json(json)`
|
|
||||||
- Browser bridge on `game.html`:
|
|
||||||
- `window.__FERROUS_DEBUG__.snapshot()`
|
|
||||||
- `window.__FERROUS_DEBUG__.legalMoves()`
|
|
||||||
- `window.__FERROUS_DEBUG__.moveHistory()`
|
|
||||||
- `window.__FERROUS_DEBUG__.applyLegalMove(index)`
|
|
||||||
- `window.__FERROUS_DEBUG__.applyMove(move)`
|
|
||||||
- `window.__FERROUS_DEBUG__.failureReport()`
|
|
||||||
- `window.__FERROUS_DEBUG__.runAutoplay(options)`
|
|
||||||
|
|
||||||
## Required failure payload
|
|
||||||
|
|
||||||
Every automation failure should capture:
|
|
||||||
|
|
||||||
- seed
|
|
||||||
- move history
|
|
||||||
- current game state
|
|
||||||
- screenshot
|
|
||||||
- browser trace
|
|
||||||
- console logs
|
|
||||||
|
|
||||||
`failureReport()` provides the engine-side fields (`seed`, `moveHistory`,
|
|
||||||
`currentState`) so UI harnesses only need to attach browser artifacts.
|
|
||||||
|
|
||||||
## Execution guidance
|
|
||||||
|
|
||||||
- Fast verification:
|
|
||||||
- `cargo test -p solitaire_core -p solitaire_wasm`
|
|
||||||
- Full verification:
|
|
||||||
- `cargo test --workspace`
|
|
||||||
- `cargo clippy --workspace -- -D warnings`
|
|
||||||
- Long unattended soak:
|
|
||||||
- `cargo test -p solitaire_wasm debug_api_autonomous_thousands_seed_soak -- --ignored`
|
|
||||||
|
|
||||||
### Browser e2e harness
|
|
||||||
|
|
||||||
The Playwright suite lives under `solitaire_server/e2e/` and boots
|
|
||||||
`solitaire_server` via Playwright `webServer` config.
|
|
||||||
|
|
||||||
- Install + run:
|
|
||||||
- `cd solitaire_server/e2e`
|
|
||||||
- `npm ci`
|
|
||||||
- `npx playwright install chromium`
|
|
||||||
- `npm test`
|
|
||||||
- Cycle metrics batch run:
|
|
||||||
- `cd solitaire_server/e2e`
|
|
||||||
- `npm run review:cycles -- --games 1000 --steps 350 --policy baseline --max-visits 1 --out /tmp/cycle-baseline.json`
|
|
||||||
- `npm run review:cycles -- --games 1000 --steps 350 --policy loop_aware --max-visits 2 --out /tmp/cycle-loop-aware.json`
|
|
||||||
- `npm run review:cycles:regression` (thresholded gate, writes `test-results/cycle-regression.json`)
|
|
||||||
- `npm run review:cycles:candidate` (loop-aware candidate run, writes `test-results/cycle-candidate.json`)
|
|
||||||
|
|
||||||
### Cycle-risk regression baseline and guardrails
|
|
||||||
|
|
||||||
- Current regression gate command:
|
|
||||||
- `npm run review:cycles:regression`
|
|
||||||
- config: `games=240`, `steps=350`, `policy=baseline`, `max-visits=1`
|
|
||||||
- Current guardrail thresholds:
|
|
||||||
- `all.cycle_rate_pct <= 86`
|
|
||||||
- `draw1.cycle_rate_pct <= 76`
|
|
||||||
- `draw3.cycle_rate_pct <= 95`
|
|
||||||
- `all.win_rate_pct >= 14`
|
|
||||||
- zero invariant/apply/page/console issue counts
|
|
||||||
- Baseline sample (240 games):
|
|
||||||
- overall: `win_rate=15.8%`, `cycle_rate=84.2%`
|
|
||||||
- draw-one: `win_rate=25.8%`, `cycle_rate=74.2%`
|
|
||||||
- draw-three: `win_rate=5.8%`, `cycle_rate=94.2%`
|
|
||||||
- Candidate loop-aware sample (240 games, lookahead via simulated move + restore):
|
|
||||||
- overall: `win_rate=20.4%`, `cycle_rate=32.5%`
|
|
||||||
- draw-one: `win_rate=33.3%`, `cycle_rate=16.7%`
|
|
||||||
- draw-three: `win_rate=7.5%`, `cycle_rate=48.3%`
|
|
||||||
- no invariant/apply/page/console issues in the sampled run
|
|
||||||
- Additional 500-game candidate soak:
|
|
||||||
- overall: `win_rate=20.2%`, `cycle_rate=28.6%`, `step_budget=51.2%`
|
|
||||||
- draw-three remains the dominant risk (`cycle_rate=45.2%`)
|
|
||||||
- Fix applied: cycle metrics regression now supports explicit
|
|
||||||
`max_step_budget_rate_*` thresholds. Candidate command now enforces
|
|
||||||
`max_step_budget_rate_all <= 60` to prevent silent drift from cycles into
|
|
||||||
step-budget stalls.
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
# Android testing
|
|
||||||
|
|
||||||
This directory contains lightweight Android test helpers for Ferrous Solitaire.
|
|
||||||
They are intended to run against either a physical Android device or an emulator
|
|
||||||
connected through `adb`. When no device is connected the smoke script can
|
|
||||||
automatically launch an AVD for you.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Android SDK and NDK installed.
|
|
||||||
- `adb` available on `PATH`.
|
|
||||||
- One device/emulator visible in `adb devices`, **or** at least one AVD created
|
|
||||||
(the script will launch one automatically if `LAUNCH_AVD=1`, which is the default).
|
|
||||||
- If multiple devices are connected, set `ADB_SERIAL` to the target device serial.
|
|
||||||
- Environment variables required by `scripts/build_android_apk.sh` when building:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export ANDROID_HOME=/path/to/android-sdk
|
|
||||||
export ANDROID_NDK_HOME=/path/to/android-ndk
|
|
||||||
export BUILD_TOOLS_VERSION=34.0.0
|
|
||||||
export PLATFORM=android-34
|
|
||||||
```
|
|
||||||
|
|
||||||
## Smoke test
|
|
||||||
|
|
||||||
From the workspace root (`Rusty_Solitaire/`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The smoke test first checks whether `adb` can see a ready device. If no device
|
|
||||||
is connected and `LAUNCH_AVD=1` (default), it:
|
|
||||||
|
|
||||||
1. locates the `emulator` binary under `ANDROID_HOME` or `PATH`,
|
|
||||||
2. picks the first available AVD (or uses `AVD_NAME`),
|
|
||||||
3. launches the emulator in the foreground (or headless with `AVD_HEADLESS=1`),
|
|
||||||
4. waits for `sys.boot_completed=1` before proceeding,
|
|
||||||
5. dismisses the lock screen so the screenshot shows the app.
|
|
||||||
|
|
||||||
Once a device is ready (auto-launched or pre-existing) the script:
|
|
||||||
|
|
||||||
1. builds the APK using `scripts/build_android_apk.sh`,
|
|
||||||
2. installs it with `adb install -r -d` so debug smoke builds can replace newer local builds,
|
|
||||||
3. force-stops the package by default for a clean launch,
|
|
||||||
4. clears `logcat`,
|
|
||||||
5. launches `com.ferrousapp.solitaire/android.app.NativeActivity`,
|
|
||||||
6. waits for the app to settle,
|
|
||||||
7. verifies the process is still running,
|
|
||||||
8. captures a screenshot and `logcat`, and
|
|
||||||
9. fails on fatal log patterns such as native crashes, JNI fatal errors, real ANRs,
|
|
||||||
and Rust panics.
|
|
||||||
|
|
||||||
On exit the script kills any emulator it launched (`SHUTDOWN_AVD_ON_EXIT=1` by
|
|
||||||
default). Set `SHUTDOWN_AVD_ON_EXIT=0` to keep the emulator open for inspection.
|
|
||||||
|
|
||||||
Artifacts are written to `target/android-smoke/<timestamp>/` by default. A successful run includes:
|
|
||||||
|
|
||||||
- `device.txt` — selected device and display metadata,
|
|
||||||
- `df-data-before.txt` / `df-data-after.txt` — emulator/device storage snapshots,
|
|
||||||
- `emulator.log` — stdout/stderr from the emulator process (AVD runs only),
|
|
||||||
- `emulator.pid` — PID of the emulator process (AVD runs only),
|
|
||||||
- `launch.png` — screenshot after the wait period,
|
|
||||||
- `logcat.txt` — full captured log,
|
|
||||||
- `log-summary.txt` — grep summary for warnings, errors, JNI, safe-area, and crash terms, and
|
|
||||||
- `pid.txt` — running app process id.
|
|
||||||
|
|
||||||
## Creating an AVD
|
|
||||||
|
|
||||||
If no AVDs exist, create one before running the smoke test:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Install a system image
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
|
||||||
'system-images;android-34;google_apis;x86_64'
|
|
||||||
|
|
||||||
# Create the AVD
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd \
|
|
||||||
-n Pixel_7_API_34 \
|
|
||||||
-k 'system-images;android-34;google_apis;x86_64' \
|
|
||||||
--device 'pixel_7'
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the smoke test — it will pick `Pixel_7_API_34` automatically:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Faster iteration
|
|
||||||
|
|
||||||
If you already built the APK and only want to reinstall/relaunch:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
BUILD_APK=0 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
If the APK is already installed and you only want to relaunch/capture logs:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
BUILD_APK=0 INSTALL_APK=0 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
By default the script force-stops the package before launch so logcat and screenshots represent a clean app start. To test warm-launch behavior instead:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
BUILD_APK=0 INSTALL_APK=0 FORCE_STOP=0 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This is also useful when an already-installed build is good enough for launch/log checks. On install failure, the script writes `adb-install.txt`, storage snapshots, and installed-package diagnostics to the output directory.
|
|
||||||
|
|
||||||
If install fails with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, the smoke script uninstalls the package and retries once by default (`RESET_ON_SIGNATURE_MISMATCH=1`). This resets app data on the device/emulator. Disable it with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
RESET_ON_SIGNATURE_MISMATCH=0 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To write artifacts to a stable path:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
When reusing an output directory, previous files are removed by default so stale artifacts do not contaminate the latest result. To keep existing files:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
CLEAN_OUT_DIR=0 OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To target a specific device when more than one is attached:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ADB_SERIAL=emulator-5554 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To wait longer for safe-area inset polling or slow devices:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
WAIT_SECS=8 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## AVD options
|
|
||||||
|
|
||||||
To pick a specific AVD by name instead of auto-selecting the first one:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To run headless (no emulator window) — useful in CI or on a display-less machine:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
AVD_HEADLESS=1 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To give a slow machine more time to boot the emulator (default is 120 s):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
AVD_BOOT_TIMEOUT=180 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To keep the emulator running after the test (useful for manual inspection):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To pass extra flags to the emulator (e.g. disable snapshot for a completely
|
|
||||||
cold boot, or change GPU mode):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
AVD_EXTRA_ARGS="-gpu swiftshader_indirect" scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To disable AVD auto-launch entirely and fail immediately if no device is
|
|
||||||
connected:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
LAUNCH_AVD=0 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
For build-only validation without requiring a connected device, use the lower-level APK builder directly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/build_android_apk.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
For smoke testing, `scripts/android_smoke.sh` defaults to the connected device's primary ABI when `BUILD_APK=1`, which keeps emulator APKs much smaller than the full multi-ABI default. You can still override it explicitly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ABIS=x86_64 scripts/android_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
For build-only validation, `scripts/build_android_apk.sh` still defaults to all configured ABIs unless you set `ABIS` yourself:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ABIS=x86_64 scripts/build_android_apk.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The APK builder signs debug builds with a persistent keystore at `target/android/debug.keystore` by default. This avoids signature churn across smoke-test runs.
|
|
||||||
|
|
||||||
The APK builder also strips native debug symbols by default before packaging (`STRIP_NATIVE_LIBS=1`). This keeps debug APKs installable on emulators with limited `/data` storage. To preserve native debug symbols for low-level debugging:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
STRIP_NATIVE_LIBS=0 ABIS=x86_64 scripts/build_android_apk.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Device checklist
|
|
||||||
|
|
||||||
The script is only a smoke test. Before shipping Android builds, also verify:
|
|
||||||
|
|
||||||
- safe-area insets arrive and shift the HUD after a few seconds,
|
|
||||||
- HUD does not overlap the top status bar,
|
|
||||||
- modal Done buttons are above the gesture/navigation bar,
|
|
||||||
- stock tap works,
|
|
||||||
- drag-and-drop works on tableau, waste, and foundation piles,
|
|
||||||
- Settings/Help/Profile modals open and close,
|
|
||||||
- login tokens persist after app restart, and
|
|
||||||
- `target/android-smoke/.../logcat.txt` contains no fatal JNI/native crash output.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `adb shell input tap X Y` uses physical pixels, not Bevy logical pixels.
|
|
||||||
- The project’s common test device mapping is physical `1080×2400`, Bevy logical
|
|
||||||
`900×2000`, scale factor `1.20`; multiply logical coordinates by `1.20` for
|
|
||||||
scripted `adb shell input` commands on that device.
|
|
||||||
- Keep generated screenshots/logs under `target/android-smoke/` so they stay out
|
|
||||||
of source control.
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Android smoke test for Ferrous Solitaire.
|
|
||||||
#
|
|
||||||
# Builds (optional), installs, launches, captures logcat + screenshot, and
|
|
||||||
# fails on fatal Android log patterns. Designed as a lightweight device/emulator
|
|
||||||
# sanity check rather than a full UI automation suite.
|
|
||||||
#
|
|
||||||
# Required:
|
|
||||||
# adb on PATH
|
|
||||||
# Android SDK/NDK env required by scripts/build_android_apk.sh when BUILD_APK=1
|
|
||||||
#
|
|
||||||
# Optional environment:
|
|
||||||
# BUILD_APK=1|0 Build APK before install (default: 1)
|
|
||||||
# INSTALL_APK=1|0 Install APK before launch (default: 1)
|
|
||||||
# RESET_ON_SIGNATURE_MISMATCH=1|0
|
|
||||||
# Uninstall/retry if debug signatures differ (default: 1)
|
|
||||||
# LAUNCH_APP=1|0 Launch app before checks (default: 1)
|
|
||||||
# FORCE_STOP=1|0 Force-stop package before launch for clean logs (default: 1)
|
|
||||||
# CAPTURE_SCREENSHOT=1|0 Capture screenshot (default: 1)
|
|
||||||
# ADB_SERIAL=... Device serial to use when multiple devices are connected
|
|
||||||
# APK_PATH=... APK to install (default: target/debug/apk/ferrous-solitaire.apk)
|
|
||||||
# PACKAGE=... Android package (default: com.ferrousapp.solitaire)
|
|
||||||
# ACTIVITY=... Activity class (default: android.app.NativeActivity)
|
|
||||||
# OUT_DIR=... Artifact directory (default: target/android-smoke/<timestamp>)
|
|
||||||
# CLEAN_OUT_DIR=1|0 Remove prior artifacts from OUT_DIR first (default: 1)
|
|
||||||
# WAIT_SECS=... Seconds to wait after launch (default: 5)
|
|
||||||
# ABIS=... Passed to build script. If unset and BUILD_APK=1,
|
|
||||||
# defaults to the connected device's primary ABI.
|
|
||||||
#
|
|
||||||
# AVD auto-launch (used when no device/emulator is already connected):
|
|
||||||
# LAUNCH_AVD=1|0 Auto-launch an AVD when no device is ready (default: 1)
|
|
||||||
# AVD_NAME=... AVD name to launch (default: first from `emulator -list-avds`)
|
|
||||||
# AVD_BOOT_TIMEOUT=... Seconds to wait for the emulator to finish booting (default: 120)
|
|
||||||
# AVD_HEADLESS=1|0 Run with -no-window -no-audio for CI/no-display environments (default: 0)
|
|
||||||
# AVD_EXTRA_ARGS=... Extra arguments appended verbatim to the emulator command line
|
|
||||||
# SHUTDOWN_AVD_ON_EXIT=1|0
|
|
||||||
# Kill the AVD this script launched on exit (default: 1).
|
|
||||||
# Set to 0 to leave the emulator running after the test.
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# scripts/android_smoke.sh
|
|
||||||
# BUILD_APK=0 scripts/android_smoke.sh
|
|
||||||
# LAUNCH_AVD=0 scripts/android_smoke.sh # error out if no device, never auto-launch
|
|
||||||
# AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
|
|
||||||
# AVD_HEADLESS=1 scripts/android_smoke.sh # CI / no-display
|
|
||||||
# SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh # keep emulator open after test
|
|
||||||
# OUT_DIR=target/android-smoke/latest WAIT_SECS=8 scripts/android_smoke.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
BUILD_APK="${BUILD_APK:-1}"
|
|
||||||
INSTALL_APK="${INSTALL_APK:-1}"
|
|
||||||
RESET_ON_SIGNATURE_MISMATCH="${RESET_ON_SIGNATURE_MISMATCH:-1}"
|
|
||||||
LAUNCH_APP="${LAUNCH_APP:-1}"
|
|
||||||
FORCE_STOP="${FORCE_STOP:-1}"
|
|
||||||
CAPTURE_SCREENSHOT="${CAPTURE_SCREENSHOT:-1}"
|
|
||||||
APK_PATH="${APK_PATH:-target/debug/apk/ferrous-solitaire.apk}"
|
|
||||||
PACKAGE="${PACKAGE:-com.ferrousapp.solitaire}"
|
|
||||||
ACTIVITY="${ACTIVITY:-android.app.NativeActivity}"
|
|
||||||
WAIT_SECS="${WAIT_SECS:-5}"
|
|
||||||
OUT_DIR="${OUT_DIR:-target/android-smoke/$(date +%Y%m%d-%H%M%S)}"
|
|
||||||
CLEAN_OUT_DIR="${CLEAN_OUT_DIR:-1}"
|
|
||||||
REMOTE_SCREENSHOT="/sdcard/ferrous-solitaire-smoke.png"
|
|
||||||
|
|
||||||
LAUNCH_AVD="${LAUNCH_AVD:-1}"
|
|
||||||
AVD_NAME="${AVD_NAME:-}"
|
|
||||||
AVD_BOOT_TIMEOUT="${AVD_BOOT_TIMEOUT:-120}"
|
|
||||||
AVD_HEADLESS="${AVD_HEADLESS:-0}"
|
|
||||||
AVD_EXTRA_ARGS="${AVD_EXTRA_ARGS:-}"
|
|
||||||
SHUTDOWN_AVD_ON_EXIT="${SHUTDOWN_AVD_ON_EXIT:-1}"
|
|
||||||
|
|
||||||
ADB=(adb)
|
|
||||||
if [ -n "${ADB_SERIAL:-}" ]; then
|
|
||||||
ADB+=( -s "$ADB_SERIAL" )
|
|
||||||
fi
|
|
||||||
|
|
||||||
# PID of any emulator we start so the EXIT trap can clean it up.
|
|
||||||
_LAUNCHED_EMULATOR_PID=""
|
|
||||||
|
|
||||||
_cleanup_emulator() {
|
|
||||||
if [ -n "$_LAUNCHED_EMULATOR_PID" ] && [ "$SHUTDOWN_AVD_ON_EXIT" = "1" ]; then
|
|
||||||
echo ">>> shutdown emulator (PID $_LAUNCHED_EMULATOR_PID)"
|
|
||||||
kill "$_LAUNCHED_EMULATOR_PID" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap _cleanup_emulator EXIT
|
|
||||||
|
|
||||||
require_cmd() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || {
|
|
||||||
echo "missing required command: $1" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
if [ "$CLEAN_OUT_DIR" = "1" ]; then
|
|
||||||
find "$OUT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
|
||||||
fi
|
|
||||||
require_cmd adb
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Device / emulator availability
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
|
||||||
if [ "$DEVICE_STATE" != "device" ]; then
|
|
||||||
if [ "$LAUNCH_AVD" != "1" ]; then
|
|
||||||
adb devices > "$OUT_DIR/adb-devices.txt" 2>&1 || true
|
|
||||||
if [ -n "${ADB_SERIAL:-}" ]; then
|
|
||||||
echo "Android device '$ADB_SERIAL' is not connected/ready (state: ${DEVICE_STATE:-unknown})." >&2
|
|
||||||
else
|
|
||||||
echo "No Android device/emulator is connected and ready." >&2
|
|
||||||
fi
|
|
||||||
echo "Run 'adb devices' or start an emulator, then retry." >&2
|
|
||||||
echo "Device list saved to $OUT_DIR/adb-devices.txt" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- locate emulator binary -----------------------------------------------
|
|
||||||
# Priority: ANDROID_HOME env → PATH → common SDK install locations.
|
|
||||||
_find_sdk_root() {
|
|
||||||
for candidate in \
|
|
||||||
"$HOME/Android/Sdk" \
|
|
||||||
"$HOME/Library/Android/sdk" \
|
|
||||||
"/opt/android-sdk" \
|
|
||||||
"/usr/lib/android-sdk"; do
|
|
||||||
[ -d "$candidate" ] && echo "$candidate" && return
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
EMULATOR_BIN=""
|
|
||||||
if [ -n "${ANDROID_HOME:-}" ] && [ -x "$ANDROID_HOME/emulator/emulator" ]; then
|
|
||||||
EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
|
|
||||||
elif command -v emulator >/dev/null 2>&1; then
|
|
||||||
EMULATOR_BIN="$(command -v emulator)"
|
|
||||||
else
|
|
||||||
_SDK_ROOT="$(_find_sdk_root)"
|
|
||||||
if [ -n "$_SDK_ROOT" ] && [ -x "$_SDK_ROOT/emulator/emulator" ]; then
|
|
||||||
EMULATOR_BIN="$_SDK_ROOT/emulator/emulator"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$EMULATOR_BIN" ]; then
|
|
||||||
echo "No Android device found and 'emulator' binary is not available." >&2
|
|
||||||
echo " • Install the Android SDK emulator component, or" >&2
|
|
||||||
echo " • Set ANDROID_HOME to your SDK root, or" >&2
|
|
||||||
echo " • Start a device/emulator manually then retry with LAUNCH_AVD=0." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo ">>> emulator binary: $EMULATOR_BIN"
|
|
||||||
|
|
||||||
# --- select AVD -----------------------------------------------------------
|
|
||||||
if [ -z "$AVD_NAME" ]; then
|
|
||||||
AVD_NAME="$("$EMULATOR_BIN" -list-avds 2>/dev/null | head -n 1 | tr -d '\r')"
|
|
||||||
if [ -z "$AVD_NAME" ]; then
|
|
||||||
echo "No AVDs found. Create one first, for example:" >&2
|
|
||||||
echo " sdkmanager 'system-images;android-34;google_apis;x86_64'" >&2
|
|
||||||
echo " avdmanager create avd -n Pixel_7_API_34 \\" >&2
|
|
||||||
echo " -k 'system-images;android-34;google_apis;x86_64' --device 'pixel_7'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo ">>> auto-selected AVD: $AVD_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- launch emulator -------------------------------------------------------
|
|
||||||
EMULATOR_ARGS=( -avd "$AVD_NAME" -no-snapshot-load )
|
|
||||||
[ "$AVD_HEADLESS" = "1" ] && EMULATOR_ARGS+=( -no-window -no-audio )
|
|
||||||
# Split AVD_EXTRA_ARGS on whitespace only (disable glob expansion).
|
|
||||||
set -f
|
|
||||||
# shellcheck disable=SC2206
|
|
||||||
[ -n "$AVD_EXTRA_ARGS" ] && EMULATOR_ARGS+=( $AVD_EXTRA_ARGS )
|
|
||||||
set +f
|
|
||||||
|
|
||||||
echo ">>> launch emulator: $AVD_NAME"
|
|
||||||
"$EMULATOR_BIN" "${EMULATOR_ARGS[@]}" > "$OUT_DIR/emulator.log" 2>&1 &
|
|
||||||
_LAUNCHED_EMULATOR_PID=$!
|
|
||||||
echo "$_LAUNCHED_EMULATOR_PID" > "$OUT_DIR/emulator.pid"
|
|
||||||
echo " emulator PID: $_LAUNCHED_EMULATOR_PID"
|
|
||||||
echo " emulator log: $OUT_DIR/emulator.log"
|
|
||||||
|
|
||||||
# --- wait for adb transport -----------------------------------------------
|
|
||||||
# Poll adb get-state (≠ wait-for-device which blocks indefinitely) so we can
|
|
||||||
# honour AVD_BOOT_TIMEOUT for the whole boot sequence.
|
|
||||||
echo ">>> waiting for device to appear in adb (timeout: ${AVD_BOOT_TIMEOUT}s)"
|
|
||||||
_ELAPSED=0
|
|
||||||
while true; do
|
|
||||||
_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
|
||||||
if [ "$_STATE" = "device" ] || [ "$_STATE" = "offline" ]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
|
|
||||||
echo "Device did not appear in adb within ${AVD_BOOT_TIMEOUT}s" >&2
|
|
||||||
echo "emulator log:" >&2
|
|
||||||
tail -20 "$OUT_DIR/emulator.log" >&2 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
_ELAPSED=$(( _ELAPSED + 3 ))
|
|
||||||
echo " ... ${_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Capture emulator serial (emulator-5554 etc.) so all subsequent adb calls
|
|
||||||
# target the right device when ADB_SERIAL was not set by the caller.
|
|
||||||
if [ -z "${ADB_SERIAL:-}" ]; then
|
|
||||||
_EMU_SERIAL="$(adb devices 2>/dev/null | awk '/^emulator-/{print $1; exit}' | tr -d '\r')"
|
|
||||||
if [ -n "$_EMU_SERIAL" ]; then
|
|
||||||
ADB_SERIAL="$_EMU_SERIAL"
|
|
||||||
ADB=(adb -s "$ADB_SERIAL")
|
|
||||||
echo ">>> detected emulator serial: $ADB_SERIAL"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- wait for full Android boot -------------------------------------------
|
|
||||||
# adb get-state returning "device" means the transport is up, but the
|
|
||||||
# Android framework may still be initialising. Poll sys.boot_completed.
|
|
||||||
echo ">>> waiting for boot_completed (timeout: ${AVD_BOOT_TIMEOUT}s)"
|
|
||||||
_BOOT_ELAPSED=0
|
|
||||||
_BOOT_INTERVAL=5
|
|
||||||
while true; do
|
|
||||||
_BOOT="$("${ADB[@]}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
|
|
||||||
if [ "$_BOOT" = "1" ]; then
|
|
||||||
echo ">>> emulator boot complete"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$_BOOT_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
|
|
||||||
echo "Emulator did not finish booting within ${AVD_BOOT_TIMEOUT}s" >&2
|
|
||||||
echo "emulator log:" >&2
|
|
||||||
tail -20 "$OUT_DIR/emulator.log" >&2 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep "$_BOOT_INTERVAL"
|
|
||||||
_BOOT_ELAPSED=$(( _BOOT_ELAPSED + _BOOT_INTERVAL ))
|
|
||||||
echo " ... ${_BOOT_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s (boot_completed='${_BOOT}')"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Dismiss the lock screen so later screencap shows the app, not the keyguard.
|
|
||||||
"${ADB[@]}" shell input keyevent 82 2>/dev/null || true
|
|
||||||
|
|
||||||
# Final sanity check — device must be fully ready before we proceed.
|
|
||||||
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
|
|
||||||
if [ "$DEVICE_STATE" != "device" ]; then
|
|
||||||
echo "Emulator is running but adb state is '${DEVICE_STATE:-unknown}'." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Device metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
{
|
|
||||||
echo "adb_serial=${ADB_SERIAL:-default}"
|
|
||||||
echo "package=$PACKAGE"
|
|
||||||
echo "activity=$ACTIVITY"
|
|
||||||
echo "device_state=$DEVICE_STATE"
|
|
||||||
"${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' | sed 's/^/product_model=/'
|
|
||||||
"${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' | sed 's/^/android_release=/'
|
|
||||||
"${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' | sed 's/^/android_sdk=/'
|
|
||||||
"${ADB[@]}" shell wm size 2>/dev/null | tr -d '\r' | sed 's/^/wm_size=/'
|
|
||||||
"${ADB[@]}" shell wm density 2>/dev/null | tr -d '\r' | sed 's/^/wm_density=/'
|
|
||||||
} > "$OUT_DIR/device.txt"
|
|
||||||
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-before.txt" 2>&1 || true
|
|
||||||
|
|
||||||
if [ "$BUILD_APK" = "1" ]; then
|
|
||||||
if [ -z "${ABIS:-}" ]; then
|
|
||||||
DEVICE_ABI="$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')"
|
|
||||||
case "$DEVICE_ABI" in
|
|
||||||
x86_64|arm64-v8a|armeabi-v7a)
|
|
||||||
export ABIS="$DEVICE_ABI"
|
|
||||||
;;
|
|
||||||
armeabi*)
|
|
||||||
export ABIS="armeabi-v7a"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Could not map device ABI '$DEVICE_ABI'; using build script default ABIS." >&2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
echo ">>> build Android APK${ABIS:+ (ABIS=$ABIS)}"
|
|
||||||
scripts/build_android_apk.sh
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$INSTALL_APK" = "1" ]; then
|
|
||||||
[ -f "$APK_PATH" ] || {
|
|
||||||
echo "APK not found: $APK_PATH" >&2
|
|
||||||
echo "Set APK_PATH or run with BUILD_APK=1." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
ls -lh "$APK_PATH" > "$OUT_DIR/apk.txt"
|
|
||||||
echo ">>> install $APK_PATH"
|
|
||||||
if ! "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install.txt" 2>&1; then
|
|
||||||
if [ "$RESET_ON_SIGNATURE_MISMATCH" = "1" ] && grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE" "$OUT_DIR/adb-install.txt"; then
|
|
||||||
echo ">>> signature mismatch; uninstalling $PACKAGE and retrying install"
|
|
||||||
"${ADB[@]}" uninstall "$PACKAGE" > "$OUT_DIR/adb-uninstall-before-retry.txt" 2>&1 || true
|
|
||||||
if "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install-retry.txt" 2>&1; then
|
|
||||||
cat "$OUT_DIR/adb-install-retry.txt" >> "$OUT_DIR/adb-install.txt"
|
|
||||||
else
|
|
||||||
cat "$OUT_DIR/adb-install.txt" >&2
|
|
||||||
cat "$OUT_DIR/adb-install-retry.txt" >&2
|
|
||||||
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
|
|
||||||
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
|
|
||||||
echo "APK install retry failed. Diagnostics saved in $OUT_DIR" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
cat "$OUT_DIR/adb-install.txt" >&2
|
|
||||||
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
|
|
||||||
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
|
|
||||||
echo "APK install failed. Diagnostics saved in $OUT_DIR" >&2
|
|
||||||
echo "If the package is already installed and you only need launch/log checks, retry with INSTALL_APK=0." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$FORCE_STOP" = "1" ]; then
|
|
||||||
echo ">>> force-stop $PACKAGE"
|
|
||||||
"${ADB[@]}" shell am force-stop "$PACKAGE" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ">>> clear logcat"
|
|
||||||
"${ADB[@]}" logcat -c
|
|
||||||
|
|
||||||
if [ "$LAUNCH_APP" = "1" ]; then
|
|
||||||
echo ">>> launch $PACKAGE/$ACTIVITY"
|
|
||||||
"${ADB[@]}" shell am start -n "$PACKAGE/$ACTIVITY" > "$OUT_DIR/am-start.txt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ">>> wait ${WAIT_SECS}s"
|
|
||||||
sleep "$WAIT_SECS"
|
|
||||||
|
|
||||||
PID="$("${ADB[@]}" shell pidof "$PACKAGE" | tr -d '\r' || true)"
|
|
||||||
if [ -z "$PID" ]; then
|
|
||||||
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt" || true
|
|
||||||
echo "app process is not running after launch: $PACKAGE" >&2
|
|
||||||
echo "logcat saved to $OUT_DIR/logcat.txt" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "$PID" > "$OUT_DIR/pid.txt"
|
|
||||||
|
|
||||||
if [ "$CAPTURE_SCREENSHOT" = "1" ]; then
|
|
||||||
echo ">>> capture screenshot"
|
|
||||||
"${ADB[@]}" shell screencap -p "$REMOTE_SCREENSHOT"
|
|
||||||
"${ADB[@]}" pull "$REMOTE_SCREENSHOT" "$OUT_DIR/launch.png" >/dev/null
|
|
||||||
"${ADB[@]}" shell rm -f "$REMOTE_SCREENSHOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ">>> capture logcat"
|
|
||||||
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt"
|
|
||||||
grep -iE "panic|fatal|jni|native crash|\bANR\b|exception|error|warn|keystore|safe_area" "$OUT_DIR/logcat.txt" > "$OUT_DIR/log-summary.txt" || true
|
|
||||||
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after.txt" 2>&1 || true
|
|
||||||
|
|
||||||
# Fatal patterns only. Avoid matching generic "error" because Android logs are
|
|
||||||
# noisy and many non-fatal framework lines contain that word.
|
|
||||||
if grep -iE "fatal exception|jni detected error|native crash|signal [0-9]+|ANR in|Application Not Responding|Input dispatching timed out|thread exiting with uncaught exception|panicked at" "$OUT_DIR/logcat.txt"; then
|
|
||||||
echo "Android smoke test found fatal log output" >&2
|
|
||||||
echo "Artifacts saved in $OUT_DIR" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ">>> Android smoke test passed"
|
|
||||||
echo "Artifacts saved in $OUT_DIR"
|
|
||||||
+16
-108
@@ -6,15 +6,11 @@
|
|||||||
# ndk-build crate that we couldn't isolate; running each Android toolchain
|
# ndk-build crate that we couldn't isolate; running each Android toolchain
|
||||||
# step explicitly gives us a debuggable pipeline.
|
# step explicitly gives us a debuggable pipeline.
|
||||||
#
|
#
|
||||||
# Environment:
|
# Required environment:
|
||||||
# ANDROID_HOME Path to Android SDK root. If unset, common SDK
|
# ANDROID_HOME Path to Android SDK root
|
||||||
# locations such as ~/Android/Sdk are tried.
|
# ANDROID_NDK_HOME Path to the specific NDK version
|
||||||
# ANDROID_NDK_HOME Path to the specific NDK version. If unset, the
|
# BUILD_TOOLS_VERSION e.g. "34.0.0"
|
||||||
# newest $ANDROID_HOME/ndk/* directory is used.
|
# PLATFORM e.g. "android-34"
|
||||||
# BUILD_TOOLS_VERSION e.g. "34.0.0". If unset, newest installed build-tools
|
|
||||||
# version is used.
|
|
||||||
# PLATFORM e.g. "android-34". If unset, newest installed
|
|
||||||
# $ANDROID_HOME/platforms/android-* platform is used.
|
|
||||||
#
|
#
|
||||||
# Optional environment:
|
# Optional environment:
|
||||||
# PROFILE "debug" (default) | "release"
|
# PROFILE "debug" (default) | "release"
|
||||||
@@ -23,8 +19,7 @@
|
|||||||
# fit the runner's disk budget — a full three-ABI
|
# fit the runner's disk budget — a full three-ABI
|
||||||
# debug build can exceed 25 GB of target/ output.
|
# debug build can exceed 25 GB of target/ output.
|
||||||
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
|
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
|
||||||
# STRIP_NATIVE_LIBS 1 to strip .so files before packaging (default: 1)
|
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
|
||||||
# KEYSTORE Path to keystore for signing (default: target/android/debug.keystore)
|
|
||||||
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
||||||
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
||||||
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
|
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
|
||||||
@@ -33,63 +28,18 @@
|
|||||||
# $APK_OUT Signed, zipaligned APK
|
# $APK_OUT Signed, zipaligned APK
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
infer_latest_dir_name() {
|
: "${ANDROID_HOME:?ANDROID_HOME must be set}"
|
||||||
local pattern="$1"
|
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
|
||||||
local latest=""
|
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
|
||||||
shopt -s nullglob
|
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
|
||||||
local dirs=( $pattern )
|
|
||||||
shopt -u nullglob
|
|
||||||
if [ ${#dirs[@]} -gt 0 ]; then
|
|
||||||
latest="$(printf '%s\n' "${dirs[@]}" | sort -V | tail -n 1)"
|
|
||||||
basename "$latest"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -z "${ANDROID_HOME:-}" ]; then
|
|
||||||
for candidate in "$HOME/Android/Sdk" "$HOME/Library/Android/sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do
|
|
||||||
if [ -d "$candidate" ]; then
|
|
||||||
ANDROID_HOME="$candidate"
|
|
||||||
export ANDROID_HOME
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
: "${ANDROID_HOME:?ANDROID_HOME must be set or discoverable under a common SDK path}"
|
|
||||||
|
|
||||||
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
|
||||||
NDK_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/ndk/*")"
|
|
||||||
if [ -n "$NDK_VERSION" ]; then
|
|
||||||
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
|
|
||||||
export ANDROID_NDK_HOME
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set or discoverable under ANDROID_HOME/ndk}"
|
|
||||||
|
|
||||||
if [ -z "${BUILD_TOOLS_VERSION:-}" ]; then
|
|
||||||
BUILD_TOOLS_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/build-tools/*")"
|
|
||||||
export BUILD_TOOLS_VERSION
|
|
||||||
fi
|
|
||||||
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set or discoverable under ANDROID_HOME/build-tools}"
|
|
||||||
|
|
||||||
if [ -z "${PLATFORM:-}" ]; then
|
|
||||||
PLATFORM="$(infer_latest_dir_name "$ANDROID_HOME/platforms/android-*")"
|
|
||||||
export PLATFORM
|
|
||||||
fi
|
|
||||||
: "${PLATFORM:?PLATFORM must be set or discoverable under ANDROID_HOME/platforms}"
|
|
||||||
|
|
||||||
PROFILE="${PROFILE:-debug}"
|
PROFILE="${PROFILE:-debug}"
|
||||||
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
||||||
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
|
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
|
||||||
STRIP_NATIVE_LIBS="${STRIP_NATIVE_LIBS:-1}"
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
echo ">>> Android SDK: $ANDROID_HOME"
|
|
||||||
echo ">>> Android NDK: $ANDROID_NDK_HOME"
|
|
||||||
echo ">>> Build tools: $BUILD_TOOLS_VERSION"
|
|
||||||
echo ">>> Platform: $PLATFORM"
|
|
||||||
|
|
||||||
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
|
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
|
||||||
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
|
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
|
||||||
MANIFEST="solitaire_app/android/AndroidManifest.xml"
|
MANIFEST="solitaire_app/android/AndroidManifest.xml"
|
||||||
@@ -119,24 +69,6 @@ fi
|
|||||||
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
|
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
|
||||||
cargo ndk "${CARGO_NDK_ARGS[@]}"
|
cargo ndk "${CARGO_NDK_ARGS[@]}"
|
||||||
|
|
||||||
if [ "$STRIP_NATIVE_LIBS" = "1" ]; then
|
|
||||||
LLVM_STRIP=""
|
|
||||||
shopt -s nullglob
|
|
||||||
STRIP_CANDIDATES=( "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/llvm-strip )
|
|
||||||
shopt -u nullglob
|
|
||||||
if [ ${#STRIP_CANDIDATES[@]} -gt 0 ]; then
|
|
||||||
LLVM_STRIP="${STRIP_CANDIDATES[0]}"
|
|
||||||
fi
|
|
||||||
if [ -z "$LLVM_STRIP" ]; then
|
|
||||||
echo "llvm-strip not found under ANDROID_NDK_HOME; native libraries will remain unstripped" >&2
|
|
||||||
else
|
|
||||||
echo ">>> strip native libraries with $LLVM_STRIP"
|
|
||||||
find "$STAGING/lib" -name '*.so' -print0 | while IFS= read -r -d '' so; do
|
|
||||||
"$LLVM_STRIP" --strip-debug "$so"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 2. compile + link resources and manifest ------------------------------
|
# --- 2. compile + link resources and manifest ------------------------------
|
||||||
if [ -d "$RES_DIR" ]; then
|
if [ -d "$RES_DIR" ]; then
|
||||||
echo ">>> aapt2 compile resources"
|
echo ">>> aapt2 compile resources"
|
||||||
@@ -188,15 +120,11 @@ rm -f "$STAGING/app-unsigned.apk"
|
|||||||
|
|
||||||
# --- 5. sign ---------------------------------------------------------------
|
# --- 5. sign ---------------------------------------------------------------
|
||||||
if [ -z "${KEYSTORE:-}" ]; then
|
if [ -z "${KEYSTORE:-}" ]; then
|
||||||
KEYSTORE="target/android/debug.keystore"
|
# Generate a deterministic debug keystore on the fly.
|
||||||
fi
|
KEYSTORE="$STAGING/debug.keystore"
|
||||||
|
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
|
||||||
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
|
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
|
||||||
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
|
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
||||||
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
|
||||||
|
|
||||||
if [ ! -f "$KEYSTORE" ]; then
|
|
||||||
mkdir -p "$(dirname "$KEYSTORE")"
|
|
||||||
echo ">>> generating debug keystore at $KEYSTORE"
|
echo ">>> generating debug keystore at $KEYSTORE"
|
||||||
keytool -genkeypair -v \
|
keytool -genkeypair -v \
|
||||||
-keystore "$KEYSTORE" \
|
-keystore "$KEYSTORE" \
|
||||||
@@ -213,35 +141,15 @@ 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 --min-sdk-version 26 --verbose "$APK_OUT"
|
"$BT/apksigner" verify --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"
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Update Quaternions registry dependencies and run the full safety gate.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
|
|
||||||
#
|
|
||||||
# Example:
|
|
||||||
# scripts/update_quaternions_deps.sh 0.3.1 0.4.1
|
|
||||||
#
|
|
||||||
# This script updates Cargo.lock to the requested versions (within the semver
|
|
||||||
# ranges already declared in Cargo.toml), then runs the project's required
|
|
||||||
# verification steps plus deterministic replay checks.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ "$#" -ne 2 ]; then
|
|
||||||
echo "usage: $0 <klondike_version> <card_game_version>"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
KLONDIKE_VERSION="$1"
|
|
||||||
CARD_GAME_VERSION="$2"
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
echo ">>> Quaternions registry:"
|
|
||||||
echo " https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
|
||||||
echo
|
|
||||||
echo ">>> Review upstream release notes / changelogs before proceeding:"
|
|
||||||
echo " - https://git.aleshym.co/Quaternions/card_game"
|
|
||||||
echo " - https://git.aleshym.co/Quaternions/klondike"
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo ">>> Updating lockfile to klondike=$KLONDIKE_VERSION card_game=$CARD_GAME_VERSION"
|
|
||||||
cargo update -p klondike --precise "$KLONDIKE_VERSION"
|
|
||||||
cargo update -p card_game --precise "$CARD_GAME_VERSION"
|
|
||||||
|
|
||||||
echo ">>> Verifying dependency graph"
|
|
||||||
cargo tree -p solitaire_core --depth 2 | cat
|
|
||||||
|
|
||||||
echo ">>> Running workspace tests"
|
|
||||||
cargo test --workspace
|
|
||||||
|
|
||||||
echo ">>> Running workspace clippy"
|
|
||||||
cargo clippy --workspace -- -D warnings
|
|
||||||
|
|
||||||
echo ">>> Running deterministic replay / debug-api smoke checks"
|
|
||||||
cargo test -p solitaire_wasm debug_snapshot_exposes_replayable_seed_and_history -- --exact
|
|
||||||
cargo test -p solitaire_wasm debug_api_autonomous_seed_batch_smoke -- --exact
|
|
||||||
|
|
||||||
echo ">>> Quaternions dependency upgrade gate passed"
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
|
||||||
#
|
|
||||||
# Polls recent workflow runs and prints a compact status block each cycle.
|
|
||||||
# Stops when the newest docker-build (the deploy) has completed and no
|
|
||||||
# web-wasm-rebuild is still pending — i.e. the full fix is live.
|
|
||||||
#
|
|
||||||
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
|
||||||
# token is read from ~/.config/tea/config.yml (never printed).
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
REPO="funman300/Ferrous-Solitaire"
|
|
||||||
BASE="https://git.aleshym.co/api/v1/repos/${REPO}"
|
|
||||||
INTERVAL="${1:-20}"
|
|
||||||
CFG="${HOME}/.config/tea/config.yml"
|
|
||||||
|
|
||||||
TOKEN="$(grep -E '^[[:space:]]*token:' "$CFG" | head -1 | sed -E 's/.*token:[[:space:]]*//' | tr -d '"'\'' ')"
|
|
||||||
[ -z "$TOKEN" ] && { echo "error: no token in $CFG" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "── watching ${REPO} deploy (poll ${INTERVAL}s, Ctrl-C to stop) ──"
|
|
||||||
|
|
||||||
while :; do
|
|
||||||
json="$(curl -s --max-time 20 -H "Authorization: token ${TOKEN}" "${BASE}/actions/runs?limit=6")"
|
|
||||||
# Print rows + emit the deploy state on the last line (parsed below).
|
|
||||||
# JSON is passed via env var because `python3 -` reads its program from the
|
|
||||||
# heredoc on stdin, so stdin can't also carry the data.
|
|
||||||
out="$(JSON_DATA="$json" python3 - <<'PY'
|
|
||||||
import os, sys, json, datetime
|
|
||||||
now = datetime.datetime.now().strftime("%H:%M:%S")
|
|
||||||
raw = os.environ.get("JSON_DATA", "").strip()
|
|
||||||
try:
|
|
||||||
d = json.loads(raw)
|
|
||||||
except Exception:
|
|
||||||
print("[%s] (api unavailable, retrying)" % now)
|
|
||||||
print("STATE=DEPLOYING")
|
|
||||||
sys.exit(0)
|
|
||||||
runs = d.get("workflow_runs", [])[:6]
|
|
||||||
icons = {("completed","success"):"OK ", ("completed","failure"):"FAIL",
|
|
||||||
("completed","cancelled"):"CXL "}
|
|
||||||
def ic(s, c):
|
|
||||||
if s == "queued": return "queue"
|
|
||||||
if s == "in_progress": return "run.."
|
|
||||||
return icons.get((s, c), s or "?")
|
|
||||||
print("[%s]" % datetime.datetime.now().strftime("%H:%M:%S"))
|
|
||||||
for r in runs:
|
|
||||||
wf = str(r.get("path","")).split("@")[0].split("/")[-1].replace(".yml","")
|
|
||||||
print(" %-5s %-5s %-7s %-18s %s/%s" % (
|
|
||||||
ic(r.get("status"), r.get("conclusion")),
|
|
||||||
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
|
||||||
r.get("status"), r.get("conclusion")))
|
|
||||||
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
|
||||||
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" in str(r.get("path")))
|
|
||||||
top = db[0] if db else None
|
|
||||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
|
|
||||||
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
echo "$out" | grep -v '^STATE='
|
|
||||||
if echo "$out" | grep -q '^STATE=LIVE'; then
|
|
||||||
echo ""
|
|
||||||
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
|
|
||||||
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep "$INTERVAL"
|
|
||||||
done
|
|
||||||
@@ -22,13 +22,6 @@ 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
|
||||||
@@ -106,18 +99,3 @@ 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"
|
|
||||||
|
|||||||
+12
-68
@@ -25,10 +25,7 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
|||||||
use bevy::winit::WinitWindows;
|
use bevy::winit::WinitWindows;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use bevy::winit::{UpdateMode, WinitSettings};
|
use bevy::winit::{UpdateMode, WinitSettings};
|
||||||
use solitaire_data::{
|
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
|
||||||
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
|
|
||||||
settings_file_path,
|
|
||||||
};
|
|
||||||
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
||||||
|
|
||||||
fn load_settings() -> Settings {
|
fn load_settings() -> Settings {
|
||||||
@@ -52,12 +49,6 @@ pub fn run() {
|
|||||||
// and any debugger attached still sees the panic).
|
// and any debugger attached still sees the panic).
|
||||||
install_crash_log_hook();
|
install_crash_log_hook();
|
||||||
|
|
||||||
// Remove any *.tmp files left behind by a crash between an atomic write
|
|
||||||
// and its rename. Safe to call unconditionally — missing data dir is a
|
|
||||||
// no-op. Must run before GamePlugin loads saved state so orphaned files
|
|
||||||
// don't accumulate across launches.
|
|
||||||
let _ = cleanup_orphaned_tmp_files();
|
|
||||||
|
|
||||||
// Initialise the platform keyring store before any token operations.
|
// Initialise the platform keyring store before any token operations.
|
||||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||||
@@ -65,9 +56,10 @@ pub fn run() {
|
|||||||
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||||
//
|
//
|
||||||
// Android: `keyring` isn't compiled in (its `rpassword` transitive
|
// Android: `keyring` isn't compiled in (its `rpassword` transitive
|
||||||
// pulls a libc symbol Android's bionic doesn't expose). The Android
|
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
|
||||||
// auth-token path uses Android Keystore via JNI; `android_main` passes
|
// ships an Android stub that returns KeychainUnavailable for every
|
||||||
// the process JavaVM pointer into `solitaire_data` before `run()`.
|
// call — the runtime behaviour is "session login required each launch"
|
||||||
|
// until we wire Android Keystore via JNI in the Phase-Android round.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
if let Err(e) = keyring::use_native_store(true) {
|
if let Err(e) = keyring::use_native_store(true) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -144,7 +136,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: WindowResizeConstraints {
|
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||||
min_width: 800.0,
|
min_width: 800.0,
|
||||||
min_height: 600.0,
|
min_height: 600.0,
|
||||||
..default()
|
..default()
|
||||||
@@ -166,7 +158,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(AssetPlugin {
|
.set(bevy::asset::AssetPlugin {
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
file_path: "../assets".to_string(),
|
file_path: "../assets".to_string(),
|
||||||
..default()
|
..default()
|
||||||
@@ -180,16 +172,13 @@ fn build_app_with_settings(
|
|||||||
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
||||||
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
||||||
//
|
//
|
||||||
// focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when
|
// The focused mode stays Continuous so that card-slide animations remain
|
||||||
// an event arrives (touch, resize, etc.) or an animation system writes
|
// smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the
|
||||||
// RequestRedraw. The 100 ms ceiling is a fallback that ensures the game
|
// display refresh rate (~60 Hz) when foregrounded, which already prevents
|
||||||
// timer ticks at least 10×/s even with no input, while keeping the GPU
|
// the GPU from spinning at 200+ fps between vsync intervals.
|
||||||
// completely idle between frames when the board is static.
|
|
||||||
// PresentMode::AutoVsync (set above) still caps the GPU at the display
|
|
||||||
// refresh rate when frames do render.
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.insert_resource(WinitSettings {
|
app.insert_resource(WinitSettings {
|
||||||
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
|
focused_mode: UpdateMode::Continuous,
|
||||||
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,57 +352,12 @@ fn set_window_icon(
|
|||||||
/// works on a function named `main`; our shared entry point is `run`, so
|
/// works on a function named `main`; our shared entry point is `run`, so
|
||||||
/// we emit the equivalent expansion manually.
|
/// we emit the equivalent expansion manually.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[allow(unsafe_code)]
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
||||||
if let Err(e) = init_android_jni(&android_app) {
|
|
||||||
eprintln!("warn: could not initialise Android JNI bridge ({e})");
|
|
||||||
}
|
|
||||||
let _ = bevy::android::ANDROID_APP.set(android_app);
|
let _ = bevy::android::ANDROID_APP.set(android_app);
|
||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
|
|
||||||
/// Android runtime and registers safe wrappers with `solitaire_data`.
|
|
||||||
///
|
|
||||||
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
|
|
||||||
/// reconstruction. Every other crate consumes the safe
|
|
||||||
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
|
|
||||||
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
|
|
||||||
/// function and the `#[unsafe(no_mangle)]` entry point above.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[allow(unsafe_code)]
|
|
||||||
fn init_android_jni(
|
|
||||||
android_app: &bevy::android::android_activity::AndroidApp,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use jni::JavaVM;
|
|
||||||
use jni::objects::JObject;
|
|
||||||
|
|
||||||
let vm_ptr = android_app.vm_as_ptr();
|
|
||||||
if vm_ptr.is_null() {
|
|
||||||
return Err("JavaVM pointer is null".into());
|
|
||||||
}
|
|
||||||
// SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
|
|
||||||
// Android runtime; it is valid for the lifetime of the process.
|
|
||||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;
|
|
||||||
|
|
||||||
let env = vm
|
|
||||||
.attach_current_thread_permanently()
|
|
||||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
||||||
|
|
||||||
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
|
|
||||||
// valid for the lifetime of the process. Promote it to a global reference
|
|
||||||
// so the safe bridge can hand it to any thread.
|
|
||||||
let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
|
|
||||||
let activity_ref = env
|
|
||||||
.new_global_ref(&activity)
|
|
||||||
.map_err(|e| format!("activity global ref: {e}"))?;
|
|
||||||
|
|
||||||
solitaire_data::android_jni::set_jvm(vm);
|
|
||||||
solitaire_data::android_jni::set_activity(activity_ref);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wraps the default panic hook with one that also appends a crash log
|
/// Wraps the default panic hook with one that also appends a crash log
|
||||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||||
/// still runs afterwards, so stderr output and debugger integration are
|
/// still runs afterwards, so stderr output and debugger integration are
|
||||||
|
|||||||
@@ -30,6 +30,3 @@ path = "src/bin/gen_seeds.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen_difficulty_seeds"
|
name = "gen_difficulty_seeds"
|
||||||
path = "src/bin/gen_difficulty_seeds.rs"
|
path = "src/bin/gen_difficulty_seeds.rs"
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
||||||
//! `solitaire_data/src/difficulty_seeds.rs`.
|
//! `solitaire_data/src/difficulty_seeds.rs`.
|
||||||
//!
|
//!
|
||||||
//! A seed's tier is determined by the **smallest** solve budget at which it is
|
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
|
||||||
//! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget
|
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
|
||||||
//! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded
|
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
|
||||||
//! (we only emit provably-winnable seeds).
|
//! provably-winnable seeds).
|
||||||
//!
|
//!
|
||||||
//! # Usage
|
//! # Usage
|
||||||
//!
|
//!
|
||||||
@@ -19,12 +19,12 @@
|
|||||||
//! --per-tier Seeds to emit per tier (default 40)
|
//! --per-tier Seeds to emit per tier (default 40)
|
||||||
//! --help Print this message
|
//! --help Print this message
|
||||||
|
|
||||||
use solitaire_core::DrawStockConfig;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
||||||
|
|
||||||
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||||
// whose budget proves it Winnable.
|
// whose budget proves it Winnable.
|
||||||
const BUDGETS: &[(&str, u64, u64)] = &[
|
const BUDGETS: &[(&str, u64, usize)] = &[
|
||||||
("Easy", 1_000, 1_000),
|
("Easy", 1_000, 1_000),
|
||||||
("Medium", 5_000, 5_000),
|
("Medium", 5_000, 5_000),
|
||||||
("Hard", 25_000, 25_000),
|
("Hard", 25_000, 25_000),
|
||||||
@@ -74,7 +74,7 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let draw_mode = DrawStockConfig::DrawOne;
|
let draw_mode = DrawMode::DrawOne;
|
||||||
let num_tiers = BUDGETS.len();
|
let num_tiers = BUDGETS.len();
|
||||||
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||||
let mut tried: u64 = 0;
|
let mut tried: u64 = 0;
|
||||||
@@ -99,8 +99,12 @@ fn main() {
|
|||||||
if buckets[i].len() >= per_tier {
|
if buckets[i].len() >= per_tier {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) {
|
let cfg = SolverConfig {
|
||||||
Ok(Some(_)) => {
|
move_budget,
|
||||||
|
state_budget,
|
||||||
|
};
|
||||||
|
match try_solve(seed, draw_mode, &cfg) {
|
||||||
|
SolverResult::Winnable => {
|
||||||
buckets[i].push(seed);
|
buckets[i].push(seed);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
||||||
@@ -109,13 +113,13 @@ fn main() {
|
|||||||
);
|
);
|
||||||
break 'tier; // assign to the cheapest tier that proves it winnable
|
break 'tier; // assign to the cheapest tier that proves it winnable
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
SolverResult::Unwinnable => {
|
||||||
// Definitely unsolvable — skip all remaining tiers.
|
// Definitely unsolvable — skip all remaining tiers.
|
||||||
break 'tier;
|
break 'tier;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
SolverResult::Inconclusive => {
|
||||||
// Budget exhausted without proof — try the next larger tier.
|
// Budget exhausted without proof — try the next larger tier.
|
||||||
// If this is the last tier, the seed is discarded (inconclusive
|
// If this is the last tier, the seed is discarded (Inconclusive
|
||||||
// at max budget means "probably but not provably winnable").
|
// at max budget means "probably but not provably winnable").
|
||||||
if i == num_tiers - 1 {
|
if i == num_tiers - 1 {
|
||||||
break 'tier;
|
break 'tier;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
||||||
//!
|
//!
|
||||||
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
||||||
//! collects only those proven winnable (`Ok(Some(_))`; inconclusive is
|
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
|
||||||
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
||||||
//! pasting into `solitaire_data/src/challenge.rs`.
|
//! pasting into `solitaire_data/src/challenge.rs`.
|
||||||
//!
|
//!
|
||||||
@@ -17,9 +17,8 @@
|
|||||||
//! --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::DrawStockConfig;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
||||||
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut args = std::env::args().skip(1).peekable();
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
@@ -68,7 +67,8 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let draw_mode = DrawStockConfig::DrawOne;
|
let cfg = SolverConfig::default();
|
||||||
|
let draw_mode = DrawMode::DrawOne;
|
||||||
let mut found: Vec<u64> = Vec::with_capacity(count);
|
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||||
let mut tried: u64 = 0;
|
let mut tried: u64 = 0;
|
||||||
let mut seed = start;
|
let mut seed = start;
|
||||||
@@ -77,15 +77,7 @@ fn main() {
|
|||||||
|
|
||||||
while found.len() < count {
|
while found.len() < count {
|
||||||
tried += 1;
|
tried += 1;
|
||||||
if matches!(
|
if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) {
|
||||||
GameState::solve_fresh_deal(
|
|
||||||
seed,
|
|
||||||
draw_mode,
|
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
|
||||||
DEFAULT_SOLVE_STATES_BUDGET
|
|
||||||
),
|
|
||||||
Ok(Some(_))
|
|
||||||
) {
|
|
||||||
found.push(seed);
|
found.push(seed);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
||||||
|
|||||||
@@ -4,18 +4,7 @@ version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
test-support = []
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
proptest = "1"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
klondike = { workspace = true }
|
rand = { workspace = true }
|
||||||
card_game = { workspace = true }
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Card suit.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum Suit {
|
||||||
|
Clubs,
|
||||||
|
Diamonds,
|
||||||
|
Hearts,
|
||||||
|
Spades,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Suit {
|
||||||
|
/// All four suits in declaration order.
|
||||||
|
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
|
||||||
|
|
||||||
|
/// Returns `true` for red suits (Diamonds, Hearts).
|
||||||
|
pub fn is_red(self) -> bool {
|
||||||
|
matches!(self, Suit::Diamonds | Suit::Hearts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` for black suits (Clubs, Spades).
|
||||||
|
pub fn is_black(self) -> bool {
|
||||||
|
!self.is_red()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card rank, Ace through King.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum Rank {
|
||||||
|
Ace = 1,
|
||||||
|
Two = 2,
|
||||||
|
Three = 3,
|
||||||
|
Four = 4,
|
||||||
|
Five = 5,
|
||||||
|
Six = 6,
|
||||||
|
Seven = 7,
|
||||||
|
Eight = 8,
|
||||||
|
Nine = 9,
|
||||||
|
Ten = 10,
|
||||||
|
Jack = 11,
|
||||||
|
Queen = 12,
|
||||||
|
King = 13,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rank {
|
||||||
|
/// All thirteen ranks in ascending order.
|
||||||
|
pub const RANKS: [Self; 13] = [
|
||||||
|
Self::Ace,
|
||||||
|
Self::Two,
|
||||||
|
Self::Three,
|
||||||
|
Self::Four,
|
||||||
|
Self::Five,
|
||||||
|
Self::Six,
|
||||||
|
Self::Seven,
|
||||||
|
Self::Eight,
|
||||||
|
Self::Nine,
|
||||||
|
Self::Ten,
|
||||||
|
Self::Jack,
|
||||||
|
Self::Queen,
|
||||||
|
Self::King,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Numeric value: Ace = 1, King = 13.
|
||||||
|
pub fn value(self) -> u8 {
|
||||||
|
self as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn new(n: u8) -> Option<Self> {
|
||||||
|
match n {
|
||||||
|
1 => Some(Self::Ace),
|
||||||
|
2 => Some(Self::Two),
|
||||||
|
3 => Some(Self::Three),
|
||||||
|
4 => Some(Self::Four),
|
||||||
|
5 => Some(Self::Five),
|
||||||
|
6 => Some(Self::Six),
|
||||||
|
7 => Some(Self::Seven),
|
||||||
|
8 => Some(Self::Eight),
|
||||||
|
9 => Some(Self::Nine),
|
||||||
|
10 => Some(Self::Ten),
|
||||||
|
11 => Some(Self::Jack),
|
||||||
|
12 => Some(Self::Queen),
|
||||||
|
13 => Some(Self::King),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
|
||||||
|
pub const fn checked_add(self, n: u8) -> Option<Self> {
|
||||||
|
Self::new((self as u8).saturating_add(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
|
||||||
|
pub const fn checked_sub(self, n: u8) -> Option<Self> {
|
||||||
|
match (self as u8).checked_sub(n) {
|
||||||
|
Some(v) => Self::new(v),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single playing card.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Card {
|
||||||
|
/// Unique identifier for this card within the deal. Stable across moves and undo.
|
||||||
|
pub id: u32,
|
||||||
|
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
|
||||||
|
pub suit: Suit,
|
||||||
|
/// The card's rank (Ace through King).
|
||||||
|
pub rank: Rank,
|
||||||
|
/// Whether the card is visible to the player. Face-down cards may not be moved.
|
||||||
|
pub face_up: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_values_are_sequential() {
|
||||||
|
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||||
|
assert_eq!(r.value(), (i + 1) as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_as_u8_matches_value() {
|
||||||
|
for r in Rank::RANKS {
|
||||||
|
assert_eq!(r as u8, r.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_checked_add_boundary() {
|
||||||
|
assert_eq!(Rank::King.checked_add(1), None);
|
||||||
|
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
|
||||||
|
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
|
||||||
|
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_checked_sub_boundary() {
|
||||||
|
assert_eq!(Rank::Ace.checked_sub(1), None);
|
||||||
|
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
|
||||||
|
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
|
||||||
|
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suit_suits_contains_all_four() {
|
||||||
|
assert_eq!(Suit::SUITS.len(), 4);
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Clubs));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Diamonds));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Hearts));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Spades));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suit_red_and_black_are_complementary() {
|
||||||
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
|
assert_ne!(
|
||||||
|
suit.is_red(),
|
||||||
|
suit.is_black(),
|
||||||
|
"{suit:?} must be exactly one of red/black"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||||
|
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
use crate::card::{Card, Rank, Suit};
|
||||||
|
use crate::pile::{Pile, PileType};
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
use rand::{SeedableRng, seq::SliceRandom};
|
||||||
|
|
||||||
|
const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
const ALL_RANKS: [Rank; 13] = [
|
||||||
|
Rank::Ace,
|
||||||
|
Rank::Two,
|
||||||
|
Rank::Three,
|
||||||
|
Rank::Four,
|
||||||
|
Rank::Five,
|
||||||
|
Rank::Six,
|
||||||
|
Rank::Seven,
|
||||||
|
Rank::Eight,
|
||||||
|
Rank::Nine,
|
||||||
|
Rank::Ten,
|
||||||
|
Rank::Jack,
|
||||||
|
Rank::Queen,
|
||||||
|
Rank::King,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A standard 52-card deck.
|
||||||
|
pub struct Deck {
|
||||||
|
/// All 52 cards in the deck, in deal order.
|
||||||
|
pub cards: Vec<Card>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deck {
|
||||||
|
/// Creates an unshuffled deck with all 52 unique cards (id 0–51).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut cards = Vec::with_capacity(52);
|
||||||
|
let mut id = 0u32;
|
||||||
|
for &suit in &ALL_SUITS {
|
||||||
|
for &rank in &ALL_RANKS {
|
||||||
|
cards.push(Card {
|
||||||
|
id,
|
||||||
|
suit,
|
||||||
|
rank,
|
||||||
|
face_up: false,
|
||||||
|
});
|
||||||
|
id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { cards }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shuffles the deck in-place using Fisher-Yates with a seeded `StdRng`.
|
||||||
|
/// The same seed always produces the same order on any platform.
|
||||||
|
pub fn shuffle(&mut self, seed: u64) {
|
||||||
|
let mut rng = StdRng::seed_from_u64(seed);
|
||||||
|
self.cards.shuffle(&mut rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Deck {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deals a standard Klondike layout from a pre-shuffled deck.
|
||||||
|
///
|
||||||
|
/// Returns 7 tableau piles and the remaining stock pile.
|
||||||
|
/// Column `i` contains `i + 1` cards; only the top card is face-up.
|
||||||
|
/// Stock receives the remaining 24 cards, all face-down.
|
||||||
|
pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) {
|
||||||
|
debug_assert_eq!(
|
||||||
|
deck.cards.len(),
|
||||||
|
52,
|
||||||
|
"deal_klondike requires a full 52-card deck"
|
||||||
|
);
|
||||||
|
let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i)));
|
||||||
|
// Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded.
|
||||||
|
let mut idx = 0usize;
|
||||||
|
|
||||||
|
for (col, pile) in tableau.iter_mut().enumerate() {
|
||||||
|
for row in 0..=col {
|
||||||
|
let mut card = deck.cards[idx].clone();
|
||||||
|
card.face_up = row == col;
|
||||||
|
pile.cards.push(card);
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stock = Pile::new(PileType::Stock);
|
||||||
|
stock.cards.extend(deck.cards.into_iter().skip(idx));
|
||||||
|
(tableau, stock)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deck_new_has_52_cards() {
|
||||||
|
assert_eq!(Deck::new().cards.len(), 52);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deck_new_has_unique_ids() {
|
||||||
|
let deck = Deck::new();
|
||||||
|
let mut ids: Vec<u32> = deck.cards.iter().map(|c| c.id).collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
assert_eq!(ids.len(), 52);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deck_new_has_all_suits_and_ranks() {
|
||||||
|
let deck = Deck::new();
|
||||||
|
for suit in ALL_SUITS {
|
||||||
|
for rank in ALL_RANKS {
|
||||||
|
assert!(
|
||||||
|
deck.cards.iter().any(|c| c.suit == suit && c.rank == rank),
|
||||||
|
"missing {rank:?} {suit:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_seed_produces_same_order() {
|
||||||
|
let mut d1 = Deck::new();
|
||||||
|
d1.shuffle(42);
|
||||||
|
let mut d2 = Deck::new();
|
||||||
|
d2.shuffle(42);
|
||||||
|
assert_eq!(d1.cards, d2.cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_seeds_produce_different_orders() {
|
||||||
|
let mut d1 = Deck::new();
|
||||||
|
d1.shuffle(1);
|
||||||
|
let mut d2 = Deck::new();
|
||||||
|
d2.shuffle(2);
|
||||||
|
assert_ne!(d1.cards, d2.cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_klondike_correct_tableau_sizes() {
|
||||||
|
let mut deck = Deck::new();
|
||||||
|
deck.shuffle(0);
|
||||||
|
let (tableau, stock) = deal_klondike(deck);
|
||||||
|
for (i, pile) in tableau.iter().enumerate() {
|
||||||
|
assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size");
|
||||||
|
}
|
||||||
|
assert_eq!(stock.cards.len(), 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_klondike_top_cards_are_face_up() {
|
||||||
|
let mut deck = Deck::new();
|
||||||
|
deck.shuffle(0);
|
||||||
|
let (tableau, _) = deal_klondike(deck);
|
||||||
|
for pile in &tableau {
|
||||||
|
assert!(pile.cards.last().unwrap().face_up);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_klondike_non_top_cards_are_face_down() {
|
||||||
|
let mut deck = Deck::new();
|
||||||
|
deck.shuffle(0);
|
||||||
|
let (tableau, _) = deal_klondike(deck);
|
||||||
|
for pile in &tableau {
|
||||||
|
for card in &pile.cards[..pile.cards.len().saturating_sub(1)] {
|
||||||
|
assert!(!card.face_up);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_klondike_stock_is_face_down() {
|
||||||
|
let mut deck = Deck::new();
|
||||||
|
deck.shuffle(0);
|
||||||
|
let (_, stock) = deal_klondike(deck);
|
||||||
|
assert!(stock.cards.iter().all(|c| !c.face_up));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_klondike_all_52_cards_present() {
|
||||||
|
let mut deck = Deck::new();
|
||||||
|
deck.shuffle(99);
|
||||||
|
let (tableau, stock) = deal_klondike(deck);
|
||||||
|
let mut ids: Vec<u32> = stock.cards.iter().map(|c| c.id).collect();
|
||||||
|
for pile in &tableau {
|
||||||
|
ids.extend(pile.cards.iter().map(|c| c.id));
|
||||||
|
}
|
||||||
|
ids.sort_unstable();
|
||||||
|
assert_eq!(ids, (0u32..52).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1980
-1161
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
|||||||
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
|
|
||||||
//!
|
|
||||||
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
|
||||||
//! - building [`KlondikeConfig`] from Ferrous settings
|
|
||||||
//! - translating between local and upstream types
|
|
||||||
//!
|
|
||||||
//! Ferrous-specific scoring policy (the win-time bonus) lives in
|
|
||||||
//! [`crate::scoring`], not here.
|
|
||||||
//!
|
|
||||||
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
|
|
||||||
//! upstream `card_game` / `klondike` types live here so that the product modules
|
|
||||||
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
|
||||||
|
|
||||||
use klondike::{
|
|
||||||
DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
|
|
||||||
SkipCards, Tableau,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Bridges `solitaire_core` game config to the upstream `klondike` crate.
|
|
||||||
///
|
|
||||||
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
|
||||||
/// state, and exists only as a namespace for configuration and conversion
|
|
||||||
/// helpers.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
pub struct KlondikeAdapter;
|
|
||||||
|
|
||||||
impl KlondikeAdapter {
|
|
||||||
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
|
||||||
pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig {
|
|
||||||
KlondikeConfig {
|
|
||||||
draw_stock: draw_mode,
|
|
||||||
move_from_foundation: if take_from_foundation {
|
|
||||||
MoveFromFoundationConfig::Allowed
|
|
||||||
} else {
|
|
||||||
MoveFromFoundationConfig::Disallowed
|
|
||||||
},
|
|
||||||
scoring: ScoringConfig::DEFAULT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
|
||||||
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
|
|
||||||
match index {
|
|
||||||
0 => Some(Tableau::Tableau1),
|
|
||||||
1 => Some(Tableau::Tableau2),
|
|
||||||
2 => Some(Tableau::Tableau3),
|
|
||||||
3 => Some(Tableau::Tableau4),
|
|
||||||
4 => Some(Tableau::Tableau5),
|
|
||||||
5 => Some(Tableau::Tableau6),
|
|
||||||
6 => Some(Tableau::Tableau7),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
|
|
||||||
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
|
|
||||||
match slot {
|
|
||||||
0 => Some(Foundation::Foundation1),
|
|
||||||
1 => Some(Foundation::Foundation2),
|
|
||||||
2 => Some(Foundation::Foundation3),
|
|
||||||
3 => Some(Foundation::Foundation4),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
|
|
||||||
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
|
||||||
match skip {
|
|
||||||
0 => Some(SkipCards::Skip0),
|
|
||||||
1 => Some(SkipCards::Skip1),
|
|
||||||
2 => Some(SkipCards::Skip2),
|
|
||||||
3 => Some(SkipCards::Skip3),
|
|
||||||
4 => Some(SkipCards::Skip4),
|
|
||||||
5 => Some(SkipCards::Skip5),
|
|
||||||
6 => Some(SkipCards::Skip6),
|
|
||||||
7 => Some(SkipCards::Skip7),
|
|
||||||
8 => Some(SkipCards::Skip8),
|
|
||||||
9 => Some(SkipCards::Skip9),
|
|
||||||
10 => Some(SkipCards::Skip10),
|
|
||||||
11 => Some(SkipCards::Skip11),
|
|
||||||
12 => Some(SkipCards::Skip12),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,9 @@
|
|||||||
pub mod achievement;
|
pub mod achievement;
|
||||||
|
pub mod card;
|
||||||
|
pub mod deck;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod game_state;
|
pub mod game_state;
|
||||||
pub mod klondike_adapter;
|
pub mod pile;
|
||||||
|
pub mod rules;
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
|
pub mod solver;
|
||||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
|
||||||
// downstream crates (engine, wasm) can import from one place without a direct
|
|
||||||
// `klondike` / `card_game` dep.
|
|
||||||
//
|
|
||||||
// `KlondikePileStack`, `SkipCards` and `TableauStack` are intentionally NOT
|
|
||||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
|
||||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
|
||||||
// appear in any public method signature.
|
|
||||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
|
||||||
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
|
||||||
|
|
||||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
|
||||||
// former `solitaire_data::solver` wrapper module.
|
|
||||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod proptest_tests;
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use crate::card::{Card, Suit};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Identifies which pile on the board a set of cards belongs to.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum PileType {
|
||||||
|
/// The face-down draw pile.
|
||||||
|
Stock,
|
||||||
|
/// The face-up discard pile drawn to.
|
||||||
|
Waste,
|
||||||
|
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||||||
|
/// is derived from the bottom card of the pile (always an Ace by
|
||||||
|
/// construction).
|
||||||
|
Foundation(u8),
|
||||||
|
/// One of the seven tableau columns (0–6).
|
||||||
|
Tableau(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A named collection of cards in a specific board position.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Pile {
|
||||||
|
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
||||||
|
pub pile_type: PileType,
|
||||||
|
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||||
|
pub cards: Vec<Card>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pile {
|
||||||
|
/// Creates a new empty pile of the given type.
|
||||||
|
pub fn new(pile_type: PileType) -> Self {
|
||||||
|
Self {
|
||||||
|
pile_type,
|
||||||
|
cards: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the top (last) card, or `None` if empty.
|
||||||
|
pub fn top(&self) -> Option<&Card> {
|
||||||
|
self.cards.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For foundation piles: returns `Some(suit)` once at least one card has
|
||||||
|
/// landed (the bottom card is always an Ace of the claimed suit).
|
||||||
|
/// Returns `None` for empty foundations or non-foundation piles.
|
||||||
|
pub fn claimed_suit(&self) -> Option<Suit> {
|
||||||
|
match self.pile_type {
|
||||||
|
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::card::{Card, Rank, Suit};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_pile_is_empty() {
|
||||||
|
let pile = Pile::new(PileType::Stock);
|
||||||
|
assert!(pile.cards.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_top_returns_last_card() {
|
||||||
|
let mut pile = Pile::new(PileType::Waste);
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: 0,
|
||||||
|
suit: Suit::Hearts,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: 1,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Two,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
assert_eq!(pile.top().unwrap().id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_top_on_empty_is_none() {
|
||||||
|
let pile = Pile::new(PileType::Waste);
|
||||||
|
assert!(pile.top().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_type_foundation_uses_slot_index() {
|
||||||
|
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_type_tableau_uses_index() {
|
||||||
|
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claimed_suit_is_none_for_empty_foundation() {
|
||||||
|
let pile = Pile::new(PileType::Foundation(0));
|
||||||
|
assert!(pile.claimed_suit().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claimed_suit_is_none_for_non_foundation() {
|
||||||
|
let mut pile = Pile::new(PileType::Tableau(0));
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: 0,
|
||||||
|
suit: Suit::Hearts,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
assert!(pile.claimed_suit().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claimed_suit_returns_bottom_card_suit() {
|
||||||
|
let mut pile = Pile::new(PileType::Foundation(2));
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: 0,
|
||||||
|
suit: Suit::Hearts,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: 1,
|
||||||
|
suit: Suit::Hearts,
|
||||||
|
rank: Rank::Two,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
use card_game::{Card, Game};
|
|
||||||
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
|
|
||||||
use proptest::prelude::*;
|
|
||||||
|
|
||||||
use crate::game_state::GameState;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Collect all cards across every pile in a fixed traversal order:
|
|
||||||
/// stock → waste → foundations 1–4 → tableaux 1–7.
|
|
||||||
///
|
|
||||||
/// The order is deterministic for a given game state, so two calls on
|
|
||||||
/// equivalent states produce identical Vec outputs — the right fingerprint
|
|
||||||
/// for undo-reversibility checks.
|
|
||||||
fn all_cards(game: &GameState) -> Vec<Card> {
|
|
||||||
let foundations = [
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
];
|
|
||||||
let tableaux = [
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
|
|
||||||
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
|
|
||||||
for f in &foundations {
|
|
||||||
cards.extend(
|
|
||||||
game.pile(KlondikePile::Foundation(*f))
|
|
||||||
.iter()
|
|
||||||
.map(|(c, _)| c.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for t in &tableaux {
|
|
||||||
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
|
||||||
}
|
|
||||||
cards
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
|
||||||
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
|
||||||
///
|
|
||||||
/// Each action is `(draw_flag, move_index)`:
|
|
||||||
/// - `draw_flag = true` → call `game.draw()`
|
|
||||||
/// - `draw_flag = false` → pick the `move_index % len`th legal instruction
|
|
||||||
/// from `possible_instructions()` and apply it via `apply_instruction()`.
|
|
||||||
///
|
|
||||||
/// `possible_instructions()` may return `RotateStock`, which
|
|
||||||
/// `apply_instruction()` dispatches to `game.draw()`; ordinary instructions
|
|
||||||
/// are equivalent to `move_cards(from, to, count)`.
|
|
||||||
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
|
|
||||||
for &(do_draw, idx) in actions {
|
|
||||||
if do_draw {
|
|
||||||
let _ = game.draw();
|
|
||||||
} else {
|
|
||||||
let moves = game.possible_instructions();
|
|
||||||
if moves.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let instruction = moves[idx % moves.len()];
|
|
||||||
let _ = game.apply_instruction(instruction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply one move from `possible_instructions()` (or a draw if no move is
|
|
||||||
/// available), using `move_idx` to select among the legal options.
|
|
||||||
/// Returns `true` when a move was successfully applied.
|
|
||||||
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
|
|
||||||
if game.is_won() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let moves = game.possible_instructions();
|
|
||||||
if moves.is_empty() {
|
|
||||||
return game.draw().is_ok();
|
|
||||||
}
|
|
||||||
let instruction = moves[move_idx % moves.len()];
|
|
||||||
game.apply_instruction(instruction).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Properties
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
proptest! {
|
|
||||||
/// `check_auto_complete()` and `is_win_trivial()` must agree on every
|
|
||||||
/// reachable game state.
|
|
||||||
///
|
|
||||||
/// The upstream `Klondike::is_win_trivial()` checks that the stock pile
|
|
||||||
/// (both face-down and face-up halves) is completely empty AND that all
|
|
||||||
/// tableau columns have no face-down cards. Ferrous `check_auto_complete()`
|
|
||||||
/// checks the same three conditions individually (stock empty, waste empty,
|
|
||||||
/// all tableau cards face-up). This property guards against any semantic
|
|
||||||
/// drift between the two implementations so that delegating to upstream is
|
|
||||||
/// safe.
|
|
||||||
///
|
|
||||||
/// If this property ever fails, `check_auto_complete()` must NOT be fully
|
|
||||||
/// replaced — the Ferrous conditions must be preserved and `is_win_trivial()`
|
|
||||||
/// used only as a supplementary guard.
|
|
||||||
#[test]
|
|
||||||
fn check_auto_complete_agrees_with_is_win_trivial(
|
|
||||||
seed in any::<u64>(),
|
|
||||||
draw_mode in draw_mode_strategy(),
|
|
||||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
|
||||||
) {
|
|
||||||
let mut game = GameState::new(seed, draw_mode);
|
|
||||||
apply_random_actions(&mut game, &actions);
|
|
||||||
prop_assert_eq!(
|
|
||||||
game.check_auto_complete(),
|
|
||||||
game.session().state().state().is_win_trivial(),
|
|
||||||
"check_auto_complete() disagreed with is_win_trivial() after {:?} actions",
|
|
||||||
actions.len(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `check_win()` and `is_win()` must agree on every reachable game state.
|
|
||||||
#[test]
|
|
||||||
fn check_win_agrees_with_is_win(
|
|
||||||
seed in any::<u64>(),
|
|
||||||
draw_mode in draw_mode_strategy(),
|
|
||||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
|
||||||
) {
|
|
||||||
let mut game = GameState::new(seed, draw_mode);
|
|
||||||
apply_random_actions(&mut game, &actions);
|
|
||||||
prop_assert_eq!(
|
|
||||||
game.check_win(),
|
|
||||||
game.session().state().state().is_win(),
|
|
||||||
"check_win() disagreed with is_win()",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All 52 card IDs must be present exactly once across every pile after
|
|
||||||
/// any reachable sequence of draw + move_cards actions.
|
|
||||||
///
|
|
||||||
/// Catches two bug classes at once:
|
|
||||||
/// - Card loss (fewer than 52 unique IDs after the sequence).
|
|
||||||
/// - Card duplication (52 total but deduplication reduces the set).
|
|
||||||
#[test]
|
|
||||||
fn all_52_cards_always_present(
|
|
||||||
seed in any::<u64>(),
|
|
||||||
draw_mode in draw_mode_strategy(),
|
|
||||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
|
||||||
) {
|
|
||||||
let mut game = GameState::new(seed, draw_mode);
|
|
||||||
apply_random_actions(&mut game, &actions);
|
|
||||||
|
|
||||||
let cards = all_cards(&game);
|
|
||||||
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
|
|
||||||
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
|
|
||||||
prop_assert_eq!(
|
|
||||||
unique.len(), 52,
|
|
||||||
"duplicate cards found after dedup — a card was cloned"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GameState::new(seed, draw_mode)` must be deterministic: two calls
|
|
||||||
/// with the same arguments must produce identical initial pile layouts.
|
|
||||||
///
|
|
||||||
/// Pins that the deal is seeded from `seed` alone and not from any
|
|
||||||
/// implicit source like wall-clock time or global state.
|
|
||||||
#[test]
|
|
||||||
fn deal_is_deterministic(
|
|
||||||
seed in any::<u64>(),
|
|
||||||
draw_mode in draw_mode_strategy(),
|
|
||||||
) {
|
|
||||||
let a = GameState::new(seed, draw_mode);
|
|
||||||
let b = GameState::new(seed, draw_mode);
|
|
||||||
prop_assert_eq!(
|
|
||||||
all_cards(&a),
|
|
||||||
all_cards(&b),
|
|
||||||
"same seed + draw_mode produced different deals",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After applying any single legal move and immediately undoing it, the
|
|
||||||
/// pile layout and move_count must be identical to their pre-move values.
|
|
||||||
///
|
|
||||||
/// `setup_actions` drives the game to an arbitrary mid-game position;
|
|
||||||
/// `move_idx` selects which legal move to apply and then undo.
|
|
||||||
///
|
|
||||||
/// The score is intentionally excluded: `undo()` applies a −15 penalty
|
|
||||||
/// that is by design, not a regression.
|
|
||||||
#[test]
|
|
||||||
fn undo_restores_pile_layout_and_move_count(
|
|
||||||
seed in any::<u64>(),
|
|
||||||
draw_mode in draw_mode_strategy(),
|
|
||||||
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
|
||||||
move_idx in 0usize..200,
|
|
||||||
) {
|
|
||||||
let mut game = GameState::new(seed, draw_mode);
|
|
||||||
apply_random_actions(&mut game, &setup_actions);
|
|
||||||
|
|
||||||
// Snapshot the state before the move.
|
|
||||||
let before_ids = all_cards(&game);
|
|
||||||
let before_move_count = game.move_count();
|
|
||||||
|
|
||||||
// Apply one move.
|
|
||||||
if !apply_one_move(&mut game, move_idx) || game.is_won() {
|
|
||||||
return Ok(()); // nothing to undo
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo and verify.
|
|
||||||
prop_assert!(
|
|
||||||
game.undo().is_ok(),
|
|
||||||
"undo must succeed immediately after a successful move",
|
|
||||||
);
|
|
||||||
prop_assert_eq!(
|
|
||||||
all_cards(&game),
|
|
||||||
before_ids,
|
|
||||||
"pile layout after undo differs from the pre-move snapshot",
|
|
||||||
);
|
|
||||||
prop_assert_eq!(
|
|
||||||
game.move_count(),
|
|
||||||
before_move_count,
|
|
||||||
"move_count after undo must equal the pre-move value",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every move returned by `possible_instructions()` must succeed when
|
|
||||||
/// applied via `move_cards()`.
|
|
||||||
///
|
|
||||||
/// `possible_instructions()` and `move_cards()` both validate moves
|
|
||||||
/// through the same upstream rule engine. This property ensures no
|
|
||||||
/// drift has opened up between what the engine reports as legal and
|
|
||||||
/// what it actually accepts.
|
|
||||||
#[test]
|
|
||||||
fn legal_moves_always_succeed(
|
|
||||||
seed in any::<u64>(),
|
|
||||||
draw_mode in draw_mode_strategy(),
|
|
||||||
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
|
||||||
) {
|
|
||||||
let mut game = GameState::new(seed, draw_mode);
|
|
||||||
apply_random_actions(&mut game, &setup_actions);
|
|
||||||
|
|
||||||
for instruction in game.possible_instructions() {
|
|
||||||
// Clone so each move is tried from the same starting state.
|
|
||||||
let mut trial = game.clone();
|
|
||||||
let result = trial.apply_instruction(instruction);
|
|
||||||
prop_assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"possible_instructions() reported {instruction:?} \
|
|
||||||
as legal but the call returned Err: {result:?}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
use crate::card::{Card, Rank};
|
||||||
|
use crate::pile::Pile;
|
||||||
|
|
||||||
|
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||||
|
///
|
||||||
|
/// Foundation rules:
|
||||||
|
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
||||||
|
/// becomes the pile's claimed suit (derived from the bottom card via
|
||||||
|
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||||
|
/// - When the pile is non-empty, the next card must match the top card's
|
||||||
|
/// suit and be exactly one rank higher.
|
||||||
|
#[must_use]
|
||||||
|
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||||
|
match pile.cards.last() {
|
||||||
|
None => card.rank == Rank::Ace,
|
||||||
|
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
||||||
|
///
|
||||||
|
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
||||||
|
#[must_use]
|
||||||
|
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||||
|
match pile.cards.last() {
|
||||||
|
None => card.rank == Rank::King,
|
||||||
|
Some(top) => {
|
||||||
|
top.face_up
|
||||||
|
&& card.rank.checked_add(1) == Some(top.rank)
|
||||||
|
&& card.suit.is_red() != top.suit.is_red()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if `cards` is a legal tableau run on its own — every
|
||||||
|
/// adjacent pair descends by one rank and alternates colour. A single
|
||||||
|
/// card is trivially valid. The destination check is separate; this
|
||||||
|
/// only validates the sequence's *internal* structure, which the tableau
|
||||||
|
/// move path must enforce so a player can't smuggle an arbitrary stack
|
||||||
|
/// onto another column when the bottom card happens to land legally.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||||
|
cards.windows(2).all(|w| {
|
||||||
|
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::card::{Card, Rank, Suit};
|
||||||
|
use crate::pile::{Pile, PileType};
|
||||||
|
|
||||||
|
fn card(suit: Suit, rank: Rank) -> Card {
|
||||||
|
Card {
|
||||||
|
id: 0,
|
||||||
|
suit,
|
||||||
|
rank,
|
||||||
|
face_up: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
|
||||||
|
Pile { pile_type, cards }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foundation tests
|
||||||
|
#[test]
|
||||||
|
fn foundation_ace_on_empty_is_valid() {
|
||||||
|
// Every suit's Ace must land on an empty foundation slot regardless of
|
||||||
|
// its slot index; the slot claims the suit only after the Ace lands.
|
||||||
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
|
let c = card(suit, Rank::Ace);
|
||||||
|
let p = Pile::new(PileType::Foundation(0));
|
||||||
|
assert!(
|
||||||
|
can_place_on_foundation(&c, &p),
|
||||||
|
"Ace of {suit:?} must land on empty slot 0",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_non_ace_on_empty_is_invalid() {
|
||||||
|
let c = card(Suit::Hearts, Rank::Two);
|
||||||
|
let p = Pile::new(PileType::Foundation(0));
|
||||||
|
assert!(!can_place_on_foundation(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_two_on_ace_same_suit_is_valid() {
|
||||||
|
let c = card(Suit::Clubs, Rank::Two);
|
||||||
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||||
|
assert!(can_place_on_foundation(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_second_card_must_match_claimed_suit() {
|
||||||
|
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
||||||
|
// because the slot's claimed suit is Hearts after the Ace lands.
|
||||||
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
|
||||||
|
let c = card(Suit::Spades, Rank::Two);
|
||||||
|
assert!(!can_place_on_foundation(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_skipping_rank_is_invalid() {
|
||||||
|
let c = card(Suit::Diamonds, Rank::Three);
|
||||||
|
let p = pile_with(
|
||||||
|
PileType::Foundation(0),
|
||||||
|
vec![card(Suit::Diamonds, Rank::Ace)],
|
||||||
|
);
|
||||||
|
assert!(!can_place_on_foundation(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tableau tests
|
||||||
|
#[test]
|
||||||
|
fn tableau_king_on_empty_is_valid() {
|
||||||
|
let c = card(Suit::Hearts, Rank::King);
|
||||||
|
let p = Pile::new(PileType::Tableau(0));
|
||||||
|
assert!(can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_non_king_on_empty_is_invalid() {
|
||||||
|
let c = card(Suit::Hearts, Rank::Queen);
|
||||||
|
let p = Pile::new(PileType::Tableau(0));
|
||||||
|
assert!(!can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_red_on_black_one_lower_is_valid() {
|
||||||
|
let c = card(Suit::Hearts, Rank::Nine);
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||||
|
assert!(can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_same_color_is_invalid() {
|
||||||
|
let c = card(Suit::Clubs, Rank::Nine);
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||||
|
assert!(!can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_wrong_rank_difference_is_invalid() {
|
||||||
|
let c = card(Suit::Hearts, Rank::Eight);
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||||
|
assert!(!can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_black_on_red_one_lower_is_valid() {
|
||||||
|
let c = card(Suit::Clubs, Rank::Six);
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
|
||||||
|
assert!(can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_king_on_queen_completes_suit() {
|
||||||
|
// The last card placed to complete a foundation is always King on Queen.
|
||||||
|
let c = card(Suit::Spades, Rank::King);
|
||||||
|
let p = pile_with(
|
||||||
|
PileType::Foundation(0),
|
||||||
|
vec![card(Suit::Spades, Rank::Queen)],
|
||||||
|
);
|
||||||
|
assert!(can_place_on_foundation(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_king_wrong_suit_is_invalid() {
|
||||||
|
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
|
||||||
|
let c = card(Suit::Hearts, Rank::King);
|
||||||
|
let p = pile_with(
|
||||||
|
PileType::Foundation(0),
|
||||||
|
vec![card(Suit::Spades, Rank::Queen)],
|
||||||
|
);
|
||||||
|
assert!(!can_place_on_foundation(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_ace_on_two_different_color_is_valid() {
|
||||||
|
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
|
||||||
|
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
|
||||||
|
let c = card(Suit::Hearts, Rank::Ace);
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
|
||||||
|
assert!(can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_same_rank_different_color_is_invalid() {
|
||||||
|
// Two cards of the same rank cannot be stacked regardless of colour.
|
||||||
|
let c = card(Suit::Hearts, Rank::Nine);
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
|
||||||
|
assert!(!can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_face_down_destination_top_is_invalid() {
|
||||||
|
// A face-down top card must never be a valid placement target.
|
||||||
|
let c = card(Suit::Hearts, Rank::Nine);
|
||||||
|
let mut top = card(Suit::Spades, Rank::Ten);
|
||||||
|
top.face_up = false;
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![top]);
|
||||||
|
assert!(!can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_sequence_validation() {
|
||||||
|
// Single card is trivially a valid sequence.
|
||||||
|
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
|
||||||
|
// Valid descending alternating-colour run K♠ Q♥ J♣.
|
||||||
|
assert!(is_valid_tableau_sequence(&[
|
||||||
|
card(Suit::Spades, Rank::King),
|
||||||
|
card(Suit::Hearts, Rank::Queen),
|
||||||
|
card(Suit::Clubs, Rank::Jack),
|
||||||
|
]));
|
||||||
|
// Same colour twice (Q♠ on K♠) — invalid.
|
||||||
|
assert!(!is_valid_tableau_sequence(&[
|
||||||
|
card(Suit::Spades, Rank::King),
|
||||||
|
card(Suit::Spades, Rank::Queen),
|
||||||
|
]));
|
||||||
|
// Rank gap (K♠ → J♥) — invalid.
|
||||||
|
assert!(!is_valid_tableau_sequence(&[
|
||||||
|
card(Suit::Spades, Rank::King),
|
||||||
|
card(Suit::Hearts, Rank::Jack),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,50 @@
|
|||||||
//! Ferrous-specific scoring policy layered on top of upstream `klondike`.
|
use crate::pile::PileType;
|
||||||
//!
|
|
||||||
//! Upstream [`klondike::KlondikeStats::score`] owns the per-move point values
|
/// Score delta for moving cards from `from` to `to`.
|
||||||
//! (move-to-foundation, flip-up bonus, recycle penalty, etc.). The functions
|
///
|
||||||
//! here are the Ferrous Solitaire house rules that upstream has no opinion on —
|
/// Windows XP Standard scoring:
|
||||||
//! currently just the win-time bonus shown in the win modal.
|
/// - +10 for any card reaching a foundation pile
|
||||||
|
/// - +5 for a waste → tableau move
|
||||||
|
/// - -15 for a foundation → tableau (take-from-foundation) move
|
||||||
|
/// - 0 for all other moves
|
||||||
|
///
|
||||||
|
/// Note: the +5 flip bonus for exposing a face-down tableau card is applied
|
||||||
|
/// separately in `game_state::move_cards` because it depends on post-move state.
|
||||||
|
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
|
||||||
|
match to {
|
||||||
|
PileType::Foundation(_) => 10,
|
||||||
|
PileType::Tableau(_) => match from {
|
||||||
|
PileType::Waste => 5,
|
||||||
|
PileType::Foundation(_) => -15,
|
||||||
|
_ => 0,
|
||||||
|
},
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score penalty applied when the player uses undo: -15.
|
||||||
|
pub fn score_undo() -> i32 {
|
||||||
|
-15
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score bonus awarded when a face-down tableau card is flipped face-up: +5.
|
||||||
|
pub fn score_flip() -> i32 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score penalty for recycling the waste pile back to stock.
|
||||||
|
///
|
||||||
|
/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3).
|
||||||
|
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
|
||||||
|
/// `recycle_count` is the new total count **after** this recycle.
|
||||||
|
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||||
|
let (free, penalty) = if is_draw_three {
|
||||||
|
(3_u32, -20_i32)
|
||||||
|
} else {
|
||||||
|
(1_u32, -100_i32)
|
||||||
|
};
|
||||||
|
if recycle_count > free { penalty } else { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||||
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||||
@@ -13,3 +54,99 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
|||||||
}
|
}
|
||||||
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_to_foundation_scores_ten() {
|
||||||
|
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
||||||
|
assert_eq!(
|
||||||
|
score_move(&PileType::Tableau(0), &PileType::Foundation(0)),
|
||||||
|
10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waste_to_tableau_scores_five() {
|
||||||
|
assert_eq!(score_move(&PileType::Waste, &PileType::Tableau(3)), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_to_tableau_scores_zero() {
|
||||||
|
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Tableau(1)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_penalty_is_negative_fifteen() {
|
||||||
|
assert_eq!(score_undo(), -15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_bonus_at_100_seconds() {
|
||||||
|
assert_eq!(compute_time_bonus(100), 7000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_bonus_at_zero_is_zero() {
|
||||||
|
assert_eq!(compute_time_bonus(0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_bonus_at_one_second() {
|
||||||
|
assert_eq!(compute_time_bonus(1), 700_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_to_tableau_penalises_fifteen() {
|
||||||
|
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
|
||||||
|
assert_eq!(
|
||||||
|
score_move(&PileType::Foundation(0), &PileType::Tableau(0)),
|
||||||
|
-15
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_to_stock_or_waste_scores_zero() {
|
||||||
|
// These destinations are illegal moves in practice, but the function
|
||||||
|
// must not panic and should return 0.
|
||||||
|
assert_eq!(score_move(&PileType::Waste, &PileType::Stock), 0);
|
||||||
|
assert_eq!(score_move(&PileType::Waste, &PileType::Waste), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
||||||
|
// Very short elapsed time would overflow without the .min() guard.
|
||||||
|
let bonus = compute_time_bonus(1);
|
||||||
|
assert!(
|
||||||
|
bonus >= 0,
|
||||||
|
"time bonus must be non-negative after u64→i32 cast"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flip_bonus_is_five() {
|
||||||
|
assert_eq!(score_flip(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycle_draw1_first_pass_free() {
|
||||||
|
assert_eq!(score_recycle(1, false), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycle_draw1_second_pass_penalised() {
|
||||||
|
assert_eq!(score_recycle(2, false), -100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycle_draw3_third_pass_free() {
|
||||||
|
assert_eq!(score_recycle(3, true), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycle_draw3_fourth_pass_penalised() {
|
||||||
|
assert_eq!(score_recycle(4, true), -20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,23 +7,15 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
solitaire_core = { workspace = true }
|
solitaire_core = { workspace = true }
|
||||||
solitaire_sync = { workspace = true }
|
solitaire_sync = { workspace = true }
|
||||||
klondike = { workspace = true }
|
|
||||||
card_game = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
uuid = { workspace = true }
|
|
||||||
|
|
||||||
# These deps are not available / not needed on wasm32:
|
|
||||||
# dirs — platform data directories (no filesystem on browser)
|
|
||||||
# reqwest — native HTTP client (sync/analytics gated out on wasm32)
|
|
||||||
# tokio — OS-threaded async runtime (mio doesn't compile on wasm32)
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
# `keyring-core` is the typed Entry/Error API used by
|
# `keyring-core` is the typed Entry/Error API used by
|
||||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||||
@@ -32,14 +24,17 @@ tokio = { workspace = true }
|
|||||||
# on bionic). On Android `auth_tokens` falls back to a stub
|
# on bionic). On Android `auth_tokens` falls back to a stub
|
||||||
# implementation that always returns `KeychainUnavailable`; the
|
# implementation that always returns `KeychainUnavailable`; the
|
||||||
# real backend lands when we wire Android Keystore via JNI.
|
# real backend lands when we wire Android Keystore via JNI.
|
||||||
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
keyring-core = { workspace = true }
|
keyring-core = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
jni = { workspace = true }
|
jni = { workspace = true }
|
||||||
|
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
|
||||||
|
# process-wide JavaVM handle for JNI. Must be listed here so the
|
||||||
|
# symbol resolves when cross-compiling for Android targets.
|
||||||
|
bevy = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
solitaire_core = { workspace = true, features = ["test-support"] }
|
|
||||||
solitaire_server = { path = "../solitaire_server" }
|
solitaire_server = { path = "../solitaire_server" }
|
||||||
solitaire_sync = { workspace = true }
|
solitaire_sync = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
@@ -47,6 +42,3 @@ sqlx = { workspace = true }
|
|||||||
jsonwebtoken = { workspace = true }
|
jsonwebtoken = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
//! Safe JNI bridge for Android platform integration.
|
|
||||||
//!
|
|
||||||
//! Reconstructing the raw `JavaVM` / `NativeActivity` handles handed over by
|
|
||||||
//! the Android runtime requires `unsafe` FFI. That single reconstruction lives
|
|
||||||
//! in `solitaire_app`'s entry point ([`solitaire_app::android_main`]); this
|
|
||||||
//! module only ever stores the resulting *safe* [`JavaVM`] and activity
|
|
||||||
//! [`GlobalRef`] and hands callers an attached [`JNIEnv`].
|
|
||||||
//!
|
|
||||||
//! As a result every consumer of Android JNI — the keystore, clipboard, and
|
|
||||||
//! safe-area subsystems — goes through the safe functions here and stays
|
|
||||||
//! `forbid(unsafe_code)`. The only crate carrying `unsafe` is `solitaire_app`.
|
|
||||||
//!
|
|
||||||
//! Only compiled and linked on `target_os = "android"`.
|
|
||||||
|
|
||||||
use jni::objects::{GlobalRef, JObject};
|
|
||||||
use jni::{JNIEnv, JavaVM};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
|
|
||||||
static ANDROID_ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Store the process-wide [`JavaVM`]. Called once from Android startup
|
|
||||||
/// (`solitaire_app::android_main`); subsequent calls are ignored.
|
|
||||||
pub fn set_jvm(vm: JavaVM) {
|
|
||||||
let _ = ANDROID_JVM.set(vm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a global reference to the `NativeActivity`. Called once from Android
|
|
||||||
/// startup; subsequent calls are ignored.
|
|
||||||
pub fn set_activity(activity: GlobalRef) {
|
|
||||||
let _ = ANDROID_ACTIVITY.set(activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `f` with a [`JNIEnv`] attached to the current thread.
|
|
||||||
///
|
|
||||||
/// Returns an error string if the bridge has not been initialised yet or the
|
|
||||||
/// thread cannot be attached. The closure's JNI errors are surfaced through the
|
|
||||||
/// same `String` channel so callers have a single error type to map.
|
|
||||||
pub fn with_env<F, R>(f: F) -> Result<R, String>
|
|
||||||
where
|
|
||||||
F: for<'local> FnOnce(&mut JNIEnv<'local>) -> jni::errors::Result<R>,
|
|
||||||
{
|
|
||||||
let vm = ANDROID_JVM
|
|
||||||
.get()
|
|
||||||
.ok_or_else(|| "Android JavaVM not initialised".to_string())?;
|
|
||||||
let mut env = vm
|
|
||||||
.attach_current_thread_permanently()
|
|
||||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
||||||
f(&mut env).map_err(|e| format!("JNI: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `f` with an attached [`JNIEnv`] and the `NativeActivity` object.
|
|
||||||
///
|
|
||||||
/// Like [`with_env`] but also resolves the cached activity global reference, so
|
|
||||||
/// callers that need to invoke instance methods on the activity (clipboard,
|
|
||||||
/// window insets) never touch a raw handle.
|
|
||||||
pub fn with_activity_env<F, R>(f: F) -> Result<R, String>
|
|
||||||
where
|
|
||||||
F: for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> jni::errors::Result<R>,
|
|
||||||
{
|
|
||||||
let activity = ANDROID_ACTIVITY
|
|
||||||
.get()
|
|
||||||
.ok_or_else(|| "Android activity not initialised".to_string())?;
|
|
||||||
with_env(|env| f(env, activity.as_obj()))
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
///
|
///
|
||||||
/// Only compiled and linked on `target_os = "android"`.
|
/// Only compiled and linked on `target_os = "android"`.
|
||||||
use jni::{
|
use jni::{
|
||||||
JNIEnv,
|
JNIEnv, JavaVM,
|
||||||
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -36,15 +36,23 @@ struct TokenBlob {
|
|||||||
// JVM helper
|
// JVM helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Run `f` with an attached `JNIEnv`, delegating thread attach and the
|
|
||||||
/// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
|
|
||||||
/// initialised once from Android startup, so the keystore never touches a raw
|
|
||||||
/// pointer and this module stays `forbid(unsafe_code)`.
|
|
||||||
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
||||||
where
|
where
|
||||||
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
||||||
{
|
{
|
||||||
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
|
let app = bevy::android::ANDROID_APP
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?;
|
||||||
|
|
||||||
|
// SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime.
|
||||||
|
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
|
||||||
|
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread_permanently()
|
||||||
|
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
|
||||||
|
|
||||||
|
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -199,10 +207,9 @@ fn encrypt_gcm(
|
|||||||
.v()?;
|
.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()?;
|
||||||
let iv_arr = JByteArray::from(iv_jobj);
|
// SAFETY: the method signature guarantees a byte array return.
|
||||||
|
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
|
||||||
let iv = env.convert_byte_array(&iv_arr)?;
|
let 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)?;
|
||||||
@@ -210,7 +217,8 @@ 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()?;
|
||||||
let ct_arr = JByteArray::from(ct_jobj);
|
// SAFETY: doFinal([B) returns [B.
|
||||||
|
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
|
||||||
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
let 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());
|
||||||
@@ -261,7 +269,8 @@ 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()?;
|
||||||
let pt_arr = JByteArray::from(pt_jobj);
|
// SAFETY: doFinal([B) returns [B.
|
||||||
|
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
|
||||||
env.convert_byte_array(&pt_arr)
|
env.convert_byte_array(&pt_arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,29 +357,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 {
|
||||||
&& lpath.exists()
|
if lpath.exists() {
|
||||||
{
|
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||||
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
other => other,
|
||||||
other => other,
|
|
||||||
})?;
|
|
||||||
if data.len() >= 12 {
|
|
||||||
let plaintext = with_jvm(|env| {
|
|
||||||
let key = load_or_create_key(env)?;
|
|
||||||
decrypt_gcm(env, &key, &data)
|
|
||||||
})?;
|
})?;
|
||||||
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
if data.len() >= 12 {
|
||||||
let mut map = HashMap::new();
|
let plaintext = with_jvm(|env| {
|
||||||
map.insert(blob.username.clone(), blob);
|
let key = load_or_create_key(env)?;
|
||||||
// Write to the new location, then remove the legacy file.
|
decrypt_gcm(env, &key, &data)
|
||||||
if write_map_inner(&map).is_ok() {
|
})?;
|
||||||
let _ = std::fs::remove_file(lpath);
|
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert(blob.username.clone(), blob);
|
||||||
|
// Write to the new location, then remove the legacy file.
|
||||||
|
if write_map_inner(&map).is_ok() {
|
||||||
|
let _ = std::fs::remove_file(lpath);
|
||||||
|
}
|
||||||
|
return Ok(map);
|
||||||
}
|
}
|
||||||
return Ok(map);
|
|
||||||
}
|
}
|
||||||
|
// Legacy file corrupt or unrecognised — treat as empty.
|
||||||
}
|
}
|
||||||
// Legacy file corrupt or unrecognised — treat as empty.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3. No file found ---
|
// --- 3. No file found ---
|
||||||
@@ -459,11 +468,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() {
|
||||||
&& path.exists()
|
if path.exists() {
|
||||||
{
|
std::fs::remove_file(&path)
|
||||||
std::fs::remove_file(&path)
|
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||||
|
|||||||
@@ -14,14 +14,15 @@
|
|||||||
//! the Bevy `App`). If no default store is set, all operations in this module
|
//! the Bevy `App`). If no default store is set, all operations in this module
|
||||||
//! will return [`TokenError::KeychainUnavailable`].
|
//! will return [`TokenError::KeychainUnavailable`].
|
||||||
//!
|
//!
|
||||||
//! # Android
|
//! # Android stub
|
||||||
//!
|
//!
|
||||||
//! `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 every function in this module returns
|
||||||
//! JNI backend. `solitaire_app` must initialise the safe
|
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
|
||||||
//! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
|
//! the same way they handle a Linux box without Secret Service. The
|
||||||
//! startup before token operations can succeed.
|
//! real Android backend will arrive in the Phase-Android round when we
|
||||||
|
//! wire Android Keystore via JNI.
|
||||||
//!
|
//!
|
||||||
//! # Note: no unit tests — requires live OS keychain.
|
//! # Note: no unit tests — requires live OS keychain.
|
||||||
|
|
||||||
|
|||||||
@@ -26,227 +26,227 @@ use solitaire_core::game_state::DifficultyLevel;
|
|||||||
|
|
||||||
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
|
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
|
||||||
pub const EASY_SEEDS: &[u64] = &[
|
pub const EASY_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-06-04)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
|
||||||
|
0xD1FF_0000_0000_0001,
|
||||||
|
0xD1FF_0000_0000_0002,
|
||||||
|
0xD1FF_0000_0000_0007,
|
||||||
|
0xD1FF_0000_0000_0008,
|
||||||
0xD1FF_0000_0000_0009,
|
0xD1FF_0000_0000_0009,
|
||||||
0xD1FF_0000_0000_0087,
|
0xD1FF_0000_0000_000E,
|
||||||
0xD1FF_0000_0000_00EB,
|
0xD1FF_0000_0000_0013,
|
||||||
0xD1FF_0000_0000_017F,
|
0xD1FF_0000_0000_0015,
|
||||||
0xD1FF_0000_0000_01CE,
|
0xD1FF_0000_0000_0018,
|
||||||
0xD1FF_0000_0000_020F,
|
0xD1FF_0000_0000_001D,
|
||||||
0xD1FF_0000_0000_0251,
|
0xD1FF_0000_0000_0021,
|
||||||
0xD1FF_0000_0000_0275,
|
0xD1FF_0000_0000_0022,
|
||||||
0xD1FF_0000_0000_029C,
|
0xD1FF_0000_0000_0026,
|
||||||
0xD1FF_0000_0000_02BD,
|
0xD1FF_0000_0000_002C,
|
||||||
0xD1FF_0000_0000_02ED,
|
0xD1FF_0000_0000_002E,
|
||||||
0xD1FF_0000_0000_038F,
|
0xD1FF_0000_0000_002F,
|
||||||
0xD1FF_0000_0000_03C9,
|
0xD1FF_0000_0000_0035,
|
||||||
0xD1FF_0000_0000_0415,
|
0xD1FF_0000_0000_0036,
|
||||||
0xD1FF_0000_0000_045F,
|
0xD1FF_0000_0000_003C,
|
||||||
0xD1FF_0000_0000_04C4,
|
0xD1FF_0000_0000_0045,
|
||||||
0xD1FF_0000_0000_04CC,
|
0xD1FF_0000_0000_0046,
|
||||||
0xD1FF_0000_0000_04EE,
|
0xD1FF_0000_0000_0048,
|
||||||
0xD1FF_0000_0000_0631,
|
0xD1FF_0000_0000_0049,
|
||||||
0xD1FF_0000_0000_0651,
|
0xD1FF_0000_0000_004D,
|
||||||
0xD1FF_0000_0000_0689,
|
0xD1FF_0000_0000_004F,
|
||||||
0xD1FF_0000_0000_0735,
|
0xD1FF_0000_0000_0050,
|
||||||
0xD1FF_0000_0000_0748,
|
0xD1FF_0000_0000_0051,
|
||||||
0xD1FF_0000_0000_0801,
|
0xD1FF_0000_0000_0053,
|
||||||
0xD1FF_0000_0000_0820,
|
0xD1FF_0000_0000_0054,
|
||||||
0xD1FF_0000_0000_08F9,
|
0xD1FF_0000_0000_0057,
|
||||||
0xD1FF_0000_0000_091C,
|
0xD1FF_0000_0000_0058,
|
||||||
0xD1FF_0000_0000_0937,
|
0xD1FF_0000_0000_005A,
|
||||||
0xD1FF_0000_0000_09A6,
|
0xD1FF_0000_0000_005B,
|
||||||
0xD1FF_0000_0000_09C3,
|
0xD1FF_0000_0000_005C,
|
||||||
0xD1FF_0000_0000_09DD,
|
0xD1FF_0000_0000_005D,
|
||||||
0xD1FF_0000_0000_0BD9,
|
0xD1FF_0000_0000_005F,
|
||||||
0xD1FF_0000_0000_0BEC,
|
0xD1FF_0000_0000_0061,
|
||||||
0xD1FF_0000_0000_0BF2,
|
0xD1FF_0000_0000_0062,
|
||||||
0xD1FF_0000_0000_0C1B,
|
0xD1FF_0000_0000_0063,
|
||||||
0xD1FF_0000_0000_0C26,
|
0xD1FF_0000_0000_0069,
|
||||||
0xD1FF_0000_0000_0C36,
|
|
||||||
0xD1FF_0000_0000_0C4B,
|
|
||||||
0xD1FF_0000_0000_0C78,
|
|
||||||
0xD1FF_0000_0000_0CBC,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
|
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
|
||||||
pub const MEDIUM_SEEDS: &[u64] = &[
|
pub const MEDIUM_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-06-04)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
|
||||||
|
0xD1FF_0000_0000_0000,
|
||||||
0xD1FF_0000_0000_0012,
|
0xD1FF_0000_0000_0012,
|
||||||
0xD1FF_0000_0000_002C,
|
0xD1FF_0000_0000_0016,
|
||||||
0xD1FF_0000_0000_004B,
|
0xD1FF_0000_0000_001B,
|
||||||
0xD1FF_0000_0000_0052,
|
0xD1FF_0000_0000_001C,
|
||||||
0xD1FF_0000_0000_0058,
|
0xD1FF_0000_0000_0020,
|
||||||
0xD1FF_0000_0000_005E,
|
0xD1FF_0000_0000_002A,
|
||||||
0xD1FF_0000_0000_0063,
|
0xD1FF_0000_0000_0034,
|
||||||
|
0xD1FF_0000_0000_003A,
|
||||||
|
0xD1FF_0000_0000_0041,
|
||||||
|
0xD1FF_0000_0000_0043,
|
||||||
|
0xD1FF_0000_0000_0060,
|
||||||
|
0xD1FF_0000_0000_006A,
|
||||||
|
0xD1FF_0000_0000_006C,
|
||||||
|
0xD1FF_0000_0000_006E,
|
||||||
|
0xD1FF_0000_0000_006F,
|
||||||
|
0xD1FF_0000_0000_0071,
|
||||||
|
0xD1FF_0000_0000_0072,
|
||||||
|
0xD1FF_0000_0000_0075,
|
||||||
|
0xD1FF_0000_0000_0076,
|
||||||
|
0xD1FF_0000_0000_007B,
|
||||||
|
0xD1FF_0000_0000_007E,
|
||||||
|
0xD1FF_0000_0000_0081,
|
||||||
|
0xD1FF_0000_0000_0083,
|
||||||
|
0xD1FF_0000_0000_0084,
|
||||||
|
0xD1FF_0000_0000_0087,
|
||||||
|
0xD1FF_0000_0000_0090,
|
||||||
|
0xD1FF_0000_0000_0092,
|
||||||
|
0xD1FF_0000_0000_0093,
|
||||||
|
0xD1FF_0000_0000_0098,
|
||||||
0xD1FF_0000_0000_0099,
|
0xD1FF_0000_0000_0099,
|
||||||
0xD1FF_0000_0000_00A9,
|
0xD1FF_0000_0000_009A,
|
||||||
|
0xD1FF_0000_0000_009E,
|
||||||
|
0xD1FF_0000_0000_00A5,
|
||||||
|
0xD1FF_0000_0000_00A8,
|
||||||
|
0xD1FF_0000_0000_00AA,
|
||||||
|
0xD1FF_0000_0000_00AB,
|
||||||
|
0xD1FF_0000_0000_00AE,
|
||||||
0xD1FF_0000_0000_00AF,
|
0xD1FF_0000_0000_00AF,
|
||||||
0xD1FF_0000_0000_00BB,
|
0xD1FF_0000_0000_00B0,
|
||||||
0xD1FF_0000_0000_00D1,
|
|
||||||
0xD1FF_0000_0000_00E3,
|
|
||||||
0xD1FF_0000_0000_0108,
|
|
||||||
0xD1FF_0000_0000_010D,
|
|
||||||
0xD1FF_0000_0000_0110,
|
|
||||||
0xD1FF_0000_0000_012F,
|
|
||||||
0xD1FF_0000_0000_0139,
|
|
||||||
0xD1FF_0000_0000_013C,
|
|
||||||
0xD1FF_0000_0000_0148,
|
|
||||||
0xD1FF_0000_0000_015E,
|
|
||||||
0xD1FF_0000_0000_016A,
|
|
||||||
0xD1FF_0000_0000_016F,
|
|
||||||
0xD1FF_0000_0000_0179,
|
|
||||||
0xD1FF_0000_0000_019E,
|
|
||||||
0xD1FF_0000_0000_01A8,
|
|
||||||
0xD1FF_0000_0000_01AB,
|
|
||||||
0xD1FF_0000_0000_01B5,
|
|
||||||
0xD1FF_0000_0000_01B8,
|
|
||||||
0xD1FF_0000_0000_01D3,
|
|
||||||
0xD1FF_0000_0000_01EE,
|
|
||||||
0xD1FF_0000_0000_01F3,
|
|
||||||
0xD1FF_0000_0000_0202,
|
|
||||||
0xD1FF_0000_0000_0203,
|
|
||||||
0xD1FF_0000_0000_021E,
|
|
||||||
0xD1FF_0000_0000_022C,
|
|
||||||
0xD1FF_0000_0000_022D,
|
|
||||||
0xD1FF_0000_0000_0233,
|
|
||||||
0xD1FF_0000_0000_0245,
|
|
||||||
0xD1FF_0000_0000_024E,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
|
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
|
||||||
pub const HARD_SEEDS: &[u64] = &[
|
pub const HARD_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-06-04)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
|
||||||
0xD1FF_0000_0000_0006,
|
0xD1FF_0000_0000_001F,
|
||||||
0xD1FF_0000_0000_0008,
|
0xD1FF_0000_0000_0024,
|
||||||
0xD1FF_0000_0000_000F,
|
0xD1FF_0000_0000_0025,
|
||||||
0xD1FF_0000_0000_0011,
|
0xD1FF_0000_0000_0031,
|
||||||
0xD1FF_0000_0000_0022,
|
0xD1FF_0000_0000_0032,
|
||||||
0xD1FF_0000_0000_0023,
|
0xD1FF_0000_0000_003E,
|
||||||
0xD1FF_0000_0000_002A,
|
0xD1FF_0000_0000_004A,
|
||||||
0xD1FF_0000_0000_002D,
|
0xD1FF_0000_0000_006D,
|
||||||
0xD1FF_0000_0000_0040,
|
|
||||||
0xD1FF_0000_0000_0042,
|
|
||||||
0xD1FF_0000_0000_0050,
|
|
||||||
0xD1FF_0000_0000_005B,
|
|
||||||
0xD1FF_0000_0000_005D,
|
|
||||||
0xD1FF_0000_0000_0067,
|
|
||||||
0xD1FF_0000_0000_0069,
|
|
||||||
0xD1FF_0000_0000_006E,
|
|
||||||
0xD1FF_0000_0000_0072,
|
|
||||||
0xD1FF_0000_0000_0079,
|
0xD1FF_0000_0000_0079,
|
||||||
0xD1FF_0000_0000_007C,
|
0xD1FF_0000_0000_007C,
|
||||||
0xD1FF_0000_0000_0080,
|
0xD1FF_0000_0000_0080,
|
||||||
0xD1FF_0000_0000_0081,
|
0xD1FF_0000_0000_008A,
|
||||||
0xD1FF_0000_0000_0083,
|
0xD1FF_0000_0000_0097,
|
||||||
0xD1FF_0000_0000_0091,
|
|
||||||
0xD1FF_0000_0000_009B,
|
|
||||||
0xD1FF_0000_0000_00A1,
|
|
||||||
0xD1FF_0000_0000_00B1,
|
0xD1FF_0000_0000_00B1,
|
||||||
|
0xD1FF_0000_0000_00B2,
|
||||||
|
0xD1FF_0000_0000_00B3,
|
||||||
|
0xD1FF_0000_0000_00B5,
|
||||||
|
0xD1FF_0000_0000_00B7,
|
||||||
|
0xD1FF_0000_0000_00B8,
|
||||||
|
0xD1FF_0000_0000_00B9,
|
||||||
|
0xD1FF_0000_0000_00BA,
|
||||||
|
0xD1FF_0000_0000_00BB,
|
||||||
|
0xD1FF_0000_0000_00BC,
|
||||||
|
0xD1FF_0000_0000_00BD,
|
||||||
|
0xD1FF_0000_0000_00C2,
|
||||||
0xD1FF_0000_0000_00C3,
|
0xD1FF_0000_0000_00C3,
|
||||||
|
0xD1FF_0000_0000_00C5,
|
||||||
|
0xD1FF_0000_0000_00CC,
|
||||||
|
0xD1FF_0000_0000_00CE,
|
||||||
|
0xD1FF_0000_0000_00D1,
|
||||||
|
0xD1FF_0000_0000_00D2,
|
||||||
0xD1FF_0000_0000_00D6,
|
0xD1FF_0000_0000_00D6,
|
||||||
0xD1FF_0000_0000_00DD,
|
0xD1FF_0000_0000_00D7,
|
||||||
0xD1FF_0000_0000_00E8,
|
0xD1FF_0000_0000_00DC,
|
||||||
0xD1FF_0000_0000_00F2,
|
0xD1FF_0000_0000_00DF,
|
||||||
0xD1FF_0000_0000_0101,
|
0xD1FF_0000_0000_00E0,
|
||||||
0xD1FF_0000_0000_010F,
|
0xD1FF_0000_0000_00E1,
|
||||||
0xD1FF_0000_0000_0113,
|
0xD1FF_0000_0000_00E4,
|
||||||
0xD1FF_0000_0000_0118,
|
0xD1FF_0000_0000_00E6,
|
||||||
0xD1FF_0000_0000_0119,
|
0xD1FF_0000_0000_00E7,
|
||||||
0xD1FF_0000_0000_012D,
|
|
||||||
0xD1FF_0000_0000_0133,
|
|
||||||
0xD1FF_0000_0000_0144,
|
|
||||||
0xD1FF_0000_0000_0147,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
|
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
|
||||||
pub const EXPERT_SEEDS: &[u64] = &[
|
pub const EXPERT_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-06-04)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
|
||||||
0xD1FF_0000_0000_0000,
|
0xD1FF_0000_0000_0006,
|
||||||
0xD1FF_0000_0000_0002,
|
0xD1FF_0000_0000_000B,
|
||||||
0xD1FF_0000_0000_000A,
|
0xD1FF_0000_0000_0019,
|
||||||
0xD1FF_0000_0000_0013,
|
|
||||||
0xD1FF_0000_0000_0017,
|
|
||||||
0xD1FF_0000_0000_001C,
|
|
||||||
0xD1FF_0000_0000_001F,
|
|
||||||
0xD1FF_0000_0000_0021,
|
|
||||||
0xD1FF_0000_0000_0024,
|
|
||||||
0xD1FF_0000_0000_0029,
|
|
||||||
0xD1FF_0000_0000_002E,
|
|
||||||
0xD1FF_0000_0000_0035,
|
|
||||||
0xD1FF_0000_0000_0045,
|
|
||||||
0xD1FF_0000_0000_0048,
|
|
||||||
0xD1FF_0000_0000_0049,
|
|
||||||
0xD1FF_0000_0000_004F,
|
|
||||||
0xD1FF_0000_0000_0062,
|
|
||||||
0xD1FF_0000_0000_006D,
|
|
||||||
0xD1FF_0000_0000_0074,
|
|
||||||
0xD1FF_0000_0000_0076,
|
|
||||||
0xD1FF_0000_0000_0082,
|
0xD1FF_0000_0000_0082,
|
||||||
0xD1FF_0000_0000_008F,
|
0xD1FF_0000_0000_00CB,
|
||||||
0xD1FF_0000_0000_0090,
|
0xD1FF_0000_0000_00D5,
|
||||||
0xD1FF_0000_0000_0097,
|
0xD1FF_0000_0000_00D8,
|
||||||
0xD1FF_0000_0000_009A,
|
0xD1FF_0000_0000_00E8,
|
||||||
0xD1FF_0000_0000_009F,
|
0xD1FF_0000_0000_00EA,
|
||||||
0xD1FF_0000_0000_00A5,
|
0xD1FF_0000_0000_00EB,
|
||||||
0xD1FF_0000_0000_00A8,
|
0xD1FF_0000_0000_00EC,
|
||||||
0xD1FF_0000_0000_00AD,
|
|
||||||
0xD1FF_0000_0000_00AE,
|
|
||||||
0xD1FF_0000_0000_00B8,
|
|
||||||
0xD1FF_0000_0000_00B9,
|
|
||||||
0xD1FF_0000_0000_00BC,
|
|
||||||
0xD1FF_0000_0000_00C5,
|
|
||||||
0xD1FF_0000_0000_00CA,
|
|
||||||
0xD1FF_0000_0000_00CE,
|
|
||||||
0xD1FF_0000_0000_00DE,
|
|
||||||
0xD1FF_0000_0000_00ED,
|
0xD1FF_0000_0000_00ED,
|
||||||
0xD1FF_0000_0000_00EE,
|
0xD1FF_0000_0000_00F2,
|
||||||
0xD1FF_0000_0000_00EF,
|
0xD1FF_0000_0000_00F3,
|
||||||
|
0xD1FF_0000_0000_00F4,
|
||||||
|
0xD1FF_0000_0000_00FE,
|
||||||
|
0xD1FF_0000_0000_00FF,
|
||||||
|
0xD1FF_0000_0000_0102,
|
||||||
|
0xD1FF_0000_0000_0103,
|
||||||
|
0xD1FF_0000_0000_0104,
|
||||||
|
0xD1FF_0000_0000_0105,
|
||||||
|
0xD1FF_0000_0000_0106,
|
||||||
|
0xD1FF_0000_0000_0109,
|
||||||
|
0xD1FF_0000_0000_010B,
|
||||||
|
0xD1FF_0000_0000_010C,
|
||||||
|
0xD1FF_0000_0000_0110,
|
||||||
|
0xD1FF_0000_0000_0113,
|
||||||
|
0xD1FF_0000_0000_0114,
|
||||||
|
0xD1FF_0000_0000_011B,
|
||||||
|
0xD1FF_0000_0000_011C,
|
||||||
|
0xD1FF_0000_0000_011E,
|
||||||
|
0xD1FF_0000_0000_0120,
|
||||||
|
0xD1FF_0000_0000_0121,
|
||||||
|
0xD1FF_0000_0000_0122,
|
||||||
|
0xD1FF_0000_0000_0123,
|
||||||
|
0xD1FF_0000_0000_0124,
|
||||||
|
0xD1FF_0000_0000_0126,
|
||||||
|
0xD1FF_0000_0000_012B,
|
||||||
|
0xD1FF_0000_0000_012C,
|
||||||
|
0xD1FF_0000_0000_012E,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
|
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
|
||||||
pub const GRANDMASTER_SEEDS: &[u64] = &[
|
pub const GRANDMASTER_SEEDS: &[u64] = &[
|
||||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-06-04)
|
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
|
||||||
0xD1FF_0000_0000_003C,
|
0xD1FF_0000_0000_0027,
|
||||||
0xD1FF_0000_0000_0047,
|
0xD1FF_0000_0000_00A0,
|
||||||
0xD1FF_0000_0000_005A,
|
0xD1FF_0000_0000_00C4,
|
||||||
0xD1FF_0000_0000_009C,
|
0xD1FF_0000_0000_00D4,
|
||||||
0xD1FF_0000_0000_00D2,
|
0xD1FF_0000_0000_00DE,
|
||||||
0xD1FF_0000_0000_00F4,
|
0xD1FF_0000_0000_00F9,
|
||||||
0xD1FF_0000_0000_00F6,
|
0xD1FF_0000_0000_0107,
|
||||||
0xD1FF_0000_0000_0104,
|
0xD1FF_0000_0000_0108,
|
||||||
0xD1FF_0000_0000_0106,
|
0xD1FF_0000_0000_0130,
|
||||||
0xD1FF_0000_0000_0111,
|
0xD1FF_0000_0000_0132,
|
||||||
0xD1FF_0000_0000_0112,
|
0xD1FF_0000_0000_0133,
|
||||||
0xD1FF_0000_0000_0116,
|
0xD1FF_0000_0000_0134,
|
||||||
0xD1FF_0000_0000_0117,
|
|
||||||
0xD1FF_0000_0000_011A,
|
|
||||||
0xD1FF_0000_0000_0123,
|
|
||||||
0xD1FF_0000_0000_012B,
|
|
||||||
0xD1FF_0000_0000_012E,
|
|
||||||
0xD1FF_0000_0000_0135,
|
0xD1FF_0000_0000_0135,
|
||||||
|
0xD1FF_0000_0000_0137,
|
||||||
|
0xD1FF_0000_0000_0139,
|
||||||
0xD1FF_0000_0000_013A,
|
0xD1FF_0000_0000_013A,
|
||||||
0xD1FF_0000_0000_013B,
|
0xD1FF_0000_0000_013D,
|
||||||
|
0xD1FF_0000_0000_013F,
|
||||||
|
0xD1FF_0000_0000_0140,
|
||||||
0xD1FF_0000_0000_0141,
|
0xD1FF_0000_0000_0141,
|
||||||
|
0xD1FF_0000_0000_0142,
|
||||||
|
0xD1FF_0000_0000_0143,
|
||||||
|
0xD1FF_0000_0000_0145,
|
||||||
|
0xD1FF_0000_0000_0146,
|
||||||
0xD1FF_0000_0000_014A,
|
0xD1FF_0000_0000_014A,
|
||||||
0xD1FF_0000_0000_014B,
|
0xD1FF_0000_0000_014B,
|
||||||
0xD1FF_0000_0000_014E,
|
0xD1FF_0000_0000_014C,
|
||||||
|
0xD1FF_0000_0000_014D,
|
||||||
|
0xD1FF_0000_0000_014F,
|
||||||
0xD1FF_0000_0000_0150,
|
0xD1FF_0000_0000_0150,
|
||||||
0xD1FF_0000_0000_0155,
|
0xD1FF_0000_0000_0151,
|
||||||
|
0xD1FF_0000_0000_0152,
|
||||||
|
0xD1FF_0000_0000_0153,
|
||||||
0xD1FF_0000_0000_0157,
|
0xD1FF_0000_0000_0157,
|
||||||
0xD1FF_0000_0000_0158,
|
0xD1FF_0000_0000_0158,
|
||||||
0xD1FF_0000_0000_0159,
|
0xD1FF_0000_0000_015B,
|
||||||
0xD1FF_0000_0000_015A,
|
|
||||||
0xD1FF_0000_0000_015C,
|
0xD1FF_0000_0000_015C,
|
||||||
0xD1FF_0000_0000_015D,
|
0xD1FF_0000_0000_015E,
|
||||||
0xD1FF_0000_0000_015F,
|
0xD1FF_0000_0000_0162,
|
||||||
0xD1FF_0000_0000_0166,
|
0xD1FF_0000_0000_0164,
|
||||||
0xD1FF_0000_0000_0173,
|
|
||||||
0xD1FF_0000_0000_0174,
|
|
||||||
0xD1FF_0000_0000_0178,
|
|
||||||
0xD1FF_0000_0000_017D,
|
|
||||||
0xD1FF_0000_0000_0182,
|
|
||||||
0xD1FF_0000_0000_0187,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
||||||
/// silently no-op'd by the engine's push-on-win system, matching
|
/// silently no-op'd by the engine's push-on-win system, matching
|
||||||
/// the same pattern `pull` / `push` follow.
|
/// the same pattern `pull` / `push` follow.
|
||||||
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError> {
|
async fn push_replay(&self, _replay: &crate::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: &Replay) -> Result<String, SyncError> {
|
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||||
(**self).push_replay(replay).await
|
(**self).push_replay(replay).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,8 +118,8 @@ pub use achievements::{
|
|||||||
|
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub use progress::{
|
pub use progress::{
|
||||||
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
|
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
|
||||||
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
|
save_progress_to, xp_for_win,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod weekly;
|
pub mod weekly;
|
||||||
@@ -143,39 +143,27 @@ pub use settings::{
|
|||||||
load_settings_from, save_settings_to, settings_file_path,
|
load_settings_from, save_settings_to, settings_file_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
pub mod android_jni;
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod android_keystore;
|
mod android_keystore;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use auth_tokens::{
|
pub use auth_tokens::{
|
||||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod sync_client;
|
pub mod sync_client;
|
||||||
pub use sync_client::LocalOnlyProvider;
|
pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
|
||||||
|
|
||||||
pub mod replay;
|
pub mod replay;
|
||||||
pub use replay::{
|
pub use replay::{
|
||||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||||
ReplayHistory, append_replay_to_history, load_replay_history_from,
|
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
|
||||||
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
||||||
};
|
};
|
||||||
// `latest_replay_path` is still consumed by the engine's one-shot legacy
|
|
||||||
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
|
|
||||||
// outside `replay.rs` and were dropped from the public surface.
|
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
pub use replay::latest_replay_path;
|
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub mod matomo_client;
|
pub mod matomo_client;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use matomo_client::MatomoClient;
|
pub use matomo_client::MatomoClient;
|
||||||
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
|||||||
@@ -114,62 +114,3 @@ fn url_encode(s: &str) -> String {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn pending(client: &MatomoClient) -> Vec<String> {
|
|
||||||
client.pending.lock().expect("pending lock").clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn event_buffers_encoded_matomo_query() {
|
|
||||||
let client = MatomoClient::new(
|
|
||||||
"https://analytics.example.com/",
|
|
||||||
7,
|
|
||||||
Some("alice bob".into()),
|
|
||||||
);
|
|
||||||
|
|
||||||
client.event("Game Flow", "Won+Fast", Some("draw three"), Some(42.5));
|
|
||||||
|
|
||||||
let pending = pending(&client);
|
|
||||||
assert_eq!(pending.len(), 1);
|
|
||||||
let query = &pending[0];
|
|
||||||
assert!(query.contains("idsite=7"));
|
|
||||||
assert!(query.contains("rec=1"));
|
|
||||||
assert!(query.contains("e_c=Game%20Flow"));
|
|
||||||
assert!(query.contains("e_a=Won%2BFast"));
|
|
||||||
assert!(query.contains("e_n=draw%20three"));
|
|
||||||
assert!(query.contains("e_v=42.5"));
|
|
||||||
assert!(query.contains("uid=alice%20bob"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn event_buffer_drops_oldest_entries_when_capacity_exceeded() {
|
|
||||||
let client = MatomoClient::new("https://analytics.example.com", 1, None);
|
|
||||||
|
|
||||||
for idx in 0..101 {
|
|
||||||
client.event("Game", "Start", Some(&format!("event-{idx}")), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let pending = pending(&client);
|
|
||||||
assert_eq!(pending.len(), 51);
|
|
||||||
assert!(
|
|
||||||
pending[0].contains("event-50"),
|
|
||||||
"oldest retained event should be event-50, got {}",
|
|
||||||
pending[0]
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
pending[50].contains("event-100"),
|
|
||||||
"newest retained event should be event-100, got {}",
|
|
||||||
pending[50]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn url_encode_leaves_unreserved_bytes_and_escapes_everything_else() {
|
|
||||||
assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~");
|
|
||||||
assert_eq!(url_encode("a b+c/d?"), "a%20b%2Bc%2Fd%3F");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -55,15 +55,7 @@ pub fn data_dir() -> Option<PathBuf> {
|
|||||||
{
|
{
|
||||||
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
|
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(not(target_os = "android"))]
|
||||||
{
|
|
||||||
// No filesystem on the browser; all persistence goes through
|
|
||||||
// WasmStorage (localStorage-backed). Return None so every caller
|
|
||||||
// degrades gracefully (the same path they take on a
|
|
||||||
// misconfigured desktop environment).
|
|
||||||
None
|
|
||||||
}
|
|
||||||
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
|
|
||||||
{
|
{
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,34 +25,12 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
|||||||
y * 10_000 + m * 100 + d
|
y * 10_000 + m * 100 + d
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Component breakdown of the XP awarded for a win.
|
/// XP awarded for winning a game.
|
||||||
///
|
|
||||||
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
|
|
||||||
/// it for the total, and UI that displays the individual lines (the win-summary
|
|
||||||
/// modal) reads the parts from here so the breakdown can never drift from the
|
|
||||||
/// total.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct XpBreakdown {
|
|
||||||
/// Flat base XP granted for any win.
|
|
||||||
pub base: u64,
|
|
||||||
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
|
|
||||||
pub speed_bonus: u64,
|
|
||||||
/// Bonus for winning without using undo (25, else 0).
|
|
||||||
pub no_undo_bonus: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl XpBreakdown {
|
|
||||||
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
|
|
||||||
pub fn total(self) -> u64 {
|
|
||||||
self.base + self.speed_bonus + self.no_undo_bonus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Component breakdown of the XP awarded for a win.
|
|
||||||
///
|
///
|
||||||
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
||||||
/// the player did not use undo.
|
/// the player did not use undo.
|
||||||
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
|
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||||
|
let base: u64 = 50;
|
||||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
let speed_bonus: u64 = if time_seconds >= 120 {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -61,16 +39,8 @@ pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
|
|||||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
||||||
scaled.max(10)
|
scaled.max(10)
|
||||||
};
|
};
|
||||||
XpBreakdown {
|
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
||||||
base: 50,
|
base + speed_bonus + no_undo_bonus
|
||||||
speed_bonus,
|
|
||||||
no_undo_bonus: if used_undo { 0 } else { 25 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
|
|
||||||
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
|
||||||
xp_breakdown(time_seconds, used_undo).total()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform-specific default path for `progress.json`.
|
/// Platform-specific default path for `progress.json`.
|
||||||
|
|||||||
@@ -12,22 +12,13 @@
|
|||||||
//! 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 the
|
//! The recording is intentionally minimal — only [`ReplayMove`] entries
|
||||||
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
//! that successfully advanced the game. `Undo` is **not** recorded: a
|
||||||
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
//! replay represents the canonical path the player ultimately took to win,
|
||||||
//! represents the canonical path the player ultimately took to win, so
|
//! so backed-out missteps simply do not appear in the move list. The
|
||||||
//! backed-out missteps simply do not appear in the move list. The starting
|
//! starting deal is not stored either — the [`seed`](Replay::seed) +
|
||||||
//! 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;
|
||||||
@@ -35,7 +26,8 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||||
@@ -73,17 +65,14 @@ fn history_schema_v0() -> u32 {
|
|||||||
/// seeing a broken one.
|
/// seeing a broken one.
|
||||||
///
|
///
|
||||||
/// History:
|
/// History:
|
||||||
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
|
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
|
||||||
/// variants which carried the *outcome* of a stock interaction rather
|
/// variants which carried the *outcome* of a stock interaction rather
|
||||||
/// than the player's atomic input.
|
/// than the player's atomic input.
|
||||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
|
||||||
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
/// variant. The engine resolves draw-vs-recycle deterministically from
|
||||||
/// are now stored directly as upstream
|
/// the current stock state, so the input alone is sufficient and the
|
||||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
/// replay model now stores atomic player inputs end-to-end.
|
||||||
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
|
||||||
/// 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`]
|
||||||
@@ -92,6 +81,32 @@ fn schema_v0() -> u32 {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One atomic player input recorded during a winning game, in the order
|
||||||
|
/// it was applied to the live `GameState`.
|
||||||
|
///
|
||||||
|
/// `Undo` is intentionally absent — see the module-level docs.
|
||||||
|
///
|
||||||
|
/// The variants represent *inputs*, not outcomes. `StockClick` covers
|
||||||
|
/// every player click on the stock pile; the engine then resolves
|
||||||
|
/// draw-vs-recycle deterministically from the current state during both
|
||||||
|
/// recording and playback, so the same input always produces the same
|
||||||
|
/// effect on the same starting deal.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ReplayMove {
|
||||||
|
/// A successful `move_cards(from, to, count)` call.
|
||||||
|
Move {
|
||||||
|
/// Source pile.
|
||||||
|
from: PileType,
|
||||||
|
/// Destination pile.
|
||||||
|
to: PileType,
|
||||||
|
/// Number of cards moved.
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
/// A click on the stock pile. Resolves to a draw when stock is
|
||||||
|
/// non-empty and to a waste→stock recycle when stock is empty.
|
||||||
|
StockClick,
|
||||||
|
}
|
||||||
|
|
||||||
/// A complete recording of a single winning game.
|
/// A complete recording of a single winning game.
|
||||||
///
|
///
|
||||||
/// Replays are reconstructed by rebuilding a fresh
|
/// Replays are reconstructed by rebuilding a fresh
|
||||||
@@ -109,7 +124,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: DrawStockConfig,
|
pub draw_mode: DrawMode,
|
||||||
/// 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
|
||||||
@@ -119,11 +134,9 @@ 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 the atomic
|
/// Ordered move list. Each entry is what the player did, replayable
|
||||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
/// against a fresh `GameState` constructed from the seed.
|
||||||
/// issued, replayable against a fresh `GameState` constructed from the
|
pub moves: Vec<ReplayMove>,
|
||||||
/// 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
|
||||||
@@ -167,12 +180,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: DrawStockConfig,
|
draw_mode: DrawMode,
|
||||||
mode: GameMode,
|
mode: GameMode,
|
||||||
time_seconds: u64,
|
time_seconds: u64,
|
||||||
final_score: i32,
|
final_score: i32,
|
||||||
recorded_at: NaiveDate,
|
recorded_at: NaiveDate,
|
||||||
moves: Vec<KlondikeInstruction>,
|
moves: Vec<ReplayMove>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: REPLAY_SCHEMA_VERSION,
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
@@ -429,9 +442,6 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
|||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use klondike::{
|
|
||||||
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
|
||||||
};
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
@@ -442,22 +452,24 @@ 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,
|
||||||
DrawStockConfig::DrawThree,
|
DrawMode::DrawThree,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
134,
|
134,
|
||||||
5_120,
|
5_120,
|
||||||
date,
|
date,
|
||||||
vec![
|
vec![
|
||||||
KlondikeInstruction::RotateStock,
|
ReplayMove::StockClick,
|
||||||
KlondikeInstruction::DstTableau(DstTableau {
|
ReplayMove::Move {
|
||||||
src: KlondikePileStack::Stock,
|
from: PileType::Waste,
|
||||||
tableau: Tableau::Tableau4,
|
to: PileType::Tableau(3),
|
||||||
}),
|
count: 1,
|
||||||
KlondikeInstruction::RotateStock,
|
},
|
||||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
ReplayMove::StockClick,
|
||||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
ReplayMove::Move {
|
||||||
foundation: Foundation::Foundation1,
|
from: PileType::Tableau(3),
|
||||||
}),
|
to: PileType::Foundation(0),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -583,12 +595,12 @@ mod tests {
|
|||||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||||
Replay::new(
|
Replay::new(
|
||||||
id as u64,
|
id as u64,
|
||||||
DrawStockConfig::DrawOne,
|
DrawMode::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
60,
|
60,
|
||||||
id,
|
id,
|
||||||
date,
|
date,
|
||||||
vec![KlondikeInstruction::RotateStock],
|
vec![ReplayMove::StockClick],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,11 +836,9 @@ 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 current-schema replay JSON with no
|
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
||||||
// win_move_index field — the additive field must still default to None.
|
let v2_no_field = r#"{
|
||||||
let no_field = format!(
|
"schema_version": 2,
|
||||||
r#"{{
|
|
||||||
"schema_version": {schema},
|
|
||||||
"seed": 1,
|
"seed": 1,
|
||||||
"draw_mode": "DrawOne",
|
"draw_mode": "DrawOne",
|
||||||
"mode": "Classic",
|
"mode": "Classic",
|
||||||
@@ -836,10 +846,8 @@ mod tests {
|
|||||||
"final_score": 100,
|
"final_score": 100,
|
||||||
"recorded_at": "2026-05-02",
|
"recorded_at": "2026-05-02",
|
||||||
"moves": []
|
"moves": []
|
||||||
}}"#,
|
}"#;
|
||||||
schema = REPLAY_SCHEMA_VERSION,
|
fs::write(&path, v2_no_field).expect("write fixture");
|
||||||
);
|
|
||||||
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::{DrawStockConfig, game_state::DifficultyLevel};
|
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||||
|
|
||||||
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: DrawStockConfig,
|
pub draw_mode: DrawMode,
|
||||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
||||||
#[serde(default = "default_sfx_volume")]
|
#[serde(default = "default_sfx_volume")]
|
||||||
pub sfx_volume: f32,
|
pub sfx_volume: f32,
|
||||||
@@ -200,7 +200,7 @@ pub struct Settings {
|
|||||||
#[serde(default = "default_time_bonus_multiplier")]
|
#[serde(default = "default_time_bonus_multiplier")]
|
||||||
pub time_bonus_multiplier: f32,
|
pub time_bonus_multiplier: f32,
|
||||||
/// When `true`, the engine rejects new-game deals the
|
/// When `true`, the engine rejects new-game deals the
|
||||||
/// the solver cannot prove winnable, retrying
|
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
||||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||||
/// giving up and using the last tried seed. Off by default —
|
/// giving up and using the last tried seed. Off by default —
|
||||||
/// the solver adds a few hundred milliseconds of latency on the
|
/// the solver adds a few hundred milliseconds of latency on the
|
||||||
@@ -288,8 +288,8 @@ pub struct Settings {
|
|||||||
pub touch_input_mode: TouchInputMode,
|
pub touch_input_mode: TouchInputMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawStockConfig {
|
fn default_draw_mode() -> DrawMode {
|
||||||
DrawStockConfig::DrawOne
|
DrawMode::DrawOne
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sfx_volume() -> f32 {
|
fn default_sfx_volume() -> f32 {
|
||||||
@@ -381,9 +381,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
|||||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||||
/// is willing to attempt before giving up and accepting the latest
|
/// is willing to attempt before giving up and accepting the latest
|
||||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||||
/// every retry comes back provably unwinnable (`Ok(None)` from the
|
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
||||||
/// solver, which would be very unusual) we'd rather hand the player a
|
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
||||||
/// possibly-unwinnable deal than spin forever on the main thread.
|
/// deal than spin forever on the main thread.
|
||||||
///
|
///
|
||||||
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||||
/// the upper bound on UI freeze when the toggle is on.
|
/// the upper bound on UI freeze when the toggle is on.
|
||||||
@@ -392,7 +392,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
|||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
draw_mode: DrawStockConfig::DrawOne,
|
draw_mode: DrawMode::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 [`DrawStockConfig`] from `solitaire_core`.
|
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::game_state::{DrawMode, 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 [`DrawStockConfig`] keep
|
/// long-standing call sites that only know about [`DrawMode`] keep
|
||||||
/// compiling.
|
/// compiling.
|
||||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig);
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
||||||
|
|
||||||
/// Updates the per-mode best score and fastest-win-time fields for the
|
/// 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: &DrawStockConfig) {
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
||||||
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 {
|
||||||
DrawStockConfig::DrawOne => self.draw_one_wins += 1,
|
DrawMode::DrawOne => self.draw_one_wins += 1,
|
||||||
DrawStockConfig::DrawThree => self.draw_three_wins += 1,
|
DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(1500, 120, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
||||||
s.update_on_win(100, 120, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 120, &DrawMode::DrawOne);
|
||||||
s.update_on_win(100, 500, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 500, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 100, &DrawMode::DrawOne);
|
||||||
s.update_on_win(100, 200, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 200, &DrawMode::DrawOne);
|
||||||
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 300, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(500, 60, &DrawMode::DrawOne);
|
||||||
s.update_on_win(300, 60, &DrawStockConfig::DrawOne);
|
s.update_on_win(300, 60, &DrawMode::DrawOne);
|
||||||
assert_eq!(s.best_single_score, 500);
|
assert_eq!(s.best_single_score, 500);
|
||||||
s.update_on_win(800, 60, &DrawStockConfig::DrawOne);
|
s.update_on_win(800, 60, &DrawMode::DrawOne);
|
||||||
assert_eq!(s.best_single_score, 800);
|
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, &DrawStockConfig::DrawOne);
|
s.update_on_win(-50, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||||
s.update_on_win(100, 60, &DrawStockConfig::DrawThree);
|
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(100, 60, &DrawMode::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, &DrawStockConfig::DrawOne);
|
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.lifetime_score,
|
s.lifetime_score,
|
||||||
u64::MAX,
|
u64::MAX,
|
||||||
|
|||||||
+43
-174
@@ -3,13 +3,13 @@
|
|||||||
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
||||||
//! loss during a write never corrupts the saved data.
|
//! loss during a write never corrupts the saved data.
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
||||||
|
|
||||||
use crate::stats::StatsSnapshot;
|
use crate::stats::StatsSnapshot;
|
||||||
|
|
||||||
@@ -85,13 +85,16 @@ pub fn game_state_file_path() -> Option<PathBuf> {
|
|||||||
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||||
let data = fs::read(path).ok()?;
|
let data = fs::read(path).ok()?;
|
||||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||||
if gs.is_won() { None } else { Some(gs) }
|
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if gs.is_won { None } else { Some(gs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
|
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
|
||||||
/// because a completed game should not be resumed.
|
/// because a completed game should not be resumed.
|
||||||
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
|
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
|
||||||
if gs.is_won() {
|
if gs.is_won {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -231,7 +234,9 @@ pub fn load_time_attack_session_from_at(
|
|||||||
/// See [`load_time_attack_session_from_at`] for the rules under which
|
/// See [`load_time_attack_session_from_at`] for the rules under which
|
||||||
/// the call returns `None` (missing file, corrupt JSON, expired window).
|
/// the call returns `None` (missing file, corrupt JSON, expired window).
|
||||||
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
|
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
|
||||||
let now = Utc::now().timestamp().max(0) as u64;
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_secs());
|
||||||
load_time_attack_session_from_at(path, now)
|
load_time_attack_session_from_at(path, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +254,9 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
|||||||
/// current wall-clock time. Equivalent to constructing the struct
|
/// current wall-clock time. Equivalent to constructing the struct
|
||||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
||||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
||||||
let now = Utc::now().timestamp().max(0) as u64;
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_secs());
|
||||||
TimeAttackSession {
|
TimeAttackSession {
|
||||||
remaining_secs,
|
remaining_secs,
|
||||||
wins,
|
wins,
|
||||||
@@ -279,7 +286,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::DrawStockConfig;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
@@ -292,7 +299,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, &DrawStockConfig::DrawOne);
|
stats.update_on_win(1000, 180, &DrawMode::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);
|
||||||
@@ -377,17 +384,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_round_trip() {
|
fn game_state_round_trip() {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
let path = gs_path("round_trip");
|
let path = gs_path("round_trip");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let gs = GameState::new(12345, DrawStockConfig::DrawOne);
|
let gs = GameState::new(12345, DrawMode::DrawOne);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path).expect("load");
|
let loaded = load_game_state_from(&path).expect("load");
|
||||||
assert_eq!(loaded.seed, gs.seed);
|
assert_eq!(loaded.seed, gs.seed);
|
||||||
assert_eq!(loaded.draw_mode(), gs.draw_mode());
|
assert_eq!(loaded.draw_mode, gs.draw_mode);
|
||||||
assert!(!loaded.is_won());
|
assert!(!loaded.is_won);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -406,12 +413,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_game_state_skips_won_games() {
|
fn save_game_state_skips_won_games() {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
let path = gs_path("won_skip");
|
let path = gs_path("won_skip");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
|
let mut gs = GameState::new(99, DrawMode::DrawOne);
|
||||||
gs.set_test_won(true);
|
gs.is_won = true;
|
||||||
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
|
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
|
||||||
assert!(
|
assert!(
|
||||||
!path.exists(),
|
!path.exists(),
|
||||||
@@ -419,11 +426,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_game_state_ignores_won_games() {
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
let path = gs_path("won_load");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// Write a won game directly (bypassing save_game_state_to's guard).
|
||||||
|
let mut gs = GameState::new(77, DrawMode::DrawOne);
|
||||||
|
gs.is_won = true;
|
||||||
|
let json = serde_json::to_string_pretty(&gs).unwrap();
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes()).unwrap();
|
||||||
|
fs::rename(&tmp, &path).unwrap();
|
||||||
|
|
||||||
|
assert!(load_game_state_from(&path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_game_state_removes_file() {
|
fn delete_game_state_removes_file() {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
let path = gs_path("delete");
|
let path = gs_path("delete");
|
||||||
let gs = GameState::new(1, DrawStockConfig::DrawOne);
|
let gs = GameState::new(1, DrawMode::DrawOne);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
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");
|
||||||
@@ -439,9 +463,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_game_state_is_atomic() {
|
fn save_game_state_is_atomic() {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
let path = gs_path("atomic");
|
let path = gs_path("atomic");
|
||||||
let gs = GameState::new(55, DrawStockConfig::DrawThree);
|
let gs = GameState::new(55, DrawMode::DrawThree);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
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");
|
||||||
@@ -492,161 +516,6 @@ mod tests {
|
|||||||
assert_eq!(loaded, StatsSnapshot::default());
|
assert_eq!(loaded, StatsSnapshot::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schema v4 serialises the instruction history using upstream
|
|
||||||
/// `KlondikeInstruction` serde (named enum variants). The deserialiser
|
|
||||||
/// replays all `saved_moves` to reconstruct every pile.
|
|
||||||
///
|
|
||||||
/// A fresh-game test (zero moves) never exercises that replay path, so this
|
|
||||||
/// test plays several real moves — including an undo — before saving.
|
|
||||||
///
|
|
||||||
/// Since schema v5 no longer persists `score`/`undo_count`/`recycle_count`
|
|
||||||
/// (they are derived from the replayed session stats), round-trip fidelity is
|
|
||||||
/// verified by **re-save idempotency**: reloading the save and serialising it
|
|
||||||
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
|
||||||
/// to 0 on load because only the forward instruction history is persisted.
|
|
||||||
#[test]
|
|
||||||
fn game_state_v5_mid_game_round_trip() {
|
|
||||||
use solitaire_core::KlondikeInstruction;
|
|
||||||
use solitaire_core::game_state::GameState;
|
|
||||||
|
|
||||||
let path = gs_path("v4_mid_game");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
|
|
||||||
// Draw several times to populate the instruction history with
|
|
||||||
// RotateStock entries and expose waste cards for further moves.
|
|
||||||
for _ in 0..6 {
|
|
||||||
if gs.draw().is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the first available DstTableau or DstFoundation move so the
|
|
||||||
// instruction history contains a type other than RotateStock.
|
|
||||||
if let Some(instruction) = gs.possible_instructions().into_iter().find(|i| {
|
|
||||||
matches!(
|
|
||||||
i,
|
|
||||||
KlondikeInstruction::DstTableau(_) | KlondikeInstruction::DstFoundation(_)
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
let _ = gs.apply_instruction(instruction);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo once: verifies that `undo_count` is persisted and that the
|
|
||||||
// truncated history (post-undo) replays back to the correct state.
|
|
||||||
if gs.undo_stack_len() > 0 {
|
|
||||||
let _ = gs.undo();
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
gs.undo_stack_len() > 0,
|
|
||||||
"instruction history must be non-empty (seed 42 always produces draws)",
|
|
||||||
);
|
|
||||||
|
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
|
||||||
|
|
||||||
// Verify the file carries the v5 schema marker.
|
|
||||||
let json = fs::read_to_string(&path).expect("read json");
|
|
||||||
assert!(
|
|
||||||
json.contains("\"schema_version\"") && json.contains('5'),
|
|
||||||
"saved file must use schema version 5",
|
|
||||||
);
|
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path)
|
|
||||||
.expect("a valid in-progress game must load without error");
|
|
||||||
|
|
||||||
// The forward instruction history round-trips, so the reconstructed board
|
|
||||||
// re-serialises to byte-identical JSON.
|
|
||||||
let path_reload = gs_path("v5_mid_game_reload");
|
|
||||||
let _ = fs::remove_file(&path_reload);
|
|
||||||
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(&path).expect("read original save"),
|
|
||||||
fs::read_to_string(&path_reload).expect("read re-saved"),
|
|
||||||
"re-saving the loaded game must reproduce the original save exactly",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Derived board reads match the live game (move count + recycle count are
|
|
||||||
// both rebuilt from the replayed forward history).
|
|
||||||
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
|
|
||||||
assert_eq!(
|
|
||||||
loaded.recycle_count(),
|
|
||||||
gs.recycle_count(),
|
|
||||||
"recycle_count round-trips",
|
|
||||||
);
|
|
||||||
// undo_count is intentionally not persisted: it resets to 0 on load.
|
|
||||||
assert_eq!(
|
|
||||||
loaded.undo_count(),
|
|
||||||
0,
|
|
||||||
"undo_count resets across save/load under schema v5",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A schema v3 save (instruction history using the old u8-index mirror
|
|
||||||
/// types) is no longer loadable. The legacy migration path was dropped,
|
|
||||||
/// so any file claiming `schema_version: 3` must be rejected and the
|
|
||||||
/// player started on a fresh game.
|
|
||||||
#[test]
|
|
||||||
fn game_state_v3_is_rejected() {
|
|
||||||
let path = gs_path("v3_reject");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
|
|
||||||
let v3_json = r#"{
|
|
||||||
"draw_mode": "DrawOne",
|
|
||||||
"mode": "Classic",
|
|
||||||
"score": 0,
|
|
||||||
"elapsed_seconds": 0,
|
|
||||||
"seed": 42,
|
|
||||||
"undo_count": 0,
|
|
||||||
"recycle_count": 0,
|
|
||||||
"take_from_foundation": true,
|
|
||||||
"schema_version": 3,
|
|
||||||
"saved_moves": ["RotateStock"]
|
|
||||||
}"#;
|
|
||||||
fs::write(&path, v3_json).expect("write v3 fixture");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
load_game_state_from(&path).is_none(),
|
|
||||||
"schema v3 must be rejected (no migration path)",
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
|
||||||
/// history). Any file claiming `schema_version: 2` must be rejected so
|
|
||||||
/// players upgrading from an older build start with a fresh game rather
|
|
||||||
/// than a half-reconstructed state.
|
|
||||||
#[test]
|
|
||||||
fn save_format_v2_is_rejected() {
|
|
||||||
let path = gs_path("schema_v2");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
// Structurally valid JSON for `PersistedGameState` but with
|
|
||||||
// `schema_version: 2`. The schema-version gate in
|
|
||||||
// `GameState::deserialize` must reject this before replay starts.
|
|
||||||
let v2_json = r#"{
|
|
||||||
"draw_mode": "DrawOne",
|
|
||||||
"mode": "Classic",
|
|
||||||
"score": 0,
|
|
||||||
"elapsed_seconds": 0,
|
|
||||||
"seed": 42,
|
|
||||||
"undo_count": 0,
|
|
||||||
"recycle_count": 0,
|
|
||||||
"take_from_foundation": true,
|
|
||||||
"schema_version": 2,
|
|
||||||
"saved_moves": []
|
|
||||||
}"#;
|
|
||||||
fs::write(&path, v2_json).expect("write v2 fixture");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
load_game_state_from(&path).is_none(),
|
|
||||||
"schema v2 game_state.json must be rejected — player must start a fresh game",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Time Attack session persistence
|
// Time Attack session persistence
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -12,14 +12,10 @@
|
|||||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
|
||||||
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
|
|
||||||
use solitaire_sync::{SyncPayload, SyncResponse};
|
|
||||||
|
|
||||||
use crate::{SyncError, SyncProvider};
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
SyncError, SyncProvider,
|
||||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||||
replay::Replay,
|
replay::Replay,
|
||||||
settings::SyncBackend,
|
settings::SyncBackend,
|
||||||
@@ -58,17 +54,12 @@ impl SyncProvider for LocalOnlyProvider {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SolitaireServerClient
|
// SolitaireServerClient
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Native-only: HTTP sync client and factory function.
|
|
||||||
// On wasm32 these are gated out because reqwest uses native OS networking
|
|
||||||
// (mio + hyper) which does not compile for wasm32-unknown-unknown.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
|
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
|
||||||
///
|
///
|
||||||
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
||||||
/// client automatically attempts a token refresh and retries the request once
|
/// client automatically attempts a token refresh and retries the request once
|
||||||
/// before returning an error.
|
/// before returning an error.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub struct SolitaireServerClient {
|
pub struct SolitaireServerClient {
|
||||||
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||||
/// Trailing slashes are stripped on construction.
|
/// Trailing slashes are stripped on construction.
|
||||||
@@ -79,7 +70,6 @@ pub struct SolitaireServerClient {
|
|||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
impl SolitaireServerClient {
|
impl SolitaireServerClient {
|
||||||
/// Construct a new client for the given server URL and username.
|
/// Construct a new client for the given server URL and username.
|
||||||
///
|
///
|
||||||
@@ -211,7 +201,6 @@ impl SolitaireServerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SyncProvider for SolitaireServerClient {
|
impl SyncProvider for SolitaireServerClient {
|
||||||
/// Fetch the latest sync payload from the server.
|
/// Fetch the latest sync payload from the server.
|
||||||
@@ -497,7 +486,6 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
impl SolitaireServerClient {
|
impl SolitaireServerClient {
|
||||||
/// Pulled out of `push_replay` so both the first attempt and the
|
/// Pulled out of `push_replay` so both the first attempt and the
|
||||||
/// post-401-retry attempt go through the same parse path.
|
/// post-401-retry attempt go through the same parse path.
|
||||||
@@ -593,10 +581,9 @@ impl SolitaireServerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Response extraction helpers (native-only, use reqwest::Response)
|
// Response extraction helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
||||||
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
||||||
///
|
///
|
||||||
@@ -620,7 +607,6 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
|
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
|
||||||
async fn extract_leaderboard_body(
|
async fn extract_leaderboard_body(
|
||||||
resp: reqwest::Response,
|
resp: reqwest::Response,
|
||||||
@@ -635,7 +621,6 @@ async fn extract_leaderboard_body(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
||||||
/// statuses to the appropriate [`SyncError`].
|
/// statuses to the appropriate [`SyncError`].
|
||||||
///
|
///
|
||||||
@@ -667,7 +652,6 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
|
|||||||
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
||||||
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
||||||
/// and remains backend-agnostic.
|
/// and remains backend-agnostic.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||||
match backend {
|
match backend {
|
||||||
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
use solitaire_core::DrawStockConfig;
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
|
||||||
/// 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: DrawStockConfig,
|
pub draw_mode: DrawMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
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 == DrawStockConfig::DrawThree,
|
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ mod tests {
|
|||||||
WeeklyGoalContext {
|
WeeklyGoalContext {
|
||||||
time_seconds: time,
|
time_seconds: time,
|
||||||
used_undo: undo,
|
used_undo: undo,
|
||||||
draw_mode: DrawStockConfig::DrawOne,
|
draw_mode: DrawMode::DrawOne,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ mod tests {
|
|||||||
WeeklyGoalContext {
|
WeeklyGoalContext {
|
||||||
time_seconds: time,
|
time_seconds: time,
|
||||||
used_undo: false,
|
used_undo: false,
|
||||||
draw_mode: DrawStockConfig::DrawThree,
|
draw_mode: DrawMode::DrawThree,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-19
@@ -7,11 +7,14 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = { workspace = true }
|
bevy = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
kira = { workspace = true }
|
||||||
solitaire_core = { workspace = true }
|
solitaire_core = { workspace = true }
|
||||||
solitaire_data = { workspace = true }
|
solitaire_data = { workspace = true }
|
||||||
solitaire_sync = { workspace = true }
|
solitaire_sync = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
@@ -19,24 +22,17 @@ usvg = { workspace = true }
|
|||||||
resvg = { workspace = true }
|
resvg = { workspace = true }
|
||||||
tiny-skia = { workspace = true }
|
tiny-skia = { workspace = true }
|
||||||
ron = { workspace = true }
|
ron = { workspace = true }
|
||||||
|
|
||||||
# These deps are not available / not needed on wasm32:
|
|
||||||
# reqwest — uses mio/hyper native networking (sync plugin is gated out)
|
|
||||||
# kira — uses cpal OS audio (audio plugin is gated out)
|
|
||||||
# tokio — multi-threaded runtime (TokioRuntimeResource is gated out)
|
|
||||||
# dirs — platform data directories (storage uses WasmStorage instead)
|
|
||||||
# zip — theme ZIP importer (importer is gated out on wasm32)
|
|
||||||
# arboard — clipboard (no wasm backend; stats copy-link uses localStorage)
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
kira = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
|
|
||||||
# `arboard` has no Android backend and no wasm32 backend. Gate it out for
|
# `arboard` provides clipboard access for the Stats overlay's
|
||||||
# both; the copy-share-link button surfaces an informational toast instead.
|
# "Copy share link" button. The crate has no Android backend
|
||||||
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
|
# (its `platform::Clipboard` module is unimplemented for the
|
||||||
|
# android target — `cargo apk build` fails with E0433 if this is
|
||||||
|
# left unconditional). On Android the same button surfaces an
|
||||||
|
# informational toast instead; see
|
||||||
|
# `stats_plugin::handle_copy_share_link_button`.
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
arboard = { workspace = true }
|
arboard = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
@@ -51,7 +47,3 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
solitaire_core = { workspace = true, features = ["test-support"] }
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ impl Plugin for AchievementPlugin {
|
|||||||
// achievements-scroll system also runs cleanly under
|
// achievements-scroll system also runs cleanly under
|
||||||
// `MinimalPlugins` in tests.
|
// `MinimalPlugins` in tests.
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
.add_message::<TouchInput>()
|
.add_message::<bevy::input::touch::TouchInput>()
|
||||||
// Run after GameMutation (so GameWonEvent is available), after
|
// Run after GameMutation (so GameWonEvent is available), after
|
||||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||||
@@ -176,9 +176,9 @@ fn evaluate_on_win(
|
|||||||
daily_challenge_streak: progress.0.daily_challenge_streak,
|
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||||
last_win_score: ev.score,
|
last_win_score: ev.score,
|
||||||
last_win_time_seconds: ev.time_seconds,
|
last_win_time_seconds: ev.time_seconds,
|
||||||
last_win_used_undo: game.0.undo_count() > 0,
|
last_win_used_undo: game.0.undo_count > 0,
|
||||||
wall_clock_hour: Some(Local::now().hour()),
|
wall_clock_hour: Some(Local::now().hour()),
|
||||||
last_win_recycle_count: game.0.recycle_count(),
|
last_win_recycle_count: game.0.recycle_count,
|
||||||
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -671,7 +671,7 @@ mod tests {
|
|||||||
.add_plugins(AchievementPlugin::headless());
|
.add_plugins(AchievementPlugin::headless());
|
||||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||||
// MinimalPlugins it isn't auto-registered.
|
// MinimalPlugins it isn't auto-registered.
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -779,7 +779,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.force_test_undos(1);
|
.undo_count = 1;
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1000,
|
score: 1000,
|
||||||
@@ -819,7 +819,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.set_test_draw_mode(DrawStockConfig::DrawThree);
|
.draw_mode = solitaire_core::game_state::DrawMode::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(DrawStockConfig::DrawThree);
|
.draw_mode = solitaire_core::game_state::DrawMode::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 =
|
||||||
GameMode::Zen;
|
solitaire_core::game_state::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,
|
||||||
GameMode::Classic
|
solitaire_core::game_state::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::<ButtonInput<KeyCode>>();
|
app.init_resource::<bevy::input::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::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
use solitaire_data::Replay;
|
use solitaire_data::{Replay, ReplayMove};
|
||||||
|
|
||||||
/// 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,
|
||||||
DrawStockConfig::DrawOne,
|
DrawMode::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![KlondikeInstruction::RotateStock],
|
vec![ReplayMove::StockClick],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,61 +204,3 @@ fn mode_str(mode: GameMode) -> &'static str {
|
|||||||
GameMode::Difficulty(_) => "difficulty",
|
GameMode::Difficulty(_) => "difficulty",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use solitaire_core::game_state::DifficultyLevel;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_for_requires_analytics_opt_in() {
|
|
||||||
let settings = Settings {
|
|
||||||
analytics_enabled: false,
|
|
||||||
matomo_url: Some("https://analytics.example.com".into()),
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(client_for(&settings).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_for_requires_matomo_url() {
|
|
||||||
let settings = Settings {
|
|
||||||
analytics_enabled: true,
|
|
||||||
matomo_url: None,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(client_for(&settings).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_for_creates_client_when_enabled_and_configured() {
|
|
||||||
let settings = Settings {
|
|
||||||
analytics_enabled: true,
|
|
||||||
matomo_url: Some("https://analytics.example.com".into()),
|
|
||||||
matomo_site_id: 2,
|
|
||||||
sync_backend: SyncBackend::SolitaireServer {
|
|
||||||
url: "https://solitaire.example.com".into(),
|
|
||||||
username: "alice".into(),
|
|
||||||
avatar_url: None,
|
|
||||||
},
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(client_for(&settings).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mode_labels_match_analytics_payload_contract() {
|
|
||||||
assert_eq!(mode_str(GameMode::Classic), "classic");
|
|
||||||
assert_eq!(mode_str(GameMode::Zen), "zen");
|
|
||||||
assert_eq!(mode_str(GameMode::Challenge), "challenge");
|
|
||||||
assert_eq!(mode_str(GameMode::TimeAttack), "time_attack");
|
|
||||||
assert_eq!(
|
|
||||||
mode_str(GameMode::Difficulty(DifficultyLevel::Grandmaster)),
|
|
||||||
"difficulty"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
/// 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 safe [`solitaire_data::android_jni`] bridge. Only compiled and
|
/// through the JNI. Only compiled and linked on `target_os = "android"`.
|
||||||
/// 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 jni::objects::JValueOwned;
|
use bevy::android::ANDROID_APP;
|
||||||
use solitaire_data::android_jni;
|
use jni::{
|
||||||
|
JavaVM,
|
||||||
|
objects::{JObject, JValueOwned},
|
||||||
|
};
|
||||||
|
|
||||||
android_jni::with_activity_env(|env, activity| {
|
let app = ANDROID_APP
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||||
|
|
||||||
|
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
|
||||||
|
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||||
|
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||||
|
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread_permanently()
|
||||||
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||||
|
|
||||||
|
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
|
||||||
|
// valid for the lifetime of the process.
|
||||||
|
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
||||||
|
|
||||||
|
(|| -> jni::errors::Result<()> {
|
||||||
// ClipboardManager cm = activity.getSystemService("clipboard")
|
// 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()],
|
||||||
@@ -42,5 +60,6 @@ pub fn set_text(text: &str) -> Result<(), String> {
|
|||||||
&[clip_val.borrow()],
|
&[clip_val.borrow()],
|
||||||
)?
|
)?
|
||||||
.v()
|
.v()
|
||||||
})
|
})()
|
||||||
|
.map_err(|e| format!("clipboard JNI: {e}"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
|
||||||
use solitaire_data::{AnimSpeed, Settings};
|
use solitaire_data::{AnimSpeed, Settings};
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
@@ -181,7 +180,6 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
@@ -354,7 +352,7 @@ fn handle_win_cascade(
|
|||||||
end: target.truncate(),
|
end: target.truncate(),
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration,
|
duration,
|
||||||
curve: MotionCurve::Expressive,
|
curve: crate::card_animation::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,
|
||||||
@@ -1078,7 +1076,7 @@ mod tests {
|
|||||||
// Pairs the existing audio (`card_invalid.wav`) and visual
|
// Pairs the existing audio (`card_invalid.wav`) and visual
|
||||||
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
|
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
|
||||||
// with an accessibility-focused readable text cue.
|
// with an accessibility-focused readable text cue.
|
||||||
use solitaire_core::{KlondikePile, Tableau};
|
use solitaire_core::pile::PileType;
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
@@ -1090,8 +1088,8 @@ mod tests {
|
|||||||
.count();
|
.count();
|
||||||
|
|
||||||
app.world_mut().write_message(MoveRejectedEvent {
|
app.world_mut().write_message(MoveRejectedEvent {
|
||||||
from: KlondikePile::Tableau(Tableau::Tableau1),
|
from: PileType::Tableau(0),
|
||||||
to: KlondikePile::Tableau(Tableau::Tableau2),
|
to: PileType::Tableau(1),
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
//! red/black colour split.
|
//! red/black colour split.
|
||||||
|
|
||||||
use bevy::math::UVec2;
|
use bevy::math::UVec2;
|
||||||
use solitaire_core::{Rank, Suit};
|
use solitaire_core::card::{Rank, Suit};
|
||||||
|
|
||||||
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
||||||
/// `SvgLoaderSettings` resolution).
|
/// `SvgLoaderSettings` resolution).
|
||||||
|
|||||||
@@ -47,16 +47,12 @@
|
|||||||
//! comments on each call out the pairing so a future reader doesn't
|
//! comments on each call out the pairing so a future reader doesn't
|
||||||
//! accidentally drop one half.
|
//! accidentally drop one half.
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use bevy::asset::AssetApp;
|
use bevy::asset::AssetApp;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use bevy::asset::io::AssetSourceBuilder;
|
use bevy::asset::io::AssetSourceBuilder;
|
||||||
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
|
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use bevy::asset::io::file::FileAssetReader;
|
use bevy::asset::io::file::FileAssetReader;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::assets::user_dir::user_theme_dir;
|
use crate::assets::user_dir::user_theme_dir;
|
||||||
|
|
||||||
/// `AssetSourceId` of the user-themes asset source. Use it as
|
/// `AssetSourceId` of the user-themes asset source. Use it as
|
||||||
@@ -115,10 +111,6 @@ 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"),
|
||||||
@@ -176,8 +168,6 @@ 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"),
|
||||||
@@ -245,16 +235,11 @@ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
|
|||||||
/// Returns the `&mut App` so the call can be chained from the binary
|
/// Returns the `&mut App` so the call can be chained from the binary
|
||||||
/// entry point.
|
/// entry point.
|
||||||
pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
|
pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
|
||||||
// User themes are stored on the filesystem; wasm32 has no filesystem and
|
let root = user_theme_dir();
|
||||||
// `FileAssetReader` is not available on that target.
|
app.register_asset_source(
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
USER_THEMES,
|
||||||
{
|
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
||||||
let root = user_theme_dir();
|
);
|
||||||
app.register_asset_source(
|
|
||||||
USER_THEMES,
|
|
||||||
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
|
|||||||
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
||||||
use usvg::FontResolver;
|
use usvg::FontResolver;
|
||||||
|
|
||||||
FontResolver {
|
usvg::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 + Serialize + serde::de::DeserializeOwned>() {}
|
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
|
||||||
assert_loader_settings::<SvgLoaderSettings>();
|
assert_loader_settings::<SvgLoaderSettings>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,23 +82,13 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
|||||||
/// the panic message names the supported workaround.
|
/// the panic message names the supported workaround.
|
||||||
fn detected_platform_data_dir() -> PathBuf {
|
fn detected_platform_data_dir() -> PathBuf {
|
||||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||||
// On wasm32, data_dir() always returns None — there is no filesystem.
|
panic!(
|
||||||
// User themes are not supported in the browser build; return an empty
|
"user_theme_dir(): platform data directory is unavailable. \
|
||||||
// path so callers produce a benign empty dir rather than panicking.
|
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||||
#[cfg(target_arch = "wasm32")]
|
the OS reported no Application Support / AppData path. \
|
||||||
{
|
As a workaround call solitaire_engine::assets::user_dir::\
|
||||||
PathBuf::new()
|
set_user_theme_dir() before App::run()."
|
||||||
}
|
)
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
panic!(
|
|
||||||
"user_theme_dir(): platform data directory is unavailable. \
|
|
||||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
|
||||||
the OS reported no Application Support / AppData path. \
|
|
||||||
As a workaround call solitaire_engine::assets::user_dir::\
|
|
||||||
set_user_theme_dir() before App::run()."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use crate::events::{
|
|||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||||
const RECYCLE_VOLUME: f64 = 0.5;
|
const RECYCLE_VOLUME: f64 = 0.5;
|
||||||
@@ -373,7 +374,10 @@ fn play_on_draw(
|
|||||||
// When the stock pile is empty the draw action recycles the waste pile
|
// When the stock pile is empty the draw action recycles the waste pile
|
||||||
// back to stock. Play the flip sound at half volume to give audible
|
// back to stock. Play the flip sound at half volume to give audible
|
||||||
// feedback that distinguishes a recycle from a normal draw.
|
// feedback that distinguishes a recycle from a normal draw.
|
||||||
let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
|
let stock_len = game
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|g| g.0.piles.get(&PileType::Stock))
|
||||||
|
.map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound
|
||||||
|
|
||||||
if is_recycle(stock_len) {
|
if is_recycle(stock_len) {
|
||||||
let mut data = lib.flip.clone();
|
let mut data = lib.flip.clone();
|
||||||
|
|||||||
@@ -9,9 +9,7 @@
|
|||||||
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
@@ -22,18 +20,11 @@ use crate::resources::GameStateResource;
|
|||||||
///
|
///
|
||||||
/// Plays the win fanfare at half volume so it is clearly distinguishable from
|
/// Plays the win fanfare at half volume so it is clearly distinguishable from
|
||||||
/// both normal card-place sounds and the full win fanfare that fires later.
|
/// both normal card-place sounds and the full win fanfare that fires later.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
|
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
|
||||||
|
|
||||||
/// Seconds between consecutive auto-complete moves.
|
/// Seconds between consecutive auto-complete moves.
|
||||||
const STEP_INTERVAL: f32 = 0.12;
|
const STEP_INTERVAL: f32 = 0.12;
|
||||||
|
|
||||||
/// Seconds to wait after detection before firing the first auto-complete move.
|
|
||||||
///
|
|
||||||
/// This pause gives the player a moment to register that the game is
|
|
||||||
/// transitioning into auto-complete mode before cards start moving.
|
|
||||||
const AUTO_COMPLETE_INITIAL_DELAY: f32 = 0.75;
|
|
||||||
|
|
||||||
/// Tracks whether auto-complete is active and when the next move fires.
|
/// Tracks whether auto-complete is active and when the next move fires.
|
||||||
#[derive(Resource, Default, Debug)]
|
#[derive(Resource, Default, Debug)]
|
||||||
pub struct AutoCompleteState {
|
pub struct AutoCompleteState {
|
||||||
@@ -48,18 +39,16 @@ pub struct AutoCompletePlugin;
|
|||||||
|
|
||||||
impl Plugin for AutoCompletePlugin {
|
impl Plugin for AutoCompletePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<AutoCompleteState>()
|
app.init_resource::<AutoCompleteState>().add_systems(
|
||||||
.add_message::<RequestRedraw>()
|
Update,
|
||||||
.add_systems(
|
(
|
||||||
Update,
|
detect_auto_complete,
|
||||||
(
|
on_auto_complete_start,
|
||||||
detect_auto_complete,
|
drive_auto_complete,
|
||||||
on_auto_complete_start,
|
)
|
||||||
drive_auto_complete,
|
.chain()
|
||||||
)
|
.after(GameMutation),
|
||||||
.chain()
|
);
|
||||||
.after(GameMutation),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,28 +65,21 @@ fn detect_auto_complete(
|
|||||||
}
|
}
|
||||||
changed.clear();
|
changed.clear();
|
||||||
|
|
||||||
if game.0.is_won() {
|
if game.0.is_won {
|
||||||
state.active = false;
|
state.active = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if game.0.is_auto_completable() && !state.active {
|
if game.0.is_auto_completable && !state.active {
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
state.cooldown = 0.0; // fire first move immediately
|
||||||
} else if !game.0.is_auto_completable() && state.active {
|
|
||||||
// `is_auto_completable` only becomes false after an explicit undo
|
|
||||||
// (which puts a card back on the tableau or re-fills the stock/waste)
|
|
||||||
// or a new-game reset — never as a transient gap during a normal
|
|
||||||
// auto-complete sequence. Deactivate here so `drive_auto_complete`
|
|
||||||
// does not keep retrying indefinitely after the player undoes out of
|
|
||||||
// the sequence.
|
|
||||||
//
|
|
||||||
// Note: the transient-`None` case mentioned in older versions of this
|
|
||||||
// comment referred to `next_auto_complete_move()` returning `None`, not
|
|
||||||
// to `is_auto_completable` being false. Those are independent fields;
|
|
||||||
// `drive_auto_complete` still retries on a transient `None` return from
|
|
||||||
// `next_auto_complete_move` because that check happens there, not here.
|
|
||||||
state.active = false;
|
|
||||||
}
|
}
|
||||||
|
// Intentionally no `else if !is_auto_completable` branch here.
|
||||||
|
// Deactivating on every frame where `is_auto_completable` is false
|
||||||
|
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
|
||||||
|
// transiently returns `None` (e.g. while the previous move is still
|
||||||
|
// in-flight). The `is_won` check above already handles the definitive
|
||||||
|
// end-of-game case; `drive_auto_complete` simply retries next tick
|
||||||
|
// when no move is available yet.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays a distinct chime the moment auto-complete first activates.
|
/// Plays a distinct chime the moment auto-complete first activates.
|
||||||
@@ -106,7 +88,6 @@ fn detect_auto_complete(
|
|||||||
/// exactly once on the `false → true` edge. The win fanfare is played at half
|
/// exactly once on the `false → true` edge. The win fanfare is played at half
|
||||||
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
|
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
|
||||||
/// not overwhelm the card-place sounds that follow immediately.
|
/// not overwhelm the card-place sounds that follow immediately.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
fn on_auto_complete_start(
|
fn on_auto_complete_start(
|
||||||
state: Res<AutoCompleteState>,
|
state: Res<AutoCompleteState>,
|
||||||
mut was_active: Local<bool>,
|
mut was_active: Local<bool>,
|
||||||
@@ -127,12 +108,6 @@ fn on_auto_complete_start(
|
|||||||
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
|
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No audio on wasm — stub keeps the system registration unconditional.
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn on_auto_complete_start(state: Res<AutoCompleteState>, mut was_active: Local<bool>) {
|
|
||||||
*was_active = state.active;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
||||||
fn drive_auto_complete(
|
fn drive_auto_complete(
|
||||||
mut state: ResMut<AutoCompleteState>,
|
mut state: ResMut<AutoCompleteState>,
|
||||||
@@ -167,9 +142,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::{Deck, Rank, Suit};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -177,45 +152,36 @@ mod tests {
|
|||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(AutoCompletePlugin);
|
.add_plugins(AutoCompletePlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
/// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other
|
||||||
let mut g = GameState::new(1, DrawStockConfig::DrawOne);
|
/// tableau piles empty, stock/waste empty, Clubs foundation empty.
|
||||||
g.set_test_stock_cards(Vec::new());
|
fn nearly_won_state() -> GameState {
|
||||||
g.set_test_waste_cards(Vec::new());
|
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||||
for foundation in [
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
Foundation::Foundation1,
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
Foundation::Foundation2,
|
for i in 0..7 {
|
||||||
Foundation::Foundation3,
|
g.piles
|
||||||
Foundation::Foundation4,
|
.get_mut(&PileType::Tableau(i))
|
||||||
] {
|
.unwrap()
|
||||||
g.set_test_foundation_cards(foundation, Vec::new());
|
.cards
|
||||||
|
.clear();
|
||||||
}
|
}
|
||||||
for tableau in [
|
g.piles
|
||||||
Tableau::Tableau1,
|
.get_mut(&PileType::Tableau(0))
|
||||||
Tableau::Tableau2,
|
.unwrap()
|
||||||
Tableau::Tableau3,
|
.cards
|
||||||
Tableau::Tableau4,
|
.push(Card {
|
||||||
Tableau::Tableau5,
|
id: 99,
|
||||||
Tableau::Tableau6,
|
suit: Suit::Clubs,
|
||||||
Tableau::Tableau7,
|
rank: Rank::Ace,
|
||||||
] {
|
face_up: true,
|
||||||
g.set_test_tableau_cards(tableau, Vec::new());
|
});
|
||||||
}
|
g.is_auto_completable = true;
|
||||||
g.set_test_tableau_cards(
|
g
|
||||||
Tableau::Tableau1,
|
|
||||||
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
|
||||||
);
|
|
||||||
g.set_test_auto_completable(true);
|
|
||||||
let expected = (
|
|
||||||
KlondikePile::Tableau(Tableau::Tableau1),
|
|
||||||
KlondikePile::Foundation(Foundation::Foundation1),
|
|
||||||
);
|
|
||||||
assert_eq!(g.next_auto_complete_move(), Some(expected));
|
|
||||||
(g, expected)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -227,9 +193,8 @@ 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, DrawStockConfig::DrawOne);
|
// Install a nearly-won state and fire StateChangedEvent.
|
||||||
g.set_test_auto_completable(true);
|
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
|
||||||
app.world_mut().write_message(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -239,14 +204,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn drive_fires_move_request_when_active() {
|
fn drive_fires_move_request_when_active() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
|
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
|
||||||
app.world_mut().write_message(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update(); // detect runs, sets active
|
app.update(); // detect runs, sets active
|
||||||
|
|
||||||
// Zero out the cooldown so drive fires on the next update regardless
|
|
||||||
// of the initial delay constant.
|
|
||||||
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
|
|
||||||
app.update(); // drive fires the move
|
app.update(); // drive fires the move
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||||
@@ -254,16 +214,17 @@ mod tests {
|
|||||||
let fired: Vec<_> = cursor.read(events).collect();
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
// At least one MoveRequestEvent should have been fired.
|
// At least one MoveRequestEvent should have been fired.
|
||||||
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
||||||
assert_eq!(fired[0].from, expected_from);
|
assert_eq!(fired[0].from, PileType::Tableau(0));
|
||||||
assert_eq!(fired[0].to, expected_to);
|
// First empty foundation slot wins on a fresh nearly-won board.
|
||||||
|
assert_eq!(fired[0].to, PileType::Foundation(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drive_deactivates_on_win() {
|
fn drive_deactivates_on_win() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Inject a won game state — active should not be set.
|
// Inject a won game state — active should not be set.
|
||||||
let (mut gs, _) = seeded_state_with_auto_move();
|
let mut gs = nearly_won_state();
|
||||||
gs.set_test_won(true);
|
gs.is_won = true;
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
app.world_mut().write_message(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ pub struct AvatarFetchEvent {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message for AvatarFetchEvent {}
|
impl bevy::prelude::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,6 @@ use std::collections::VecDeque;
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_core::Card;
|
|
||||||
|
|
||||||
use super::animation::CardAnimation;
|
use super::animation::CardAnimation;
|
||||||
use super::tuning::AnimationTuning;
|
use super::tuning::AnimationTuning;
|
||||||
@@ -73,7 +72,7 @@ pub struct HoverState {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BufferedInput {
|
pub enum BufferedInput {
|
||||||
Move {
|
Move {
|
||||||
from: MoveRequestEvent,
|
from: crate::events::MoveRequestEvent,
|
||||||
},
|
},
|
||||||
Draw,
|
Draw,
|
||||||
Undo,
|
Undo,
|
||||||
@@ -211,12 +210,12 @@ pub(crate) fn apply_drag_visual(
|
|||||||
|
|
||||||
// Only lift cards that are in a *committed* drag. Pending drags (below
|
// Only lift cards that are in a *committed* drag. Pending drags (below
|
||||||
// threshold) must stay at scale 1.0 to avoid visible premature lift.
|
// threshold) must stay at scale 1.0 to avoid visible premature lift.
|
||||||
let (dragged_cards, committed): (&[Card], bool) = drag
|
let (dragged_ids, committed): (&[u32], bool) = drag
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
|
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
|
||||||
|
|
||||||
for (_, card, mut transform) in &mut cards {
|
for (_, card, mut transform) in &mut cards {
|
||||||
let is_active_drag = committed && dragged_cards.contains(&card.card);
|
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
|
||||||
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
|
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
|
||||||
let current = transform.scale.x;
|
let current = transform.scale.x;
|
||||||
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ pub use timing::{
|
|||||||
pub use tuning::{AnimationTuning, InputPlatform};
|
pub use tuning::{AnimationTuning, InputPlatform};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||||
@@ -126,7 +125,6 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
.add_message::<DrawRequestEvent>()
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
|
||||||
.init_resource::<DragState>()
|
.init_resource::<DragState>()
|
||||||
.init_resource::<HoverState>()
|
.init_resource::<HoverState>()
|
||||||
.init_resource::<InputBuffer>()
|
.init_resource::<InputBuffer>()
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ impl AnimationTuning {
|
|||||||
platform: InputPlatform::Mouse,
|
platform: InputPlatform::Mouse,
|
||||||
duration_scale: 1.0,
|
duration_scale: 1.0,
|
||||||
overshoot_scale: 1.0,
|
overshoot_scale: 1.0,
|
||||||
drag_threshold_px: 6.0,
|
drag_threshold_px: 4.0,
|
||||||
drag_scale: 1.08,
|
drag_scale: 1.08,
|
||||||
hover_scale: 1.04,
|
hover_scale: 1.04,
|
||||||
hover_lerp_speed: 14.0,
|
hover_lerp_speed: 14.0,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::game_state::{DrawMode, 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, DrawStockConfig::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawMode::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, DrawStockConfig::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -13,18 +13,16 @@ use crate::platform::{
|
|||||||
default_storage_backend,
|
default_storage_backend,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin,
|
||||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
|
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||||
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
|
SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider,
|
||||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||||
};
|
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
WinSummaryPlugin,
|
||||||
use crate::{
|
|
||||||
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||||
@@ -47,7 +45,6 @@ impl Plugin for CoreGamePlugin {
|
|||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
};
|
};
|
||||||
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
|
|
||||||
let sync_provider = sync_provider
|
let sync_provider = sync_provider
|
||||||
.take()
|
.take()
|
||||||
.expect("CoreGamePlugin::build called twice");
|
.expect("CoreGamePlugin::build called twice");
|
||||||
@@ -107,26 +104,21 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(HomePlugin::default())
|
.add_plugins(HomePlugin::default())
|
||||||
|
.add_plugins(AvatarPlugin)
|
||||||
.add_plugins(ProfilePlugin)
|
.add_plugins(ProfilePlugin)
|
||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
|
.add_plugins(AudioPlugin)
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
|
.add_plugins(SyncSetupPlugin)
|
||||||
|
.add_plugins(AnalyticsPlugin)
|
||||||
|
.add_plugins(LeaderboardPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.add_plugins(UiFocusPlugin)
|
.add_plugins(UiFocusPlugin)
|
||||||
.add_plugins(UiTooltipPlugin)
|
.add_plugins(UiTooltipPlugin)
|
||||||
.add_plugins(SplashPlugin)
|
.add_plugins(SplashPlugin)
|
||||||
.add_plugins(DiagnosticsHudPlugin);
|
.add_plugins(DiagnosticsHudPlugin);
|
||||||
|
|
||||||
// Plugins that use kira/cpal audio or multi-threaded Tokio are not
|
|
||||||
// compatible with the single-threaded wasm32 runtime. Gate them out
|
|
||||||
// so the browser build boots silently and without a sync backend.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
app.add_plugins(AvatarPlugin)
|
|
||||||
.add_plugins(AudioPlugin)
|
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
|
||||||
.add_plugins(SyncSetupPlugin)
|
|
||||||
.add_plugins(AnalyticsPlugin)
|
|
||||||
.add_plugins(LeaderboardPlugin);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||||
use solitaire_core::Card;
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::card_plugin::RightClickHighlight;
|
use crate::card_plugin::RightClickHighlight;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
@@ -66,10 +66,10 @@ const MARKER_VALID: Color = Color::srgba(0.675, 0.761, 0.404, 0.55);
|
|||||||
|
|
||||||
/// Marker component on a parent entity that owns one drop-target overlay
|
/// Marker component on a parent entity that owns one drop-target overlay
|
||||||
/// (a translucent fill plus four outline edges as children). The wrapped
|
/// (a translucent fill plus four outline edges as children). The wrapped
|
||||||
/// `KlondikePile` identifies which pile this overlay highlights, so test
|
/// `PileType` identifies which pile this overlay highlights, so test
|
||||||
/// queries and the despawn-on-target-change logic can filter by pile.
|
/// queries and the despawn-on-target-change logic can filter by pile.
|
||||||
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DropTargetOverlay(pub KlondikePile);
|
pub struct DropTargetOverlay(pub PileType);
|
||||||
|
|
||||||
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
||||||
pub struct CursorPlugin;
|
pub struct CursorPlugin;
|
||||||
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_cursor_icon,
|
update_cursor_icon,
|
||||||
update_drop_highlights.run_if(resource_changed::<DragState>),
|
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
|
||||||
update_drop_target_overlays,
|
update_drop_target_overlays,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -163,34 +163,33 @@ fn update_cursor_icon(
|
|||||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||||
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
||||||
let piles = [
|
let piles = [
|
||||||
KlondikePile::Stock,
|
PileType::Waste,
|
||||||
KlondikePile::Foundation(Foundation::Foundation1),
|
PileType::Foundation(0),
|
||||||
KlondikePile::Foundation(Foundation::Foundation2),
|
PileType::Foundation(1),
|
||||||
KlondikePile::Foundation(Foundation::Foundation3),
|
PileType::Foundation(2),
|
||||||
KlondikePile::Foundation(Foundation::Foundation4),
|
PileType::Foundation(3),
|
||||||
KlondikePile::Tableau(Tableau::Tableau1),
|
PileType::Tableau(0),
|
||||||
KlondikePile::Tableau(Tableau::Tableau2),
|
PileType::Tableau(1),
|
||||||
KlondikePile::Tableau(Tableau::Tableau3),
|
PileType::Tableau(2),
|
||||||
KlondikePile::Tableau(Tableau::Tableau4),
|
PileType::Tableau(3),
|
||||||
KlondikePile::Tableau(Tableau::Tableau5),
|
PileType::Tableau(4),
|
||||||
KlondikePile::Tableau(Tableau::Tableau6),
|
PileType::Tableau(5),
|
||||||
KlondikePile::Tableau(Tableau::Tableau7),
|
PileType::Tableau(6),
|
||||||
];
|
];
|
||||||
|
|
||||||
for pile in piles {
|
for pile in piles {
|
||||||
let pile_cards = pile_cards(game, &pile);
|
let Some(pile_cards) = game.piles.get(&pile) else {
|
||||||
if pile_cards.is_empty() {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
|
let is_tableau = matches!(pile, PileType::Tableau(_));
|
||||||
let base = layout.pile_positions[&pile];
|
let base = layout.pile_positions[&pile];
|
||||||
|
|
||||||
for (i, card) in pile_cards.iter().enumerate().rev() {
|
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
|
||||||
if !card.1 {
|
if !card.face_up {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Only the topmost card is draggable on non-tableau piles.
|
// Only the topmost card is draggable on non-tableau piles.
|
||||||
if !is_tableau && i != pile_cards.len() - 1 {
|
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
|
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
|
||||||
@@ -227,14 +226,38 @@ fn update_drop_highlights(
|
|||||||
|
|
||||||
let Some(game) = game else { return };
|
let Some(game) = game else { return };
|
||||||
|
|
||||||
let drag_count = drag.cards.len();
|
// The first element of drag.cards is the bottom card that lands on the target.
|
||||||
|
let Some(&bottom_id) = drag.cards.first() else {
|
||||||
let Some(origin) = drag.origin_pile.as_ref() else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let bottom_card = game
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.values()
|
||||||
|
.flat_map(|p| p.cards.iter())
|
||||||
|
.find(|c| c.id == bottom_id)
|
||||||
|
.cloned();
|
||||||
|
let Some(bottom_card) = bottom_card else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let drag_count = drag.cards.len();
|
||||||
|
|
||||||
for (marker, mut sprite, _rch) in &mut markers {
|
for (marker, mut sprite, _rch) in &mut markers {
|
||||||
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
|
let valid = match &marker.0 {
|
||||||
|
PileType::Foundation(slot) => {
|
||||||
|
if drag_count != 1 {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
let pile = game.0.piles.get(&PileType::Foundation(*slot));
|
||||||
|
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PileType::Tableau(idx) => {
|
||||||
|
let pile = game.0.piles.get(&PileType::Tableau(*idx));
|
||||||
|
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,7 +297,20 @@ fn update_drop_target_overlays(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(origin) = drag.origin_pile.as_ref() else {
|
// Resolve the bottom card of the dragged stack — same logic as
|
||||||
|
// `update_drop_highlights` so rules can't drift between the marker
|
||||||
|
// tint and the overlay.
|
||||||
|
let Some(&bottom_id) = drag.cards.first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bottom_card = game
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.values()
|
||||||
|
.flat_map(|p| p.cards.iter())
|
||||||
|
.find(|c| c.id == bottom_id)
|
||||||
|
.cloned();
|
||||||
|
let Some(bottom_card) = bottom_card else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let drag_count = drag.cards.len();
|
let drag_count = drag.cards.len();
|
||||||
@@ -282,24 +318,44 @@ fn update_drop_target_overlays(
|
|||||||
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
||||||
// Waste are excluded because they are never legal drop targets.
|
// Waste are excluded because they are never legal drop targets.
|
||||||
let candidates = [
|
let candidates = [
|
||||||
KlondikePile::Foundation(Foundation::Foundation1),
|
PileType::Foundation(0),
|
||||||
KlondikePile::Foundation(Foundation::Foundation2),
|
PileType::Foundation(1),
|
||||||
KlondikePile::Foundation(Foundation::Foundation3),
|
PileType::Foundation(2),
|
||||||
KlondikePile::Foundation(Foundation::Foundation4),
|
PileType::Foundation(3),
|
||||||
KlondikePile::Tableau(Tableau::Tableau1),
|
PileType::Tableau(0),
|
||||||
KlondikePile::Tableau(Tableau::Tableau2),
|
PileType::Tableau(1),
|
||||||
KlondikePile::Tableau(Tableau::Tableau3),
|
PileType::Tableau(2),
|
||||||
KlondikePile::Tableau(Tableau::Tableau4),
|
PileType::Tableau(3),
|
||||||
KlondikePile::Tableau(Tableau::Tableau5),
|
PileType::Tableau(4),
|
||||||
KlondikePile::Tableau(Tableau::Tableau6),
|
PileType::Tableau(5),
|
||||||
KlondikePile::Tableau(Tableau::Tableau7),
|
PileType::Tableau(6),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Compute the new set of valid piles for this frame.
|
// Compute the new set of valid piles for this frame.
|
||||||
let mut valid: Vec<KlondikePile> = Vec::new();
|
let mut valid: Vec<PileType> = Vec::new();
|
||||||
for pile in &candidates {
|
for pile in &candidates {
|
||||||
if game.0.can_move_cards(origin, pile, drag_count) {
|
let is_valid = match pile {
|
||||||
valid.push(*pile);
|
PileType::Foundation(_) => {
|
||||||
|
if drag_count != 1 {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
game.0
|
||||||
|
.piles
|
||||||
|
.get(pile)
|
||||||
|
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PileType::Tableau(_) => game
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get(pile)
|
||||||
|
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
// Don't highlight the origin pile — dropping onto the source is
|
||||||
|
// a no-op.
|
||||||
|
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
|
||||||
|
valid.push(pile.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,9 +367,9 @@ fn update_drop_target_overlays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn overlays for piles that are now valid but don't yet have one.
|
// Spawn overlays for piles that are now valid but don't yet have one.
|
||||||
let already_overlaid: Vec<KlondikePile> = overlays
|
let already_overlaid: Vec<PileType> = overlays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, m)| m.0)
|
.map(|(_, m)| m.0.clone())
|
||||||
.filter(|p| valid.contains(p))
|
.filter(|p| valid.contains(p))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -332,14 +388,10 @@ fn update_drop_target_overlays(
|
|||||||
/// for everything else it is card-sized. Replicated here rather than
|
/// for everything else it is card-sized. Replicated here rather than
|
||||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||||
/// this overlay is the only other consumer.
|
/// this overlay is the only other consumer.
|
||||||
fn drop_overlay_rect(
|
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
|
||||||
pile: &KlondikePile,
|
|
||||||
layout: &Layout,
|
|
||||||
game: &GameState,
|
|
||||||
) -> Option<(Vec2, Vec2)> {
|
|
||||||
let centre = layout.pile_positions.get(pile).copied()?;
|
let centre = layout.pile_positions.get(pile).copied()?;
|
||||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
if matches!(pile, PileType::Tableau(_)) {
|
||||||
let card_count = game.pile(*pile).len();
|
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||||
if card_count > 1 {
|
if card_count > 1 {
|
||||||
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||||
@@ -360,7 +412,7 @@ fn drop_overlay_rect(
|
|||||||
/// the appropriate world position for `pile`.
|
/// the appropriate world position for `pile`.
|
||||||
fn spawn_drop_target_overlay(
|
fn spawn_drop_target_overlay(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
pile: &KlondikePile,
|
pile: &PileType,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
) {
|
) {
|
||||||
@@ -378,7 +430,7 @@ fn spawn_drop_target_overlay(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
||||||
DropTargetOverlay(*pile),
|
DropTargetOverlay(pile.clone()),
|
||||||
))
|
))
|
||||||
.with_children(|parent| {
|
.with_children(|parent| {
|
||||||
// Top edge.
|
// Top edge.
|
||||||
@@ -427,7 +479,7 @@ fn spawn_drop_target_overlay(
|
|||||||
fn tableau_or_stack_pos(
|
fn tableau_or_stack_pos(
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
pile: &KlondikePile,
|
pile: &PileType,
|
||||||
index: usize,
|
index: usize,
|
||||||
base: Vec2,
|
base: Vec2,
|
||||||
is_tableau: bool,
|
is_tableau: bool,
|
||||||
@@ -437,8 +489,8 @@ fn tableau_or_stack_pos(
|
|||||||
base.x,
|
base.x,
|
||||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||||
)
|
)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
||||||
let pile_len = game.waste_cards().len();
|
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||||
let visible_start = pile_len.saturating_sub(3);
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
let slot = index.saturating_sub(visible_start) as f32;
|
let slot = index.saturating_sub(visible_start) as f32;
|
||||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||||
@@ -447,14 +499,6 @@ fn tableau_or_stack_pos(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|
||||||
if matches!(pile, KlondikePile::Stock) {
|
|
||||||
game.waste_cards()
|
|
||||||
} else {
|
|
||||||
game.pile(*pile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||||
let half = size / 2.0;
|
let half = size / 2.0;
|
||||||
point.x >= center.x - half.x
|
point.x >= center.x - half.x
|
||||||
@@ -563,9 +607,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::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
let game = GameState::new(42, DrawMode::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
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 +624,8 @@ mod tests {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
use solitaire_core::game_state::{DrawMode, 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
|
||||||
@@ -605,8 +649,12 @@ mod tests {
|
|||||||
/// card. Used to make a specific tableau column accept a chosen
|
/// card. Used to make a specific tableau column accept a chosen
|
||||||
/// drag stack.
|
/// drag stack.
|
||||||
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
||||||
let tableau = GameState::tableau_from_index(idx).expect("tableau pile exists");
|
let pile = game
|
||||||
game.set_test_tableau_cards(tableau, vec![card]);
|
.piles
|
||||||
|
.get_mut(&PileType::Tableau(idx))
|
||||||
|
.expect("tableau pile exists");
|
||||||
|
pile.cards.clear();
|
||||||
|
pile.cards.push(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts a single face-up dragged card into the waste pile and
|
/// Inserts a single face-up dragged card into the waste pile and
|
||||||
@@ -616,41 +664,148 @@ mod tests {
|
|||||||
// Place the dragged card on the waste pile (origin).
|
// Place the dragged card on the waste pile (origin).
|
||||||
{
|
{
|
||||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
game.0.set_test_waste_cards(vec![dragged.clone()]);
|
let waste = game
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Waste)
|
||||||
|
.expect("waste pile exists");
|
||||||
|
waste.cards.clear();
|
||||||
|
waste.cards.push(dragged.clone());
|
||||||
}
|
}
|
||||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||||
drag.cards = vec![dragged];
|
drag.cards = vec![dragged.id];
|
||||||
drag.origin_pile = Some(KlondikePile::Stock);
|
drag.origin_pile = Some(PileType::Waste);
|
||||||
drag.committed = true;
|
drag.committed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
|
||||||
|
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
|
||||||
|
// (black, rank 6) — alternating colour, one rank lower → legal.
|
||||||
|
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||||
|
set_tableau_top(
|
||||||
|
&mut game,
|
||||||
|
2,
|
||||||
|
Card {
|
||||||
|
id: 9001,
|
||||||
|
suit: Suit::Spades,
|
||||||
|
rank: Rank::Six,
|
||||||
|
face_up: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let dragged = Card {
|
||||||
|
id: 9002,
|
||||||
|
suit: Suit::Hearts,
|
||||||
|
rank: Rank::Five,
|
||||||
|
face_up: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut app = overlay_test_app(game);
|
||||||
|
begin_drag_with(&mut app, dragged);
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let overlays: Vec<PileType> = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&DropTargetOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.map(|o| o.0.clone())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
overlays.contains(&PileType::Tableau(2)),
|
||||||
|
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||||
// — same colour family, illegal. Tableau(2) must NOT be
|
// — same colour family, illegal. Tableau(2) must NOT be
|
||||||
// highlighted.
|
// highlighted.
|
||||||
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||||
set_tableau_top(
|
set_tableau_top(
|
||||||
&mut game,
|
&mut game,
|
||||||
2,
|
2,
|
||||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
|
Card {
|
||||||
|
id: 9101,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Six,
|
||||||
|
face_up: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
let dragged = Card {
|
||||||
|
id: 9102,
|
||||||
|
suit: Suit::Spades,
|
||||||
|
rank: Rank::Five,
|
||||||
|
face_up: true,
|
||||||
|
};
|
||||||
|
|
||||||
let mut app = overlay_test_app(game);
|
let mut app = overlay_test_app(game);
|
||||||
begin_drag_with(&mut app, dragged);
|
begin_drag_with(&mut app, dragged);
|
||||||
|
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let overlays: Vec<KlondikePile> = app
|
let overlays: Vec<PileType> = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<&DropTargetOverlay>()
|
.query::<&DropTargetOverlay>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.map(|o| o.0)
|
.map(|o| o.0.clone())
|
||||||
.collect();
|
.collect();
|
||||||
assert!(
|
assert!(
|
||||||
!overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)),
|
!overlays.contains(&PileType::Tableau(2)),
|
||||||
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_target_overlays_despawn_on_drag_end() {
|
||||||
|
// Set up a scenario that produces at least one valid overlay,
|
||||||
|
// confirm it spawns, then clear the drag and confirm every
|
||||||
|
// overlay is despawned.
|
||||||
|
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||||
|
set_tableau_top(
|
||||||
|
&mut game,
|
||||||
|
2,
|
||||||
|
Card {
|
||||||
|
id: 9201,
|
||||||
|
suit: Suit::Spades,
|
||||||
|
rank: Rank::Six,
|
||||||
|
face_up: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let dragged = Card {
|
||||||
|
id: 9202,
|
||||||
|
suit: Suit::Hearts,
|
||||||
|
rank: Rank::Five,
|
||||||
|
face_up: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut app = overlay_test_app(game);
|
||||||
|
begin_drag_with(&mut app, dragged);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let count_during_drag = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&DropTargetOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
count_during_drag >= 1,
|
||||||
|
"expected ≥1 overlay during drag, got {count_during_drag}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// End the drag — every overlay should despawn next frame.
|
||||||
|
app.world_mut().resource_mut::<DragState>().clear();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let count_after_drag = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&DropTargetOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
count_after_drag, 0,
|
||||||
|
"all overlays must despawn when the drag ends"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,9 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
||||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use solitaire_sync::ChallengeGoal;
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -27,7 +25,6 @@ use crate::events::{
|
|||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
|
|
||||||
/// Bonus XP awarded for completing today's daily challenge.
|
/// Bonus XP awarded for completing today's daily challenge.
|
||||||
@@ -80,13 +77,8 @@ pub struct DailyChallengeCompletedEvent {
|
|||||||
/// Holds the in-flight server challenge fetch so the result can be polled
|
/// Holds the in-flight server challenge fetch so the result can be polled
|
||||||
/// each frame without blocking the main thread.
|
/// each frame without blocking the main thread.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||||
|
|
||||||
#[derive(Resource, Default)]
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
struct DailyChallengeTask;
|
|
||||||
|
|
||||||
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
|
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
|
||||||
/// already fired for, so the toast spawns at most once per day.
|
/// already fired for, so the toast spawns at most once per day.
|
||||||
///
|
///
|
||||||
@@ -124,21 +116,17 @@ impl Plugin for DailyChallengePlugin {
|
|||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
|
.add_systems(Startup, fetch_server_challenge)
|
||||||
|
.add_systems(Update, poll_server_challenge)
|
||||||
// record/award after the base ProgressUpdate so we don't fight
|
// record/award after the base ProgressUpdate so we don't fight
|
||||||
// ProgressPlugin's add_xp on the same frame.
|
// ProgressPlugin's add_xp on the same frame.
|
||||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||||
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
||||||
.add_systems(Update, check_daily_expiry_warning)
|
.add_systems(Update, check_daily_expiry_warning)
|
||||||
.add_systems(Update, check_date_rollover);
|
.add_systems(Update, check_date_rollover);
|
||||||
|
|
||||||
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
app.add_systems(Startup, fetch_server_challenge)
|
|
||||||
.add_systems(Update, poll_server_challenge);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
/// Startup system: spawns an async task to fetch the server's daily challenge.
|
/// Startup system: spawns an async task to fetch the server's daily challenge.
|
||||||
///
|
///
|
||||||
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
|
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
|
||||||
@@ -154,7 +142,6 @@ fn fetch_server_challenge(
|
|||||||
task_res.0 = Some(task);
|
task_res.0 = Some(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
/// Update system: polls the server-challenge fetch task.
|
/// Update system: polls the server-challenge fetch task.
|
||||||
///
|
///
|
||||||
/// On success, replaces the locally-computed seed in `DailyChallengeResource`
|
/// On success, replaces the locally-computed seed in `DailyChallengeResource`
|
||||||
@@ -354,6 +341,7 @@ fn check_date_rollover(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -362,7 +350,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::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -391,7 +379,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, DrawStockConfig::DrawOne);
|
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -419,7 +407,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), DrawStockConfig::DrawOne);
|
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -442,7 +430,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, DrawStockConfig::DrawOne);
|
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! because the starting position is effectively random (player-chosen timing
|
//! because the starting position is effectively random (player-chosen timing
|
||||||
//! determines which seed in the 40-entry catalog they start at).
|
//! determines which seed in the 40-entry catalog they start at).
|
||||||
|
|
||||||
use chrono::Utc;
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
||||||
@@ -104,9 +104,10 @@ fn handle_difficulty_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn seed_from_system_time() -> u64 {
|
fn seed_from_system_time() -> u64 {
|
||||||
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
|
SystemTime::now()
|
||||||
// std::time::SystemTime panics on wasm32-unknown-unknown).
|
.duration_since(UNIX_EPOCH)
|
||||||
Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Cross-system events used by the engine's plugins.
|
//! Cross-system events used by the engine's plugins.
|
||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::{Card, Suit};
|
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
use solitaire_sync::SyncResponse;
|
use solitaire_sync::SyncResponse;
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ use solitaire_sync::SyncResponse;
|
|||||||
/// consumed by `GamePlugin`.
|
/// consumed by `GamePlugin`.
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct MoveRequestEvent {
|
pub struct MoveRequestEvent {
|
||||||
pub from: KlondikePile,
|
pub from: PileType,
|
||||||
pub to: KlondikePile,
|
pub to: PileType,
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ pub struct StateChangedEvent;
|
|||||||
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct MoveRejectedEvent {
|
pub struct MoveRejectedEvent {
|
||||||
pub from: KlondikePile,
|
pub from: PileType,
|
||||||
pub to: KlondikePile,
|
pub to: PileType,
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fired when a card's face-up state changes during gameplay.
|
/// Fired when a card's face-up state changes during gameplay.
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct CardFlippedEvent(pub Card);
|
pub struct CardFlippedEvent(pub u32);
|
||||||
|
|
||||||
/// Fired by the flip animation at its midpoint — the instant the card face
|
/// Fired by the flip animation at its midpoint — the instant the card face
|
||||||
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
|
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
|
||||||
@@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub Card);
|
|||||||
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
||||||
/// so the flip sound is synchronised with the visual reveal, not the move
|
/// so the flip sound is synchronised with the visual reveal, not the move
|
||||||
/// that triggered the animation.
|
/// that triggered the animation.
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct CardFaceRevealedEvent(pub Card);
|
pub struct CardFaceRevealedEvent(pub u32);
|
||||||
|
|
||||||
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||||
@@ -299,8 +299,8 @@ pub struct ScanThemesRequestEvent;
|
|||||||
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
|
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct HintVisualEvent {
|
pub struct HintVisualEvent {
|
||||||
/// The source card to be highlighted.
|
/// The `Card::id` of the source card to be highlighted.
|
||||||
pub source_card: Card,
|
pub source_card_id: u32,
|
||||||
/// The destination pile whose `PileMarker` should be tinted gold.
|
/// The destination pile whose `PileMarker` should be tinted gold.
|
||||||
pub dest_pile: KlondikePile,
|
pub dest_pile: solitaire_core::pile::PileType,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,10 +42,7 @@ use std::f32::consts::PI;
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_core::Card;
|
|
||||||
use solitaire_core::KlondikePile;
|
|
||||||
use solitaire_core::klondike_adapter::foundation_from_slot;
|
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
@@ -207,7 +204,6 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -247,16 +243,18 @@ fn start_shake_anim(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let dest_pile = &ev.to;
|
let dest_pile = &ev.to;
|
||||||
// Collect the cards that belong to the destination pile.
|
// Collect the card ids that belong to the destination pile.
|
||||||
let dest_cards = pile_cards(&game.0, dest_pile);
|
let Some(pile) = game.0.piles.get(dest_pile) else {
|
||||||
let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect();
|
continue;
|
||||||
|
};
|
||||||
|
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
|
||||||
|
|
||||||
if dest_card_set.is_empty() {
|
if dest_card_ids.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (entity, card_marker, transform) in card_entities.iter() {
|
for (entity, card_marker, transform) in card_entities.iter() {
|
||||||
if dest_card_set.contains(&card_marker.card) {
|
if dest_card_ids.contains(&card_marker.card_id) {
|
||||||
commands.entity(entity).insert(ShakeAnim {
|
commands.entity(entity).insert(ShakeAnim {
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
origin_x: transform.translation.x,
|
origin_x: transform.translation.x,
|
||||||
@@ -313,27 +311,27 @@ fn start_settle_anim(
|
|||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
// Build the list of cards that should bounce this frame from every
|
// Build the list of card ids that should bounce this frame from every
|
||||||
// queued request; multiple events can fire in the same frame (e.g. a move
|
// queued request; multiple events can fire in the same frame (e.g. a move
|
||||||
// followed by a draw via keyboard accelerators).
|
// followed by a draw via keyboard accelerators).
|
||||||
let mut bounce_ids: Vec<Card> = Vec::new();
|
let mut bounce_ids: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
for ev in moves.read() {
|
for ev in moves.read() {
|
||||||
let pile = pile_cards(&game.0, &ev.to);
|
if let Some(pile) = game.0.piles.get(&ev.to) {
|
||||||
if !pile.is_empty() {
|
// The moved cards land on top — take the last `count` ids.
|
||||||
// The moved cards land on top — take the last `count` cards.
|
let n = ev.count.min(pile.cards.len());
|
||||||
let n = ev.count.min(pile.len());
|
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
let start = pile.len() - n;
|
let start = pile.cards.len() - n;
|
||||||
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
|
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if draws.read().next().is_some()
|
if draws.read().next().is_some()
|
||||||
&& let Some((top, _)) = game.0.waste_cards().last()
|
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
|
||||||
|
&& let Some(top) = pile.cards.last()
|
||||||
{
|
{
|
||||||
bounce_ids.push(top.clone());
|
bounce_ids.push(top.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if bounce_ids.is_empty() {
|
if bounce_ids.is_empty() {
|
||||||
@@ -341,7 +339,7 @@ fn start_settle_anim(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (entity, card_marker) in card_entities.iter() {
|
for (entity, card_marker) in card_entities.iter() {
|
||||||
if bounce_ids.contains(&card_marker.card) {
|
if bounce_ids.contains(&card_marker.card_id) {
|
||||||
commands.entity(entity).insert(SettleAnim::default());
|
commands.entity(entity).insert(SettleAnim::default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,11 +393,11 @@ fn start_deal_anim(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Only animate a fresh deal (no moves made yet).
|
// Only animate a fresh deal (no moves made yet).
|
||||||
if game.0.move_count() != 0 {
|
if game.0.move_count != 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(layout) = layout else { return };
|
let Some(layout) = layout else { return };
|
||||||
let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
|
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
||||||
@@ -409,14 +407,10 @@ 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, so the deal feels organic
|
// ±10 % jitter, deterministic per card id, so the deal feels organic
|
||||||
// without losing reproducibility (a given deal produces the same
|
// without losing reproducibility (a given seed still produces the
|
||||||
// per-card stagger pattern across runs). The seed is a hash of the
|
// same per-card stagger pattern across runs).
|
||||||
// card's own identity — no separate numeric id needed.
|
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
|
||||||
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 {
|
||||||
@@ -524,19 +518,21 @@ fn start_foundation_flourish(
|
|||||||
if reduce_motion {
|
if reduce_motion {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(foundation) = foundation_from_slot(ev.slot) else {
|
let pile_type = PileType::Foundation(ev.slot);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let pile_type = KlondikePile::Foundation(foundation);
|
|
||||||
// Top card of the completed foundation is the King.
|
// Top card of the completed foundation is the King.
|
||||||
let cards = game.0.pile(pile_type);
|
let Some(king_id) = game
|
||||||
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
|
.0
|
||||||
|
.piles
|
||||||
|
.get(&pile_type)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
|
.map(|c| c.id)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tag the King's card entity.
|
// Tag the King's card entity.
|
||||||
for (entity, card_marker) in card_entities.iter() {
|
for (entity, card_marker) in card_entities.iter() {
|
||||||
if card_marker.card == king_card {
|
if card_marker.card_id == king_id {
|
||||||
commands.entity(entity).insert(FoundationFlourish {
|
commands.entity(entity).insert(FoundationFlourish {
|
||||||
foundation_slot: ev.slot,
|
foundation_slot: ev.slot,
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
@@ -636,16 +632,6 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pile_cards(
|
|
||||||
game: &solitaire_core::game_state::GameState,
|
|
||||||
pile: &KlondikePile,
|
|
||||||
) -> Vec<(Card, bool)> {
|
|
||||||
match pile {
|
|
||||||
KlondikePile::Stock => game.waste_cards(),
|
|
||||||
_ => game.pile(*pile),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unit tests (pure functions only — no Bevy world required)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -845,14 +831,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn shake_anim_skipped_under_reduce_motion() {
|
fn shake_anim_skipped_under_reduce_motion() {
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::Tableau;
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
||||||
use solitaire_data::Settings;
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -860,25 +845,26 @@ mod tests {
|
|||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Pick a card from Tableau(0) so the event refers to a real pile.
|
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||||
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
|
let dest_pile = PileType::Tableau(0);
|
||||||
let card = app
|
let card_id = app
|
||||||
.world()
|
.world()
|
||||||
.resource::<GameStateResource>()
|
.resource::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.pile(dest_pile)
|
.piles
|
||||||
.last()
|
.get(&dest_pile)
|
||||||
.map(|(c, _)| c.clone())
|
.and_then(|p| p.cards.last())
|
||||||
|
.map(|c| c.id)
|
||||||
.expect("Tableau(0) should have at least one card in a fresh game");
|
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||||
|
|
||||||
// Spawn a minimal CardEntity matching that card so the system would
|
// Spawn a minimal CardEntity matching that id so the system would
|
||||||
// find it and insert ShakeAnim if the gate were absent.
|
// find it and insert ShakeAnim if the gate were absent.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.spawn((CardEntity { card }, Transform::default()));
|
.spawn((CardEntity { card_id }, Transform::default()));
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<Messages<MoveRejectedEvent>>()
|
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||||
.write(MoveRejectedEvent {
|
.write(MoveRejectedEvent {
|
||||||
from: KlondikePile::Stock,
|
from: PileType::Stock,
|
||||||
to: dest_pile,
|
to: dest_pile,
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
@@ -900,13 +886,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::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::game_state::{DrawMode, 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, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::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 +903,7 @@ mod tests {
|
|||||||
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||||
.write(FoundationCompletedEvent {
|
.write(FoundationCompletedEvent {
|
||||||
slot: 0,
|
slot: 0,
|
||||||
suit: solitaire_core::Suit::Spades,
|
suit: solitaire_core::card::Suit::Spades,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! 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 logs a warning and continues with
|
//! validated at startup; a parse failure aborts the program with a clear
|
||||||
//! glyph-less UI rather than aborting, since crashing on a corrupt embed is
|
//! error because it means the binary is corrupt.
|
||||||
//! worse than degraded text.
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ impl Plugin for HelpPlugin {
|
|||||||
// plugin under `DefaultPlugins`; register them explicitly so
|
// plugin under `DefaultPlugins`; register them explicitly so
|
||||||
// scroll systems run cleanly under `MinimalPlugins` in tests.
|
// scroll systems run cleanly under `MinimalPlugins` in tests.
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
.add_message::<TouchInput>()
|
.add_message::<bevy::input::touch::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::{DrawStockConfig, game_state::DifficultyLevel};
|
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||||
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(DrawStockConfig::DrawOne),
|
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::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 {
|
||||||
DrawStockConfig::DrawOne
|
DrawMode::DrawOne
|
||||||
} else {
|
} else {
|
||||||
DrawStockConfig::DrawThree
|
DrawMode::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: DrawStockConfig,
|
draw_mode: DrawMode,
|
||||||
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, DrawStockConfig::DrawOne);
|
let active_one = matches!(ctx.draw_mode, DrawMode::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, Has<Disabled>)>()
|
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.map(|(c, d)| (c.0, d))
|
.map(|(c, d)| (c.0, d))
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,430 +0,0 @@
|
|||||||
//! HUD feedback effects: action-bar fades, score pulses and floaters,
|
|
||||||
//! and streak flourishes.
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Auto-fade state for the action button bar. The bar fades out when
|
|
||||||
/// the cursor is in the play area (below the HUD band) and back in when
|
|
||||||
/// the cursor approaches the top of the window — same UX as a video
|
|
||||||
/// player's auto-hide controls. Buttons remain fully interactive when
|
|
||||||
/// visible; when faded out they're geometrically out of cursor reach
|
|
||||||
/// (hover requires the cursor to be on a button), so no extra
|
|
||||||
/// pointer-events guard is needed.
|
|
||||||
#[derive(Resource, Debug, Clone, Copy)]
|
|
||||||
pub struct HudActionFade {
|
|
||||||
/// Currently displayed alpha. Lerped toward `target` each frame.
|
|
||||||
pub alpha: f32,
|
|
||||||
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
|
|
||||||
pub target: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for HudActionFade {
|
|
||||||
fn default() -> Self {
|
|
||||||
// Start visible so the player sees the controls on first launch
|
|
||||||
// before they've moved the cursor anywhere.
|
|
||||||
Self {
|
|
||||||
alpha: 1.0,
|
|
||||||
target: 1.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
|
|
||||||
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
|
|
||||||
/// cursor approaches, not only when it crosses into the band itself.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
|
||||||
|
|
||||||
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
|
||||||
/// transition — fast enough to feel responsive without flashing on
|
|
||||||
/// brief cursor wanders into the reveal zone.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
|
||||||
|
|
||||||
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
|
||||||
/// the cursor is in the reveal zone (bottom of window) or off-screen
|
|
||||||
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
|
||||||
/// `target` at a fixed rate so the visual transition is smooth across
|
|
||||||
/// variable framerates.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
|
|
||||||
let Ok(window) = windows.single() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let height = window.resolution.height();
|
|
||||||
fade.target = match window.cursor_position() {
|
|
||||||
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
|
|
||||||
Some(_) => 0.0,
|
|
||||||
// Off-window cursor: assume keyboard navigation and keep the
|
|
||||||
// bar visible so Tab cycling doesn't lead to invisible focus.
|
|
||||||
None => 1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let dt = time.delta_secs();
|
|
||||||
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
|
|
||||||
let diff = fade.target - fade.alpha;
|
|
||||||
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applies the current fade alpha to every action button's
|
|
||||||
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
|
|
||||||
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
|
||||||
/// same frame doesn't override the fade with an opaque idle / hover
|
|
||||||
/// colour.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub(super) fn apply_action_fade(
|
|
||||||
fade: Res<HudActionFade>,
|
|
||||||
// Excludes `PopoverRow` so the auto-fade only applies to the
|
|
||||||
// top-level action bar buttons. Popover rows live inside an
|
|
||||||
// explicitly-opened dropdown panel and need to stay visible
|
|
||||||
// regardless of the bar's fade state — without the exclusion
|
|
||||||
// the rows fade to invisible while the popover container stays
|
|
||||||
// visible, leaving a solid background block with no readable
|
|
||||||
// content.
|
|
||||||
mut buttons: Query<
|
|
||||||
(&Children, &mut BackgroundColor),
|
|
||||||
(With<ActionButton>, Without<PopoverRow>),
|
|
||||||
>,
|
|
||||||
mut text_q: Query<&mut TextColor>,
|
|
||||||
) {
|
|
||||||
for (children, mut bg) in &mut buttons {
|
|
||||||
let mut c = bg.0;
|
|
||||||
c.set_alpha(fade.alpha);
|
|
||||||
bg.0 = c;
|
|
||||||
for child in children.iter() {
|
|
||||||
if let Ok(mut tc) = text_q.get_mut(child) {
|
|
||||||
let mut cc = tc.0;
|
|
||||||
cc.set_alpha(fade.alpha);
|
|
||||||
tc.0 = cc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Visual feedback for every action button — paints idle / hover / pressed
|
|
||||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
|
||||||
/// changes. One query covers all action buttons via the shared
|
|
||||||
/// `ActionButton` marker.
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub(super) fn paint_action_buttons(
|
|
||||||
mut buttons: Query<
|
|
||||||
(&Interaction, &mut BackgroundColor),
|
|
||||||
(With<ActionButton>, Changed<Interaction>),
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
for (interaction, mut bg) in &mut buttons {
|
|
||||||
bg.0 = match interaction {
|
|
||||||
Interaction::Pressed => ACTION_BTN_PRESSED,
|
|
||||||
Interaction::Hovered => ACTION_BTN_HOVER,
|
|
||||||
Interaction::None => ACTION_BTN_IDLE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure
|
|
||||||
/// function so the test suite can assert on the curve directly
|
|
||||||
/// without spinning up a Bevy app.
|
|
||||||
///
|
|
||||||
/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`,
|
|
||||||
/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and
|
|
||||||
/// ends at 1.1 instead of 1.0. The corrected form below preserves the
|
|
||||||
/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous
|
|
||||||
/// triangle peaking at 1.1.
|
|
||||||
pub(super) fn score_pulse_scale(t: f32) -> f32 {
|
|
||||||
let clamped = t.clamp(0.0, 1.0);
|
|
||||||
if clamped < 0.5 {
|
|
||||||
1.0 + 0.2 * clamped
|
|
||||||
} else {
|
|
||||||
1.1 - 0.2 * (clamped - 0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vertical pixels the floating "+N" drifts up over its lifetime.
|
|
||||||
const FLOATER_DRIFT_PX: f32 = 40.0;
|
|
||||||
|
|
||||||
/// Diffs the current `GameStateResource.score` against
|
|
||||||
/// [`PreviousScore`]. On a positive delta:
|
|
||||||
///
|
|
||||||
/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity
|
|
||||||
/// so the readout pulses 1.0 → 1.1 → 1.0.
|
|
||||||
/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating
|
|
||||||
/// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score
|
|
||||||
/// readout (see the doc comment on [`ScoreFloater`] for why this is a
|
|
||||||
/// UI Node rather than a `Text2d`).
|
|
||||||
pub(super) fn detect_score_change(
|
|
||||||
game: Res<GameStateResource>,
|
|
||||||
settings: Option<Res<SettingsResource>>,
|
|
||||||
mut prev: ResMut<PreviousScore>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
score_q: Query<Entity, With<HudScore>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let current = game.0.score();
|
|
||||||
let delta = current - prev.0;
|
|
||||||
prev.0 = current;
|
|
||||||
if delta <= 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
|
||||||
if reduce_motion {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let speed = settings
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.0.animation_speed)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed);
|
|
||||||
let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed);
|
|
||||||
|
|
||||||
// Refresh ScorePulse on every score readout entity (in practice
|
|
||||||
// there's exactly one, but iterating is cheaper than asserting).
|
|
||||||
for entity in &score_q {
|
|
||||||
commands.entity(entity).insert(ScorePulse {
|
|
||||||
elapsed: 0.0,
|
|
||||||
duration: pulse_secs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if delta < SCORE_FLOATER_THRESHOLD {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let font = TextFont {
|
|
||||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
// Spawned as an absolutely-positioned UI Node so the floater rides
|
|
||||||
// the same screen-coordinate system as the score readout. Using a
|
|
||||||
// `Text2d` here would require translating UI layout coordinates to
|
|
||||||
// world space every frame; a UI node piggybacks on the same
|
|
||||||
// anchoring `update_hud` already uses for the score and stays
|
|
||||||
// testable under `MinimalPlugins`.
|
|
||||||
commands.spawn((
|
|
||||||
ScoreFloater {
|
|
||||||
elapsed: 0.0,
|
|
||||||
duration: floater_secs,
|
|
||||||
},
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
// Anchored next to the HUD column; matches the
|
|
||||||
// `spawn_hud` left/top offsets so the floater appears
|
|
||||||
// overlaid on the score line and drifts up from there.
|
|
||||||
left: VAL_SPACE_3,
|
|
||||||
top: Val::Px(0.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
ZIndex(Z_HUD_TOP),
|
|
||||||
Text::new(format!("+{delta}")),
|
|
||||||
font,
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advances every [`ScorePulse`], scaling its entity's `Transform`
|
|
||||||
/// using [`score_pulse_scale`]. Removes the component once
|
|
||||||
/// `elapsed >= duration` (or immediately under
|
|
||||||
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is
|
|
||||||
/// 0) and pins the scale back to 1.0 so no float drift survives.
|
|
||||||
pub(super) fn advance_score_pulse(
|
|
||||||
time: Res<Time>,
|
|
||||||
mut commands: Commands,
|
|
||||||
mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
|
|
||||||
) {
|
|
||||||
let dt = time.delta_secs();
|
|
||||||
for (entity, mut pulse, mut transform) in &mut q {
|
|
||||||
let t = if pulse.duration <= 0.0 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
pulse.elapsed += dt;
|
|
||||||
(pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
|
|
||||||
};
|
|
||||||
let scale = score_pulse_scale(t);
|
|
||||||
transform.scale = Vec3::new(scale, scale, 1.0);
|
|
||||||
if t >= 1.0 {
|
|
||||||
transform.scale = Vec3::ONE;
|
|
||||||
commands.entity(entity).remove::<ScorePulse>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advances every [`ScoreFloater`]: drifts the node upward by up to
|
|
||||||
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
|
|
||||||
/// its lifetime. Despawns the entity once `elapsed >= duration`.
|
|
||||||
pub(super) fn advance_score_floater(
|
|
||||||
time: Res<Time>,
|
|
||||||
mut commands: Commands,
|
|
||||||
mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
|
|
||||||
) {
|
|
||||||
let dt = time.delta_secs();
|
|
||||||
for (entity, mut floater, mut node, mut color) in &mut nodes {
|
|
||||||
let t = if floater.duration <= 0.0 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
floater.elapsed += dt;
|
|
||||||
(floater.elapsed / floater.duration).clamp(0.0, 1.0)
|
|
||||||
};
|
|
||||||
// Drift upward: top decreases as t grows. Starting top=0 keeps
|
|
||||||
// the floater on the score line; ending at -FLOATER_DRIFT_PX
|
|
||||||
// pulls it up off the readout.
|
|
||||||
node.top = Val::Px(-FLOATER_DRIFT_PX * t);
|
|
||||||
// Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
|
|
||||||
let mut c = ACCENT_PRIMARY;
|
|
||||||
c.set_alpha(1.0 - t);
|
|
||||||
color.0 = c;
|
|
||||||
if t >= 1.0 {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Streak-milestone flourish
|
|
||||||
//
|
|
||||||
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
|
|
||||||
// completion celebration; the streak flourish is its lifetime equivalent —
|
|
||||||
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
|
|
||||||
// readout pulses larger than a normal score-change pulse and tints magenta
|
|
||||||
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
|
|
||||||
//
|
|
||||||
// Why the score readout: there is no always-on streak number on the HUD
|
|
||||||
// today (the readout lives in the Stats overlay), and the score is the
|
|
||||||
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
|
|
||||||
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
|
|
||||||
// player who isn't watching the score still sees the celebration land.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Pure helper for unit tests — returns the per-frame scale factor for
|
|
||||||
/// the streak flourish at `elapsed_secs` over `duration_secs`.
|
|
||||||
///
|
|
||||||
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
|
|
||||||
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
|
||||||
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
|
|
||||||
/// Out-of-range values are clamped so the score readout never freezes
|
|
||||||
/// at a non-1.0 scale on the frame after the flourish ends.
|
|
||||||
///
|
|
||||||
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
|
|
||||||
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
|
|
||||||
/// without dividing by zero.
|
|
||||||
pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
|
||||||
if duration_secs <= 0.0 {
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
|
|
||||||
let peak = STREAK_FLOURISH_PEAK_SCALE;
|
|
||||||
if t < 0.5 {
|
|
||||||
// Climb from 1.0 at t=0 to peak at t=0.5.
|
|
||||||
1.0 + (peak - 1.0) * (t / 0.5)
|
|
||||||
} else {
|
|
||||||
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
|
|
||||||
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a
|
|
||||||
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
|
|
||||||
/// `TextColor` so `advance_streak_flourish` can restore it when the
|
|
||||||
/// timer expires; reuses any existing flourish's `original_color` so
|
|
||||||
/// re-entering the system mid-flourish doesn't snapshot the magenta
|
|
||||||
/// tint as the new "original".
|
|
||||||
///
|
|
||||||
/// Removes any concurrent [`ScorePulse`] from the same entity so the
|
|
||||||
/// flourish takes over the scale slot cleanly — score pulses last
|
|
||||||
/// 250 ms, the flourish 600 ms, and the streak crossing always
|
|
||||||
/// coincides with a positive score delta, so the flourish is the
|
|
||||||
/// louder of the two celebrations.
|
|
||||||
pub(super) fn start_streak_flourish(
|
|
||||||
mut events: MessageReader<WinStreakMilestoneEvent>,
|
|
||||||
settings: Option<Res<SettingsResource>>,
|
|
||||||
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let Some(latest) = events.read().last() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let speed = settings
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.0.animation_speed)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
|
|
||||||
for (entity, color, existing) in &score_q {
|
|
||||||
let original_color = existing.map_or(color.0, |f| f.original_color);
|
|
||||||
commands
|
|
||||||
.entity(entity)
|
|
||||||
.remove::<ScorePulse>()
|
|
||||||
.insert(StreakFlourish {
|
|
||||||
streak: latest.streak,
|
|
||||||
elapsed: 0.0,
|
|
||||||
duration,
|
|
||||||
original_color,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
|
|
||||||
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
|
|
||||||
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
|
|
||||||
/// `original_color`. Removes the component once `elapsed >= duration`
|
|
||||||
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
|
|
||||||
/// where duration is 0) and pins the scale back to 1.0 / restores the
|
|
||||||
/// original colour so no half-state is ever shown.
|
|
||||||
///
|
|
||||||
/// Filtered with `Without<ScorePulse>` so the streak flourish never
|
|
||||||
/// races a score pulse for the same `Transform.scale` slot —
|
|
||||||
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
|
|
||||||
/// score entity before this system runs, so the filter is purely a
|
|
||||||
/// belt-and-braces invariant.
|
|
||||||
pub(super) fn advance_streak_flourish(
|
|
||||||
time: Res<Time>,
|
|
||||||
mut commands: Commands,
|
|
||||||
mut q: Query<
|
|
||||||
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
|
|
||||||
Without<ScorePulse>,
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
let dt = time.delta_secs();
|
|
||||||
for (entity, mut anim, mut transform, mut color) in &mut q {
|
|
||||||
let t = if anim.duration <= 0.0 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
anim.elapsed += dt;
|
|
||||||
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
|
|
||||||
};
|
|
||||||
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
|
|
||||||
transform.scale = Vec3::new(scale, scale, 1.0);
|
|
||||||
// Tint mix: full magenta at t=0..=0.5, fades back to the
|
|
||||||
// original colour over t=0.5..=1.0.
|
|
||||||
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
|
||||||
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
|
|
||||||
if t >= 1.0 {
|
|
||||||
transform.scale = Vec3::ONE;
|
|
||||||
color.0 = anim.original_color;
|
|
||||||
commands.entity(entity).remove::<StreakFlourish>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// sRGB-space linear interpolation between two `Color`s — small local
|
|
||||||
/// helper so `advance_streak_flourish` stays readable. sRGB-space
|
|
||||||
/// lerping is fine for a brief decorative tint (a perceptually-uniform
|
|
||||||
/// space would be overkill).
|
|
||||||
pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
|
||||||
let from = from.to_srgba();
|
|
||||||
let to = to.to_srgba();
|
|
||||||
let t = t.clamp(0.0, 1.0);
|
|
||||||
Color::srgba(
|
|
||||||
from.red + (to.red - from.red) * t,
|
|
||||||
from.green + (to.green - from.green) * t,
|
|
||||||
from.blue + (to.blue - from.blue) * t,
|
|
||||||
from.alpha + (to.alpha - from.alpha) * t,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,616 +0,0 @@
|
|||||||
//! HUD interaction: action-button handlers, Modes/Menu popovers, and
|
|
||||||
//! the chrome tap-to-toggle gesture.
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
|
||||||
/// interaction state transitions, avoiding repeat events while the button
|
|
||||||
/// is held down. Each click handler fires the corresponding request event,
|
|
||||||
/// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside
|
|
||||||
/// their existing keyboard handlers.
|
|
||||||
pub(super) fn handle_new_game_button(
|
|
||||||
interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>,
|
|
||||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
|
||||||
) {
|
|
||||||
for interaction in &interaction_query {
|
|
||||||
if *interaction == Interaction::Pressed {
|
|
||||||
new_game.write(NewGameRequestEvent::default());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn handle_undo_button(
|
|
||||||
interaction_query: Query<&Interaction, (With<UndoButton>, Changed<Interaction>)>,
|
|
||||||
mut undo: MessageWriter<UndoRequestEvent>,
|
|
||||||
) {
|
|
||||||
for interaction in &interaction_query {
|
|
||||||
if *interaction == Interaction::Pressed {
|
|
||||||
undo.write(UndoRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn handle_pause_button(
|
|
||||||
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
|
||||||
mut pause: MessageWriter<PauseRequestEvent>,
|
|
||||||
) {
|
|
||||||
for interaction in &interaction_query {
|
|
||||||
if *interaction == Interaction::Pressed {
|
|
||||||
pause.write(PauseRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn handle_help_button(
|
|
||||||
interaction_query: Query<&Interaction, (With<HelpButton>, Changed<Interaction>)>,
|
|
||||||
mut help: MessageWriter<HelpRequestEvent>,
|
|
||||||
) {
|
|
||||||
for interaction in &interaction_query {
|
|
||||||
if *interaction == Interaction::Pressed {
|
|
||||||
help.write(HelpRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn handle_hint_button(
|
|
||||||
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
|
|
||||||
paused: Option<Res<PausedResource>>,
|
|
||||||
game: Option<Res<GameStateResource>>,
|
|
||||||
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
|
|
||||||
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
|
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
|
||||||
) {
|
|
||||||
for interaction in &interaction_query {
|
|
||||||
if *interaction != Interaction::Pressed {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if paused.as_ref().is_some_and(|p| p.0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(ref g) = game else { return };
|
|
||||||
if g.0.is_won() {
|
|
||||||
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
|
|
||||||
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
|
|
||||||
/// second click. Mode rows are populated per the player's current level so
|
|
||||||
/// only unlocked options appear.
|
|
||||||
pub(super) fn handle_modes_button(
|
|
||||||
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
|
||||||
popovers: Query<Entity, With<ModesPopover>>,
|
|
||||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
|
||||||
progress: Option<Res<ProgressResource>>,
|
|
||||||
daily: Option<Res<DailyChallengeResource>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
|
||||||
if !pressed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(entity) = popovers.single() {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
for e in &backdrops {
|
|
||||||
commands.entity(e).despawn();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
spawn_modes_popover(
|
|
||||||
&mut commands,
|
|
||||||
progress.as_deref(),
|
|
||||||
daily.as_deref(),
|
|
||||||
font_res.as_deref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the modes popover anchored just below the action bar's right
|
|
||||||
/// edge. Always includes Classic; includes Daily Challenge when a daily
|
|
||||||
/// resource is loaded; includes Zen / Challenge / Time Attack once the
|
|
||||||
/// player reaches the challenge unlock level.
|
|
||||||
pub(super) fn spawn_modes_popover(
|
|
||||||
commands: &mut Commands,
|
|
||||||
progress: Option<&ProgressResource>,
|
|
||||||
daily: Option<&DailyChallengeResource>,
|
|
||||||
font_res: Option<&FontResource>,
|
|
||||||
) {
|
|
||||||
let level = progress.map_or(0, |p| p.0.level);
|
|
||||||
let font = TextFont {
|
|
||||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
||||||
font_size: 15.0,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Each row carries a tooltip alongside its label so hover reveals
|
|
||||||
// a one-line description of what the mode does — mirroring the
|
|
||||||
// tooltips on the action-bar buttons that opened this popover.
|
|
||||||
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
|
|
||||||
ModeOption::Classic,
|
|
||||||
"Classic",
|
|
||||||
"Standard Klondike. Score, timer, and full progression.",
|
|
||||||
)];
|
|
||||||
if daily.is_some() {
|
|
||||||
rows.push((
|
|
||||||
ModeOption::DailyChallenge,
|
|
||||||
"Daily Challenge",
|
|
||||||
"Today's seeded deal. Same for every player worldwide.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if level >= CHALLENGE_UNLOCK_LEVEL {
|
|
||||||
rows.push((
|
|
||||||
ModeOption::Zen,
|
|
||||||
"Zen",
|
|
||||||
"No timer, no score, no penalties. Just play.",
|
|
||||||
));
|
|
||||||
rows.push((
|
|
||||||
ModeOption::Challenge,
|
|
||||||
"Challenge",
|
|
||||||
"Hand-picked hard seeds. No undo allowed.",
|
|
||||||
));
|
|
||||||
rows.push((
|
|
||||||
ModeOption::TimeAttack,
|
|
||||||
"Time Attack",
|
|
||||||
"Win as many games as you can in ten minutes.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Popover opens upward from just above the bottom action bar.
|
|
||||||
// Use a platform-aware offset that clears the bar height + safe-area
|
|
||||||
// gesture zone on Android, and the flat bar height on desktop.
|
|
||||||
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
|
||||||
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
ModesPopover,
|
|
||||||
HudPopoverOpen,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
right: VAL_SPACE_3,
|
|
||||||
bottom: popover_bottom,
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
row_gap: VAL_SPACE_1,
|
|
||||||
padding: UiRect::all(VAL_SPACE_2),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BG_ELEVATED),
|
|
||||||
ZIndex(Z_HUD_POPOVER),
|
|
||||||
))
|
|
||||||
.with_children(|panel| {
|
|
||||||
for (option, label, tooltip) in rows {
|
|
||||||
panel
|
|
||||||
.spawn((
|
|
||||||
option,
|
|
||||||
ActionButton,
|
|
||||||
PopoverRow,
|
|
||||||
Button,
|
|
||||||
Tooltip::new(tooltip),
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
|
||||||
justify_content: JustifyContent::FlexStart,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
min_width: Val::Px(150.0),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACTION_BTN_IDLE),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
|
|
||||||
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
|
|
||||||
commands.spawn((
|
|
||||||
ModesPopoverBackdrop,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
top: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Percent(100.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::NONE),
|
|
||||||
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatches the click on a popover row to the matching request event,
|
|
||||||
/// then despawns the popover.
|
|
||||||
///
|
|
||||||
/// Classic uses [`NewGameRequestEvent`] directly; the other modes use
|
|
||||||
/// their `Start*RequestEvent` so the existing keyboard handler runs
|
|
||||||
/// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) —
|
|
||||||
/// the popover stays a thin entry point and never duplicates that logic.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(super) fn handle_mode_option_click(
|
|
||||||
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
|
||||||
popovers: Query<Entity, With<ModesPopover>>,
|
|
||||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
|
||||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
|
||||||
mut zen: MessageWriter<StartZenRequestEvent>,
|
|
||||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
|
||||||
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
|
||||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let mut clicked_any = false;
|
|
||||||
for (interaction, option) in &interaction_query {
|
|
||||||
if *interaction != Interaction::Pressed {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
clicked_any = true;
|
|
||||||
match option {
|
|
||||||
ModeOption::Classic => {
|
|
||||||
new_game.write(NewGameRequestEvent::default());
|
|
||||||
}
|
|
||||||
ModeOption::DailyChallenge => {
|
|
||||||
daily.write(StartDailyChallengeRequestEvent);
|
|
||||||
}
|
|
||||||
ModeOption::Zen => {
|
|
||||||
zen.write(StartZenRequestEvent);
|
|
||||||
}
|
|
||||||
ModeOption::Challenge => {
|
|
||||||
challenge.write(StartChallengeRequestEvent);
|
|
||||||
}
|
|
||||||
ModeOption::TimeAttack => {
|
|
||||||
time_attack.write(StartTimeAttackRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if clicked_any && let Ok(entity) = popovers.single() {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
for e in &backdrops {
|
|
||||||
commands.entity(e).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
|
|
||||||
/// second click. The popover lists the five overlays previously only
|
|
||||||
/// reachable via the S / A / P / O / L hotkeys.
|
|
||||||
pub(super) fn handle_menu_button(
|
|
||||||
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
|
||||||
popovers: Query<Entity, With<MenuPopover>>,
|
|
||||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
|
||||||
scrims: Query<(), With<ModalScrim>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
|
||||||
if !pressed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(entity) = popovers.single() {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
for e in &backdrops {
|
|
||||||
commands.entity(e).despawn();
|
|
||||||
}
|
|
||||||
} else if scrims.is_empty() {
|
|
||||||
spawn_menu_popover(&mut commands, font_res.as_deref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the menu popover anchored just below the action bar, with one
|
|
||||||
/// row per overlay. Each row dispatches its corresponding
|
|
||||||
/// `Toggle*RequestEvent` so the existing toggle handler runs (and the
|
|
||||||
/// HUD never duplicates spawn / despawn / fetch logic).
|
|
||||||
pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) {
|
|
||||||
let font = TextFont {
|
|
||||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
||||||
font_size: 15.0,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Each row carries a tooltip alongside its label so hover reveals
|
|
||||||
// a one-line description of what each overlay shows — mirroring
|
|
||||||
// the tooltips on the action-bar buttons that opened this popover.
|
|
||||||
let rows: [(MenuOption, &'static str, &'static str); 7] = [
|
|
||||||
(
|
|
||||||
MenuOption::Help,
|
|
||||||
"Help",
|
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
MenuOption::Modes,
|
|
||||||
"Game Modes",
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
MenuOption::Stats,
|
|
||||||
"Stats",
|
|
||||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
MenuOption::Achievements,
|
|
||||||
"Achievements",
|
|
||||||
"Browse unlocked achievements and the rewards still ahead.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
MenuOption::Profile,
|
|
||||||
"Profile",
|
|
||||||
"Your level, XP progress, and sync status.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
MenuOption::Settings,
|
|
||||||
"Settings",
|
|
||||||
"Audio, animations, theme, draw mode, and sync.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
MenuOption::Leaderboard,
|
|
||||||
"Leaderboard",
|
|
||||||
"Top players from your sync server. Opt in from Profile.",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Same upward-opening placement as ModesPopover.
|
|
||||||
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
|
||||||
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
MenuPopover,
|
|
||||||
HudPopoverOpen,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
right: VAL_SPACE_3,
|
|
||||||
bottom: popover_bottom,
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
row_gap: VAL_SPACE_1,
|
|
||||||
padding: UiRect::all(VAL_SPACE_2),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BG_ELEVATED),
|
|
||||||
ZIndex(Z_HUD_POPOVER),
|
|
||||||
))
|
|
||||||
.with_children(|panel| {
|
|
||||||
for (option, label, tooltip) in rows {
|
|
||||||
panel
|
|
||||||
.spawn((
|
|
||||||
option,
|
|
||||||
ActionButton,
|
|
||||||
PopoverRow,
|
|
||||||
Button,
|
|
||||||
Tooltip::new(tooltip),
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
|
||||||
justify_content: JustifyContent::FlexStart,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
min_width: Val::Px(150.0),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACTION_BTN_IDLE),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transparent fullscreen backdrop behind the popover — tapping anywhere
|
|
||||||
// outside the panel light-dismisses it via handle_menu_backdrop_click.
|
|
||||||
commands.spawn((
|
|
||||||
MenuPopoverBackdrop,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
top: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Percent(100.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::NONE),
|
|
||||||
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatches the click on a menu row to the matching toggle event,
|
|
||||||
/// then despawns the popover.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(super) fn handle_menu_option_click(
|
|
||||||
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
|
||||||
popovers: Query<Entity, With<MenuPopover>>,
|
|
||||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
|
||||||
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
|
||||||
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
|
||||||
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
|
||||||
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
|
||||||
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
|
||||||
mut help: MessageWriter<HelpRequestEvent>,
|
|
||||||
progress: Option<Res<ProgressResource>>,
|
|
||||||
daily: Option<Res<DailyChallengeResource>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let mut clicked_any = false;
|
|
||||||
let mut open_modes = false;
|
|
||||||
for (interaction, option) in &interaction_query {
|
|
||||||
if *interaction != Interaction::Pressed {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
clicked_any = true;
|
|
||||||
match option {
|
|
||||||
MenuOption::Help => {
|
|
||||||
help.write(HelpRequestEvent);
|
|
||||||
}
|
|
||||||
MenuOption::Modes => {
|
|
||||||
open_modes = true;
|
|
||||||
}
|
|
||||||
MenuOption::Stats => {
|
|
||||||
stats.write(ToggleStatsRequestEvent);
|
|
||||||
}
|
|
||||||
MenuOption::Achievements => {
|
|
||||||
achievements.write(ToggleAchievementsRequestEvent);
|
|
||||||
}
|
|
||||||
MenuOption::Profile => {
|
|
||||||
profile.write(ToggleProfileRequestEvent);
|
|
||||||
}
|
|
||||||
MenuOption::Settings => {
|
|
||||||
settings.write(ToggleSettingsRequestEvent);
|
|
||||||
}
|
|
||||||
MenuOption::Leaderboard => {
|
|
||||||
leaderboard.write(ToggleLeaderboardRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if clicked_any && let Ok(entity) = popovers.single() {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
for e in &backdrops {
|
|
||||||
commands.entity(e).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if open_modes {
|
|
||||||
spawn_modes_popover(
|
|
||||||
&mut commands,
|
|
||||||
progress.as_deref(),
|
|
||||||
daily.as_deref(),
|
|
||||||
font_res.as_deref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
|
||||||
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
|
|
||||||
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
|
|
||||||
pub(super) fn close_modes_popover_on_escape(
|
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
|
||||||
popovers: Query<Entity, With<ModesPopover>>,
|
|
||||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for e in popovers.iter().chain(backdrops.iter()) {
|
|
||||||
commands.entity(e).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
|
|
||||||
/// is pressed while the popover is open.
|
|
||||||
pub(super) fn close_menu_popover_on_escape(
|
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
|
||||||
popovers: Query<Entity, With<MenuPopover>>,
|
|
||||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for e in popovers.iter().chain(backdrops.iter()) {
|
|
||||||
commands.entity(e).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
|
|
||||||
/// anywhere outside the panel.
|
|
||||||
pub(super) fn handle_modes_backdrop_click(
|
|
||||||
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
|
|
||||||
popovers: Query<Entity, With<ModesPopover>>,
|
|
||||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
|
||||||
if !pressed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for e in popovers.iter().chain(backdrops.iter()) {
|
|
||||||
commands.entity(e).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
|
|
||||||
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
|
|
||||||
pub(super) fn handle_menu_backdrop_click(
|
|
||||||
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
|
|
||||||
popovers: Query<Entity, With<MenuPopover>>,
|
|
||||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
|
||||||
if !pressed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for e in popovers.iter().chain(backdrops.iter()) {
|
|
||||||
commands.entity(e).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(super) fn toggle_hud_on_tap(
|
|
||||||
mut touch_events: MessageReader<TouchInput>,
|
|
||||||
drag: Res<DragState>,
|
|
||||||
scrims: Query<(), With<ModalScrim>>,
|
|
||||||
paused: Option<Res<PausedResource>>,
|
|
||||||
mut tracker: ResMut<HudTapTracker>,
|
|
||||||
mut hud_vis: ResMut<HudVisibility>,
|
|
||||||
buttons: Query<&Interaction, With<ActionButton>>,
|
|
||||||
mut game_consumed: ResMut<GameInputConsumedResource>,
|
|
||||||
) {
|
|
||||||
use bevy::input::touch::TouchPhase;
|
|
||||||
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
|
||||||
// Drain buffered events so they don't replay in the frame after
|
|
||||||
// the scrim despawns, which would trigger a spurious visibility
|
|
||||||
// toggle as the resume/close button tap's Started+Ended pair
|
|
||||||
// replays in the now-scrim-free frame.
|
|
||||||
for _ in touch_events.read() {}
|
|
||||||
tracker.start_pos = None;
|
|
||||||
tracker.started_on_button = false;
|
|
||||||
game_consumed.0 = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for event in touch_events.read() {
|
|
||||||
match event.phase {
|
|
||||||
TouchPhase::Started => {
|
|
||||||
tracker.start_pos = Some(event.position);
|
|
||||||
// Record whether the finger-down landed on a button so
|
|
||||||
// the finger-up doesn't double-fire (toggle bar + press
|
|
||||||
// button at the same time).
|
|
||||||
tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None);
|
|
||||||
}
|
|
||||||
TouchPhase::Ended if drag.is_idle() => {
|
|
||||||
// Also treat taps where game logic consumed the touch (e.g.
|
|
||||||
// drawing from stock) as "on button" so they don't toggle
|
|
||||||
// the HUD. The flag is set on TouchPhase::Started by the
|
|
||||||
// input system that consumed the tap and must be cleared here
|
|
||||||
// regardless of whether we toggle.
|
|
||||||
let on_button = tracker.started_on_button || game_consumed.0;
|
|
||||||
game_consumed.0 = false;
|
|
||||||
if let Some(start) = tracker.start_pos.take()
|
|
||||||
&& !on_button
|
|
||||||
&& (event.position - start).length() < HUD_TAP_SLOP_PX
|
|
||||||
{
|
|
||||||
*hud_vis = match *hud_vis {
|
|
||||||
HudVisibility::Visible => HudVisibility::Hidden,
|
|
||||||
HudVisibility::Hidden => HudVisibility::Visible,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
tracker.started_on_button = false;
|
|
||||||
}
|
|
||||||
// Moved: don't clear start_pos — Android fires Moved for normal
|
|
||||||
// tap jitter, and the distance check at Ended already rejects
|
|
||||||
// real drags. Clearing here would silently swallow tap toggles.
|
|
||||||
TouchPhase::Canceled => {
|
|
||||||
tracker.start_pos = None;
|
|
||||||
tracker.started_on_button = false;
|
|
||||||
game_consumed.0 = false;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
//! Persistent in-game HUD: score, move count, elapsed time, mode badge,
|
|
||||||
//! daily-challenge constraint, and undo count.
|
|
||||||
//!
|
|
||||||
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
|
|
||||||
//! refreshed whenever `GameStateResource` changes (which happens on every move
|
|
||||||
//! and every elapsed-time tick), so score, moves, and timer all stay current
|
|
||||||
//! without a separate tick system.
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
|
||||||
use bevy::window::WindowResized;
|
|
||||||
|
|
||||||
mod fx;
|
|
||||||
mod interaction;
|
|
||||||
mod spawn;
|
|
||||||
mod updates;
|
|
||||||
|
|
||||||
pub use fx::*;
|
|
||||||
use interaction::*;
|
|
||||||
use spawn::*;
|
|
||||||
use updates::*;
|
|
||||||
|
|
||||||
|
|
||||||
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
|
||||||
// Option<Res<AvatarResource>> parameters below compile without changes.
|
|
||||||
// The resource is never inserted on wasm, so every call resolves to None.
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
#[derive(bevy::prelude::Resource)]
|
|
||||||
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
|
||||||
use crate::events::{
|
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
|
||||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
|
||||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
|
||||||
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
|
||||||
UndoRequestEvent, WinStreakMilestoneEvent,
|
|
||||||
};
|
|
||||||
use crate::font_plugin::FontResource;
|
|
||||||
use crate::game_plugin::GameMutation;
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
use crate::input_plugin::TouchDragSet;
|
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
|
||||||
use crate::layout::LayoutSystem;
|
|
||||||
use crate::pause_plugin::PausedResource;
|
|
||||||
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
|
|
||||||
use crate::progress_plugin::ProgressResource;
|
|
||||||
use crate::resources::GameStateResource;
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
use crate::resources::{DragState, GameInputConsumedResource};
|
|
||||||
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
|
|
||||||
use crate::selection_plugin::SelectionState;
|
|
||||||
use crate::settings_plugin::SettingsResource;
|
|
||||||
use crate::time_attack_plugin::TimeAttackResource;
|
|
||||||
use crate::ui_focus::{FocusGroup, Focusable};
|
|
||||||
use crate::ui_modal::ModalScrim;
|
|
||||||
use crate::ui_theme::SPACE_2;
|
|
||||||
use crate::ui_theme::{
|
|
||||||
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
|
||||||
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
|
||||||
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
|
||||||
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
|
||||||
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
|
||||||
scaled_duration,
|
|
||||||
};
|
|
||||||
use crate::ui_tooltip::Tooltip;
|
|
||||||
use solitaire_data::SyncBackend;
|
|
||||||
|
|
||||||
/// Marker on the score text node.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudScore;
|
|
||||||
|
|
||||||
/// Marker on the move-count text node.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudMoves;
|
|
||||||
|
|
||||||
/// Marker on the elapsed-time text node.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudTime;
|
|
||||||
|
|
||||||
/// Marker on the mode badge text node.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudMode;
|
|
||||||
|
|
||||||
/// Marker on the daily-challenge constraint text node.
|
|
||||||
///
|
|
||||||
/// Displays the active goal (time limit or score target) when a daily challenge
|
|
||||||
/// is in progress. Empty string when no challenge is active or the game is won.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudChallenge;
|
|
||||||
|
|
||||||
/// Marker on the "won this deal before" indicator text node.
|
|
||||||
///
|
|
||||||
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
|
|
||||||
/// mode triple matches one of the entries in `ReplayHistoryResource`.
|
|
||||||
/// Empty string otherwise (including won games — the score readout
|
|
||||||
/// already conveys the win on the active deal). Only meaningful for
|
|
||||||
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
|
|
||||||
/// are filtered out implicitly because their replay entries always
|
|
||||||
/// carry a different mode tag.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudWonPreviously;
|
|
||||||
|
|
||||||
/// Marker on the undo-count text node.
|
|
||||||
///
|
|
||||||
/// Shows how many undos have been used this game. Displayed in amber when
|
|
||||||
/// `undo_count > 0` because using undo blocks the no-undo achievement.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudUndos;
|
|
||||||
|
|
||||||
/// Marker on the auto-complete badge text node.
|
|
||||||
///
|
|
||||||
/// Displays `"AUTO"` in green while `AutoCompleteState.active` is true;
|
|
||||||
/// empty string otherwise.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudAutoComplete;
|
|
||||||
|
|
||||||
/// Marker on the stock-recycle counter text node.
|
|
||||||
///
|
|
||||||
/// Displays `"Recycles: N"` whenever `recycle_count > 0`, regardless of draw
|
|
||||||
/// mode, so the player can track stock recycling in both Draw-One and
|
|
||||||
/// Draw-Three (relevant to the `comeback` achievement). Hidden (empty string)
|
|
||||||
/// until the first recycle occurs.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudRecycles;
|
|
||||||
|
|
||||||
/// Marker on the draw-cycle indicator text node.
|
|
||||||
///
|
|
||||||
/// Only shown in Draw-Three mode. Displays `"Cycle: N/3"` where N is the
|
|
||||||
/// number of cards that will be drawn on the next stock click
|
|
||||||
/// (`min(stock_len, 3)`). Shows `"Cycle: 0/3"` when the stock is empty
|
|
||||||
/// (recycle available). Hidden (empty string) in Draw-One mode or after the
|
|
||||||
/// game is won.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudDrawCycle;
|
|
||||||
|
|
||||||
/// Marker on the keyboard-selection indicator text node.
|
|
||||||
///
|
|
||||||
/// Displays `"▶ {pile_name}"` while a pile is selected via Tab, or an empty
|
|
||||||
/// string when no pile is selected. Uses a light-yellow colour so it stands
|
|
||||||
/// out from the other white HUD items.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudSelection;
|
|
||||||
|
|
||||||
/// Marker on the HUD band background node (the translucent band behind buttons).
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudBand;
|
|
||||||
|
|
||||||
/// Marker on the HUD score/info column root node.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudColumn;
|
|
||||||
|
|
||||||
/// Marker on the action button bar root node.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudActionBar;
|
|
||||||
|
|
||||||
/// Marker on the text node inside each touch-layout action-bar button.
|
|
||||||
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
struct ActionButtonLabel;
|
|
||||||
|
|
||||||
/// Marker on the circular profile-picture button anchored to the
|
|
||||||
/// top-right of the HUD band. Pressing it opens the Profile overlay.
|
|
||||||
/// Shows the server avatar image when loaded; falls back to the player's
|
|
||||||
/// initial on a filled disc when no image is available.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudAvatar;
|
|
||||||
|
|
||||||
/// Controls whether the in-game HUD (band, score column, action buttons) is
|
|
||||||
/// visible. Toggled on Android by tapping empty board space; always `Visible`
|
|
||||||
/// on desktop. Resets to `Visible` whenever a modal opens.
|
|
||||||
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum HudVisibility {
|
|
||||||
#[default]
|
|
||||||
Visible,
|
|
||||||
Hidden,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[derive(Resource, Debug, Default)]
|
|
||||||
struct HudTapTracker {
|
|
||||||
start_pos: Option<Vec2>,
|
|
||||||
/// Set `true` when the finger-down hit an action button so the
|
|
||||||
/// finger-up never toggles bar visibility.
|
|
||||||
started_on_button: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
const HUD_TAP_SLOP_PX: f32 = 25.0;
|
|
||||||
|
|
||||||
/// Drives the score-readout pulse: scales the [`HudScore`] text from
|
|
||||||
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
|
|
||||||
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
|
|
||||||
/// entity whenever the score increases; removed once `elapsed >=
|
|
||||||
/// duration`.
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub struct ScorePulse {
|
|
||||||
/// Seconds elapsed since the pulse started.
|
|
||||||
pub elapsed: f32,
|
|
||||||
/// Total duration. Zero under `AnimSpeed::Instant` — the system
|
|
||||||
/// snaps the scale back to 1.0 on first tick so no half-state
|
|
||||||
/// is ever shown.
|
|
||||||
pub duration: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker on a transient floating "+N" text spawned next to the score
|
|
||||||
/// readout when the score jumps by [`SCORE_FLOATER_THRESHOLD`] or more.
|
|
||||||
/// Drifts upward and fades out over `MOTION_SCORE_PULSE_SECS * 2`,
|
|
||||||
/// then despawns. Kept rare/meaningful by the threshold gate.
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub struct ScoreFloater {
|
|
||||||
/// Seconds elapsed since the floater spawned.
|
|
||||||
pub elapsed: f32,
|
|
||||||
/// Total lifetime. Zero under `AnimSpeed::Instant` — the system
|
|
||||||
/// despawns it on first tick.
|
|
||||||
pub duration: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drives the streak-milestone flourish: scales the [`HudScore`] text
|
|
||||||
/// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over
|
|
||||||
/// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by
|
|
||||||
/// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it
|
|
||||||
/// [`ACCENT_SECONDARY`] for the same window before restoring the
|
|
||||||
/// original colour.
|
|
||||||
///
|
|
||||||
/// The streak readout currently lives in the Stats overlay (press
|
|
||||||
/// `S`) — there is no always-on HUD streak counter — so the flourish
|
|
||||||
/// piggybacks on the score readout, which is the most prominent
|
|
||||||
/// always-visible HUD number. Mirrors the `FoundationFlourish`
|
|
||||||
/// pattern: triangular scale curve, fixed duration, restores state
|
|
||||||
/// when the timer expires.
|
|
||||||
///
|
|
||||||
/// Inserted on `HudScore` entities by `start_streak_flourish` when a
|
|
||||||
/// `WinStreakMilestoneEvent` fires; removed once `elapsed >=
|
|
||||||
/// duration` so the readout returns to its rest state for the next
|
|
||||||
/// frame's transform sync.
|
|
||||||
///
|
|
||||||
/// Coexists with [`ScorePulse`]: the streak flourish lives on a
|
|
||||||
/// dedicated marker so a streak-crossing win that also ticks the
|
|
||||||
/// score (every win does) doesn't have the two animations stomp on
|
|
||||||
/// each other's `Transform.scale` writes — the streak flourish runs
|
|
||||||
/// in a `Without<ScorePulse>` query so only the loudest of the two
|
|
||||||
/// celebrations is active at a time.
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub struct StreakFlourish {
|
|
||||||
/// The streak milestone that triggered this flourish (3, 5, 10).
|
|
||||||
/// Carried for diagnostic logging only — the visual is identical
|
|
||||||
/// for every threshold so play-testing can decide later whether
|
|
||||||
/// to differentiate.
|
|
||||||
pub streak: u32,
|
|
||||||
/// Seconds elapsed since the flourish began.
|
|
||||||
pub elapsed: f32,
|
|
||||||
/// Total animation length in seconds. Zero under
|
|
||||||
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system
|
|
||||||
/// snaps the scale back to 1.0 on the first tick so no half-state
|
|
||||||
/// is ever shown.
|
|
||||||
pub duration: f32,
|
|
||||||
/// The score readout's colour before the flourish began —
|
|
||||||
/// restored when the timer expires so the readout returns to its
|
|
||||||
/// resting `TEXT_PRIMARY` (or whatever it was) tint.
|
|
||||||
pub original_color: Color,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracks the score from the previous frame so the HUD can detect
|
|
||||||
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
|
||||||
/// pulse + floater systems on every `Update`.
|
|
||||||
#[derive(Resource, Debug, Default, Clone, Copy)]
|
|
||||||
pub struct PreviousScore(pub i32);
|
|
||||||
|
|
||||||
/// Score increase (in points) below which no floating "+N" is spawned.
|
|
||||||
/// 50 keeps the feedback for foundation drops and tableau-to-foundation
|
|
||||||
/// promotions; single-card placements (which can earn as little as +5)
|
|
||||||
/// stay quiet so the floater feels like a reward instead of noise.
|
|
||||||
pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
|
|
||||||
|
|
||||||
/// Marker shared by every clickable HUD action button so a single
|
|
||||||
/// `paint_action_buttons` system can recolour them on hover/press without
|
|
||||||
/// each button needing its own paint handler.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ActionButton;
|
|
||||||
|
|
||||||
/// Marker on rows inside a popover panel ([`ModesPopover`] or
|
|
||||||
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
|
|
||||||
/// hover/press paint path applies to them, but the auto-fade applied
|
|
||||||
/// to the top-level action bar must NOT also fade these rows — the
|
|
||||||
/// popover only renders when the player has explicitly opened it, so
|
|
||||||
/// its content should always be at full opacity. `apply_action_fade`
|
|
||||||
/// excludes entities with this marker via `Without<PopoverRow>`.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct PopoverRow;
|
|
||||||
|
|
||||||
/// Marker on the "New Game" action button anchored top-right of the play
|
|
||||||
/// area. Click fires [`NewGameRequestEvent`]; the existing
|
|
||||||
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
|
|
||||||
/// progress.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct NewGameButton;
|
|
||||||
|
|
||||||
/// Marker on the "Undo" action button. Click fires [`UndoRequestEvent`],
|
|
||||||
/// mirroring the `U` keyboard accelerator.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct UndoButton;
|
|
||||||
|
|
||||||
/// Marker on the "Pause" action button. Click fires [`PauseRequestEvent`],
|
|
||||||
/// mirroring the `Esc` keyboard accelerator. The pause overlay's own resume
|
|
||||||
/// affordance dismisses it from the paused state.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct PauseButton;
|
|
||||||
|
|
||||||
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
|
||||||
/// mirroring the `F1` keyboard accelerator.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HelpButton;
|
|
||||||
|
|
||||||
/// Marker on the "Hint" action button. Click spawns an async solver task
|
|
||||||
/// (same as the `H` keyboard accelerator) and highlights the suggested card.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HintButton;
|
|
||||||
|
|
||||||
/// Android HUD label for the Hint button — shared with the help screen's
|
|
||||||
/// controls reference so both always agree.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
|
||||||
"Menu",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
ANDROID_HINT_LABEL,
|
|
||||||
"Mode",
|
|
||||||
"New",
|
|
||||||
];
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
|
||||||
"Menu \u{2193}",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
"Hint",
|
|
||||||
"Modes \u{2193}",
|
|
||||||
"New Game",
|
|
||||||
];
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
const ACTION_BAR_COLUMN_GAP: Val = VAL_SPACE_2;
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
const ACTION_POPOVER_BOTTOM_PX: f32 = 200.0;
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
const ACTION_POPOVER_BOTTOM_PX: f32 = 80.0;
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
const HINT_WON_MSG: &str = "Game won! Tap New Game to play again";
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
const HINT_WON_MSG: &str = "Game won! Press N for a new game";
|
|
||||||
|
|
||||||
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
|
||||||
/// (a small dropdown panel) below the action bar. Each popover row starts
|
|
||||||
/// the corresponding game mode.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ModesButton;
|
|
||||||
|
|
||||||
/// Marker on the dropdown panel that opens below the [`ModesButton`].
|
|
||||||
/// Spawned on first click, despawned on second click or on mode select.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ModesPopover;
|
|
||||||
|
|
||||||
/// One row inside the [`ModesPopover`]. The variant carries which event
|
|
||||||
/// the click handler should fire — Classic uses `NewGameRequestEvent`
|
|
||||||
/// directly, the others go through their `Start*RequestEvent` so the
|
|
||||||
/// existing keyboard handler's level gate / resource setup runs.
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub enum ModeOption {
|
|
||||||
Classic,
|
|
||||||
DailyChallenge,
|
|
||||||
Zen,
|
|
||||||
Challenge,
|
|
||||||
TimeAttack,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
|
|
||||||
/// which exposes the Stats / Achievements / Profile / Settings /
|
|
||||||
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct MenuButton;
|
|
||||||
|
|
||||||
/// Marker on the dropdown panel that opens below the [`MenuButton`].
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct MenuPopover;
|
|
||||||
|
|
||||||
/// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities
|
|
||||||
/// while they are open. External systems (e.g. `PausePlugin`) query this to
|
|
||||||
/// determine whether a HUD popover is currently visible without importing the
|
|
||||||
/// individual popover types.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct HudPopoverOpen;
|
|
||||||
|
|
||||||
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
|
|
||||||
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
struct MenuPopoverBackdrop;
|
|
||||||
|
|
||||||
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
|
|
||||||
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
struct ModesPopoverBackdrop;
|
|
||||||
|
|
||||||
/// One row inside the [`MenuPopover`]. The variant selects which
|
|
||||||
/// `Toggle*RequestEvent` the click handler fires.
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub enum MenuOption {
|
|
||||||
Help,
|
|
||||||
Modes,
|
|
||||||
Stats,
|
|
||||||
Achievements,
|
|
||||||
Profile,
|
|
||||||
Settings,
|
|
||||||
Leaderboard,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
|
||||||
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
|
|
||||||
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
|
|
||||||
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
|
|
||||||
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
|
|
||||||
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
|
|
||||||
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
|
|
||||||
|
|
||||||
/// Idle / hover / pressed colours shared by every action button. Aliased
|
|
||||||
/// to the theme tokens so the HUD picks up palette changes for free.
|
|
||||||
const ACTION_BTN_IDLE: Color = BG_ELEVATED;
|
|
||||||
const ACTION_BTN_HOVER: Color = BG_ELEVATED_HI;
|
|
||||||
const ACTION_BTN_PRESSED: Color = BG_ELEVATED_PRESSED;
|
|
||||||
|
|
||||||
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
|
|
||||||
pub struct HudPlugin;
|
|
||||||
|
|
||||||
impl Plugin for HudPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
// The click handlers write to messages registered elsewhere by their
|
|
||||||
// owning plugins (`GamePlugin`, `PausePlugin`, `HelpPlugin`,
|
|
||||||
// `challenge_plugin`, `daily_challenge_plugin`, `time_attack_plugin`,
|
|
||||||
// `input_plugin`). Re-register defensively so the HUD plugin works in
|
|
||||||
// isolation under `MinimalPlugins` (tests). `add_message` is
|
|
||||||
// idempotent.
|
|
||||||
app.add_message::<NewGameRequestEvent>()
|
|
||||||
.add_message::<UndoRequestEvent>()
|
|
||||||
.add_message::<PauseRequestEvent>()
|
|
||||||
.add_message::<HelpRequestEvent>()
|
|
||||||
.add_message::<StartZenRequestEvent>()
|
|
||||||
.add_message::<StartChallengeRequestEvent>()
|
|
||||||
.add_message::<StartTimeAttackRequestEvent>()
|
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
|
||||||
.add_message::<ToggleStatsRequestEvent>()
|
|
||||||
.add_message::<ToggleAchievementsRequestEvent>()
|
|
||||||
.add_message::<ToggleProfileRequestEvent>()
|
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
|
||||||
.add_message::<WinStreakMilestoneEvent>()
|
|
||||||
.init_resource::<PreviousScore>()
|
|
||||||
.init_resource::<HudActionFade>()
|
|
||||||
.init_resource::<HudVisibility>()
|
|
||||||
// Escape-close handlers for popovers read this; init defensively
|
|
||||||
// so HudPlugin works under MinimalPlugins in tests.
|
|
||||||
.init_resource::<ButtonInput<KeyCode>>()
|
|
||||||
// WindowResized is registered by table_plugin; re-register
|
|
||||||
// defensively so the HUD plugin works standalone in tests.
|
|
||||||
.add_message::<WindowResized>()
|
|
||||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
|
||||||
.add_systems(Update, update_hud.after(GameMutation))
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
|
||||||
)
|
|
||||||
.add_systems(Update, restore_hud_on_modal)
|
|
||||||
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
|
|
||||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
|
||||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
update_selection_hud.run_if(
|
|
||||||
resource_exists_and_changed::<SelectionState>
|
|
||||||
.or(resource_exists_and_changed::<GameStateResource>),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.add_systems(Update, update_hud_typography)
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
(
|
|
||||||
detect_score_change,
|
|
||||||
advance_score_pulse,
|
|
||||||
advance_score_floater,
|
|
||||||
)
|
|
||||||
.chain()
|
|
||||||
.after(GameMutation),
|
|
||||||
)
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
(start_streak_flourish, advance_streak_flourish)
|
|
||||||
.chain()
|
|
||||||
.after(GameMutation),
|
|
||||||
)
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
(
|
|
||||||
handle_new_game_button,
|
|
||||||
handle_undo_button,
|
|
||||||
handle_pause_button,
|
|
||||||
handle_help_button,
|
|
||||||
handle_hint_button,
|
|
||||||
handle_modes_button,
|
|
||||||
handle_mode_option_click,
|
|
||||||
handle_modes_backdrop_click,
|
|
||||||
close_modes_popover_on_escape,
|
|
||||||
handle_menu_button,
|
|
||||||
handle_menu_option_click,
|
|
||||||
handle_menu_backdrop_click,
|
|
||||||
close_menu_popover_on_escape,
|
|
||||||
paint_action_buttons,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Fade lives in `Last` so it always overrides whatever the
|
|
||||||
// hover/paint pass set on `BackgroundColor` this frame.
|
|
||||||
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
|
||||||
// `paint_action_buttons` would clobber the alpha back to 1.0
|
|
||||||
// mid-fade and produce a visible blip.
|
|
||||||
;
|
|
||||||
// Desktop-only: cursor-proximity fade. On Android the bar
|
|
||||||
// visibility is toggled explicitly; cursor_position() returning
|
|
||||||
// Some(touch_pos) during a tap would otherwise fade the bar out.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
app.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
app.init_resource::<HudTapTracker>()
|
|
||||||
.add_message::<TouchInput>()
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
toggle_hud_on_tap
|
|
||||||
.after(TouchDragSet::AfterStartDrag)
|
|
||||||
.in_set(TouchDragSet::BeforeEndDrag),
|
|
||||||
);
|
|
||||||
app.add_systems(
|
|
||||||
Update,
|
|
||||||
resize_action_bar_labels
|
|
||||||
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
//! HUD construction: band, columns, avatar, and action-bar spawning.
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::avatar_plugin::AvatarResource;
|
|
||||||
|
|
||||||
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
|
||||||
/// the screen so the card layout (computed by `layout::compute_layout` using
|
|
||||||
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
|
||||||
///
|
|
||||||
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
|
||||||
/// A slim grey background is handled by each content section individually
|
|
||||||
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
|
||||||
pub(super) fn spawn_hud_band(mut commands: Commands) {
|
|
||||||
const BASE_TOP: f32 = 0.0;
|
|
||||||
commands.spawn((
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: Val::Px(BASE_TOP),
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(HUD_BAND_HEIGHT),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
ZIndex(Z_HUD - 1),
|
|
||||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
|
||||||
HudBand,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
|
||||||
/// top-left of the play area.
|
|
||||||
///
|
|
||||||
/// Tiers (top to bottom):
|
|
||||||
/// 1. **Primary** — Score (display weight) · Moves · Timer.
|
|
||||||
/// Always visible during gameplay.
|
|
||||||
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
|
|
||||||
/// Draw-cycle indicator. Each cell is empty when not relevant; the
|
|
||||||
/// row collapses visually when all cells are empty.
|
|
||||||
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
|
|
||||||
/// Both penalty counters share `STATE_WARNING` (the audit found
|
|
||||||
/// they were inconsistent: Undos amber, Recycles white).
|
|
||||||
/// 4. **Selection** — keyboard-driven pile selector chip.
|
|
||||||
///
|
|
||||||
/// The audit identified the original single-row layout (10 readouts in
|
|
||||||
/// one horizontal flex row, 5+ colour families competing) as the
|
|
||||||
/// player's #1 complaint. This restructure groups by purpose, lets
|
|
||||||
/// transient items disappear cleanly, and uses the typography scale to
|
|
||||||
/// make Score the visual protagonist.
|
|
||||||
pub(super) fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|
||||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
|
||||||
let font_score = TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_HEADLINE,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
let font_lg = TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
let font_body = TextFont {
|
|
||||||
font: font_handle,
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let row_node = || Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
column_gap: VAL_SPACE_3,
|
|
||||||
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
|
||||||
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
|
||||||
// Auto-complete, selection chip) can collectively be wider than
|
|
||||||
// the available space and overflow into the action-button column
|
|
||||||
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
|
||||||
// a second line; on a desktop window the rows stay single-line
|
|
||||||
// because the parent column has no width cap and the row never
|
|
||||||
// exceeds the natural line width.
|
|
||||||
flex_wrap: FlexWrap::Wrap,
|
|
||||||
row_gap: VAL_SPACE_1,
|
|
||||||
align_items: AlignItems::Baseline,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: VAL_SPACE_3,
|
|
||||||
top: Val::Px(SPACE_2),
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
// Cap the column at 50% of viewport so on narrow
|
|
||||||
// (mobile) widths the inner tier rows have a bounded
|
|
||||||
// width to wrap against, and the column can't bleed
|
|
||||||
// into the right-anchored action button row (also
|
|
||||||
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
|
||||||
// wider than any tier row's natural width, so the
|
|
||||||
// visible layout is unaffected.
|
|
||||||
max_width: Val::Percent(50.0),
|
|
||||||
row_gap: VAL_SPACE_1,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
ZIndex(Z_HUD),
|
|
||||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
|
||||||
HudColumn,
|
|
||||||
))
|
|
||||||
.with_children(|hud| {
|
|
||||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
|
||||||
// Moves and Timer are supporting context (BODY_LG, secondary tone).
|
|
||||||
hud.spawn(row_node()).with_children(|t1| {
|
|
||||||
t1.spawn((
|
|
||||||
HudScore,
|
|
||||||
Tooltip::new("Points earned this game. Hidden in Zen mode."),
|
|
||||||
Text::new("Score: 0"),
|
|
||||||
font_score.clone(),
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
t1.spawn((
|
|
||||||
HudMoves,
|
|
||||||
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
|
|
||||||
Text::new("Moves: 0"),
|
|
||||||
font_lg.clone(),
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
t1.spawn((
|
|
||||||
HudTime,
|
|
||||||
Tooltip::new("Time on this game. Counts down in Time Attack."),
|
|
||||||
Text::new("0:00"),
|
|
||||||
font_lg.clone(),
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tier 2 — mode context. Each cell is empty until update_hud
|
|
||||||
// populates it (and clears it when no longer relevant), so the
|
|
||||||
// row collapses when nothing in this tier applies.
|
|
||||||
hud.spawn(row_node()).with_children(|t2| {
|
|
||||||
t2.spawn((
|
|
||||||
HudMode,
|
|
||||||
Tooltip::new("Active game mode. Click Modes to switch."),
|
|
||||||
Text::new(""),
|
|
||||||
font_body.clone(),
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
t2.spawn((
|
|
||||||
HudChallenge,
|
|
||||||
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
|
|
||||||
Text::new(""),
|
|
||||||
font_body.clone(),
|
|
||||||
TextColor(STATE_INFO),
|
|
||||||
));
|
|
||||||
t2.spawn((
|
|
||||||
HudDrawCycle,
|
|
||||||
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
|
|
||||||
Text::new(""),
|
|
||||||
font_body.clone(),
|
|
||||||
TextColor(STATE_INFO),
|
|
||||||
));
|
|
||||||
t2.spawn((
|
|
||||||
HudWonPreviously,
|
|
||||||
Tooltip::new("You've won this deal before. Same seed in your replay history."),
|
|
||||||
Text::new(""),
|
|
||||||
font_body.clone(),
|
|
||||||
TextColor(STATE_SUCCESS),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
|
||||||
// warning hue so they read as the same category ("you took a
|
|
||||||
// penalty"); the auto-complete badge stays success-green.
|
|
||||||
hud.spawn(row_node()).with_children(|t3| {
|
|
||||||
t3.spawn((
|
|
||||||
HudUndos,
|
|
||||||
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
|
|
||||||
Text::new(""),
|
|
||||||
font_body.clone(),
|
|
||||||
TextColor(STATE_WARNING),
|
|
||||||
));
|
|
||||||
t3.spawn((
|
|
||||||
HudRecycles,
|
|
||||||
Tooltip::new(
|
|
||||||
"Times you've recycled the stock. Three or more unlocks Comeback.",
|
|
||||||
),
|
|
||||||
Text::new(""),
|
|
||||||
font_body.clone(),
|
|
||||||
TextColor(STATE_WARNING),
|
|
||||||
));
|
|
||||||
t3.spawn((
|
|
||||||
HudAutoComplete,
|
|
||||||
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
|
|
||||||
Text::new(""),
|
|
||||||
font_body.clone(),
|
|
||||||
TextColor(STATE_SUCCESS),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tier 4 — selection chip. Stays in HUD for now; a future
|
|
||||||
// pass can reposition it next to the selected pile.
|
|
||||||
hud.spawn(row_node()).with_children(|t4| {
|
|
||||||
t4.spawn((
|
|
||||||
HudSelection,
|
|
||||||
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
|
|
||||||
Text::new(""),
|
|
||||||
font_body,
|
|
||||||
TextColor(ACCENT_SECONDARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the circular avatar / initials button anchored to the top-right
|
|
||||||
/// of the HUD band. Initial content is seeded from whatever resources are
|
|
||||||
/// available at startup; `update_hud_avatar` replaces the children whenever
|
|
||||||
/// `AvatarResource` or `SettingsResource` later changes.
|
|
||||||
pub(super) fn spawn_hud_avatar(
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
avatar: Option<Res<AvatarResource>>,
|
|
||||||
settings: Option<Res<SettingsResource>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
const SIZE: f32 = 32.0;
|
|
||||||
let id = commands
|
|
||||||
.spawn((
|
|
||||||
HudAvatar,
|
|
||||||
Button,
|
|
||||||
Tooltip::new("Your profile — tap to open."),
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: Val::Px(SPACE_2),
|
|
||||||
right: VAL_SPACE_3,
|
|
||||||
width: Val::Px(SIZE),
|
|
||||||
height: Val::Px(SIZE),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACCENT_PRIMARY),
|
|
||||||
ZIndex(Z_HUD),
|
|
||||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
|
||||||
))
|
|
||||||
.id();
|
|
||||||
spawn_avatar_child(
|
|
||||||
&mut commands,
|
|
||||||
id,
|
|
||||||
avatar.as_deref(),
|
|
||||||
settings.as_deref(),
|
|
||||||
font_res.as_deref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-spawns the avatar circle content (image or initials) whenever either
|
|
||||||
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
|
|
||||||
/// image arriving after download and the username changing after login.
|
|
||||||
pub(super) fn update_hud_avatar(
|
|
||||||
avatar: Option<Res<AvatarResource>>,
|
|
||||||
settings: Option<Res<SettingsResource>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
q: Query<Entity, With<HudAvatar>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
|
|
||||||
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
|
|
||||||
if !avatar_changed && !settings_changed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Ok(entity) = q.single() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
commands.entity(entity).despawn_related::<Children>();
|
|
||||||
spawn_avatar_child(
|
|
||||||
&mut commands,
|
|
||||||
entity,
|
|
||||||
avatar.as_deref(),
|
|
||||||
settings.as_deref(),
|
|
||||||
font_res.as_deref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Populates the avatar container with either the downloaded image or an
|
|
||||||
/// initials fallback disc. Called from both the startup spawn and the
|
|
||||||
/// reactive update system so the rendering logic lives in one place.
|
|
||||||
pub(super) fn spawn_avatar_child(
|
|
||||||
commands: &mut Commands,
|
|
||||||
parent: Entity,
|
|
||||||
avatar: Option<&AvatarResource>,
|
|
||||||
settings: Option<&SettingsResource>,
|
|
||||||
font_res: Option<&FontResource>,
|
|
||||||
) {
|
|
||||||
const SIZE: f32 = 32.0;
|
|
||||||
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
|
||||||
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
|
||||||
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
|
|
||||||
// Image fills the circle container; border_radius clips it to a disc.
|
|
||||||
commands.entity(parent).with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
ImageNode::new(handle),
|
|
||||||
Node {
|
|
||||||
width: Val::Px(SIZE),
|
|
||||||
height: Val::Px(SIZE),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let initial = settings
|
|
||||||
.and_then(|s| match &s.0.sync_backend {
|
|
||||||
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
|
|
||||||
SyncBackend::Local => None,
|
|
||||||
})
|
|
||||||
.and_then(|c| c.to_uppercase().next())
|
|
||||||
.unwrap_or('?');
|
|
||||||
// Real initial (logged in) keeps the red accent disc; the '?'
|
|
||||||
// unauthenticated fallback uses a neutral grey so it reads as a
|
|
||||||
// "tap to log in" affordance rather than an error.
|
|
||||||
let disc_bg = if initial == '?' {
|
|
||||||
BG_ELEVATED_HI
|
|
||||||
} else {
|
|
||||||
ACCENT_PRIMARY
|
|
||||||
};
|
|
||||||
commands.entity(parent).insert(BackgroundColor(disc_bg));
|
|
||||||
commands.entity(parent).with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new(initial.to_string()),
|
|
||||||
TextFont {
|
|
||||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
||||||
font_size: 14.0,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the Profile overlay when the avatar button is pressed.
|
|
||||||
pub(super) fn handle_avatar_button(
|
|
||||||
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
|
|
||||||
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
|
|
||||||
) {
|
|
||||||
for interaction in &interaction_query {
|
|
||||||
if *interaction == Interaction::Pressed {
|
|
||||||
toggle_profile.write(ToggleProfileRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the action button bar anchored to the top-right of the window.
|
|
||||||
/// Each child is a clickable button mirroring a keyboard accelerator —
|
|
||||||
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
|
||||||
/// are the primary entry point and the hotkeys are optional.
|
|
||||||
///
|
|
||||||
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
|
||||||
/// because it's the most consequential action; the destructive button sits
|
|
||||||
/// on its own visual edge.
|
|
||||||
pub(super) fn spawn_action_buttons(
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
windows: Query<&Window>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let action_font_size =
|
|
||||||
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
|
|
||||||
let font = TextFont {
|
|
||||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
|
||||||
font_size: action_font_size,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
|
||||||
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
|
||||||
// Android reports it (frames 1-3); initial value is 0.0.
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
bottom: Val::Px(0.0),
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
flex_wrap: FlexWrap::Wrap,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
column_gap: ACTION_BAR_COLUMN_GAP,
|
|
||||||
row_gap: VAL_SPACE_2,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
padding: UiRect {
|
|
||||||
left: VAL_SPACE_3,
|
|
||||||
right: VAL_SPACE_3,
|
|
||||||
top: VAL_SPACE_2,
|
|
||||||
bottom: VAL_SPACE_2,
|
|
||||||
},
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BG_HUD_BAND),
|
|
||||||
ZIndex(Z_HUD),
|
|
||||||
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
|
||||||
HudActionBar,
|
|
||||||
))
|
|
||||||
.with_children(|row| {
|
|
||||||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
|
||||||
// so Tab cycles the action bar in visual reading order.
|
|
||||||
// Undo and Pause are the primary gameplay actions — full brightness.
|
|
||||||
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
MenuButton,
|
|
||||||
ACTION_BAR_LABELS[0],
|
|
||||||
None,
|
|
||||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
|
||||||
&font,
|
|
||||||
0,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
UndoButton,
|
|
||||||
ACTION_BAR_LABELS[1],
|
|
||||||
Some("U"),
|
|
||||||
"Take back your last move. Costs points and blocks No Undo.",
|
|
||||||
&font,
|
|
||||||
1,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
PauseButton,
|
|
||||||
ACTION_BAR_LABELS[2],
|
|
||||||
Some("Esc"),
|
|
||||||
"Pause the game and freeze the timer.",
|
|
||||||
&font,
|
|
||||||
2,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
HelpButton,
|
|
||||||
ACTION_BAR_LABELS[3],
|
|
||||||
Some("F1"),
|
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
|
||||||
&font,
|
|
||||||
3,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
HintButton,
|
|
||||||
ACTION_BAR_LABELS[4],
|
|
||||||
Some("H"),
|
|
||||||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
|
||||||
&font,
|
|
||||||
4,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
ModesButton,
|
|
||||||
ACTION_BAR_LABELS[5],
|
|
||||||
None,
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
||||||
&font,
|
|
||||||
5,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
NewGameButton,
|
|
||||||
ACTION_BAR_LABELS[6],
|
|
||||||
Some("N"),
|
|
||||||
"Start a fresh deal. Confirms first if a game is in progress.",
|
|
||||||
&font,
|
|
||||||
6,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns a single action button as a child of `row`. Each button shares
|
|
||||||
/// the same node geometry, idle colour, and `ActionButton` marker so
|
|
||||||
/// `paint_action_buttons` can recolour all of them with one query.
|
|
||||||
///
|
|
||||||
/// `order` is the button's index inside the action bar (0 for the
|
|
||||||
/// leftmost). It propagates into the [`Focusable`] this function inserts
|
|
||||||
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
|
|
||||||
///
|
|
||||||
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
|
||||||
/// action button ships with one — there is no opt-out — because each button
|
|
||||||
/// represents a player-triggered action and benefits from a one-line
|
|
||||||
/// reminder of what it does.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(super) fn spawn_action_button<M: Component>(
|
|
||||||
row: &mut ChildSpawnerCommands,
|
|
||||||
marker: M,
|
|
||||||
label: &str,
|
|
||||||
hotkey: Option<&'static str>,
|
|
||||||
tooltip: &'static str,
|
|
||||||
font: &TextFont,
|
|
||||||
order: i32,
|
|
||||||
text_color: Color,
|
|
||||||
) {
|
|
||||||
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
|
||||||
// touch device — the button itself is the affordance — and they
|
|
||||||
// visibly clutter the narrow-viewport action row. The chevrons on
|
|
||||||
// Menu/Modes remain because they indicate dropdown behaviour.
|
|
||||||
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
|
|
||||||
hotkey
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let hotkey_font = TextFont {
|
|
||||||
font: font.font.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
let (pad, min_w, min_h) = action_button_metrics();
|
|
||||||
|
|
||||||
row.spawn((
|
|
||||||
marker,
|
|
||||||
ActionButton,
|
|
||||||
Button,
|
|
||||||
Tooltip::new(tooltip),
|
|
||||||
Focusable {
|
|
||||||
group: FocusGroup::Hud,
|
|
||||||
order,
|
|
||||||
},
|
|
||||||
Node {
|
|
||||||
padding: pad,
|
|
||||||
min_width: min_w,
|
|
||||||
min_height: min_h,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
column_gap: VAL_SPACE_2,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACTION_BTN_IDLE),
|
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
spawn_action_button_label(b, label, font, text_color);
|
|
||||||
if let Some(key) = hotkey {
|
|
||||||
// Hotkey hint rendered as a dim caption next to the label —
|
|
||||||
// keeps the keyboard accelerator discoverable without
|
|
||||||
// hijacking the button's primary affordance.
|
|
||||||
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,873 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
|
||||||
use crate::game_plugin::GamePlugin;
|
|
||||||
use crate::table_plugin::TablePlugin;
|
|
||||||
use chrono::Local;
|
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
||||||
|
|
||||||
fn headless_app() -> App {
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(GamePlugin)
|
|
||||||
.add_plugins(TablePlugin)
|
|
||||||
.add_plugins(HudPlugin);
|
|
||||||
app.update();
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hud_plugin_registers_without_panic() {
|
|
||||||
let _app = headless_app();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn update_hud_runs_after_game_mutation_without_panic() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
app.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_hud_text<M: Component>(app: &mut App) -> String {
|
|
||||||
app.world_mut()
|
|
||||||
.query_filtered::<&Text, With<M>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.next()
|
|
||||||
.map(|t| t.0.clone())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn score_reflects_game_state() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn moves_reflects_game_state() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.set_test_move_count(42);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn draw_three_mode_shows_draw_3_badge() {
|
|
||||||
use solitaire_core::game_state::GameMode;
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zen_mode_hides_score() {
|
|
||||||
use solitaire_core::game_state::GameMode;
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
|
|
||||||
app.update();
|
|
||||||
// Zen mode spec: "No score display" → text must be empty.
|
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn time_display_uses_mm_ss_format() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.elapsed_seconds = 125;
|
|
||||||
app.update();
|
|
||||||
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
|
||||||
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// format_time_limit (pure function)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_time_limit_300_is_5_00() {
|
|
||||||
assert_eq!(format_time_limit(300), "5:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_time_limit_zero() {
|
|
||||||
assert_eq!(format_time_limit(0), "0:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_time_limit_pads_seconds() {
|
|
||||||
assert_eq!(format_time_limit(65), "1:05");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// challenge_hud_text (pure function)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_text_shows_time_limit() {
|
|
||||||
let dc = DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 1,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: Some(300),
|
|
||||||
};
|
|
||||||
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_text_shows_score_goal() {
|
|
||||||
let dc = DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 1,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: Some(4000),
|
|
||||||
max_time_secs: None,
|
|
||||||
};
|
|
||||||
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_text_empty_when_no_constraints() {
|
|
||||||
let dc = DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 1,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: None,
|
|
||||||
};
|
|
||||||
assert_eq!(challenge_hud_text(&dc), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_above_60_is_info() {
|
|
||||||
let c = challenge_time_color(61);
|
|
||||||
assert_eq!(c, STATE_INFO);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_exactly_60_is_info() {
|
|
||||||
let c = challenge_time_color(60);
|
|
||||||
assert_eq!(c, STATE_INFO);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_59_is_warning() {
|
|
||||||
let c = challenge_time_color(59);
|
|
||||||
assert_eq!(c, STATE_WARNING);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_30_is_warning() {
|
|
||||||
let c = challenge_time_color(30);
|
|
||||||
assert_eq!(c, STATE_WARNING);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_29_is_danger() {
|
|
||||||
let c = challenge_time_color(29);
|
|
||||||
assert_eq!(c, STATE_DANGER);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_time_color_zero_is_danger() {
|
|
||||||
let c = challenge_time_color(0);
|
|
||||||
assert_eq!(c, STATE_DANGER);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// HudChallenge in-app tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_empty_when_no_daily_resource() {
|
|
||||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_shows_time_limit_when_resource_present() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().insert_resource(DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 42,
|
|
||||||
goal_description: Some("Win fast".to_string()),
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: Some(300),
|
|
||||||
});
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_shows_score_goal_when_resource_present() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().insert_resource(DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 42,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: Some(4000),
|
|
||||||
max_time_secs: None,
|
|
||||||
});
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_hud_clears_on_win() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().insert_resource(DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 42,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: Some(300),
|
|
||||||
});
|
|
||||||
// Mark the game as won — HudChallenge should be empty.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// HudUndos in-app tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn undos_hud_empty_at_game_start() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn undos_hud_shows_count_after_undo() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.force_test_undos(3);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// HudAutoComplete in-app tests (Task #56)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn headless_app_with_auto_complete() -> App {
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(GamePlugin)
|
|
||||||
.add_plugins(TablePlugin)
|
|
||||||
.add_plugins(HudPlugin);
|
|
||||||
app.init_resource::<AutoCompleteState>();
|
|
||||||
app.update();
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_complete_badge_shows_auto_when_active() {
|
|
||||||
let mut app = headless_app_with_auto_complete();
|
|
||||||
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
|
|
||||||
// Also trigger game state change so the update fires.
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.set_test_move_count(1);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_complete_badge_empty_when_inactive() {
|
|
||||||
let mut app = headless_app_with_auto_complete();
|
|
||||||
// active is false by default.
|
|
||||||
app.world_mut()
|
|
||||||
.resource_mut::<GameStateResource>()
|
|
||||||
.0
|
|
||||||
.set_test_move_count(1);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// HudRecycles in-app tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Draw-One, no recycles yet — text must be empty.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Draw-Three, no recycles yet — text must also be empty.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
|
||||||
GameState::new(42, DrawStockConfig::DrawThree);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recycles_hud_shows_count_draw_three() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
|
|
||||||
gs.force_test_recycles(3);
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recycles_hud_shows_count_draw_one() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
|
||||||
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
gs.force_test_recycles(2);
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
|
||||||
app.update();
|
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Score-change feedback (G2)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Tells `TimePlugin` to advance by `secs` on every subsequent
|
|
||||||
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
|
|
||||||
/// local to avoid coupling the two test modules.
|
|
||||||
fn set_manual_time_step(app: &mut App, secs: f32) {
|
|
||||||
use bevy::time::TimeUpdateStrategy;
|
|
||||||
use std::time::Duration;
|
|
||||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
|
||||||
secs,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Counts entities matching component `M` currently in the world.
|
|
||||||
fn count_with<M: Component>(app: &mut App) -> usize {
|
|
||||||
app.world_mut().query::<&M>().iter(app.world()).count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
|
|
||||||
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
|
|
||||||
/// component is also inserted on the score readout — both signals
|
|
||||||
/// fire from the same delta detection.
|
|
||||||
#[test]
|
|
||||||
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
|
|
||||||
// can be asserted exactly: with Automatic strategy a few ms
|
|
||||||
// of wall-clock time leaks in between updates and the alpha
|
|
||||||
// drifts below 1.0 by `dt / lifetime`.
|
|
||||||
set_manual_time_step(&mut app, 0.0);
|
|
||||||
// Initial state has score=0; bumping by 50 (the threshold)
|
|
||||||
// is the smallest jump that triggers the floater.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// One floater should now exist.
|
|
||||||
let count = count_with::<ScoreFloater>(&mut app);
|
|
||||||
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
|
|
||||||
|
|
||||||
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
|
|
||||||
// detect system spawns the floater coloured ACCENT_PRIMARY
|
|
||||||
// and at dt=0 the first advance tick leaves alpha = 1.0.
|
|
||||||
let world = app.world_mut();
|
|
||||||
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
|
|
||||||
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
|
|
||||||
assert_eq!(color.0, ACCENT_PRIMARY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
|
|
||||||
/// the floater has reached the end of its lifetime and despawned.
|
|
||||||
#[test]
|
|
||||||
fn score_floater_despawns_after_full_lifetime() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
|
||||||
|
|
||||||
// Advance by a delta well past the floater's lifetime — the
|
|
||||||
// single oversized tick clamps t at 1.0 and the entity is
|
|
||||||
// despawned in the same `Update`.
|
|
||||||
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
|
|
||||||
app.update();
|
|
||||||
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
count_with::<ScoreFloater>(&mut app),
|
|
||||||
0,
|
|
||||||
"floater should have despawned after its full lifetime"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A small score change (below the threshold) inserts a pulse on
|
|
||||||
/// the readout but never spawns a floater — keeping the floating
|
|
||||||
/// "+N" reserved for meaningful score jumps.
|
|
||||||
#[test]
|
|
||||||
fn score_increase_below_threshold_does_not_spawn_floater() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// +5 mirrors a single tableau-to-foundation move; well below
|
|
||||||
// the 50-point threshold so the floater path stays dormant.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(
|
|
||||||
count_with::<ScoreFloater>(&mut app),
|
|
||||||
0,
|
|
||||||
"delta of +5 must not spawn a floater"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
|
|
||||||
/// returns to 1.0 at the endpoints. Pure-function check that
|
|
||||||
/// guards the curve shape against future tweaks.
|
|
||||||
#[test]
|
|
||||||
fn score_pulse_scale_is_triangular() {
|
|
||||||
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
|
|
||||||
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
|
|
||||||
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
|
|
||||||
// Values outside [0,1] are clamped before the curve runs.
|
|
||||||
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
|
|
||||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
|
||||||
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
|
||||||
/// curve test — the two animations share a triangular shape so a
|
|
||||||
/// future tweak that desyncs them shows up here.
|
|
||||||
#[test]
|
|
||||||
fn streak_flourish_scale_curves_through_one_one_one() {
|
|
||||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
|
||||||
assert!(
|
|
||||||
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
|
||||||
"streak flourish scale at t=0 must be 1.0",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
|
||||||
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
|
||||||
"streak flourish scale at t=duration must return to 1.0",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Out-of-range values are clamped, not extrapolated. Matches the
|
|
||||||
/// foundation flourish's clamp behaviour so the score readout never
|
|
||||||
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
|
||||||
#[test]
|
|
||||||
fn streak_flourish_scale_clamps_out_of_range() {
|
|
||||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
|
||||||
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
|
||||||
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
|
||||||
/// divides by zero.
|
|
||||||
#[test]
|
|
||||||
fn streak_flourish_scale_zero_duration_is_one() {
|
|
||||||
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
|
||||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
|
||||||
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
|
||||||
#[test]
|
|
||||||
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
|
||||||
use solitaire_data::Settings;
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.insert_resource(SettingsResource(Settings {
|
|
||||||
reduce_motion_mode: true,
|
|
||||||
..Settings::default()
|
|
||||||
}));
|
|
||||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
|
||||||
app.update();
|
|
||||||
assert_eq!(
|
|
||||||
count_with::<ScorePulse>(&mut app),
|
|
||||||
0,
|
|
||||||
"ScorePulse must not spawn under reduce-motion"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
count_with::<ScoreFloater>(&mut app),
|
|
||||||
0,
|
|
||||||
"ScoreFloater must not spawn under reduce-motion"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Phase 2: keyboard focus ring — HUD action bar
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Returns the `Focusable` carried by the unique entity matching
|
|
||||||
/// marker `M`. Helper for the HUD focus tests.
|
|
||||||
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
|
|
||||||
app.world_mut()
|
|
||||||
.query_filtered::<&Focusable, With<M>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.next()
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hud_buttons_get_focusable_marker() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Every action-bar button is in `FocusGroup::Hud`.
|
|
||||||
for f in [
|
|
||||||
focusable_for::<MenuButton>(&mut app),
|
|
||||||
focusable_for::<UndoButton>(&mut app),
|
|
||||||
focusable_for::<PauseButton>(&mut app),
|
|
||||||
focusable_for::<HelpButton>(&mut app),
|
|
||||||
focusable_for::<HintButton>(&mut app),
|
|
||||||
focusable_for::<ModesButton>(&mut app),
|
|
||||||
focusable_for::<NewGameButton>(&mut app),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
f.group,
|
|
||||||
FocusGroup::Hud,
|
|
||||||
"every HUD action button must be in FocusGroup::Hud"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the tooltip string carried by the unique entity matching
|
|
||||||
/// marker `M`. Panics if zero or more than one such entity exists,
|
|
||||||
/// which is the invariant we want to enforce for HUD readouts and
|
|
||||||
/// action buttons (each marker is spawned exactly once).
|
|
||||||
fn tooltip_for<M: Component>(app: &mut App) -> String {
|
|
||||||
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
|
|
||||||
let world = app.world();
|
|
||||||
let mut iter = q.iter(world);
|
|
||||||
let first = iter
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
panic!(
|
|
||||||
"expected a Tooltip on the {} entity",
|
|
||||||
std::any::type_name::<M>()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.0
|
|
||||||
.clone()
|
|
||||||
.into_owned();
|
|
||||||
assert!(
|
|
||||||
iter.next().is_none(),
|
|
||||||
"expected exactly one Tooltip-bearing entity for {}",
|
|
||||||
std::any::type_name::<M>()
|
|
||||||
);
|
|
||||||
first
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every HUD readout and action button must spawn with a `Tooltip`
|
|
||||||
/// carrying the approved canonical microcopy. Mirrors the structure
|
|
||||||
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
|
|
||||||
/// invariant — one marker entity, one tooltip, exact text — is
|
|
||||||
/// asserted consistently across every element.
|
|
||||||
#[test]
|
|
||||||
fn hud_elements_carry_expected_tooltip_strings() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
|
|
||||||
// HUD readouts (left column, top to bottom).
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudScore>(&mut app),
|
|
||||||
"Points earned this game. Hidden in Zen mode."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudMoves>(&mut app),
|
|
||||||
"Moves you've made this game. Counts placements and stock draws."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudTime>(&mut app),
|
|
||||||
"Time on this game. Counts down in Time Attack."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudMode>(&mut app),
|
|
||||||
"Active game mode. Click Modes to switch."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudChallenge>(&mut app),
|
|
||||||
"Today's daily challenge target. Beat it for bonus XP."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudDrawCycle>(&mut app),
|
|
||||||
"Cards drawn on the next stock click in Draw-Three."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudUndos>(&mut app),
|
|
||||||
"Undos used this game. Any undo blocks the No Undo achievement."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudRecycles>(&mut app),
|
|
||||||
"Times you've recycled the stock. Three or more unlocks Comeback."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudAutoComplete>(&mut app),
|
|
||||||
"Board is solvable from here. Press Enter to auto-finish."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HudSelection>(&mut app),
|
|
||||||
"Pile selected with Tab. Use arrows or Enter to act."
|
|
||||||
);
|
|
||||||
|
|
||||||
// Action bar (left to right).
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<MenuButton>(&mut app),
|
|
||||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<UndoButton>(&mut app),
|
|
||||||
"Take back your last move. Costs points and blocks No Undo."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<PauseButton>(&mut app),
|
|
||||||
"Pause the game and freeze the timer."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HelpButton>(&mut app),
|
|
||||||
"Show controls, rules, and keyboard shortcuts."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<HintButton>(&mut app),
|
|
||||||
"Highlight a suggested move. Cycles through alternatives on repeat taps."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<ModesButton>(&mut app),
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tooltip_for::<NewGameButton>(&mut app),
|
|
||||||
"Start a fresh deal. Confirms first if a game is in progress."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every interior row of the Modes and Menu popovers must carry a
|
|
||||||
/// `Tooltip`. The popovers open from action-bar buttons whose own
|
|
||||||
/// tooltips are already covered above; this test extends the
|
|
||||||
/// invariant inward so hover discoverability is uniform across the
|
|
||||||
/// HUD's nested controls.
|
|
||||||
///
|
|
||||||
/// We invoke the popover spawn helpers directly with a maxed-out
|
|
||||||
/// `ProgressResource` and a `DailyChallengeResource` so every row
|
|
||||||
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
|
|
||||||
/// Headless click simulation isn't needed — the contract under
|
|
||||||
/// test is "every popover row spawns with a tooltip", which is a
|
|
||||||
/// property of the spawn helpers themselves.
|
|
||||||
#[test]
|
|
||||||
fn popover_rows_carry_tooltip_strings() {
|
|
||||||
use crate::progress_plugin::ProgressResource;
|
|
||||||
use solitaire_sync::progress::PlayerProgress;
|
|
||||||
|
|
||||||
let mut app = headless_app();
|
|
||||||
|
|
||||||
// Force every mode row to render: level past the challenge
|
|
||||||
// unlock threshold, plus a daily challenge resource so the
|
|
||||||
// Daily row appears.
|
|
||||||
let progress = ProgressResource(PlayerProgress {
|
|
||||||
level: CHALLENGE_UNLOCK_LEVEL,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
let daily = DailyChallengeResource {
|
|
||||||
date: Local::now().date_naive(),
|
|
||||||
seed: 1,
|
|
||||||
goal_description: None,
|
|
||||||
target_score: None,
|
|
||||||
max_time_secs: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Spawn both popovers via their helpers. Mirrors how the click
|
|
||||||
// handlers invoke them in production — we just skip the click.
|
|
||||||
{
|
|
||||||
let world = app.world_mut();
|
|
||||||
let mut commands = world.commands();
|
|
||||||
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
|
|
||||||
spawn_menu_popover(&mut commands, None);
|
|
||||||
world.flush();
|
|
||||||
}
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Every ModeOption-tagged entity must also carry a Tooltip,
|
|
||||||
// and the count must match the five canonical modes.
|
|
||||||
let mut mode_q = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&Tooltip, With<ModeOption>>();
|
|
||||||
let mode_tooltips: Vec<String> = mode_q
|
|
||||||
.iter(app.world())
|
|
||||||
.map(|t| t.0.clone().into_owned())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
mode_tooltips.len(),
|
|
||||||
5,
|
|
||||||
"expected a tooltip on each of the 5 mode rows, got {}",
|
|
||||||
mode_tooltips.len()
|
|
||||||
);
|
|
||||||
// Every approved mode tooltip string must be present somewhere
|
|
||||||
// among the ModeOption rows. Order isn't asserted — the spawn
|
|
||||||
// order test elsewhere already covers that.
|
|
||||||
for expected in [
|
|
||||||
"Standard Klondike. Score, timer, and full progression.",
|
|
||||||
"Today's seeded deal. Same for every player worldwide.",
|
|
||||||
"No timer, no score, no penalties. Just play.",
|
|
||||||
"Hand-picked hard seeds. No undo allowed.",
|
|
||||||
"Win as many games as you can in ten minutes.",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
mode_tooltips.iter().any(|s| s == expected),
|
|
||||||
"missing mode tooltip: {expected:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same contract for MenuOption rows: seven entries, each with a
|
|
||||||
// tooltip, exact strings matching the approved microcopy.
|
|
||||||
let mut menu_q = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&Tooltip, With<MenuOption>>();
|
|
||||||
let menu_tooltips: Vec<String> = menu_q
|
|
||||||
.iter(app.world())
|
|
||||||
.map(|t| t.0.clone().into_owned())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
menu_tooltips.len(),
|
|
||||||
7,
|
|
||||||
"expected a tooltip on each of the 7 menu rows, got {}",
|
|
||||||
menu_tooltips.len()
|
|
||||||
);
|
|
||||||
for expected in [
|
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
||||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
|
||||||
"Browse unlocked achievements and the rewards still ahead.",
|
|
||||||
"Your level, XP progress, and sync status.",
|
|
||||||
"Audio, animations, theme, draw mode, and sync.",
|
|
||||||
"Top players from your sync server. Opt in from Profile.",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
menu_tooltips.iter().any(|s| s == expected),
|
|
||||||
"missing menu tooltip: {expected:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hud_button_order_matches_spawn_order() {
|
|
||||||
let mut app = headless_app();
|
|
||||||
// Visual reading order (left → right): Menu, Undo, Pause, Help,
|
|
||||||
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
|
|
||||||
// that order so Tab cycles them as the player reads them.
|
|
||||||
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
|
|
||||||
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
|
||||||
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
|
||||||
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
|
||||||
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
|
|
||||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
|
|
||||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hud_focus_only_engages_when_button_hovered() {
|
|
||||||
// Phase 2 declares membership in `FocusGroup::Hud`; the
|
|
||||||
// engagement rule lives in `handle_focus_keys`. Two halves to
|
|
||||||
// this test:
|
|
||||||
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
|
|
||||||
// still holds when nothing is hovered).
|
|
||||||
// (b) no modal + a HUD button hovered ⇒ Tab advances
|
|
||||||
// `FocusedButton` to a Hud-grouped entity.
|
|
||||||
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
|
|
||||||
use crate::ui_modal::UiModalPlugin;
|
|
||||||
|
|
||||||
let mut app = App::new();
|
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(UiModalPlugin)
|
|
||||||
.add_plugins(UiFocusPlugin)
|
|
||||||
.add_plugins(GamePlugin)
|
|
||||||
.add_plugins(TablePlugin)
|
|
||||||
.add_plugins(HudPlugin);
|
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// (a) Sanity: HUD buttons exist and are focusable, but no
|
|
||||||
// modal open and no hover ⇒ FocusedButton stays None.
|
|
||||||
assert!(
|
|
||||||
app.world().resource::<FocusedButton>().0.is_none(),
|
|
||||||
"no modal open, no auto-focus"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Press Tab. With no modal and no hover, `handle_focus_keys`
|
|
||||||
// resolves no active group and returns early — Tab must not
|
|
||||||
// advance the HUD focus ring on its own.
|
|
||||||
{
|
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
||||||
input.release_all();
|
|
||||||
input.clear();
|
|
||||||
input.press(KeyCode::Tab);
|
|
||||||
}
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
app.world().resource::<FocusedButton>().0.is_none(),
|
|
||||||
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
|
|
||||||
);
|
|
||||||
|
|
||||||
// (b) Hover the Menu button — the leftmost HUD action — and
|
|
||||||
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
|
|
||||||
let menu_entity = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<Entity, With<MenuButton>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.next()
|
|
||||||
.expect("MenuButton entity should exist");
|
|
||||||
app.world_mut()
|
|
||||||
.entity_mut(menu_entity)
|
|
||||||
.insert(Interaction::Hovered);
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
||||||
input.release_all();
|
|
||||||
input.clear();
|
|
||||||
input.press(KeyCode::Tab);
|
|
||||||
}
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let focused = app
|
|
||||||
.world()
|
|
||||||
.resource::<FocusedButton>()
|
|
||||||
.0
|
|
||||||
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
|
|
||||||
// The focused entity must itself be Hud-grouped (i.e. one of
|
|
||||||
// the action-bar buttons), not anything else in the world.
|
|
||||||
let focusable = app
|
|
||||||
.world()
|
|
||||||
.entity(focused)
|
|
||||||
.get::<Focusable>()
|
|
||||||
.expect("focused entity must carry Focusable");
|
|
||||||
assert_eq!(
|
|
||||||
focusable.group,
|
|
||||||
FocusGroup::Hud,
|
|
||||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,612 +0,0 @@
|
|||||||
//! Per-frame HUD text/typography/visibility updater systems.
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
use bevy::window::WindowResized;
|
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::Suit;
|
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
|
||||||
|
|
||||||
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
|
|
||||||
///
|
|
||||||
/// For example `format_time_limit(300)` returns `"5:00"`.
|
|
||||||
pub fn format_time_limit(secs: u64) -> String {
|
|
||||||
let m = secs / 60;
|
|
||||||
let s = secs % 60;
|
|
||||||
format!("{m}:{s:02}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Score-change feedback (G2)
|
|
||||||
//
|
|
||||||
// The flow for each Update tick:
|
|
||||||
// 1. `detect_score_change` diffs `GameStateResource.score` against
|
|
||||||
// `PreviousScore`. On any positive delta it inserts/refreshes
|
|
||||||
// `ScorePulse` on the score readout; on a delta ≥
|
|
||||||
// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
|
|
||||||
// anchored just below the score.
|
|
||||||
// 2. `advance_score_pulse` ticks the pulse component, applies the
|
|
||||||
// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
|
|
||||||
// component on completion.
|
|
||||||
// 3. `advance_score_floater` drifts each floater upward, fades it to
|
|
||||||
// transparent, and despawns it when its lifetime expires.
|
|
||||||
//
|
|
||||||
// The threshold of 50 (a foundation promotion's typical bonus) keeps
|
|
||||||
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
|
|
||||||
/// current deal's seed + draw_mode + mode triple matches an entry in
|
|
||||||
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
|
|
||||||
/// (the on-screen "Game won!" cue already conveys victory) and on
|
|
||||||
/// fresh deals the player hasn't won before.
|
|
||||||
///
|
|
||||||
/// Lives in its own system rather than `update_hud` to keep this
|
|
||||||
/// orthogonal: `update_hud`'s query disambiguation is already busy
|
|
||||||
/// enough; threading another marker through every Without filter
|
|
||||||
/// would touch ~10 unrelated queries for no benefit.
|
|
||||||
pub(super) fn update_won_previously(
|
|
||||||
game: Res<GameStateResource>,
|
|
||||||
// Optional because the HUD plugin's headless tests run without
|
|
||||||
// `StatsPlugin` and therefore without this resource. With the
|
|
||||||
// resource absent there's no history to compare against; the
|
|
||||||
// indicator just stays empty.
|
|
||||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
|
||||||
mut q: Query<&mut Text, With<HudWonPreviously>>,
|
|
||||||
) {
|
|
||||||
let Ok(mut text) = q.single_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let won_before = !game.0.is_won()
|
|
||||||
&& history.as_ref().is_some_and(|h| {
|
|
||||||
h.0.replays.iter().any(|r| {
|
|
||||||
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let next = if won_before {
|
|
||||||
"\u{2713} Won before"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
if text.0 != next {
|
|
||||||
text.0 = next.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
|
||||||
pub(super) fn update_hud(
|
|
||||||
game: Res<GameStateResource>,
|
|
||||||
time_attack: Option<Res<TimeAttackResource>>,
|
|
||||||
daily: Option<Res<DailyChallengeResource>>,
|
|
||||||
auto_complete: Option<Res<AutoCompleteState>>,
|
|
||||||
mut score_q: Query<
|
|
||||||
&mut Text,
|
|
||||||
(
|
|
||||||
With<HudScore>,
|
|
||||||
Without<HudMoves>,
|
|
||||||
Without<HudTime>,
|
|
||||||
Without<HudMode>,
|
|
||||||
Without<HudChallenge>,
|
|
||||||
Without<HudUndos>,
|
|
||||||
Without<HudAutoComplete>,
|
|
||||||
Without<HudRecycles>,
|
|
||||||
Without<HudDrawCycle>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
mut moves_q: Query<
|
|
||||||
&mut Text,
|
|
||||||
(
|
|
||||||
With<HudMoves>,
|
|
||||||
Without<HudScore>,
|
|
||||||
Without<HudTime>,
|
|
||||||
Without<HudMode>,
|
|
||||||
Without<HudChallenge>,
|
|
||||||
Without<HudUndos>,
|
|
||||||
Without<HudAutoComplete>,
|
|
||||||
Without<HudRecycles>,
|
|
||||||
Without<HudDrawCycle>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
mut time_q: Query<
|
|
||||||
&mut Text,
|
|
||||||
(
|
|
||||||
With<HudTime>,
|
|
||||||
Without<HudScore>,
|
|
||||||
Without<HudMoves>,
|
|
||||||
Without<HudMode>,
|
|
||||||
Without<HudChallenge>,
|
|
||||||
Without<HudUndos>,
|
|
||||||
Without<HudAutoComplete>,
|
|
||||||
Without<HudRecycles>,
|
|
||||||
Without<HudDrawCycle>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
mut mode_q: Query<
|
|
||||||
&mut Text,
|
|
||||||
(
|
|
||||||
With<HudMode>,
|
|
||||||
Without<HudScore>,
|
|
||||||
Without<HudMoves>,
|
|
||||||
Without<HudTime>,
|
|
||||||
Without<HudChallenge>,
|
|
||||||
Without<HudUndos>,
|
|
||||||
Without<HudAutoComplete>,
|
|
||||||
Without<HudRecycles>,
|
|
||||||
Without<HudDrawCycle>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
mut challenge_q: Query<
|
|
||||||
(&mut Text, &mut TextColor),
|
|
||||||
(
|
|
||||||
With<HudChallenge>,
|
|
||||||
Without<HudScore>,
|
|
||||||
Without<HudMoves>,
|
|
||||||
Without<HudTime>,
|
|
||||||
Without<HudMode>,
|
|
||||||
Without<HudUndos>,
|
|
||||||
Without<HudAutoComplete>,
|
|
||||||
Without<HudRecycles>,
|
|
||||||
Without<HudDrawCycle>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
mut undos_q: Query<
|
|
||||||
(&mut Text, &mut TextColor),
|
|
||||||
(
|
|
||||||
With<HudUndos>,
|
|
||||||
Without<HudScore>,
|
|
||||||
Without<HudMoves>,
|
|
||||||
Without<HudTime>,
|
|
||||||
Without<HudMode>,
|
|
||||||
Without<HudChallenge>,
|
|
||||||
Without<HudAutoComplete>,
|
|
||||||
Without<HudRecycles>,
|
|
||||||
Without<HudDrawCycle>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
mut auto_q: Query<
|
|
||||||
&mut Text,
|
|
||||||
(
|
|
||||||
With<HudAutoComplete>,
|
|
||||||
Without<HudScore>,
|
|
||||||
Without<HudMoves>,
|
|
||||||
Without<HudTime>,
|
|
||||||
Without<HudMode>,
|
|
||||||
Without<HudChallenge>,
|
|
||||||
Without<HudUndos>,
|
|
||||||
Without<HudRecycles>,
|
|
||||||
Without<HudDrawCycle>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
mut recycles_q: Query<
|
|
||||||
&mut Text,
|
|
||||||
(
|
|
||||||
With<HudRecycles>,
|
|
||||||
Without<HudScore>,
|
|
||||||
Without<HudMoves>,
|
|
||||||
Without<HudTime>,
|
|
||||||
Without<HudMode>,
|
|
||||||
Without<HudChallenge>,
|
|
||||||
Without<HudUndos>,
|
|
||||||
Without<HudAutoComplete>,
|
|
||||||
Without<HudDrawCycle>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
mut draw_cycle_q: Query<
|
|
||||||
&mut Text,
|
|
||||||
(
|
|
||||||
With<HudDrawCycle>,
|
|
||||||
Without<HudScore>,
|
|
||||||
Without<HudMoves>,
|
|
||||||
Without<HudTime>,
|
|
||||||
Without<HudMode>,
|
|
||||||
Without<HudChallenge>,
|
|
||||||
Without<HudUndos>,
|
|
||||||
Without<HudAutoComplete>,
|
|
||||||
Without<HudRecycles>,
|
|
||||||
Without<HudSelection>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
|
||||||
|
|
||||||
// Score, moves, mode, challenge, and undos only need updating when game state changes.
|
|
||||||
if game.is_changed() {
|
|
||||||
let g = &game.0;
|
|
||||||
let is_zen = g.mode == GameMode::Zen;
|
|
||||||
if let Ok(mut t) = score_q.single_mut() {
|
|
||||||
// Zen mode suppresses score display per spec ("No score display").
|
|
||||||
**t = if is_zen {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("Score: {}", g.score())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if let Ok(mut t) = moves_q.single_mut() {
|
|
||||||
**t = format!("Moves: {}", g.move_count());
|
|
||||||
}
|
|
||||||
if let Ok(mut t) = mode_q.single_mut() {
|
|
||||||
**t = match g.mode {
|
|
||||||
GameMode::Classic => match g.draw_mode() {
|
|
||||||
DrawStockConfig::DrawOne => String::new(),
|
|
||||||
DrawStockConfig::DrawThree => "Draw 3".to_string(),
|
|
||||||
},
|
|
||||||
GameMode::Zen => "ZEN".to_string(),
|
|
||||||
GameMode::Challenge => "CHALLENGE".to_string(),
|
|
||||||
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
|
||||||
GameMode::Difficulty(level) => level.label().to_uppercase(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Daily challenge constraint (with time-low colour warning) ---
|
|
||||||
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
|
|
||||||
if g.is_won() {
|
|
||||||
**t = String::new();
|
|
||||||
} else if let Some(dc) = daily.as_deref() {
|
|
||||||
**t = challenge_hud_text(dc);
|
|
||||||
if let Some(max_secs) = dc.max_time_secs {
|
|
||||||
let remaining = max_secs.saturating_sub(g.elapsed_seconds);
|
|
||||||
*color = TextColor(challenge_time_color(remaining));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
**t = String::new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Undo count ---
|
|
||||||
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
|
||||||
let count = g.undo_count();
|
|
||||||
if count == 0 {
|
|
||||||
**t = String::new();
|
|
||||||
*color = TextColor(TEXT_PRIMARY);
|
|
||||||
} else {
|
|
||||||
**t = format!("Undos: {count}");
|
|
||||||
// STATE_WARNING signals "you took a penalty" — same hue
|
|
||||||
// as the Recycles counter so they read as one category.
|
|
||||||
*color = TextColor(STATE_WARNING);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Recycle counter (both modes, hidden until first recycle) ---
|
|
||||||
if let Ok(mut t) = recycles_q.single_mut() {
|
|
||||||
**t = if g.recycle_count() > 0 {
|
|
||||||
format!("Recycles: {}", g.recycle_count())
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
|
||||||
if let Ok(mut t) = draw_cycle_q.single_mut() {
|
|
||||||
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
|
|
||||||
// Hide when not in Draw-Three or after the game is won.
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
let stock_len = g.stock_cards().len();
|
|
||||||
let next_draw = stock_len.min(3);
|
|
||||||
format!("Cycle: {next_draw}/3")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time display: show Time Attack countdown every frame when active;
|
|
||||||
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
|
|
||||||
// every frame so it disappears immediately on the frame Z is pressed.
|
|
||||||
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
|
||||||
let is_zen = game.0.mode == GameMode::Zen;
|
|
||||||
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
|
||||||
if update_time {
|
|
||||||
if let Ok(mut t) = time_q.single_mut() {
|
|
||||||
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
|
|
||||||
let remaining = ta.remaining_secs.max(0.0) as u64;
|
|
||||||
let m = remaining / 60;
|
|
||||||
let s = remaining % 60;
|
|
||||||
**t = format!("{m}:{s:02}");
|
|
||||||
} else {
|
|
||||||
let secs = game.0.elapsed_seconds;
|
|
||||||
let m = secs / 60;
|
|
||||||
let s = secs % 60;
|
|
||||||
**t = format!("{m}:{s:02}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if is_zen {
|
|
||||||
// Clear the time display immediately whenever Zen mode is active —
|
|
||||||
// do not guard on game.is_changed() so it clears on the same frame
|
|
||||||
// the player presses Z, before any move is made.
|
|
||||||
if let Ok(mut t) = time_q.single_mut() {
|
|
||||||
**t = String::new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Auto-complete badge ---
|
|
||||||
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
|
|
||||||
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
|
||||||
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
|
|
||||||
if (ac_changed || game.is_changed())
|
|
||||||
&& let Ok(mut t) = auto_q.single_mut()
|
|
||||||
{
|
|
||||||
**t = if ac_active {
|
|
||||||
"AUTO".to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
|
|
||||||
///
|
|
||||||
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
|
|
||||||
/// or an empty string when no pile is selected. Runs every frame so the
|
|
||||||
/// indicator stays in sync with the selection resource.
|
|
||||||
pub(super) fn update_selection_hud(
|
|
||||||
selection: Option<Res<SelectionState>>,
|
|
||||||
game: Option<Res<GameStateResource>>,
|
|
||||||
mut q: Query<&mut Text, With<HudSelection>>,
|
|
||||||
) {
|
|
||||||
let Ok(mut t) = q.single_mut() else { return };
|
|
||||||
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
|
||||||
None => String::new(),
|
|
||||||
Some(KlondikePile::Stock) => "▶ Waste".to_string(),
|
|
||||||
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
|
|
||||||
Some(g) => foundation_selection_label(*slot, &g.0),
|
|
||||||
// No game resource means we can't probe claimed_suit; show the
|
|
||||||
// slot-based placeholder so the HUD still surfaces the selection.
|
|
||||||
None => format!("▶ Foundation {}", foundation_number(*slot)),
|
|
||||||
},
|
|
||||||
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
|
|
||||||
};
|
|
||||||
**t = label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the HUD selection label for a foundation slot.
|
|
||||||
///
|
|
||||||
/// When the slot has a claimed suit (any card has landed) the announcement is
|
|
||||||
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
|
||||||
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
|
||||||
pub(super) fn foundation_selection_label(
|
|
||||||
slot: Foundation,
|
|
||||||
game: &solitaire_core::game_state::GameState,
|
|
||||||
) -> String {
|
|
||||||
let claimed = game
|
|
||||||
.pile(KlondikePile::Foundation(slot))
|
|
||||||
.first()
|
|
||||||
.map(|c| c.0.suit());
|
|
||||||
match claimed {
|
|
||||||
Some(suit) => {
|
|
||||||
let s = match suit {
|
|
||||||
Suit::Clubs => "Clubs",
|
|
||||||
Suit::Diamonds => "Diamonds",
|
|
||||||
Suit::Hearts => "Hearts",
|
|
||||||
Suit::Spades => "Spades",
|
|
||||||
};
|
|
||||||
format!("▶ {s} Foundation")
|
|
||||||
}
|
|
||||||
None => format!("▶ Foundation {}", foundation_number(slot)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn foundation_number(foundation: Foundation) -> u8 {
|
|
||||||
match foundation {
|
|
||||||
Foundation::Foundation1 => 1,
|
|
||||||
Foundation::Foundation2 => 2,
|
|
||||||
Foundation::Foundation3 => 3,
|
|
||||||
Foundation::Foundation4 => 4,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableau_number(tableau: Tableau) -> u8 {
|
|
||||||
match tableau {
|
|
||||||
Tableau::Tableau1 => 1,
|
|
||||||
Tableau::Tableau2 => 2,
|
|
||||||
Tableau::Tableau3 => 3,
|
|
||||||
Tableau::Tableau4 => 4,
|
|
||||||
Tableau::Tableau5 => 5,
|
|
||||||
Tableau::Tableau6 => 6,
|
|
||||||
Tableau::Tableau7 => 7,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
|
||||||
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
|
||||||
/// to debounce so the toast only appears on the leading edge.
|
|
||||||
pub(super) fn announce_auto_complete(
|
|
||||||
auto_complete: Option<Res<AutoCompleteState>>,
|
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
|
||||||
mut was_active: Local<bool>,
|
|
||||||
) {
|
|
||||||
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
|
||||||
if now_active && !*was_active {
|
|
||||||
toast.write(InfoToastEvent("Auto-completing...".to_string()));
|
|
||||||
}
|
|
||||||
*was_active = now_active;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the HUD text for the active daily challenge constraints.
|
|
||||||
///
|
|
||||||
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
|
|
||||||
/// score target is set, or an empty string when the challenge has no extra
|
|
||||||
/// constraints.
|
|
||||||
pub(super) fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
|
||||||
if let Some(secs) = dc.max_time_secs {
|
|
||||||
format!("Limit: {}", format_time_limit(secs))
|
|
||||||
} else if let Some(score) = dc.target_score {
|
|
||||||
format!("Goal: {score} pts")
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the colour for the challenge time-limit HUD label based on
|
|
||||||
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
|
|
||||||
/// palette changes for free.
|
|
||||||
///
|
|
||||||
/// | Remaining | Token |
|
|
||||||
/// |-------------|------------------|
|
|
||||||
/// | ≥ 60 s | `STATE_INFO` |
|
|
||||||
/// | 30 – 59 s | `STATE_WARNING` |
|
|
||||||
/// | < 30 s | `STATE_DANGER` |
|
|
||||||
pub fn challenge_time_color(remaining: u64) -> Color {
|
|
||||||
if remaining < 30 {
|
|
||||||
STATE_DANGER
|
|
||||||
} else if remaining < 60 {
|
|
||||||
STATE_WARNING
|
|
||||||
} else {
|
|
||||||
STATE_INFO
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
|
|
||||||
///
|
|
||||||
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
|
|
||||||
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
|
|
||||||
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
|
|
||||||
/// all three items remain on one row inside the 50 %-wide HUD column
|
|
||||||
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
|
|
||||||
/// restored so desktop/tablet layouts are unaffected.
|
|
||||||
type HudScoreFont<'w, 's> =
|
|
||||||
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
|
|
||||||
type HudMovesFont<'w, 's> =
|
|
||||||
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
|
|
||||||
type HudTimeFont<'w, 's> =
|
|
||||||
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
|
|
||||||
|
|
||||||
pub(super) fn update_hud_typography(
|
|
||||||
mut events: MessageReader<WindowResized>,
|
|
||||||
mut score_q: HudScoreFont,
|
|
||||||
mut moves_q: HudMovesFont,
|
|
||||||
mut time_q: HudTimeFont,
|
|
||||||
) {
|
|
||||||
let Some(ev) = events.read().last() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (score_size, secondary_size) = if ev.width < 480.0 {
|
|
||||||
(TYPE_BODY_LG, TYPE_CAPTION)
|
|
||||||
} else {
|
|
||||||
(TYPE_HEADLINE, TYPE_BODY_LG)
|
|
||||||
};
|
|
||||||
for mut font in &mut score_q {
|
|
||||||
font.font_size = score_size;
|
|
||||||
}
|
|
||||||
for mut font in &mut moves_q {
|
|
||||||
font.font_size = secondary_size;
|
|
||||||
}
|
|
||||||
for mut font in &mut time_q {
|
|
||||||
font.font_size = secondary_size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn apply_hud_visibility(
|
|
||||||
hud_vis: Res<HudVisibility>,
|
|
||||||
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
|
|
||||||
) {
|
|
||||||
if !hud_vis.is_changed() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let v = if *hud_vis == HudVisibility::Visible {
|
|
||||||
Visibility::Visible
|
|
||||||
} else {
|
|
||||||
Visibility::Hidden
|
|
||||||
};
|
|
||||||
for mut vis in &mut action_bar {
|
|
||||||
*vis = v;
|
|
||||||
}
|
|
||||||
// The bottom action bar is a pure overlay — it does not claim any
|
|
||||||
// space in the card layout, so no WindowResized event is needed.
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn restore_hud_on_modal(
|
|
||||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
|
||||||
mut hud_vis: ResMut<HudVisibility>,
|
|
||||||
) {
|
|
||||||
if !new_scrims.is_empty() {
|
|
||||||
*hud_vis = HudVisibility::Visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the action-bar label font size for a given logical window width.
|
|
||||||
pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
|
|
||||||
if USE_TOUCH_UI_LAYOUT {
|
|
||||||
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
|
|
||||||
// must share one row. The widest characters are in FiraMono (a
|
|
||||||
// monospace whose advance is ~0.62 of the font size). On a 900
|
|
||||||
// logical-px phone the row budget after bar padding (2*12) and six
|
|
||||||
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
|
|
||||||
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
|
|
||||||
// labels are advance-bound only on very narrow viewports; the real
|
|
||||||
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
|
|
||||||
// at 900 px — comfortably one row with margin to spare — clamped so it
|
|
||||||
// never drops below the 12 px legibility floor or grows past 18 px on
|
|
||||||
// landscape tablets where it would crowd the row again.
|
|
||||||
(window_width / 60.0).clamp(12.0, 18.0)
|
|
||||||
} else {
|
|
||||||
TYPE_BODY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
|
||||||
if USE_TOUCH_UI_LAYOUT {
|
|
||||||
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
|
||||||
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
|
||||||
// shortest labels ("New", "Help") shrink to their text rather than
|
|
||||||
// padding the row out past the 900 logical-px viewport. min_height
|
|
||||||
// stays at 44 px to preserve the comfortable touch target.
|
|
||||||
(
|
|
||||||
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
|
||||||
Val::Px(44.0),
|
|
||||||
Val::Px(44.0),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
|
||||||
Val::Px(48.0),
|
|
||||||
Val::Px(48.0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn spawn_action_button_label(
|
|
||||||
parent: &mut ChildSpawnerCommands,
|
|
||||||
label: &str,
|
|
||||||
font: &TextFont,
|
|
||||||
text_color: Color,
|
|
||||||
) {
|
|
||||||
if USE_TOUCH_UI_LAYOUT {
|
|
||||||
parent.spawn((
|
|
||||||
ActionButtonLabel,
|
|
||||||
Text::new(label),
|
|
||||||
font.clone(),
|
|
||||||
TextColor(text_color),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
|
||||||
/// current viewport width whenever [`LayoutResource`] changes (orientation
|
|
||||||
/// change or window resize).
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
pub(super) fn resize_action_bar_labels(
|
|
||||||
layout: Res<crate::layout::LayoutResource>,
|
|
||||||
windows: Query<&Window>,
|
|
||||||
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
|
|
||||||
) {
|
|
||||||
let w = windows
|
|
||||||
.iter()
|
|
||||||
.next()
|
|
||||||
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
|
|
||||||
let new_size = action_bar_font_size(w);
|
|
||||||
for mut font in &mut labels {
|
|
||||||
font.font_size = new_size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,707 +0,0 @@
|
|||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+65
-196
@@ -7,8 +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::pile::PileType;
|
||||||
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
|
||||||
/// explicit instead of relying on Bevy's automatic resource-conflict ordering
|
/// explicit instead of relying on Bevy's automatic resource-conflict ordering
|
||||||
@@ -92,15 +91,6 @@ 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.
|
||||||
@@ -148,9 +138,9 @@ pub struct Layout {
|
|||||||
/// Centre position of each pile, in 2D world coordinates.
|
/// Centre position of each pile, in 2D world coordinates.
|
||||||
///
|
///
|
||||||
/// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up.
|
/// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up.
|
||||||
/// Every `KlondikePile` (Stock, Waste, four Foundations, seven Tableaux) has an
|
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
|
||||||
/// entry. The map always contains exactly 13 entries after `compute_layout`.
|
/// entry. The map always contains exactly 13 entries after `compute_layout`.
|
||||||
pub pile_positions: HashMap<KlondikePile, Vec2>,
|
pub pile_positions: HashMap<PileType, Vec2>,
|
||||||
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
||||||
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
||||||
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
|
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
|
||||||
@@ -251,46 +241,30 @@ pub fn compute_layout(
|
|||||||
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0;
|
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0;
|
||||||
let tableau_y = top_y - card_height - vertical_gap;
|
let tableau_y = top_y - card_height - vertical_gap;
|
||||||
|
|
||||||
let mut pile_positions: HashMap<KlondikePile, Vec2> = HashMap::with_capacity(13);
|
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
|
||||||
|
|
||||||
pile_positions.insert(KlondikePile::Stock, Vec2::new(col_x(1), top_y));
|
pile_positions.insert(PileType::Stock, Vec2::new(col_x(0), top_y));
|
||||||
|
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
|
||||||
|
|
||||||
// Column 2 is skipped — visual separation between waste and foundations.
|
// Column 2 is skipped — visual separation between waste and foundations.
|
||||||
for slot in 0..4_u8 {
|
for slot in 0..4_u8 {
|
||||||
let foundation = match slot {
|
|
||||||
0 => Foundation::Foundation1,
|
|
||||||
1 => Foundation::Foundation2,
|
|
||||||
2 => Foundation::Foundation3,
|
|
||||||
_ => Foundation::Foundation4,
|
|
||||||
};
|
|
||||||
pile_positions.insert(
|
pile_positions.insert(
|
||||||
KlondikePile::Foundation(foundation),
|
PileType::Foundation(slot),
|
||||||
Vec2::new(col_x(3 + slot as usize), top_y),
|
Vec2::new(col_x(3 + slot as usize), top_y),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
let tableau = match i {
|
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
|
||||||
0 => Tableau::Tableau1,
|
|
||||||
1 => Tableau::Tableau2,
|
|
||||||
2 => Tableau::Tableau3,
|
|
||||||
3 => Tableau::Tableau4,
|
|
||||||
4 => Tableau::Tableau5,
|
|
||||||
5 => Tableau::Tableau6,
|
|
||||||
_ => Tableau::Tableau7,
|
|
||||||
};
|
|
||||||
pile_positions.insert(
|
|
||||||
KlondikePile::Tableau(tableau),
|
|
||||||
Vec2::new(col_x(i), tableau_y),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adaptive tableau fan fraction. On height-limited windows the height-based
|
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
|
||||||
// sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
|
// height-based sizing already ensures a worst-case 13-card column fits at
|
||||||
// so the formula returns the minimum and the clamp keeps it there. On
|
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
|
||||||
// width-limited (portrait phone) windows card_size is small and lots of
|
// keeps it there — no change from prior behaviour. On width-limited
|
||||||
// vertical space is unused; solve for the fraction that fills the available
|
// (portrait phone) windows card_size is small and lots of vertical space
|
||||||
// space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
|
// is unused; we solve for the fraction that exactly fills the available
|
||||||
|
// 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.
|
||||||
@@ -301,87 +275,20 @@ 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,
|
available_tableau_height: avail,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,37 +301,23 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn assert_all_piles_present(layout: &Layout) {
|
fn assert_all_piles_present(layout: &Layout) {
|
||||||
assert!(layout.pile_positions.contains_key(&KlondikePile::Stock));
|
assert!(layout.pile_positions.contains_key(&PileType::Stock));
|
||||||
for foundation in [
|
assert!(layout.pile_positions.contains_key(&PileType::Waste));
|
||||||
Foundation::Foundation1,
|
for slot in 0..4_u8 {
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
] {
|
|
||||||
assert!(
|
assert!(
|
||||||
layout
|
layout
|
||||||
.pile_positions
|
.pile_positions
|
||||||
.contains_key(&KlondikePile::Foundation(foundation)),
|
.contains_key(&PileType::Foundation(slot)),
|
||||||
"missing foundation slot {foundation:?}",
|
"missing foundation slot {slot}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for tableau in [
|
for i in 0..7 {
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
] {
|
|
||||||
assert!(
|
assert!(
|
||||||
layout
|
layout.pile_positions.contains_key(&PileType::Tableau(i)),
|
||||||
.pile_positions
|
"missing tableau {i}"
|
||||||
.contains_key(&KlondikePile::Tableau(tableau)),
|
|
||||||
"missing tableau {tableau:?}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(layout.pile_positions.len(), 12);
|
assert_eq!(layout.pile_positions.len(), 13);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -483,18 +376,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tableau_columns_are_sorted_left_to_right() {
|
fn tableau_columns_are_sorted_left_to_right() {
|
||||||
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);
|
||||||
let tableaus = [
|
for i in 0..6 {
|
||||||
Tableau::Tableau1,
|
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
|
||||||
Tableau::Tableau2,
|
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
];
|
|
||||||
for i in 0..tableaus.len() - 1 {
|
|
||||||
let lhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i])].x;
|
|
||||||
let rhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i + 1])].x;
|
|
||||||
assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1);
|
assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,8 +386,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn top_row_is_above_tableau_row() {
|
fn top_row_is_above_tableau_row() {
|
||||||
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);
|
||||||
let stock_y = layout.pile_positions[&KlondikePile::Stock].y;
|
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||||
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
|
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||||
assert!(stock_y > tableau_y);
|
assert!(stock_y > tableau_y);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +399,7 @@ mod tests {
|
|||||||
fn top_row_clears_hud_band() {
|
fn top_row_clears_hud_band() {
|
||||||
let window = Vec2::new(1280.0, 800.0);
|
let window = Vec2::new(1280.0, 800.0);
|
||||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||||
let stock_y = layout.pile_positions[&KlondikePile::Stock].y;
|
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||||
let card_top = stock_y + layout.card_size.y / 2.0;
|
let card_top = stock_y + layout.card_size.y / 2.0;
|
||||||
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
||||||
assert!(
|
assert!(
|
||||||
@@ -527,35 +411,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
|
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
|
||||||
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);
|
||||||
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||||
let t1_x = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau2)].x;
|
let waste_x = layout.pile_positions[&PileType::Waste].x;
|
||||||
assert!((stock_x - t1_x).abs() < 1e-5);
|
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
|
||||||
|
let t1_x = layout.pile_positions[&PileType::Tableau(1)].x;
|
||||||
|
assert!((stock_x - t0_x).abs() < 1e-5);
|
||||||
|
assert!((waste_x - t1_x).abs() < 1e-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foundations_align_with_tableau_cols_3_to_6() {
|
fn foundations_align_with_tableau_cols_3_to_6() {
|
||||||
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);
|
||||||
let target_tableaus = [
|
for slot in 0..4_u8 {
|
||||||
Tableau::Tableau4,
|
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
|
||||||
Tableau::Tableau5,
|
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
];
|
|
||||||
for (idx, foundation) in [
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let f_x = layout.pile_positions[&KlondikePile::Foundation(*foundation)].x;
|
|
||||||
let t_x = layout.pile_positions[&KlondikePile::Tableau(target_tableaus[idx])].x;
|
|
||||||
assert!(
|
assert!(
|
||||||
(f_x - t_x).abs() < 1e-5,
|
(f_x - t_x).abs() < 1e-5,
|
||||||
"foundation slot {idx} should align with tableau {}",
|
"foundation slot {slot} should align with tableau {}",
|
||||||
3 + idx,
|
3 + slot as usize,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,7 +470,7 @@ mod tests {
|
|||||||
// Default app resolution (see solitaire_app/src/main.rs).
|
// Default app resolution (see solitaire_app/src/main.rs).
|
||||||
let window = Vec2::new(1280.0, 800.0);
|
let window = Vec2::new(1280.0, 800.0);
|
||||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||||
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
|
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||||
let card_h = layout.card_size.y;
|
let card_h = layout.card_size.y;
|
||||||
// Bottom edge of the 13th fanned face-up card.
|
// Bottom edge of the 13th fanned face-up card.
|
||||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||||
@@ -616,7 +489,7 @@ mod tests {
|
|||||||
// The bug originally reproduced at 1920x1080. Lock in a regression test.
|
// The bug originally reproduced at 1920x1080. Lock in a regression test.
|
||||||
let window = Vec2::new(1920.0, 1080.0);
|
let window = Vec2::new(1920.0, 1080.0);
|
||||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||||
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
|
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||||
let card_h = layout.card_size.y;
|
let card_h = layout.card_size.y;
|
||||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||||
let h_gap = layout.card_size.x / 4.0;
|
let h_gap = layout.card_size.x / 4.0;
|
||||||
@@ -647,7 +520,7 @@ mod tests {
|
|||||||
fn expanded_fan_fits_phone_viewport() {
|
fn expanded_fan_fits_phone_viewport() {
|
||||||
let window = Vec2::new(360.0, 800.0);
|
let window = Vec2::new(360.0, 800.0);
|
||||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||||
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
|
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||||
let card_h = layout.card_size.y;
|
let card_h = layout.card_size.y;
|
||||||
let h_gap = layout.card_size.x / 4.0;
|
let h_gap = layout.card_size.x / 4.0;
|
||||||
// Bottom of the 13th (worst-case) fanned face-up card.
|
// Bottom of the 13th (worst-case) fanned face-up card.
|
||||||
@@ -706,8 +579,8 @@ mod tests {
|
|||||||
let window = Vec2::new(360.0, 800.0);
|
let window = Vec2::new(360.0, 800.0);
|
||||||
let without = compute_layout(window, 0.0, 0.0, true);
|
let without = compute_layout(window, 0.0, 0.0, true);
|
||||||
let with_inset = compute_layout(window, 32.0, 0.0, true);
|
let with_inset = compute_layout(window, 32.0, 0.0, true);
|
||||||
let stock_no_inset = without.pile_positions[&KlondikePile::Stock].y;
|
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
|
||||||
let stock_with_inset = with_inset.pile_positions[&KlondikePile::Stock].y;
|
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
|
||||||
assert!(
|
assert!(
|
||||||
stock_with_inset < stock_no_inset,
|
stock_with_inset < stock_no_inset,
|
||||||
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
|
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
|
||||||
@@ -729,10 +602,10 @@ mod tests {
|
|||||||
let without = compute_layout(window, 0.0, 0.0, true);
|
let without = compute_layout(window, 0.0, 0.0, true);
|
||||||
let with_inset = compute_layout(window, 32.0, 0.0, true);
|
let with_inset = compute_layout(window, 32.0, 0.0, true);
|
||||||
for pile in [
|
for pile in [
|
||||||
KlondikePile::Stock,
|
PileType::Stock,
|
||||||
KlondikePile::Stock,
|
PileType::Waste,
|
||||||
KlondikePile::Tableau(Tableau::Tableau1),
|
PileType::Tableau(0),
|
||||||
KlondikePile::Tableau(Tableau::Tableau7),
|
PileType::Tableau(6),
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
||||||
@@ -755,7 +628,7 @@ mod tests {
|
|||||||
with_inset.tableau_fan_frac,
|
with_inset.tableau_fan_frac,
|
||||||
);
|
);
|
||||||
let card_h = with_inset.card_size.y;
|
let card_h = with_inset.card_size.y;
|
||||||
let tableau_y = with_inset.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
|
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
|
||||||
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
|
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
|
||||||
let h_gap = with_inset.card_size.x / 4.0;
|
let h_gap = with_inset.card_size.x / 4.0;
|
||||||
let margin = -window.y / 2.0 + 48.0 + h_gap;
|
let margin = -window.y / 2.0 + 48.0 + h_gap;
|
||||||
@@ -788,8 +661,8 @@ mod tests {
|
|||||||
|
|
||||||
// Verify the "wrong" layout actually differs — the bug would push the
|
// Verify the "wrong" layout actually differs — the bug would push the
|
||||||
// top card row upward by exactly safe_top pixels.
|
// top card row upward by exactly safe_top pixels.
|
||||||
let fresh_stock_y = fresh.pile_positions[&KlondikePile::Stock].y;
|
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
|
||||||
let wrong_stock_y = wrong.pile_positions[&KlondikePile::Stock].y;
|
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
|
||||||
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
|
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
|
||||||
// downward (−y direction). So wrong_stock_y > fresh_stock_y by safe_top.
|
// downward (−y direction). So wrong_stock_y > fresh_stock_y by safe_top.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -807,22 +680,22 @@ mod tests {
|
|||||||
"card size must be preserved after resume",
|
"card size must be preserved after resume",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
(corrected.pile_positions[&KlondikePile::Stock].y - fresh_stock_y).abs() < 1e-3,
|
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
|
||||||
"stock y must match fresh launch after resume: \
|
"stock y must match fresh launch after resume: \
|
||||||
corrected={:.2} fresh={fresh_stock_y:.2}",
|
corrected={:.2} fresh={fresh_stock_y:.2}",
|
||||||
corrected.pile_positions[&KlondikePile::Stock].y,
|
corrected.pile_positions[&PileType::Stock].y,
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
(corrected.pile_positions[&KlondikePile::Stock].x
|
(corrected.pile_positions[&PileType::Stock].x
|
||||||
- fresh.pile_positions[&KlondikePile::Stock].x)
|
- fresh.pile_positions[&PileType::Stock].x)
|
||||||
.abs()
|
.abs()
|
||||||
< 1e-3,
|
< 1e-3,
|
||||||
"stock x must be unchanged after resume",
|
"stock x must be unchanged after resume",
|
||||||
);
|
);
|
||||||
// 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: &Layout| {
|
let card_top = |layout: &super::Layout| {
|
||||||
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
|
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
|
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
|
||||||
@@ -839,11 +712,7 @@ mod tests {
|
|||||||
let window = Vec2::new(360.0, 800.0);
|
let window = Vec2::new(360.0, 800.0);
|
||||||
let without = compute_layout(window, 0.0, 0.0, true);
|
let without = compute_layout(window, 0.0, 0.0, true);
|
||||||
let with_inset = compute_layout(window, 0.0, 48.0, true);
|
let with_inset = compute_layout(window, 0.0, 48.0, true);
|
||||||
for pile in [
|
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
|
||||||
KlondikePile::Stock,
|
|
||||||
KlondikePile::Tableau(Tableau::Tableau1),
|
|
||||||
KlondikePile::Tableau(Tableau::Tableau7),
|
|
||||||
] {
|
|
||||||
assert!(
|
assert!(
|
||||||
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
||||||
"{pile:?} x-position must not change with safe_area_bottom",
|
"{pile:?} x-position must not change with safe_area_bottom",
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ fn toggle_leaderboard_screen(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
|
mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
|
||||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
|
|
||||||
data: Res<LeaderboardResource>,
|
data: Res<LeaderboardResource>,
|
||||||
provider: Option<Res<SyncProviderResource>>,
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
@@ -209,11 +208,6 @@ fn toggle_leaderboard_screen(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't stack a second modal scrim over one that is already open.
|
|
||||||
if !other_modal_scrims.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn the panel immediately with whatever data we have so far.
|
// Spawn the panel immediately with whatever data we have so far.
|
||||||
let remote_available = provider
|
let remote_available = provider
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -862,7 +856,7 @@ fn handle_display_name_confirm(
|
|||||||
.leaderboard_display_name
|
.leaderboard_display_name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
if let SyncBackend::SolitaireServer {
|
if let solitaire_data::settings::SyncBackend::SolitaireServer {
|
||||||
ref username,
|
ref username,
|
||||||
..
|
..
|
||||||
} = settings.0.sync_backend
|
} = settings.0.sync_backend
|
||||||
@@ -1091,7 +1085,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::<ButtonInput<KeyCode>>();
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
//! Bevy integration layer for Ferrous Solitaire.
|
//! Bevy integration layer for Ferrous Solitaire.
|
||||||
|
|
||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub mod analytics_plugin;
|
pub mod analytics_plugin;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub mod android_clipboard;
|
pub mod android_clipboard;
|
||||||
pub mod animation_plugin;
|
pub mod animation_plugin;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub mod audio_plugin;
|
pub mod audio_plugin;
|
||||||
pub mod auto_complete_plugin;
|
pub mod auto_complete_plugin;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub mod avatar_plugin;
|
pub mod avatar_plugin;
|
||||||
pub mod card_animation;
|
pub mod card_animation;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
@@ -29,7 +26,6 @@ pub mod home_plugin;
|
|||||||
pub mod hud_plugin;
|
pub mod hud_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub mod leaderboard_plugin;
|
pub mod leaderboard_plugin;
|
||||||
pub mod onboarding_plugin;
|
pub mod onboarding_plugin;
|
||||||
pub mod pause_plugin;
|
pub mod pause_plugin;
|
||||||
@@ -47,9 +43,7 @@ pub mod selection_plugin;
|
|||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
pub mod splash_plugin;
|
pub mod splash_plugin;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub mod sync_plugin;
|
pub mod sync_plugin;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub mod sync_setup_plugin;
|
pub mod sync_setup_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
@@ -63,17 +57,14 @@ pub mod weekly_goals_plugin;
|
|||||||
pub mod win_summary_plugin;
|
pub mod win_summary_plugin;
|
||||||
|
|
||||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
|
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
|
||||||
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
|
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
|
||||||
pub use assets::{
|
pub use assets::{
|
||||||
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url,
|
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url,
|
||||||
populate_embedded_dark_theme, register_theme_asset_sources,
|
populate_embedded_dark_theme, register_theme_asset_sources,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
|
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
|
||||||
pub use card_animation::{
|
pub use card_animation::{
|
||||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||||
@@ -126,7 +117,6 @@ pub use hud_plugin::{
|
|||||||
};
|
};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
pub use layout::{Layout, LayoutResource, compute_layout};
|
pub use layout::{Layout, LayoutResource, compute_layout};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||||
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
||||||
@@ -153,6 +143,7 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
|
|||||||
pub use selection_plugin::{
|
pub use selection_plugin::{
|
||||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||||
};
|
};
|
||||||
|
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
|
||||||
pub use settings_plugin::{
|
pub use settings_plugin::{
|
||||||
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
|
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
|
||||||
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||||
@@ -164,9 +155,7 @@ pub use stats_plugin::{
|
|||||||
ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen,
|
ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen,
|
||||||
StatsUpdate, WatchReplayButton, format_replay_caption,
|
StatsUpdate, WatchReplayButton, format_replay_caption,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use sync_setup_plugin::SyncSetupPlugin;
|
pub use sync_setup_plugin::SyncSetupPlugin;
|
||||||
pub use table_plugin::{
|
pub use table_plugin::{
|
||||||
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
|
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
|
||||||
@@ -178,7 +167,6 @@ pub use theme::{
|
|||||||
pub use time_attack_plugin::{
|
pub use time_attack_plugin::{
|
||||||
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
||||||
};
|
};
|
||||||
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
|
|
||||||
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
||||||
pub use ui_modal::{
|
pub use ui_modal::{
|
||||||
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
|
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ use crate::ui_theme::{
|
|||||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
use crate::splash_plugin::SplashRoot;
|
|
||||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -154,7 +153,7 @@ pub struct OnboardingPlugin;
|
|||||||
impl Plugin for OnboardingPlugin {
|
impl Plugin for OnboardingPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<OnboardingSlideIndex>()
|
app.init_resource::<OnboardingSlideIndex>()
|
||||||
.add_systems(Update, spawn_if_first_run)
|
.add_systems(PostStartup, spawn_if_first_run)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
|
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
|
||||||
@@ -171,30 +170,11 @@ fn spawn_if_first_run(
|
|||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
mut slide_index: ResMut<OnboardingSlideIndex>,
|
mut slide_index: ResMut<OnboardingSlideIndex>,
|
||||||
splashes: Query<(), With<SplashRoot>>,
|
|
||||||
existing: Query<(), With<OnboardingScreen>>,
|
|
||||||
mut spawned: Local<bool>,
|
|
||||||
) {
|
) {
|
||||||
if *spawned {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Wait until the launch splash has despawned so the two screens
|
|
||||||
// never overlap. PostStartup would fire before the first Update
|
|
||||||
// tick, guaranteeing overlap; checking here costs one frame of
|
|
||||||
// latency after the splash clears, which is imperceptible.
|
|
||||||
if !splashes.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if !existing.is_empty() {
|
|
||||||
*spawned = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(s) = settings else { return };
|
let Some(s) = settings else { return };
|
||||||
if s.0.first_run_complete {
|
if s.0.first_run_complete {
|
||||||
*spawned = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*spawned = true;
|
|
||||||
slide_index.0 = 0;
|
slide_index.0 = 0;
|
||||||
spawn_slide(&mut commands, 0, font_res.as_deref());
|
spawn_slide(&mut commands, 0, font_res.as_deref());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::DrawStockConfig;
|
use solitaire_core::game_state::DrawMode;
|
||||||
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: DrawStockConfig) -> &'static str {
|
pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
||||||
match mode {
|
match mode {
|
||||||
DrawStockConfig::DrawOne => "Draw 1",
|
DrawMode::DrawOne => "Draw 1",
|
||||||
DrawStockConfig::DrawThree => "Draw 3",
|
DrawMode::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 {
|
||||||
DrawStockConfig::DrawOne
|
DrawMode::DrawOne
|
||||||
} else {
|
} else {
|
||||||
DrawStockConfig::DrawThree
|
DrawMode::DrawThree
|
||||||
};
|
};
|
||||||
if settings.0.draw_mode == new_mode {
|
if settings.0.draw_mode == new_mode {
|
||||||
return;
|
return;
|
||||||
@@ -340,7 +340,7 @@ fn handle_forfeit_request(
|
|||||||
if !forfeit_screens.is_empty() {
|
if !forfeit_screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won());
|
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won);
|
||||||
if !game_in_progress {
|
if !game_in_progress {
|
||||||
toast.write(InfoToastEvent("No game to forfeit".to_string()));
|
toast.write(InfoToastEvent("No game to forfeit".to_string()));
|
||||||
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<DrawStockConfig>,
|
draw_mode: Option<DrawMode>,
|
||||||
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: DrawStockConfig,
|
mode: DrawMode,
|
||||||
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 {
|
||||||
DrawStockConfig::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
||||||
DrawStockConfig::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
DrawMode::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(DrawStockConfig::DrawOne), "Draw 1");
|
assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draw_mode_label_draw_three() {
|
fn draw_mode_label_draw_three() {
|
||||||
assert_eq!(draw_mode_label(DrawStockConfig::DrawThree), "Draw 3");
|
assert_eq!(draw_mode_label(DrawMode::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 DrawStockConfig variant were added without updating
|
/// fail to compile if a new DrawMode 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 [DrawStockConfig::DrawOne, DrawStockConfig::DrawThree] {
|
for mode in [DrawMode::DrawOne, DrawMode::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 = DrawStockConfig::DrawOne;
|
.draw_mode = DrawMode::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,
|
||||||
DrawStockConfig::DrawThree,
|
DrawMode::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,
|
||||||
DrawStockConfig::DrawOne,
|
DrawMode::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::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::game_state::{DrawMode, 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, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -1020,12 +1020,12 @@ 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::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::game_state::{DrawMode, 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, DrawStockConfig::DrawOne);
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
game.set_test_won(true);
|
game.is_won = true;
|
||||||
app.insert_resource(GameStateResource(game));
|
app.insert_resource(GameStateResource(game));
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in
|
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in
|
||||||
//! `game_plugin`.
|
//! `game_plugin`.
|
||||||
//!
|
//!
|
||||||
//! The synchronous version (v0.17.0) called the solver on the main thread
|
//! The synchronous version (v0.17.0) called
|
||||||
//! on every H press. Median latency was ~2 ms but pathological positions
|
//! `solitaire_core::solver::try_solve_from_state` on the main thread on
|
||||||
//! can hit the default solve budget at ~120 ms, which is a noticeable
|
//! every H press. Median latency was ~2 ms but pathological positions
|
||||||
//! input-stall on the same frame the player sees the hint request.
|
//! can hit the `SolverConfig::default()` cap at ~120 ms, which is a
|
||||||
|
//! noticeable input-stall on the same frame the player sees the hint
|
||||||
|
//! request.
|
||||||
//!
|
//!
|
||||||
//! This module hosts the resource and polling system that move the
|
//! This module hosts the resource and polling system that move the
|
||||||
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
|
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
|
||||||
@@ -24,12 +26,13 @@
|
|||||||
|
|
||||||
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::KlondikeInstruction;
|
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
|
||||||
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint, hint_piles};
|
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint};
|
||||||
use crate::resources::{GameStateResource, HintCycleIndex};
|
use crate::resources::{GameStateResource, HintCycleIndex};
|
||||||
|
|
||||||
/// In-flight async work for the H-key hint.
|
/// In-flight async work for the H-key hint.
|
||||||
@@ -57,17 +60,23 @@ impl PendingHintTask {
|
|||||||
self.inner = None;
|
self.inner = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a new solver task for `state` with the given solve budgets.
|
/// Spawn a new solver task for `state` with `config`. Drops any
|
||||||
/// Drops any previously in-flight task first (cancel-on-replace).
|
/// previously in-flight task first (cancel-on-replace).
|
||||||
pub fn spawn(&mut self, state: GameState, moves_budget: u64, states_budget: u64) {
|
pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
|
||||||
let move_count_at_spawn = state.move_count();
|
let move_count_at_spawn = state.move_count;
|
||||||
let handle = AsyncComputeTaskPool::get().spawn(async move {
|
let handle = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
// Winnable (`Ok(Some)`) carries the first move on a winning path;
|
let outcome = try_solve_from_state(&state, &config);
|
||||||
// unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
|
match outcome.result {
|
||||||
// to the live-state heuristic so H always produces feedback.
|
SolverResult::Winnable => outcome
|
||||||
match state.solve_first_move(moves_budget, states_budget) {
|
.first_move
|
||||||
Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
|
.map(|mv| HintTaskOutput::SolverMove {
|
||||||
Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
|
from: mv.source,
|
||||||
|
to: mv.dest,
|
||||||
|
})
|
||||||
|
.unwrap_or(HintTaskOutput::NeedsHeuristic),
|
||||||
|
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||||
|
HintTaskOutput::NeedsHeuristic
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
self.inner = Some(HintTask {
|
self.inner = Some(HintTask {
|
||||||
@@ -90,10 +99,9 @@ struct HintTask {
|
|||||||
|
|
||||||
/// What the solver task carries back to the main thread.
|
/// What the solver task carries back to the main thread.
|
||||||
enum HintTaskOutput {
|
enum HintTaskOutput {
|
||||||
/// Solver verdict was winnable; here is the first move on the solution
|
/// Solver verdict was `Winnable`; here is the first move on the
|
||||||
/// path. Converted to highlighted `(from, to)` piles by the poll system
|
/// solution path.
|
||||||
/// via [`crate::input_plugin::hint_piles`].
|
SolverMove { from: PileType, to: PileType },
|
||||||
SolverMove(KlondikeInstruction),
|
|
||||||
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
|
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
|
||||||
/// runs the legacy heuristic against the live `GameState` so the
|
/// runs the legacy heuristic against the live `GameState` so the
|
||||||
/// H key always produces feedback while any legal move exists.
|
/// H key always produces feedback while any legal move exists.
|
||||||
@@ -145,22 +153,19 @@ pub fn poll_pending_hint_task(
|
|||||||
pending.inner = None;
|
pending.inner = None;
|
||||||
|
|
||||||
let Some(g) = game else { return };
|
let Some(g) = game else { return };
|
||||||
if g.0.move_count() != move_count_at_spawn {
|
if g.0.move_count != move_count_at_spawn {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the solver's first move to highlighted piles; fall back to the
|
let (from, to) = match output {
|
||||||
// live-state heuristic when there's no solver move or it maps to a no-op.
|
HintTaskOutput::SolverMove { from, to } => (from, to),
|
||||||
let solver_pair = match output {
|
HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) {
|
||||||
HintTaskOutput::SolverMove(instruction) => hint_piles(&g.0, instruction),
|
Some(pair) => pair,
|
||||||
HintTaskOutput::NeedsHeuristic => None,
|
None => {
|
||||||
};
|
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||||
let (from, to) = match solver_pair.or_else(|| find_heuristic_hint(&g.0, &mut hint_cycle)) {
|
return;
|
||||||
Some(pair) => pair,
|
}
|
||||||
None => {
|
},
|
||||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
emit_hint_visuals(
|
emit_hint_visuals(
|
||||||
&g.0,
|
&g.0,
|
||||||
@@ -178,9 +183,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
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::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::game_state::{DrawMode, 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,28 +213,23 @@ 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, DrawStockConfig::DrawOne);
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
game.set_test_stock_cards(Vec::new());
|
for slot in 0..4_u8 {
|
||||||
game.set_test_waste_cards(Vec::new());
|
game.piles
|
||||||
for foundation in [
|
.get_mut(&PileType::Foundation(slot))
|
||||||
Foundation::Foundation1,
|
.unwrap()
|
||||||
Foundation::Foundation2,
|
.cards
|
||||||
Foundation::Foundation3,
|
.clear();
|
||||||
Foundation::Foundation4,
|
|
||||||
] {
|
|
||||||
game.set_test_foundation_cards(foundation, Vec::new());
|
|
||||||
}
|
}
|
||||||
for tableau in [
|
for i in 0..7_usize {
|
||||||
Tableau::Tableau1,
|
game.piles
|
||||||
Tableau::Tableau2,
|
.get_mut(&PileType::Tableau(i))
|
||||||
Tableau::Tableau3,
|
.unwrap()
|
||||||
Tableau::Tableau4,
|
.cards
|
||||||
Tableau::Tableau5,
|
.clear();
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
] {
|
|
||||||
game.set_test_tableau_cards(tableau, Vec::new());
|
|
||||||
}
|
}
|
||||||
|
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
let ranks_below_king = [
|
let ranks_below_king = [
|
||||||
Rank::Ace,
|
Rank::Ace,
|
||||||
@@ -246,34 +245,31 @@ mod tests {
|
|||||||
Rank::Jack,
|
Rank::Jack,
|
||||||
Rank::Queen,
|
Rank::Queen,
|
||||||
];
|
];
|
||||||
for (foundation, suit) in [
|
for (slot, suit) in suits.iter().enumerate() {
|
||||||
Foundation::Foundation1,
|
let pile = game
|
||||||
Foundation::Foundation2,
|
.piles
|
||||||
Foundation::Foundation3,
|
.get_mut(&PileType::Foundation(slot as u8))
|
||||||
Foundation::Foundation4,
|
.unwrap();
|
||||||
]
|
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||||
.into_iter()
|
pile.cards.push(Card {
|
||||||
.zip(suits.iter())
|
id: (slot as u32) * 13 + i as u32,
|
||||||
{
|
suit: *suit,
|
||||||
let mut cards = Vec::new();
|
rank: *rank,
|
||||||
for rank in ranks_below_king.iter() {
|
face_up: true,
|
||||||
cards.push(Card::new(Deck::Deck1, *suit, *rank));
|
});
|
||||||
}
|
}
|
||||||
game.set_test_foundation_cards(foundation, cards);
|
|
||||||
}
|
}
|
||||||
for (tableau, suit) in [
|
for (col, suit) in suits.iter().enumerate() {
|
||||||
Tableau::Tableau1,
|
game.piles
|
||||||
Tableau::Tableau2,
|
.get_mut(&PileType::Tableau(col))
|
||||||
Tableau::Tableau3,
|
.unwrap()
|
||||||
Tableau::Tableau4,
|
.cards
|
||||||
]
|
.push(Card {
|
||||||
.into_iter()
|
id: 100 + col as u32,
|
||||||
.zip(suits.iter())
|
suit: *suit,
|
||||||
{
|
rank: Rank::King,
|
||||||
game.set_test_tableau_cards(
|
face_up: true,
|
||||||
tableau,
|
});
|
||||||
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
game
|
game
|
||||||
}
|
}
|
||||||
@@ -287,10 +283,10 @@ mod tests {
|
|||||||
fn winnable_solver_emits_hint_after_async_completes() {
|
fn winnable_solver_emits_hint_after_async_completes() {
|
||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<PendingHintTask>()
|
.resource_mut::<PendingHintTask>()
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
.spawn(near_finished_state(), cfg);
|
||||||
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||||
@@ -313,7 +309,7 @@ mod tests {
|
|||||||
"exactly one HintVisualEvent must fire when the solver returns Winnable",
|
"exactly one HintVisualEvent must fire when the solver returns Winnable",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(collected[0].dest_pile, KlondikePile::Foundation(_)),
|
matches!(collected[0].dest_pile, PileType::Foundation(_)),
|
||||||
"solver hint destination must be a foundation slot; got {:?}",
|
"solver hint destination must be a foundation slot; got {:?}",
|
||||||
collected[0].dest_pile,
|
collected[0].dest_pile,
|
||||||
);
|
);
|
||||||
@@ -326,10 +322,10 @@ mod tests {
|
|||||||
fn state_change_drops_in_flight_task() {
|
fn state_change_drops_in_flight_task() {
|
||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<PendingHintTask>()
|
.resource_mut::<PendingHintTask>()
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
.spawn(near_finished_state(), cfg);
|
||||||
assert!(
|
assert!(
|
||||||
app.world().resource::<PendingHintTask>().is_pending(),
|
app.world().resource::<PendingHintTask>().is_pending(),
|
||||||
"task is in flight after spawn",
|
"task is in flight after spawn",
|
||||||
@@ -362,12 +358,12 @@ mod tests {
|
|||||||
fn second_spawn_drops_first_in_flight_task() {
|
fn second_spawn_drops_first_in_flight_task() {
|
||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||||
|
|
||||||
// First spawn.
|
// First spawn.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<PendingHintTask>()
|
.resource_mut::<PendingHintTask>()
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
.spawn(near_finished_state(), cfg);
|
||||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||||
assert!(first_handle_present);
|
assert!(first_handle_present);
|
||||||
|
|
||||||
@@ -376,7 +372,7 @@ mod tests {
|
|||||||
// in flight.
|
// in flight.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<PendingHintTask>()
|
.resource_mut::<PendingHintTask>()
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
.spawn(near_finished_state(), cfg);
|
||||||
// Resource still pending (the second task), but the first
|
// Resource still pending (the second task), but the first
|
||||||
// is gone. We can't directly observe the first handle once
|
// is gone. We can't directly observe the first handle once
|
||||||
// it's been overwritten — what we *can* assert is that the
|
// it's been overwritten — what we *can* assert is that the
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user