Compare commits
105 Commits
6193d31497
...
v0.41.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8274581edf | |||
| 704e70f60a | |||
| b81b314197 | |||
| a2375d2fd9 | |||
| 3f5e4f4290 | |||
| 0797e9a993 | |||
| c53b42342b | |||
| 123aa6d099 | |||
| 331d932c4b | |||
| 716e5f04cf | |||
| ef9d914b99 | |||
| 005efa2ee4 | |||
| 1190ed3ce6 | |||
| 2869e1c34e | |||
| 783f01628e | |||
| 94a6feb5db | |||
| 3daaf47689 | |||
| c00656ec8f | |||
| aea2167eee | |||
| 0ed91ea24c | |||
| 18af49c0f3 | |||
| e208245036 | |||
| 0247efcb07 | |||
| 1d5266a811 | |||
| 26283b5478 | |||
| 3627e9f9cf | |||
| b81a79c51c | |||
| 968721eeb4 | |||
| 780e82ca4b | |||
| 207747db4b | |||
| c66baceb10 | |||
| 329f224ffd | |||
| 2fc190ee42 | |||
| 060efaee7b | |||
| ef599ffa17 | |||
| 22334e0dd5 | |||
| 942b9c2161 | |||
| fde863a4e4 | |||
| 0cf5fc4293 | |||
| 79ddfbc034 | |||
| 7919365775 | |||
| f88b6f61d0 | |||
| 189e0afd24 | |||
| a32d666751 | |||
| a5902ac0af | |||
| c3b83f30d1 | |||
| a46505fe45 | |||
| 1602f1952d | |||
| 81893788c1 | |||
| c232444ef0 | |||
| 56e05caaa9 | |||
| 090b5e789e | |||
| 1e0619897a | |||
| 1b5dfa3e27 | |||
| 4850e9417e | |||
| 8a2a22ff1b | |||
| b3b282ec2d | |||
| 7b5d69e164 | |||
| 923a67dc7b | |||
| d4ad184324 | |||
| 0b7a24a75b | |||
| fc45b6d261 | |||
| 2328643223 | |||
| 5b2b234c54 | |||
| 9c473d6a51 | |||
| b3a8575bbd | |||
| 5091d1b397 | |||
| da165f1622 | |||
| 43076a48d6 | |||
| 5af69d7551 | |||
| 6c9259beff | |||
| dde65a7e30 | |||
| e3b8a403ef | |||
| 9299176b2d | |||
| 6a9352cde1 | |||
| 8995a8ae9c | |||
| 0d5c9cdb1d | |||
| ceb9c950a1 | |||
| 9bbb57134f | |||
| e0a858d4e8 | |||
| 5c992cbdca | |||
| d045781119 | |||
| f0871c03e8 | |||
| e841a7ab4f | |||
| 424c8b2d50 | |||
| 372b6423d8 | |||
| 9e3c6b06b0 | |||
| f0832f3dfa | |||
| ef1efdc3b5 | |||
| dc4cf45ea0 | |||
| 0d3f037672 | |||
| cac77a54a6 | |||
| 2d0359c2ee | |||
| 056459619b | |||
| 1438fd6265 | |||
| 920f2c8597 | |||
| 37a21b9b42 | |||
| 712ed6be80 | |||
| 324003562b | |||
| a69a774edf | |||
| df4887fb36 | |||
| 159774f811 | |||
| b3c4d08dfc | |||
| f313cfd8b7 | |||
| 7fe6ac6c1c |
@@ -36,47 +36,12 @@ jobs:
|
||||
id: meta
|
||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check wasm pkg drift
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_SHA="${{ github.event.before }}"
|
||||
HEAD_SHA="${{ github.sha }}"
|
||||
if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
|
||||
RANGE="$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
RANGE="HEAD~1..HEAD"
|
||||
fi
|
||||
|
||||
CHANGED="$(git diff --name-only "$RANGE")"
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
if echo "$CHANGED" | grep -Eq '^(solitaire_wasm/|solitaire_core/|Cargo\.toml|Cargo\.lock)$|^(solitaire_wasm/|solitaire_core/)'; then
|
||||
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/solitaire_wasm\.js$|^solitaire_server/web/pkg/solitaire_wasm_bg\.wasm$'; then
|
||||
echo "error: wasm/core/Cargo changed but committed web pkg artifacts are missing."
|
||||
echo "Run: wasm-pack build --target web --out-dir solitaire_server/web/pkg --no-typescript solitaire_wasm"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hard check: solitaire_web/ is the direct Bevy WASM source — any
|
||||
# change there MUST rebuild canvas_bg.wasm or the binary goes stale.
|
||||
if echo "$CHANGED" | grep -Eq '^solitaire_web/'; then
|
||||
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
|
||||
echo "error: solitaire_web/ changed but canvas_bg.wasm not updated."
|
||||
echo "Run: ./build_wasm.sh (requires wasm-bindgen-cli + wasm32-unknown-unknown target)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Advisory notice: solitaire_engine/ and solitaire_core/ changes often
|
||||
# require a Bevy WASM rebuild but are not enforced (formatting-only
|
||||
# commits should not be blocked).
|
||||
if echo "$CHANGED" | grep -Eq '^(solitaire_engine/|solitaire_core/)' && \
|
||||
! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
|
||||
echo "notice: solitaire_engine/core changed without a canvas_bg.wasm rebuild."
|
||||
echo " If the change affects gameplay run ./build_wasm.sh before pushing."
|
||||
fi
|
||||
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
|
||||
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
|
||||
# and commits it back (CI is the single source of truth — the artifacts
|
||||
# aren't byte-reproducible on contributor machines). That pkg/ commit then
|
||||
# triggers this workflow, so the deployed image always ships fresh wasm.
|
||||
# No drift check is needed here.
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -25,6 +25,19 @@ jobs:
|
||||
- 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:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Web WASM Rebuild
|
||||
|
||||
# CI is the single source of truth for solitaire_server/web/pkg/.
|
||||
#
|
||||
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
|
||||
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
|
||||
# flags, the same Cargo.lock and remapped source paths, the output still differs
|
||||
# by host environment. So rather than police freshness with a rebuild-and-diff
|
||||
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
|
||||
# itself on every master change to a wasm-feeding crate and commits them back.
|
||||
#
|
||||
# Result: the deployed pkg/ can't silently rot, and contributors never need to
|
||||
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
|
||||
# workflow's trigger paths (so it does not re-trigger here) but does match
|
||||
# docker-build's, so the refreshed wasm deploys.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'solitaire_core/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'solitaire_data/**'
|
||||
- 'solitaire_sync/**'
|
||||
- 'solitaire_wasm/**'
|
||||
- 'solitaire_web/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build_wasm.sh'
|
||||
- '.gitea/workflows/web-wasm-rebuild.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: web-wasm-rebuild
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rebuild:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Install Rust 1.95.0
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
|
||||
|
||||
- name: Install binaryen 130 (wasm-opt, pinned)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sSL \
|
||||
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||
| tar xz
|
||||
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Rebuild WASM artifacts
|
||||
run: ./build_wasm.sh
|
||||
|
||||
- name: Commit refreshed artifacts if changed
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- solitaire_server/web/pkg/; then
|
||||
echo "pkg/ already up to date — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.email "ci@gitea.local"
|
||||
git config user.name "Gitea CI"
|
||||
git add solitaire_server/web/pkg/
|
||||
git commit -m "chore(web): regenerate wasm artifacts"
|
||||
# master is unprotected; retry once if the tip moved under us.
|
||||
git push origin HEAD:master || {
|
||||
git fetch origin master
|
||||
git rebase origin/master
|
||||
git push origin HEAD:master
|
||||
}
|
||||
+11
@@ -30,3 +30,14 @@ solitaire_server/e2e/test-results/
|
||||
deploy/matomo-secret.yaml
|
||||
deploy/*-secret.yaml
|
||||
deploy/*-auth-secret.yaml
|
||||
|
||||
# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo
|
||||
/.agents/
|
||||
/.codex/
|
||||
/AGENTS.md
|
||||
# claude-flow scratch dirs, anywhere in the tree (e.g. solitaire_engine/src/)
|
||||
.claude-flow/
|
||||
|
||||
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
||||
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
||||
scripts/*.go
|
||||
|
||||
@@ -109,6 +109,18 @@ Owns:
|
||||
- Achievement unlock condition evaluation
|
||||
- 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`
|
||||
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
||||
|
||||
|
||||
+314
@@ -6,6 +6,320 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -208,9 +208,14 @@ Embed via `include_bytes!()` only when ALL of the following are true:
|
||||
Currently embedded:
|
||||
* **Audio** — all `.wav` files in `audio_plugin.rs`
|
||||
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
|
||||
* **Bundled UI font** — `assets/fonts/main.ttf` (FiraMono) via `include_bytes!`
|
||||
in `font_plugin.rs` and `assets/svg_loader.rs`; it is the canonical UI face
|
||||
and must always be present, so it is embedded rather than `AssetServer`-loaded
|
||||
|
||||
Do NOT embed card face PNGs, background images, or user fonts —
|
||||
these are loaded via `AssetServer` so art can be swapped without recompile.
|
||||
Do NOT embed card face PNGs or background images — these are loaded via
|
||||
`AssetServer` so art can be swapped without recompile. User-supplied fonts
|
||||
(if ever added) likewise go through `AssetServer`; only the bundled FiraMono
|
||||
face above is embedded.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+11
-4
@@ -2083,11 +2083,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "card_game"
|
||||
version = "0.4.0"
|
||||
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
|
||||
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]]
|
||||
@@ -4599,12 +4601,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "klondike"
|
||||
version = "0.3.0"
|
||||
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
|
||||
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]]
|
||||
@@ -7302,6 +7306,7 @@ name = "solitaire_app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"jni 0.21.1",
|
||||
"keyring",
|
||||
"solitaire_data",
|
||||
"solitaire_engine",
|
||||
@@ -7336,11 +7341,13 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"card_game",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
"jsonwebtoken",
|
||||
"keyring-core",
|
||||
"klondike",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+18
-2
@@ -18,6 +18,22 @@ version = "0.1.0"
|
||||
license = "MIT"
|
||||
rust-version = "1.95"
|
||||
|
||||
# Pedantic correctness lints applied across every member crate via
|
||||
# `[lints] workspace = true`.
|
||||
[workspace.lints.rust]
|
||||
# Workspace-wide ban on `unsafe`. The sole exception is `solitaire_app`,
|
||||
# which sets its own `deny`-level lints (see its Cargo.toml) because the
|
||||
# Android cdylib entry point must reconstruct raw JNI handles. Every other
|
||||
# crate reaches Android JNI through the safe `solitaire_data::android_jni`
|
||||
# bridge and stays fully unsafe-free.
|
||||
unsafe_code = "forbid"
|
||||
single_use_lifetimes = "warn"
|
||||
trivial_casts = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
unused_qualifications = "warn"
|
||||
variant_size_differences = "warn"
|
||||
unexpected_cfgs = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -38,8 +54,8 @@ solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
klondike = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
|
||||
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
|
||||
klondike = { version = "0.4.0", registry = "Quaternions", features = ["serde"] }
|
||||
card_game = { version = "0.4.1", registry = "Quaternions", features = ["serde"] }
|
||||
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
|
||||
+67
-43
@@ -1,45 +1,62 @@
|
||||
# Ferrous Solitaire — Session Handoff
|
||||
|
||||
**Last updated:** 2026-06-02 — Web e2e test suite complete; `/play` canvas bridge added and tested. All commits on origin/master.
|
||||
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
- **HEAD:** `play_canvas.spec.js` added (Playwright tests for `/play` Bevy canvas route)
|
||||
- **Latest tag:** `v0.35.1`
|
||||
- **Working tree:** clean
|
||||
- **Build:** `cargo clippy --workspace -- -D warnings` clean
|
||||
- **Tests:** 1243 Rust tests passing; Playwright suite in `solitaire_server/e2e/`
|
||||
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105–#108).
|
||||
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
|
||||
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
|
||||
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
|
||||
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
|
||||
|
||||
---
|
||||
|
||||
## What shipped since the last handoff (v0.35.1 → present, 2026-06-02)
|
||||
## v0.40.0 release (2026-06-25)
|
||||
|
||||
| Commit | Summary |
|
||||
|--------|---------|
|
||||
| `64f975e` | 14 cross-platform UX/UI fixes from 500-game audit |
|
||||
| `763fdb4` | Fix input: hit-test deck at correct position; accept waste click |
|
||||
| `1cdb78c` | cargo fmt; add analytics domain to CSP |
|
||||
| `baf524e` | Rebuild Bevy canvas WASM; add SolitaireGame interactive API |
|
||||
| `9ff0585` | Remove Quaternions registry auth; canvas WASM drift guard |
|
||||
| `de7ae16` | Delay first-run modal until splash screen despawns |
|
||||
| `8b736ca` | Debug drag failures (temp logging, removed in next commit) |
|
||||
| `8b262af` | Clamp wgpu surface to CSS pixels on HiDPI (prevented WASM panic) |
|
||||
| `d45b7cb` | Add Playwright e2e test suite for web routes |
|
||||
| `2cf7282` | Add `window.__FERROUS_DEBUG__` bridge to `/play` for automation |
|
||||
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.
|
||||
|
||||
**Key audit bugs fixed (all 7 from 500-game UX audit):** timer-after-undo, radial-menu clamping, Android resume flash, tab-hidden timer, orphaned tmp files, drag threshold 4→6px, Draw-1 recycle doc comment.
|
||||
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
|
||||
|
||||
**HiDPI wgpu fix:** `WindowResolution::default().with_scale_factor_override(1.0)` added to the Bevy canvas app. Root cause was physical pixels (CSS×DPR) exceeding WebGL2's 2048px per-dimension limit on HiDPI displays.
|
||||
| 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. |
|
||||
|
||||
**E2E test architecture:** three-tier — Rust unit tests → Playwright smoke/review specs → cycle regression gate. Debug bridge contract in `docs/testing-architecture.md`.
|
||||
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 before v0.35.1
|
||||
## What shipped since v0.39.0
|
||||
|
||||
See git log. CHANGELOG.md currently ends at v0.33.0 (documentation debt, low priority).
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -110,31 +127,38 @@ Three bugs fixed:
|
||||
|
||||
## Open punch list
|
||||
|
||||
### 1. CHANGELOG documentation debt
|
||||
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
|
||||
|
||||
CHANGELOG.md currently ends at v0.33.0. All post-v0.33.0 work is in git log. Low
|
||||
priority — git log is authoritative.
|
||||
This is the **single outstanding task** for the v0.40.0 Android release. Everything
|
||||
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
|
||||
clippy, release manifest sanity, a full local signed APK, the published release, and
|
||||
Obtainium-facing delivery (public releases API, latest non-draft release, APK
|
||||
downloadable anonymously). The only thing that cannot be done without hardware is
|
||||
running the app on a real phone.
|
||||
|
||||
### 2. Android APK launch verification (Option A)
|
||||
Install the published APK on a real Android device (not AVD) and run the checklist
|
||||
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
|
||||
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
|
||||
|
||||
Physical device test: install the latest APK on a real Android device (not AVD),
|
||||
confirm:
|
||||
- App launches without crash
|
||||
- Safe area insets arrive and shift HUD correctly after ~3 frames
|
||||
- All modal Done buttons are above the gesture bar
|
||||
- Drag-and-drop works on all pile types
|
||||
- Leaderboard panel opens and the "Public name" label updates correctly after
|
||||
using "Set Name"
|
||||
The signed release APK is published (grab it from the release page, or use the local
|
||||
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
|
||||
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
|
||||
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
|
||||
|
||||
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.
|
||||
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
|
||||
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
|
||||
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
|
||||
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
|
||||
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
|
||||
filtered logcat showed no Ferrous panic/fatal/ANR.
|
||||
|
||||
### 3. Matomo analytics wiring
|
||||
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
|
||||
|
||||
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
|
||||
engine code consumes them — the analytics toggle in Settings is a no-op. If
|
||||
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
|
||||
and wired to `GameStateResource` events.
|
||||
Separate, ongoing task unrelated to the Android release. `Settings` has
|
||||
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
||||
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
|
||||
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
|
||||
validation checklist and the current web/WASM decision notes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,6 +22,22 @@ set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||
|
||||
# Reproducible builds. The wasm artifacts otherwise bake in machine-specific
|
||||
# absolute source paths (the cargo registry, the rustup std sources, and this
|
||||
# checkout), so a rebuild on a different machine produces different bytes and
|
||||
# the CI freshness gate (rebuild-and-diff) false-positives. Remap all three
|
||||
# prefixes to fixed names so the output is byte-identical anywhere.
|
||||
#
|
||||
# We must use CARGO_ENCODED_RUSTFLAGS (not RUSTFLAGS) and re-state the
|
||||
# getrandom backend cfg here: a `*_RUSTFLAGS` env var *replaces* — does not
|
||||
# merge with — the `[target.wasm32-unknown-unknown] rustflags` in
|
||||
# .cargo/config.toml, so dropping that cfg would break the wasm getrandom build.
|
||||
# Keep this `--cfg` in sync with .cargo/config.toml.
|
||||
CARGO_HOME_DIR="${CARGO_HOME:-$HOME/.cargo}"
|
||||
RUSTUP_HOME_DIR="${RUSTUP_HOME:-$HOME/.rustup}"
|
||||
US=$'\x1f' # unit separator: CARGO_ENCODED_RUSTFLAGS arg delimiter
|
||||
export CARGO_ENCODED_RUSTFLAGS="--cfg${US}getrandom_backend=\"wasm_js\"${US}--remap-path-prefix=${CARGO_HOME_DIR}=/cargo${US}--remap-path-prefix=${RUSTUP_HOME_DIR}=/rustup${US}--remap-path-prefix=${REPO_ROOT}=/build"
|
||||
|
||||
if ! command -v wasm-pack &> /dev/null; then
|
||||
echo "error: wasm-pack not found." >&2
|
||||
echo " Install with: cargo install wasm-pack" >&2
|
||||
|
||||
+57
-22
@@ -2,13 +2,13 @@
|
||||
|
||||
This doc captures the toolchain install + build invocation for the
|
||||
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
|
||||
later sections document what's known to compile, what's stubbed, and
|
||||
the next milestones.
|
||||
later sections document physical-device validation, supported platform
|
||||
surfaces, and remaining Android follow-ups.
|
||||
|
||||
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
||||
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
|
||||
> NOT yet been verified to launch on a device or emulator — that's
|
||||
> the next milestone.
|
||||
> **Status (2026-06-09):** Android build plumbing, app-directory storage,
|
||||
> JNI keystore wiring, and safe-area layout fixes have landed. The remaining
|
||||
> release gate is a physical-device smoke test; AVD tap injection does not
|
||||
> exercise the real touch path reliably enough for launch verification.
|
||||
|
||||
---
|
||||
|
||||
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
|
||||
echo ''
|
||||
echo '# Android dev'
|
||||
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
|
||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
|
||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.14904198"'
|
||||
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
|
||||
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
|
||||
} >> ~/.bashrc
|
||||
@@ -49,10 +49,15 @@ sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-34" \
|
||||
"build-tools;34.0.0" \
|
||||
"ndk;26.3.11579264" \
|
||||
"ndk;30.0.14904198" \
|
||||
"emulator" \
|
||||
"system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# The exact NDK/build-tools versions above are not load-bearing — newer ones
|
||||
# work (verified on NDK 30.0.14904198 / build-tools 37.0.0). `scripts/build_android_apk.sh`
|
||||
# auto-discovers the newest installed NDK and build-tools, so set ANDROID_NDK_HOME
|
||||
# (step 3) to whatever version you actually install here.
|
||||
|
||||
# 6. AVD for testing (one-time).
|
||||
echo no | avdmanager create avd \
|
||||
-n bevy_test \
|
||||
@@ -163,8 +168,8 @@ accepted workaround.
|
||||
Physical device:
|
||||
|
||||
```bash
|
||||
adb devices # confirm connection
|
||||
adb install target/debug/apk/ferrous-solitaire.apk
|
||||
adb devices # confirm connection
|
||||
adb install -r target/debug/apk/ferrous-solitaire.apk
|
||||
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
||||
```
|
||||
@@ -185,35 +190,65 @@ AVD.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's wired vs. what's stubbed
|
||||
## 4. Physical-device smoke test
|
||||
|
||||
The first build pass (commit `fb8b2ac`) gates four desktop-only
|
||||
crates / call sites so the workspace cross-compiles. Each gate is
|
||||
documented at its call site.
|
||||
Run this on a real phone, preferably a modern 64-bit ARM device with gesture
|
||||
navigation enabled.
|
||||
|
||||
Build and install:
|
||||
|
||||
```bash
|
||||
cargo apk build -p solitaire_app --target aarch64-linux-android --lib
|
||||
adb install -r target/debug/apk/ferrous-solitaire.apk
|
||||
adb logcat -c
|
||||
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic|WindowInsets"
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- App launches without panic or ANR.
|
||||
- Safe-area insets arrive after the first few frames and shift HUD/modal
|
||||
content away from the status and gesture bars.
|
||||
- Every modal's Done button remains above the gesture bar:
|
||||
Settings, Help, Pause, Win Summary, and Leaderboard-related dialogs.
|
||||
- Drag-and-drop works on tableau, waste, foundation, and stock/recycle paths.
|
||||
- Tap-to-select and one-tap modes both respond correctly on card stacks.
|
||||
- Leaderboard panel opens, "Set Name" saves, and the "Public name" label updates
|
||||
while the panel remains open.
|
||||
- Rotate the device once, then repeat one modal and one drag operation.
|
||||
- Close and relaunch the app; settings/progress still load.
|
||||
|
||||
Record the device model, Android version, APK commit, and pass/fail notes in the
|
||||
release notes or session handoff. If a failure occurs, keep the filtered logcat
|
||||
and note the exact screen/control path that reproduced it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Platform support matrix
|
||||
|
||||
Desktop-only crates and call sites are gated so the workspace cross-compiles.
|
||||
Each gate is documented at its call site.
|
||||
|
||||
| Surface | Desktop | Android |
|
||||
|---------|---------|---------|
|
||||
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
|
||||
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
|
||||
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
|
||||
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Android Keystore via JNI |
|
||||
| Data directory | Platform data dir | Android app files dir |
|
||||
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
|
||||
|
||||
What's NOT yet ported / not yet measured:
|
||||
Remaining Android follow-ups:
|
||||
|
||||
- `dirs::data_dir()` returns `None` on Android. Callers in
|
||||
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
||||
`achievements.rs`, `settings.rs` all need an Android-aware
|
||||
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
|
||||
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
||||
app lifecycle (suspend / resume), font scaling.
|
||||
- Android Keystore via JNI for `auth_tokens`.
|
||||
- JNI ClipboardManager for share links.
|
||||
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
|
||||
in older docs doesn't yet exist).
|
||||
|
||||
---
|
||||
|
||||
## 5. Iteration loop
|
||||
## 6. Iteration loop
|
||||
|
||||
```bash
|
||||
# Edit code…
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Analytics Validation Runbook
|
||||
|
||||
Ferrous Solitaire currently has two analytics paths:
|
||||
|
||||
- Native desktop/Android gameplay events use `solitaire_engine::AnalyticsPlugin`
|
||||
and `solitaire_data::MatomoClient`.
|
||||
- Hosted web pages include Matomo page-view snippets in
|
||||
`solitaire_server/web/*.html`.
|
||||
|
||||
The Bevy `/play` WASM canvas does not emit the native gameplay events because
|
||||
`AnalyticsPlugin` is intentionally gated out on `wasm32`; it depends on the
|
||||
native Tokio/reqwest stack.
|
||||
|
||||
## Native Matomo Validation
|
||||
|
||||
Use this when a deployed Matomo instance and a native build are available.
|
||||
|
||||
1. Configure `settings.json` with a Matomo URL and site ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"analytics_enabled": true,
|
||||
"matomo_url": "https://analytics.example.com",
|
||||
"matomo_site_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
2. Launch the native app and open Settings.
|
||||
3. Confirm the Privacy section appears and "Share usage data" is `ON`.
|
||||
4. Start a new confirmed game.
|
||||
5. Win or forfeit the game.
|
||||
6. Unlock an achievement if practical, or use an existing achievement path that
|
||||
is easy to trigger in a test profile.
|
||||
7. Wait at least 60 seconds, or close after the win/forfeit path has fired its
|
||||
immediate flush.
|
||||
8. In Matomo, confirm the following custom events arrived:
|
||||
|
||||
| Category | Action | Name |
|
||||
| --- | --- | --- |
|
||||
| `Game` | `Start` | `classic`, `zen`, `challenge`, `time_attack`, or `difficulty` |
|
||||
| `Game` | `Won` | empty |
|
||||
| `Game` | `Forfeit` | empty |
|
||||
| `Achievement` | `Unlocked` | achievement id |
|
||||
|
||||
## Web/WASM Decision
|
||||
|
||||
Keep the current split unless the project explicitly needs in-canvas gameplay
|
||||
events for `/play`.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `/`, `/play-classic`, `/account`, `/leaderboard`, and `/replays` emit Matomo
|
||||
page views through the hosted HTML snippets.
|
||||
- `/play` hosts the Bevy canvas but does not emit gameplay events from the
|
||||
engine.
|
||||
- The browser Content-Security-Policy already allows the deployed Matomo host
|
||||
for scripts, images, and connections.
|
||||
|
||||
If gameplay events are needed on `/play`, add a small `wasm32`-only analytics
|
||||
bridge instead of trying to compile the native plugin:
|
||||
|
||||
- keep the same event contract as native (`Game / Start`, `Game / Won`,
|
||||
`Game / Forfeit`, `Achievement / Unlocked`);
|
||||
- read `Settings::analytics_enabled`, `matomo_url`, and `matomo_site_id`;
|
||||
- send through browser APIs or the existing `_paq` queue;
|
||||
- keep the Settings opt-in behavior identical to native;
|
||||
- add Playwright coverage that stubs Matomo and verifies emitted payloads.
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
|
||||
|
||||
**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
|
||||
**Approach:** Integration is complete. Upstream `card_game` / `klondike` now owns
|
||||
authoritative Klondike rules, session history, undo snapshots, and solving.
|
||||
Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes,
|
||||
and typed UI errors in `solitaire_core`.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,10 +45,12 @@
|
||||
|
||||
---
|
||||
|
||||
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
|
||||
## What Ferrous Solitaire's `solitaire_core` Still Owns
|
||||
|
||||
### 1. Scoring — remaining adapter responsibilities
|
||||
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
|
||||
Ferrous uses **Windows XP Standard** scoring. The upstream library handles the
|
||||
per-move counters and configurable deltas; Ferrous adds the product-specific
|
||||
parts in `GameState` / `KlondikeAdapter`.
|
||||
|
||||
| Event | Delta | Handled by |
|
||||
|---|---|---|
|
||||
@@ -61,11 +66,13 @@ Ferrous uses **Windows XP Standard** scoring. The exact table already implemente
|
||||
|
||||
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
||||
|
||||
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The −15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
|
||||
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty.
|
||||
|
||||
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
||||
|
||||
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
|
||||
**In our wrapper:** `KlondikeAdapter::config_for` configures the upstream rules
|
||||
and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor,
|
||||
time bonus, game-mode suppression, and undo score restoration.
|
||||
|
||||
### 2. Game Modes
|
||||
Ferrous has three modes that alter scoring and undo behaviour:
|
||||
@@ -78,7 +85,9 @@ Ferrous has three modes that alter scoring and undo behaviour:
|
||||
|
||||
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
|
||||
|
||||
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
|
||||
**In our wrapper:** `GameMode` lives on `solitaire_core::GameState`; undo and
|
||||
scoring behavior are applied before/after delegating legal moves to the upstream
|
||||
session.
|
||||
|
||||
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
||||
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
||||
@@ -87,9 +96,13 @@ Zen is intended for relaxed play where the score does not matter. Challenge is a
|
||||
|
||||
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
|
||||
|
||||
Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
|
||||
The old local DFS has been replaced. `solitaire_core::solver` is now a small
|
||||
adapter around `Session::solve()` that preserves the engine-facing
|
||||
`SolverResult`, `SolverConfig`, and first-move payload contract.
|
||||
|
||||
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
|
||||
**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested
|
||||
budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget
|
||||
errors → Inconclusive.
|
||||
|
||||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
||||
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
||||
@@ -135,7 +148,9 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
|
||||
### 8. Undo Stack Approach *(resolved — not an issue)*
|
||||
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
|
||||
|
||||
**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
|
||||
**Resolution:** `GameState` uses `Session`'s built-in snapshot history. Ferrous
|
||||
keeps parallel score/recycle metadata so undo can restore product-specific score
|
||||
state that upstream snapshots do not own.
|
||||
|
||||
---
|
||||
|
||||
@@ -144,12 +159,12 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
|
||||
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
||||
|
||||
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
|
||||
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
|
||||
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
|
||||
2. ✅ **Map pile types** — project `klondike`'s stock face-up half as the engine's waste pile and expose renderer-facing pile snapshots.
|
||||
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (gap 4, upstream).
|
||||
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
|
||||
5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
|
||||
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
||||
7. **Implement `serde`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
|
||||
6. ✅ **Replace solver** — call `session.solve()` with budgets from `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
||||
7. ✅ **Implement `serde`** — serialise schema v4 with upstream `KlondikeInstruction`; auto-migrate schema v3 via `SavedInstruction` compatibility types.
|
||||
|
||||
---
|
||||
|
||||
@@ -192,5 +207,5 @@ The script enforces:
|
||||
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
||||
- Upstream solver PR: #14
|
||||
- `solitaire_core` source: `solitaire_core/src/`
|
||||
- Scoring spec: `solitaire_core/src/scoring.rs`
|
||||
- Scoring implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs`
|
||||
- Architecture overview: `ARCHITECTURE.md`
|
||||
|
||||
@@ -213,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
||||
|
||||
mkdir -p "$(dirname "$APK_OUT")"
|
||||
echo ">>> apksigner sign -> $APK_OUT"
|
||||
# Sign the schemes explicitly instead of relying on apksigner's auto behaviour.
|
||||
# Left to "auto", this pipeline produced an APK carrying invalid v1 (JAR)
|
||||
# signature files (META-INF/*.SF/.RSA present but failing v1 verification).
|
||||
# Android installs it fine via v2/v3, but Obtainium parses the APK's legacy v1
|
||||
# certificate at install time, gets an empty cert list, and crashes with
|
||||
# "RangeError (length): Invalid value: valid value range is empty: 0".
|
||||
# minSdk is 26 (solitaire_app/android/AndroidManifest.xml), so v1/JAR signing is
|
||||
# not needed at all — disable it and ship a clean v2+v3 signature, matching what
|
||||
# modern Android tooling produces for minSdk >= 24.
|
||||
"$BT/apksigner" sign \
|
||||
--ks "$KEYSTORE" \
|
||||
--ks-pass "pass:$KEYSTORE_PASS" \
|
||||
--ks-key-alias "$KEY_ALIAS" \
|
||||
--key-pass "pass:$KEY_PASS" \
|
||||
--min-sdk-version 26 \
|
||||
--v1-signing-enabled false \
|
||||
--v2-signing-enabled true \
|
||||
--v3-signing-enabled true \
|
||||
--out "$APK_OUT" \
|
||||
"$STAGING/app-aligned.apk"
|
||||
|
||||
echo ">>> verify"
|
||||
"$BT/apksigner" verify --verbose "$APK_OUT"
|
||||
"$BT/apksigner" verify --min-sdk-version 26 --verbose "$APK_OUT"
|
||||
|
||||
# Guard: no leftover v1/JAR signature files may remain — their presence (valid or
|
||||
# not) is what tripped Obtainium. Fail the build if any slipped through.
|
||||
if unzip -l "$APK_OUT" 2>/dev/null | grep -qiE 'META-INF/.*\.(SF|RSA|DSA|EC)$'; then
|
||||
echo "ERROR: APK still contains v1/JAR signature files; expected v2+v3 only" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> done: $APK_OUT"
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
||||
#
|
||||
# Polls recent workflow runs and prints a compact status block each cycle.
|
||||
# Stops when the newest docker-build (the deploy) has completed and no
|
||||
# web-wasm-rebuild is still pending — i.e. the full fix is live.
|
||||
#
|
||||
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
||||
# token is read from ~/.config/tea/config.yml (never printed).
|
||||
set -uo pipefail
|
||||
|
||||
REPO="funman300/Ferrous-Solitaire"
|
||||
BASE="https://git.aleshym.co/api/v1/repos/${REPO}"
|
||||
INTERVAL="${1:-20}"
|
||||
CFG="${HOME}/.config/tea/config.yml"
|
||||
|
||||
TOKEN="$(grep -E '^[[:space:]]*token:' "$CFG" | head -1 | sed -E 's/.*token:[[:space:]]*//' | tr -d '"'\'' ')"
|
||||
[ -z "$TOKEN" ] && { echo "error: no token in $CFG" >&2; exit 1; }
|
||||
|
||||
echo "── watching ${REPO} deploy (poll ${INTERVAL}s, Ctrl-C to stop) ──"
|
||||
|
||||
while :; do
|
||||
json="$(curl -s --max-time 20 -H "Authorization: token ${TOKEN}" "${BASE}/actions/runs?limit=6")"
|
||||
# Print rows + emit the deploy state on the last line (parsed below).
|
||||
# JSON is passed via env var because `python3 -` reads its program from the
|
||||
# heredoc on stdin, so stdin can't also carry the data.
|
||||
out="$(JSON_DATA="$json" python3 - <<'PY'
|
||||
import os, sys, json, datetime
|
||||
now = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
raw = os.environ.get("JSON_DATA", "").strip()
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
print("[%s] (api unavailable, retrying)" % now)
|
||||
print("STATE=DEPLOYING")
|
||||
sys.exit(0)
|
||||
runs = d.get("workflow_runs", [])[:6]
|
||||
icons = {("completed","success"):"OK ", ("completed","failure"):"FAIL",
|
||||
("completed","cancelled"):"CXL "}
|
||||
def ic(s, c):
|
||||
if s == "queued": return "queue"
|
||||
if s == "in_progress": return "run.."
|
||||
return icons.get((s, c), s or "?")
|
||||
print("[%s]" % datetime.datetime.now().strftime("%H:%M:%S"))
|
||||
for r in runs:
|
||||
wf = str(r.get("path","")).split("@")[0].split("/")[-1].replace(".yml","")
|
||||
print(" %-5s %-5s %-7s %-18s %s/%s" % (
|
||||
ic(r.get("status"), r.get("conclusion")),
|
||||
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
||||
r.get("status"), r.get("conclusion")))
|
||||
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
||||
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" in str(r.get("path")))
|
||||
top = db[0] if db else None
|
||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
|
||||
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
|
||||
PY
|
||||
)"
|
||||
echo "$out" | grep -v '^STATE='
|
||||
if echo "$out" | grep -q '^STATE=LIVE'; then
|
||||
echo ""
|
||||
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
|
||||
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
||||
break
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@@ -22,6 +22,13 @@ bevy = { workspace = true }
|
||||
solitaire_engine = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
|
||||
# Android-only: the entry point reconstructs the raw `JavaVM` / activity
|
||||
# handles and registers the safe `solitaire_data::android_jni` bridge. This
|
||||
# is the one crate in the workspace that performs `unsafe` FFI, so it is also
|
||||
# the only one that depends on `jni` directly at the app layer.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = { workspace = true }
|
||||
|
||||
# Desktop-only deps. `keyring`'s default-store init only matters on
|
||||
# platforms with a real keychain backend (Linux Secret Service,
|
||||
# macOS Keychain, Windows Credential Store), and its transitive
|
||||
@@ -99,3 +106,18 @@ icon = "@mipmap/ic_launcher"
|
||||
# in portrait orientation. Remove (or add a landscape layout) before
|
||||
# enabling auto-rotate.
|
||||
orientation = "portrait"
|
||||
|
||||
# `solitaire_app` is the one crate that cannot inherit the workspace
|
||||
# `forbid(unsafe_code)`: as the Android cdylib it must export the
|
||||
# `#[unsafe(no_mangle)]` entry point and reconstruct the raw JNI handles
|
||||
# there (a `no_mangle` symbol cannot live in a dependency rlib). It mirrors
|
||||
# the workspace lints but at `deny`, so the two `#[allow(unsafe_code)]`
|
||||
# scopes in the Android entry point are the only unsafe in the whole tree.
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
single_use_lifetimes = "warn"
|
||||
trivial_casts = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
unused_qualifications = "warn"
|
||||
variant_size_differences = "warn"
|
||||
unexpected_cfgs = "warn"
|
||||
|
||||
@@ -144,7 +144,7 @@ fn build_app_with_settings(
|
||||
// Android windows always fill the screen; max_width/max_height
|
||||
// default to 0.0, which panics Bevy's clamp when min > max.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
resize_constraints: WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
..default()
|
||||
@@ -166,7 +166,7 @@ fn build_app_with_settings(
|
||||
// default makes it walk *out* of the APK's assets root and
|
||||
// all loads fail silently — which is what produced the
|
||||
// solid-red card-back fallback in the v0.22.3 screenshot.
|
||||
.set(bevy::asset::AssetPlugin {
|
||||
.set(AssetPlugin {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
file_path: "../assets".to_string(),
|
||||
..default()
|
||||
@@ -363,16 +363,57 @@ fn set_window_icon(
|
||||
/// works on a function named `main`; our shared entry point is `run`, so
|
||||
/// we emit the equivalent expansion manually.
|
||||
#[cfg(target_os = "android")]
|
||||
#[allow(unsafe_code)]
|
||||
#[unsafe(no_mangle)]
|
||||
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
||||
let vm_ptr = android_app.vm_as_ptr().cast();
|
||||
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) {
|
||||
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
|
||||
if let Err(e) = init_android_jni(&android_app) {
|
||||
eprintln!("warn: could not initialise Android JNI bridge ({e})");
|
||||
}
|
||||
let _ = bevy::android::ANDROID_APP.set(android_app);
|
||||
run();
|
||||
}
|
||||
|
||||
/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
|
||||
/// Android runtime and registers safe wrappers with `solitaire_data`.
|
||||
///
|
||||
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
|
||||
/// reconstruction. Every other crate consumes the safe
|
||||
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
|
||||
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
|
||||
/// function and the `#[unsafe(no_mangle)]` entry point above.
|
||||
#[cfg(target_os = "android")]
|
||||
#[allow(unsafe_code)]
|
||||
fn init_android_jni(
|
||||
android_app: &bevy::android::android_activity::AndroidApp,
|
||||
) -> Result<(), String> {
|
||||
use jni::JavaVM;
|
||||
use jni::objects::JObject;
|
||||
|
||||
let vm_ptr = android_app.vm_as_ptr();
|
||||
if vm_ptr.is_null() {
|
||||
return Err("JavaVM pointer is null".into());
|
||||
}
|
||||
// SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
|
||||
// Android runtime; it is valid for the lifetime of the process.
|
||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;
|
||||
|
||||
let env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
|
||||
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
|
||||
// valid for the lifetime of the process. Promote it to a global reference
|
||||
// so the safe bridge can hand it to any thread.
|
||||
let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
|
||||
let activity_ref = env
|
||||
.new_global_ref(&activity)
|
||||
.map_err(|e| format!("activity global ref: {e}"))?;
|
||||
|
||||
solitaire_data::android_jni::set_jvm(vm);
|
||||
solitaire_data::android_jni::set_activity(activity_ref);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
|
||||
@@ -30,3 +30,6 @@ path = "src/bin/gen_seeds.rs"
|
||||
[[bin]]
|
||||
name = "gen_difficulty_seeds"
|
||||
path = "src/bin/gen_difficulty_seeds.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
||||
//! `solitaire_data/src/difficulty_seeds.rs`.
|
||||
//!
|
||||
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
|
||||
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
|
||||
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
|
||||
//! provably-winnable seeds).
|
||||
//! A seed's tier is determined by the **smallest** solve budget at which it is
|
||||
//! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget
|
||||
//! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded
|
||||
//! (we only emit provably-winnable seeds).
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
@@ -19,12 +19,12 @@
|
||||
//! --per-tier Seeds to emit per tier (default 40)
|
||||
//! --help Print this message
|
||||
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||
// whose budget proves it Winnable.
|
||||
const BUDGETS: &[(&str, u64, usize)] = &[
|
||||
const BUDGETS: &[(&str, u64, u64)] = &[
|
||||
("Easy", 1_000, 1_000),
|
||||
("Medium", 5_000, 5_000),
|
||||
("Hard", 25_000, 25_000),
|
||||
@@ -74,7 +74,7 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let draw_mode = DrawMode::DrawOne;
|
||||
let draw_mode = DrawStockConfig::DrawOne;
|
||||
let num_tiers = BUDGETS.len();
|
||||
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||
let mut tried: u64 = 0;
|
||||
@@ -99,12 +99,8 @@ fn main() {
|
||||
if buckets[i].len() >= per_tier {
|
||||
continue;
|
||||
}
|
||||
let cfg = SolverConfig {
|
||||
move_budget,
|
||||
state_budget,
|
||||
};
|
||||
match try_solve(seed, draw_mode, &cfg) {
|
||||
SolverResult::Winnable => {
|
||||
match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) {
|
||||
Ok(Some(_)) => {
|
||||
buckets[i].push(seed);
|
||||
eprintln!(
|
||||
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
||||
@@ -113,13 +109,13 @@ fn main() {
|
||||
);
|
||||
break 'tier; // assign to the cheapest tier that proves it winnable
|
||||
}
|
||||
SolverResult::Unwinnable => {
|
||||
Ok(None) => {
|
||||
// Definitely unsolvable — skip all remaining tiers.
|
||||
break 'tier;
|
||||
}
|
||||
SolverResult::Inconclusive => {
|
||||
Err(_) => {
|
||||
// Budget exhausted without proof — try the next larger tier.
|
||||
// If this is the last tier, the seed is discarded (Inconclusive
|
||||
// If this is the last tier, the seed is discarded (inconclusive
|
||||
// at max budget means "probably but not provably winnable").
|
||||
if i == num_tiers - 1 {
|
||||
break 'tier;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
||||
//!
|
||||
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
||||
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
|
||||
//! collects only those proven winnable (`Ok(Some(_))`; inconclusive is
|
||||
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
||||
//! pasting into `solitaire_data/src/challenge.rs`.
|
||||
//!
|
||||
@@ -17,8 +17,9 @@
|
||||
//! --count Number of Winnable seeds to emit (default 75)
|
||||
//! --help Print this message
|
||||
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
@@ -67,8 +68,7 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let cfg = SolverConfig::default();
|
||||
let draw_mode = DrawMode::DrawOne;
|
||||
let draw_mode = DrawStockConfig::DrawOne;
|
||||
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||
let mut tried: u64 = 0;
|
||||
let mut seed = start;
|
||||
@@ -77,7 +77,15 @@ fn main() {
|
||||
|
||||
while found.len() < count {
|
||||
tried += 1;
|
||||
if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) {
|
||||
if matches!(
|
||||
GameState::solve_fresh_deal(
|
||||
seed,
|
||||
draw_mode,
|
||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||
DEFAULT_SOLVE_STATES_BUDGET
|
||||
),
|
||||
Ok(Some(_))
|
||||
) {
|
||||
found.push(seed);
|
||||
eprintln!(
|
||||
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
||||
|
||||
@@ -16,3 +16,6 @@ serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use card_game::{Rank, Suit};
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// Creates a card with explicit face orientation.
|
||||
pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a face-up card.
|
||||
pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self {
|
||||
Self::new(id, suit, rank, true)
|
||||
}
|
||||
|
||||
/// Creates a face-down card.
|
||||
pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self {
|
||||
Self::new(id, suit, rank, false)
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_constructors_set_fields() {
|
||||
let up = Card::face_up(10, Suit::Spades, Rank::Queen);
|
||||
assert_eq!(up.id, 10);
|
||||
assert_eq!(up.suit, Suit::Spades);
|
||||
assert_eq!(up.rank, Rank::Queen);
|
||||
assert!(up.face_up);
|
||||
|
||||
let down = Card::face_down(11, Suit::Diamonds, Rank::King);
|
||||
assert_eq!(down.id, 11);
|
||||
assert_eq!(down.suit, Suit::Diamonds);
|
||||
assert_eq!(down.rank, Rank::King);
|
||||
assert!(!down.face_up);
|
||||
}
|
||||
}
|
||||
+574
-402
File diff suppressed because it is too large
Load Diff
@@ -3,39 +3,32 @@
|
||||
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
||||
//! - building [`KlondikeConfig`] from Ferrous settings
|
||||
//! - translating between local and upstream types
|
||||
//! - applying Ferrous-specific scoring policy on top of upstream defaults
|
||||
//!
|
||||
//! Ferrous-specific scoring policy (the win-time bonus) lives in
|
||||
//! [`crate::scoring`], not here.
|
||||
//!
|
||||
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
|
||||
//! upstream `card_game` / `klondike` types live here so that the product modules
|
||||
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
||||
|
||||
use card_game::Card as KlCard;
|
||||
use klondike::{
|
||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
|
||||
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
|
||||
TableauStack,
|
||||
DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
|
||||
SkipCards, Tableau,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::card;
|
||||
use crate::game_state::{DrawMode, GameMode};
|
||||
|
||||
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
||||
/// 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, conversion, and
|
||||
/// scoring helpers.
|
||||
/// 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: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
|
||||
pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig {
|
||||
KlondikeConfig {
|
||||
draw_stock: match draw_mode {
|
||||
DrawMode::DrawOne => DrawStockConfig::DrawOne,
|
||||
DrawMode::DrawThree => DrawStockConfig::DrawThree,
|
||||
},
|
||||
draw_stock: draw_mode,
|
||||
move_from_foundation: if take_from_foundation {
|
||||
MoveFromFoundationConfig::Allowed
|
||||
} else {
|
||||
@@ -44,116 +37,6 @@ impl KlondikeAdapter {
|
||||
scoring: ScoringConfig::DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scoring helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Score delta for a card move.
|
||||
///
|
||||
/// Reads from [`ScoringConfig`] (WXP Standard values):
|
||||
/// - Any pile → Foundation: +10
|
||||
/// - Waste → Tableau: +5
|
||||
/// - Foundation → Tableau: −15
|
||||
/// - All other moves: 0
|
||||
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
|
||||
let sc = ScoringConfig::DEFAULT;
|
||||
match (from, to) {
|
||||
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
||||
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
||||
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for exposing a face-down tableau card: +5.
|
||||
pub fn score_for_flip() -> i32 {
|
||||
ScoringConfig::DEFAULT.flip_up_bonus
|
||||
}
|
||||
|
||||
/// Score delta for undo: −15.
|
||||
///
|
||||
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
|
||||
/// defaults to 0; the solver overrides it to 0 explicitly. The −15 WXP penalty
|
||||
/// is applied here by `GameState` on every undo.
|
||||
pub fn score_for_undo() -> i32 {
|
||||
-15
|
||||
}
|
||||
|
||||
/// Score delta for recycling waste → stock.
|
||||
///
|
||||
/// [`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:
|
||||
///
|
||||
/// | Mode | Free recycles | Penalty per extra recycle |
|
||||
/// |---|---|---|
|
||||
/// | Draw-1 | 1 | −100 |
|
||||
/// | Draw-3 | 3 | −20 |
|
||||
///
|
||||
/// **Design note:** recycling is *never* blocked — only penalised.
|
||||
/// This is intentional: Draw-1 can be played indefinitely with the score
|
||||
/// dropping toward zero after the first free recycle. A hard cap would
|
||||
/// create unwinnable positions when the solver cannot find a path without
|
||||
/// additional recycling. Zen mode suppresses the penalty entirely.
|
||||
///
|
||||
/// `recycle_count` must be the new total **after** this recycle.
|
||||
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||
if is_draw_three {
|
||||
if recycle_count > 3 { -20 } else { 0 }
|
||||
} else if recycle_count > 1 {
|
||||
-100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for a card move, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
||||
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_move(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for exposing a face-down card, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`].
|
||||
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_flip()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the new score after an undo, accounting for game mode.
|
||||
///
|
||||
/// In [`GameMode::Zen`] the score is always 0. Otherwise applies the
|
||||
/// −15 undo penalty and clamps to 0 via [`Self::score_for_undo`].
|
||||
pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
(snapshot_score + Self::score_for_undo()).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for recycling, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`].
|
||||
pub fn score_for_recycle_with_mode(
|
||||
recycle_count: u32,
|
||||
is_draw_three: bool,
|
||||
mode: GameMode,
|
||||
) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_recycle(recycle_count, is_draw_three)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||||
@@ -200,293 +83,3 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`card_game::Card`] to a [`card::Card`], assigning a stable `id`
|
||||
/// derived from suit and rank (0–51, Clubs-first ordering).
|
||||
///
|
||||
/// The id is consistent for the same logical card across all reconstructions.
|
||||
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
|
||||
let suit = kl_card.suit();
|
||||
let rank = kl_card.rank();
|
||||
let suit_index = match suit {
|
||||
card::Suit::Clubs => 0,
|
||||
card::Suit::Diamonds => 1,
|
||||
card::Suit::Hearts => 2,
|
||||
card::Suit::Spades => 3,
|
||||
};
|
||||
let id = suit_index * 13 + (rank.value() as u32 - 1);
|
||||
card::Card {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
|
||||
//
|
||||
// These types were introduced when upstream `klondike` had no serde feature.
|
||||
// At rev 99b49e62, upstream provides full serde support, and `GameState`
|
||||
// serialises `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
|
||||
//
|
||||
// The mirror types are retained for three reasons:
|
||||
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
|
||||
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
|
||||
// files with u8 indices and replay them.
|
||||
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
|
||||
// type; changing it would break the on-disk replay format (schema v2).
|
||||
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
|
||||
// replay JSON is cross-compatible between the desktop and browser builds.
|
||||
//
|
||||
// These types should not be used for new serialisation concerns. If the
|
||||
// ReplayMove format is ever bumped to a new schema, migrate those callers to
|
||||
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedTableau(pub u8);
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedFoundation(pub u8);
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedSkipCards(pub u8);
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavedKlondikePile {
|
||||
Tableau(SavedTableau),
|
||||
Stock,
|
||||
Foundation(SavedFoundation),
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedTableauStack {
|
||||
pub tableau: SavedTableau,
|
||||
pub skip_cards: SavedSkipCards,
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavedKlondikePileStack {
|
||||
Tableau(SavedTableauStack),
|
||||
Stock,
|
||||
Foundation(SavedFoundation),
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedDstFoundation {
|
||||
pub src: SavedKlondikePile,
|
||||
pub foundation: SavedFoundation,
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedDstTableau {
|
||||
pub src: SavedKlondikePileStack,
|
||||
pub tableau: SavedTableau,
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
|
||||
///
|
||||
/// Convert to/from the upstream type with:
|
||||
/// ```ignore
|
||||
/// let saved = SavedInstruction::from(instruction);
|
||||
/// let instruction = KlondikeInstruction::try_from(saved)?;
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavedInstruction {
|
||||
DstFoundation(SavedDstFoundation),
|
||||
DstTableau(SavedDstTableau),
|
||||
RotateStock,
|
||||
}
|
||||
|
||||
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
|
||||
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum InvalidSavedInstruction {
|
||||
#[error("invalid tableau index {0} (expected 0–6)")]
|
||||
Tableau(u8),
|
||||
#[error("invalid foundation index {0} (expected 0–3)")]
|
||||
Foundation(u8),
|
||||
#[error("invalid skip_cards value {0} (expected 0–12)")]
|
||||
SkipCards(u8),
|
||||
}
|
||||
|
||||
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
|
||||
|
||||
impl From<Tableau> for SavedTableau {
|
||||
fn from(t: Tableau) -> Self {
|
||||
Self(t as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Foundation> for SavedFoundation {
|
||||
fn from(f: Foundation) -> Self {
|
||||
Self(f as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SkipCards> for SavedSkipCards {
|
||||
fn from(s: SkipCards) -> Self {
|
||||
Self(s as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KlondikePile> for SavedKlondikePile {
|
||||
fn from(p: KlondikePile) -> Self {
|
||||
match p {
|
||||
KlondikePile::Tableau(t) => Self::Tableau(t.into()),
|
||||
KlondikePile::Stock => Self::Stock,
|
||||
KlondikePile::Foundation(f) => Self::Foundation(f.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TableauStack> for SavedTableauStack {
|
||||
fn from(ts: TableauStack) -> Self {
|
||||
Self {
|
||||
tableau: ts.tableau.into(),
|
||||
skip_cards: ts.skip_cards.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KlondikePileStack> for SavedKlondikePileStack {
|
||||
fn from(ps: KlondikePileStack) -> Self {
|
||||
match ps {
|
||||
KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()),
|
||||
KlondikePileStack::Stock => Self::Stock,
|
||||
KlondikePileStack::Foundation(f) => Self::Foundation(f.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DstFoundation> for SavedDstFoundation {
|
||||
fn from(df: DstFoundation) -> Self {
|
||||
Self {
|
||||
src: df.src.into(),
|
||||
foundation: df.foundation.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DstTableau> for SavedDstTableau {
|
||||
fn from(dt: DstTableau) -> Self {
|
||||
Self {
|
||||
src: dt.src.into(),
|
||||
tableau: dt.tableau.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KlondikeInstruction> for SavedInstruction {
|
||||
fn from(i: KlondikeInstruction) -> Self {
|
||||
match i {
|
||||
KlondikeInstruction::RotateStock => Self::RotateStock,
|
||||
KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()),
|
||||
KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TryFrom impls: Saved* → KlondikeInstruction ──────────────────────────────
|
||||
|
||||
impl TryFrom<SavedTableau> for Tableau {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
|
||||
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedFoundation> for Foundation {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
||||
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedSkipCards> for SkipCards {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
||||
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedKlondikePile> for KlondikePile {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
|
||||
Ok(match s {
|
||||
SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?),
|
||||
SavedKlondikePile::Stock => KlondikePile::Stock,
|
||||
SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedTableauStack> for TableauStack {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
|
||||
Ok(TableauStack {
|
||||
tableau: s.tableau.try_into()?,
|
||||
skip_cards: s.skip_cards.try_into()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
|
||||
Ok(match s {
|
||||
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
|
||||
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
|
||||
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedDstFoundation> for DstFoundation {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
|
||||
Ok(DstFoundation {
|
||||
src: s.src.try_into()?,
|
||||
foundation: s.foundation.try_into()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedDstTableau> for DstTableau {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
|
||||
Ok(DstTableau {
|
||||
src: s.src.try_into()?,
|
||||
tableau: s.tableau.try_into()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedInstruction> for KlondikeInstruction {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
|
||||
Ok(match s {
|
||||
SavedInstruction::RotateStock => KlondikeInstruction::RotateStock,
|
||||
SavedInstruction::DstFoundation(df) => {
|
||||
KlondikeInstruction::DstFoundation(df.try_into()?)
|
||||
}
|
||||
SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
if elapsed_seconds == 0 {
|
||||
return 0;
|
||||
}
|
||||
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
pub mod achievement;
|
||||
pub mod card;
|
||||
pub mod error;
|
||||
pub mod game_state;
|
||||
pub mod klondike_adapter;
|
||||
pub mod pile;
|
||||
pub mod solver;
|
||||
pub mod scoring;
|
||||
|
||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||
// downstream crates (engine, wasm) can import from one place without a direct
|
||||
// `klondike` / `card_game` dep.
|
||||
//
|
||||
// `KlondikePileStack`, `SkipCards`, and `TableauStack` are intentionally NOT
|
||||
// re-exported — they are only used internally in `klondike_adapter.rs` and do
|
||||
// not appear in any public method signature.
|
||||
pub use card_game::Session;
|
||||
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
|
||||
// `KlondikePileStack`, `SkipCards` and `TableauStack` are intentionally NOT
|
||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||
// appear in any public method signature.
|
||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
||||
|
||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptest_tests;
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
use crate::card::{Card, Suit};
|
||||
use klondike::KlondikePile;
|
||||
|
||||
/// Read-only projection of a single Klondike pile, rebuilt from [`GameState`] on every sync.
|
||||
///
|
||||
/// `Pile` is a **data-transfer type**, not a game-state owner. Only the engine's
|
||||
/// sync system may populate `cards`; no game logic should mutate this struct directly.
|
||||
/// [`GameState`] is always the authoritative source of truth.
|
||||
///
|
||||
/// [`GameState`]: crate::game_state::GameState
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pile {
|
||||
/// Which logical Klondike pile this is.
|
||||
pub pile_type: KlondikePile,
|
||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||
/// Populated by the sync system; do not mutate from game-logic code.
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
impl Pile {
|
||||
/// Creates a new empty pile of the given type.
|
||||
pub fn new(pile_type: KlondikePile) -> 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 {
|
||||
KlondikePile::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(KlondikePile::Stock);
|
||||
assert!(pile.cards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_top_returns_last_card() {
|
||||
let mut pile = Pile::new(KlondikePile::Stock);
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
|
||||
assert_eq!(pile.top().unwrap().id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_top_on_empty_is_none() {
|
||||
let pile = Pile::new(KlondikePile::Stock);
|
||||
assert!(pile.top().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_empty_foundation() {
|
||||
let pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation1));
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_non_foundation() {
|
||||
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_returns_bottom_card_suit() {
|
||||
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
|
||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,20 @@
|
||||
use card_game::Game;
|
||||
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
||||
use card_game::{Card, Game};
|
||||
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::game_state::{DrawMode, GameState};
|
||||
use crate::klondike_adapter::{
|
||||
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
|
||||
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
|
||||
SavedTableauStack,
|
||||
};
|
||||
use crate::game_state::GameState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Collect all card IDs across every pile in a fixed traversal order:
|
||||
/// 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_card_ids(game: &GameState) -> Vec<u32> {
|
||||
fn all_cards(game: &GameState) -> Vec<Card> {
|
||||
let foundations = [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
@@ -36,50 +31,46 @@ fn all_card_ids(game: &GameState) -> Vec<u32> {
|
||||
Tableau::Tableau7,
|
||||
];
|
||||
|
||||
let mut ids: Vec<u32> = game.stock_cards().iter().map(|c| c.id).collect();
|
||||
ids.extend(game.waste_cards().iter().map(|c| c.id));
|
||||
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 {
|
||||
ids.extend(
|
||||
cards.extend(
|
||||
game.pile(KlondikePile::Foundation(*f))
|
||||
.iter()
|
||||
.map(|c| c.id),
|
||||
.map(|(c, _)| c.clone()),
|
||||
);
|
||||
}
|
||||
for t in &tableaux {
|
||||
ids.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|c| c.id));
|
||||
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
||||
}
|
||||
ids
|
||||
cards
|
||||
}
|
||||
|
||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
|
||||
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
|
||||
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 move from
|
||||
/// `possible_instructions()` and execute it.
|
||||
/// - `draw_flag = false` → pick the `move_index % len`th legal instruction
|
||||
/// from `possible_instructions()` and apply it via `apply_instruction()`.
|
||||
///
|
||||
/// `possible_instructions()` may return `(Stock, Stock, 1)` for the
|
||||
/// RotateStock / draw action. `move_cards(Stock, Stock, 1)` is rejected by
|
||||
/// the `from == to` guard, so those are dispatched to `game.draw()`.
|
||||
/// `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 instructions = game.possible_instructions();
|
||||
if instructions.is_empty() {
|
||||
let moves = game.possible_instructions();
|
||||
if moves.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (from, to, count) = instructions[idx % instructions.len()];
|
||||
if from == to {
|
||||
let _ = game.draw();
|
||||
} else {
|
||||
let _ = game.move_cards(from, to, count);
|
||||
}
|
||||
let instruction = moves[idx % moves.len()];
|
||||
let _ = game.apply_instruction(instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,19 +79,15 @@ fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
|
||||
/// 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 {
|
||||
if game.is_won() {
|
||||
return false;
|
||||
}
|
||||
let instructions = game.possible_instructions();
|
||||
if instructions.is_empty() {
|
||||
let moves = game.possible_instructions();
|
||||
if moves.is_empty() {
|
||||
return game.draw().is_ok();
|
||||
}
|
||||
let (from, to, count) = instructions[move_idx % instructions.len()];
|
||||
if from == to {
|
||||
game.draw().is_ok()
|
||||
} else {
|
||||
game.move_cards(from, to, count).is_ok()
|
||||
}
|
||||
let instruction = moves[move_idx % moves.len()];
|
||||
game.apply_instruction(instruction).is_ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -169,13 +156,12 @@ proptest! {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
|
||||
let mut ids = all_card_ids(&game);
|
||||
prop_assert_eq!(ids.len(), 52, "card count ≠ 52 (got {})", ids.len());
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
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!(
|
||||
ids.len(), 52,
|
||||
"duplicate card IDs found after dedup — a card was cloned"
|
||||
unique.len(), 52,
|
||||
"duplicate cards found after dedup — a card was cloned"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,8 +178,8 @@ proptest! {
|
||||
let a = GameState::new(seed, draw_mode);
|
||||
let b = GameState::new(seed, draw_mode);
|
||||
prop_assert_eq!(
|
||||
all_card_ids(&a),
|
||||
all_card_ids(&b),
|
||||
all_cards(&a),
|
||||
all_cards(&b),
|
||||
"same seed + draw_mode produced different deals",
|
||||
);
|
||||
}
|
||||
@@ -217,11 +203,11 @@ proptest! {
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
// Snapshot the state before the move.
|
||||
let before_ids = all_card_ids(&game);
|
||||
let before_move_count = game.move_count;
|
||||
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 {
|
||||
if !apply_one_move(&mut game, move_idx) || game.is_won() {
|
||||
return Ok(()); // nothing to undo
|
||||
}
|
||||
|
||||
@@ -231,12 +217,12 @@ proptest! {
|
||||
"undo must succeed immediately after a successful move",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
all_card_ids(&game),
|
||||
all_cards(&game),
|
||||
before_ids,
|
||||
"pile layout after undo differs from the pre-move snapshot",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
game.move_count,
|
||||
game.move_count(),
|
||||
before_move_count,
|
||||
"move_count after undo must equal the pre-move value",
|
||||
);
|
||||
@@ -258,132 +244,16 @@ proptest! {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
for (from, to, count) in game.possible_instructions() {
|
||||
for instruction in game.possible_instructions() {
|
||||
// Clone so each move is tried from the same starting state.
|
||||
let mut trial = game.clone();
|
||||
let result = if from == to {
|
||||
trial.draw()
|
||||
} else {
|
||||
trial.move_cards(from, to, count)
|
||||
};
|
||||
let result = trial.apply_instruction(instruction);
|
||||
prop_assert!(
|
||||
result.is_ok(),
|
||||
"possible_instructions() reported ({from:?} → {to:?} ×{count}) \
|
||||
"possible_instructions() reported {instruction:?} \
|
||||
as legal but the call returned Err: {result:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SavedInstruction ↔ KlondikeInstruction round-trip
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Every valid `SavedInstruction` survives a round-trip through
|
||||
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
|
||||
///
|
||||
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
|
||||
/// and all legal sub-field ranges:
|
||||
/// - `SavedTableau`: 0–6
|
||||
/// - `SavedFoundation`: 0–3
|
||||
/// - `SavedSkipCards`: 0–12
|
||||
#[test]
|
||||
fn saved_instruction_round_trip(
|
||||
instruction in saved_instruction_strategy(),
|
||||
) {
|
||||
let klondike = KlondikeInstruction::try_from(instruction);
|
||||
prop_assert!(
|
||||
klondike.is_ok(),
|
||||
"TryFrom failed for valid SavedInstruction {instruction:?}: {:?}",
|
||||
klondike.err(),
|
||||
);
|
||||
let saved_again = SavedInstruction::from(klondike.expect("checked above"));
|
||||
prop_assert_eq!(
|
||||
saved_again,
|
||||
instruction,
|
||||
"round-trip produced a different SavedInstruction",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proptest strategies for SavedInstruction and its sub-types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn saved_tableau_strategy() -> impl Strategy<Value = SavedTableau> {
|
||||
(0u8..=6).prop_map(SavedTableau)
|
||||
}
|
||||
|
||||
fn saved_foundation_strategy() -> impl Strategy<Value = SavedFoundation> {
|
||||
(0u8..=3).prop_map(SavedFoundation)
|
||||
}
|
||||
|
||||
fn saved_skip_cards_strategy() -> impl Strategy<Value = SavedSkipCards> {
|
||||
(0u8..=12).prop_map(SavedSkipCards)
|
||||
}
|
||||
|
||||
fn saved_klondike_pile_strategy() -> impl Strategy<Value = SavedKlondikePile> {
|
||||
prop_oneof![
|
||||
saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau),
|
||||
Just(SavedKlondikePile::Stock),
|
||||
saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation),
|
||||
]
|
||||
}
|
||||
|
||||
fn saved_klondike_pile_stack_strategy() -> impl Strategy<Value = SavedKlondikePileStack> {
|
||||
prop_oneof![
|
||||
(saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| {
|
||||
SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards })
|
||||
}),
|
||||
Just(SavedKlondikePileStack::Stock),
|
||||
saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation),
|
||||
]
|
||||
}
|
||||
|
||||
fn saved_instruction_strategy() -> impl Strategy<Value = SavedInstruction> {
|
||||
prop_oneof![
|
||||
Just(SavedInstruction::RotateStock),
|
||||
(saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map(
|
||||
|(src, foundation)| {
|
||||
SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation })
|
||||
}
|
||||
),
|
||||
(saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map(
|
||||
|(src, tableau)| {
|
||||
SavedInstruction::DstTableau(SavedDstTableau { src, tableau })
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boundary error unit tests (exact out-of-range values)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_instruction_boundary_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saved_tableau_7_is_invalid() {
|
||||
let result = Tableau::try_from(SavedTableau(7));
|
||||
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_tableau_255_is_invalid() {
|
||||
let result = Tableau::try_from(SavedTableau(255));
|
||||
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_foundation_4_is_invalid() {
|
||||
let result = Foundation::try_from(SavedFoundation(4));
|
||||
assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_skip_cards_13_is_invalid() {
|
||||
let result = SkipCards::try_from(SavedSkipCards(13));
|
||||
assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Ferrous-specific scoring policy layered on top of upstream `klondike`.
|
||||
//!
|
||||
//! Upstream [`klondike::KlondikeStats::score`] owns the per-move point values
|
||||
//! (move-to-foundation, flip-up bonus, recycle penalty, etc.). The functions
|
||||
//! here are the Ferrous Solitaire house rules that upstream has no opinion on —
|
||||
//! currently just the win-time bonus shown in the win modal.
|
||||
|
||||
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
if elapsed_seconds == 0 {
|
||||
return 0;
|
||||
}
|
||||
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
//! Klondike solvability checker using upstream `card_game::Session::solve()`.
|
||||
//!
|
||||
//! Used by the engine to back the **Settings → Gameplay → "Winnable deals only"**
|
||||
//! toggle and by the hint system when it wants the first move on a winning path.
|
||||
|
||||
use card_game::{Session, SessionConfig, SolveError, StateSnapshot};
|
||||
use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack};
|
||||
|
||||
use crate::game_state::{DrawMode, GameState};
|
||||
use crate::klondike_adapter::KlondikeAdapter;
|
||||
|
||||
/// Verdict returned by [`try_solve`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SolverResult {
|
||||
/// The solver found a sequence of moves that wins the deal.
|
||||
Winnable,
|
||||
/// The solver exhaustively searched and confirmed no win exists.
|
||||
Unwinnable,
|
||||
/// The move / state budget was exceeded before a verdict could be reached.
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// Tunable budgets controlling how long [`try_solve`] is willing to search.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SolverConfig {
|
||||
/// Maximum total moves to consider across the entire search tree.
|
||||
pub move_budget: u64,
|
||||
/// Maximum unique states to visit.
|
||||
pub state_budget: usize,
|
||||
}
|
||||
|
||||
impl Default for SolverConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
move_budget: 100_000,
|
||||
state_budget: 200_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single move the solver can recommend, expressed in engine-level pile terms.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SolverMove {
|
||||
/// Pile the move originates from.
|
||||
pub source: KlondikePile,
|
||||
/// Pile the move lands on.
|
||||
pub dest: KlondikePile,
|
||||
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Solver verdict plus, when winnable, the first move on a winning path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SolveOutcome {
|
||||
/// The high-level verdict (Winnable / Unwinnable / Inconclusive).
|
||||
pub result: SolverResult,
|
||||
/// First move on the solution path when `result == Winnable`.
|
||||
pub first_move: Option<SolverMove>,
|
||||
}
|
||||
|
||||
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||
try_solve_with_first_move(seed, draw_mode, config).result
|
||||
}
|
||||
|
||||
/// Tries to solve a fresh Classic-mode game and, when winnable, returns the
|
||||
/// first move on a winning path.
|
||||
///
|
||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||
/// take-from-foundation house rule stays disabled here.
|
||||
pub fn try_solve_with_first_move(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
config: &SolverConfig,
|
||||
) -> SolveOutcome {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
game.take_from_foundation = false;
|
||||
solve_game_state(&game, config)
|
||||
}
|
||||
|
||||
/// Tries to solve from an existing in-progress [`GameState`].
|
||||
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
|
||||
solve_game_state(state, config)
|
||||
}
|
||||
|
||||
fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome {
|
||||
if config.state_budget == 0 {
|
||||
return SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
};
|
||||
}
|
||||
|
||||
// Preserve the historical payload contract: winnable verdicts always carry
|
||||
// a first move. An already-won state therefore returns no recommendation.
|
||||
if initial.is_won {
|
||||
return SolveOutcome {
|
||||
result: SolverResult::Unwinnable,
|
||||
first_move: None,
|
||||
};
|
||||
}
|
||||
|
||||
let solver_config = SessionConfig {
|
||||
inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation),
|
||||
undo_penalty: 0,
|
||||
solve_moves_budget: config.move_budget,
|
||||
solve_states_budget: config.state_budget as u64,
|
||||
};
|
||||
let solver_session = Session::new(initial.session().state().state().clone(), solver_config);
|
||||
|
||||
match solver_session.solve() {
|
||||
Ok(Some(solution)) => {
|
||||
let first_move = solution
|
||||
.raw_solution()
|
||||
.iter()
|
||||
.find_map(snapshot_to_solver_move);
|
||||
if let Some(first_move) = first_move {
|
||||
SolveOutcome {
|
||||
result: SolverResult::Winnable,
|
||||
first_move: Some(first_move),
|
||||
}
|
||||
} else {
|
||||
SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => SolveOutcome {
|
||||
result: SolverResult::Unwinnable,
|
||||
first_move: None,
|
||||
},
|
||||
Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverMove> {
|
||||
let source_state = snapshot.state().state();
|
||||
match *snapshot.instruction() {
|
||||
KlondikeInstruction::RotateStock => Some(SolverMove {
|
||||
source: KlondikePile::Stock,
|
||||
dest: KlondikePile::Stock,
|
||||
count: 1,
|
||||
}),
|
||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||
let source = match dst_foundation.src {
|
||||
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
|
||||
KlondikePile::Stock => KlondikePile::Stock,
|
||||
KlondikePile::Foundation(_) => return None,
|
||||
};
|
||||
Some(SolverMove {
|
||||
source,
|
||||
dest: KlondikePile::Foundation(dst_foundation.foundation),
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||||
let (source, count) = match dst_tableau.src {
|
||||
KlondikePileStack::Tableau(tableau_stack) => {
|
||||
let face_up_count = source_state.tableau_face_up_cards(tableau_stack.tableau).len();
|
||||
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
(KlondikePile::Tableau(tableau_stack.tableau), count)
|
||||
}
|
||||
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
||||
KlondikePileStack::Foundation(foundation) => {
|
||||
(KlondikePile::Foundation(foundation), 1)
|
||||
}
|
||||
};
|
||||
|
||||
Some(SolverMove {
|
||||
source,
|
||||
dest: KlondikePile::Tableau(dst_tableau.tableau),
|
||||
count,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn try_solve_with_first_move_is_deterministic() {
|
||||
let config = SolverConfig::default();
|
||||
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
let b = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
let c = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(b, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_solve_with_first_move_returns_consistent_payload() {
|
||||
let config = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
match outcome.result {
|
||||
SolverResult::Winnable => assert!(outcome.first_move.is_some()),
|
||||
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||
assert!(outcome.first_move.is_none())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_solve_from_state_uses_live_game_state() {
|
||||
let mut game = GameState::new(42, DrawMode::DrawOne);
|
||||
game.draw().expect("draw must succeed");
|
||||
|
||||
let config = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
let outcome = try_solve_from_state(&game, &config);
|
||||
match outcome.result {
|
||||
SolverResult::Winnable => assert!(outcome.first_move.is_some()),
|
||||
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||
assert!(outcome.first_move.is_none())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_state_budget_is_inconclusive() {
|
||||
let config = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 0,
|
||||
};
|
||||
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
|
||||
assert_eq!(outcome.result, SolverResult::Inconclusive);
|
||||
assert!(outcome.first_move.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_is_passed_through_not_clamped() {
|
||||
// 0xD1FF_0000_0000_0012 is a Medium-tier catalog seed: Inconclusive at
|
||||
// the Easy budget (1 000 states) but Winnable at Medium (5 000 states).
|
||||
// Differing results confirm solve_game_state passes the caller's
|
||||
// state_budget unchanged to the underlying solver.
|
||||
let easy = SolverConfig { move_budget: 1_000, state_budget: 1_000 };
|
||||
let medium = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &easy),
|
||||
SolverResult::Inconclusive,
|
||||
);
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &medium),
|
||||
SolverResult::Winnable,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_above_five_thousand_is_not_clamped() {
|
||||
// 0xD1FF_0000_0000_00DE is a hard catalog seed: Inconclusive at 5 000
|
||||
// states but Winnable at 50 000. Before this fix, solve_game_state
|
||||
// applied `config.state_budget.min(5_000)` internally, so a 50k config
|
||||
// was silently reduced to 5k — making both calls return Inconclusive and
|
||||
// preventing the generator from certifying Hard/Expert/Grandmaster seeds.
|
||||
// This assertion fails if the cap is re-introduced.
|
||||
let below_cap = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
|
||||
let above_cap = SolverConfig { move_budget: 50_000, state_budget: 50_000 };
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &below_cap),
|
||||
SolverResult::Inconclusive,
|
||||
"seed must be Inconclusive at 5 000 states",
|
||||
);
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &above_cap),
|
||||
SolverResult::Winnable,
|
||||
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ edition.workspace = true
|
||||
[dependencies]
|
||||
solitaire_core = { workspace = true }
|
||||
solitaire_sync = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
@@ -37,6 +39,7 @@ keyring-core = { workspace = true }
|
||||
jni = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
solitaire_core = { workspace = true, features = ["test-support"] }
|
||||
solitaire_server = { path = "../solitaire_server" }
|
||||
solitaire_sync = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
@@ -44,3 +47,6 @@ sqlx = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Safe JNI bridge for Android platform integration.
|
||||
//!
|
||||
//! Reconstructing the raw `JavaVM` / `NativeActivity` handles handed over by
|
||||
//! the Android runtime requires `unsafe` FFI. That single reconstruction lives
|
||||
//! in `solitaire_app`'s entry point ([`solitaire_app::android_main`]); this
|
||||
//! module only ever stores the resulting *safe* [`JavaVM`] and activity
|
||||
//! [`GlobalRef`] and hands callers an attached [`JNIEnv`].
|
||||
//!
|
||||
//! As a result every consumer of Android JNI — the keystore, clipboard, and
|
||||
//! safe-area subsystems — goes through the safe functions here and stays
|
||||
//! `forbid(unsafe_code)`. The only crate carrying `unsafe` is `solitaire_app`.
|
||||
//!
|
||||
//! Only compiled and linked on `target_os = "android"`.
|
||||
|
||||
use jni::objects::{GlobalRef, JObject};
|
||||
use jni::{JNIEnv, JavaVM};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
|
||||
static ANDROID_ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
|
||||
|
||||
/// Store the process-wide [`JavaVM`]. Called once from Android startup
|
||||
/// (`solitaire_app::android_main`); subsequent calls are ignored.
|
||||
pub fn set_jvm(vm: JavaVM) {
|
||||
let _ = ANDROID_JVM.set(vm);
|
||||
}
|
||||
|
||||
/// Store a global reference to the `NativeActivity`. Called once from Android
|
||||
/// startup; subsequent calls are ignored.
|
||||
pub fn set_activity(activity: GlobalRef) {
|
||||
let _ = ANDROID_ACTIVITY.set(activity);
|
||||
}
|
||||
|
||||
/// Run `f` with a [`JNIEnv`] attached to the current thread.
|
||||
///
|
||||
/// Returns an error string if the bridge has not been initialised yet or the
|
||||
/// thread cannot be attached. The closure's JNI errors are surfaced through the
|
||||
/// same `String` channel so callers have a single error type to map.
|
||||
pub fn with_env<F, R>(f: F) -> Result<R, String>
|
||||
where
|
||||
F: for<'local> FnOnce(&mut JNIEnv<'local>) -> jni::errors::Result<R>,
|
||||
{
|
||||
let vm = ANDROID_JVM
|
||||
.get()
|
||||
.ok_or_else(|| "Android JavaVM not initialised".to_string())?;
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
f(&mut env).map_err(|e| format!("JNI: {e}"))
|
||||
}
|
||||
|
||||
/// Run `f` with an attached [`JNIEnv`] and the `NativeActivity` object.
|
||||
///
|
||||
/// Like [`with_env`] but also resolves the cached activity global reference, so
|
||||
/// callers that need to invoke instance methods on the activity (clipboard,
|
||||
/// window insets) never touch a raw handle.
|
||||
pub fn with_activity_env<F, R>(f: F) -> Result<R, String>
|
||||
where
|
||||
F: for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> jni::errors::Result<R>,
|
||||
{
|
||||
let activity = ANDROID_ACTIVITY
|
||||
.get()
|
||||
.ok_or_else(|| "Android activity not initialised".to_string())?;
|
||||
with_env(|env| f(env, activity.as_obj()))
|
||||
}
|
||||
@@ -14,19 +14,16 @@
|
||||
///
|
||||
/// Only compiled and linked on `target_os = "android"`.
|
||||
use jni::{
|
||||
JNIEnv, JavaVM,
|
||||
JNIEnv,
|
||||
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::auth_tokens::TokenError;
|
||||
|
||||
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
|
||||
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TokenBlob {
|
||||
@@ -39,43 +36,15 @@ struct TokenBlob {
|
||||
// JVM helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Initialise Android Keystore access with the process-wide `JavaVM*`.
|
||||
///
|
||||
/// This is called by `solitaire_app` from Android startup code. Keeping the
|
||||
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or
|
||||
/// engine layer just to reach platform startup state.
|
||||
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
|
||||
if vm_ptr.is_null() {
|
||||
return Err(TokenError::KeychainUnavailable(
|
||||
"JavaVM pointer is null".into(),
|
||||
));
|
||||
}
|
||||
if ANDROID_JVM.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
|
||||
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
|
||||
// the process lifetime.
|
||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
|
||||
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
|
||||
let _ = ANDROID_JVM.set(vm);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `f` with an attached `JNIEnv`, delegating thread attach and the
|
||||
/// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
|
||||
/// initialised once from Android startup, so the keystore never touches a raw
|
||||
/// pointer and this module stays `forbid(unsafe_code)`.
|
||||
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
||||
where
|
||||
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
||||
{
|
||||
let vm = ANDROID_JVM
|
||||
.get()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
|
||||
|
||||
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
|
||||
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -230,9 +199,10 @@ fn encrypt_gcm(
|
||||
.v()?;
|
||||
|
||||
// IV is generated by Android's provider; read it back after init.
|
||||
// `getIV()` returns `[B`; the safe `From<JObject>` reinterprets the
|
||||
// returned object reference as a typed byte array.
|
||||
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
|
||||
// SAFETY: the method signature guarantees a byte array return.
|
||||
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
|
||||
let iv_arr = JByteArray::from(iv_jobj);
|
||||
let iv = env.convert_byte_array(&iv_arr)?;
|
||||
|
||||
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
||||
@@ -240,8 +210,7 @@ fn encrypt_gcm(
|
||||
let ct_jobj = env
|
||||
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
||||
.l()?;
|
||||
// SAFETY: doFinal([B) returns [B.
|
||||
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
|
||||
let ct_arr = JByteArray::from(ct_jobj);
|
||||
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
||||
|
||||
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
||||
@@ -292,8 +261,7 @@ fn decrypt_gcm(
|
||||
let pt_jobj = env
|
||||
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
||||
.l()?;
|
||||
// SAFETY: doFinal([B) returns [B.
|
||||
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
|
||||
let pt_arr = JByteArray::from(pt_jobj);
|
||||
env.convert_byte_array(&pt_arr)
|
||||
}
|
||||
|
||||
@@ -380,29 +348,29 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
|
||||
}
|
||||
|
||||
// --- 2. Legacy path migration ---
|
||||
if let Some(ref lpath) = legacy_path {
|
||||
if lpath.exists() {
|
||||
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||
other => other,
|
||||
if let Some(ref lpath) = legacy_path
|
||||
&& lpath.exists()
|
||||
{
|
||||
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||
other => other,
|
||||
})?;
|
||||
if data.len() >= 12 {
|
||||
let plaintext = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
})?;
|
||||
if data.len() >= 12 {
|
||||
let plaintext = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
})?;
|
||||
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(blob.username.clone(), blob);
|
||||
// Write to the new location, then remove the legacy file.
|
||||
if write_map_inner(&map).is_ok() {
|
||||
let _ = std::fs::remove_file(lpath);
|
||||
}
|
||||
return Ok(map);
|
||||
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(blob.username.clone(), blob);
|
||||
// Write to the new location, then remove the legacy file.
|
||||
if write_map_inner(&map).is_ok() {
|
||||
let _ = std::fs::remove_file(lpath);
|
||||
}
|
||||
return Ok(map);
|
||||
}
|
||||
// Legacy file corrupt or unrecognised — treat as empty.
|
||||
}
|
||||
// Legacy file corrupt or unrecognised — treat as empty.
|
||||
}
|
||||
|
||||
// --- 3. No file found ---
|
||||
@@ -491,11 +459,11 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
|
||||
if map.is_empty() {
|
||||
// No more users — remove the file and the Keystore key.
|
||||
if let Some(path) = token_file_path() {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||
}
|
||||
if let Some(path) = token_file_path()
|
||||
&& path.exists()
|
||||
{
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||
}
|
||||
|
||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
//! `keyring-core` cannot compile for the android target (its `rpassword`
|
||||
//! transitive dep uses `libc::__errno_location`, which Android's bionic
|
||||
//! doesn't expose). On Android this module delegates to an Android Keystore
|
||||
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm`
|
||||
//! from Android startup before token operations can succeed.
|
||||
//! JNI backend. `solitaire_app` must initialise the safe
|
||||
//! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
|
||||
//! startup before token operations can succeed.
|
||||
//!
|
||||
//! # Note: no unit tests — requires live OS keychain.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ pub trait SyncProvider: Send + Sync {
|
||||
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
||||
/// silently no-op'd by the engine's push-on-win system, matching
|
||||
/// the same pattern `pull` / `push` follow.
|
||||
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError> {
|
||||
Err(SyncError::UnsupportedPlatform)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||
(**self).delete_account().await
|
||||
}
|
||||
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
|
||||
(**self).push_replay(replay).await
|
||||
}
|
||||
}
|
||||
@@ -118,8 +118,8 @@ pub use achievements::{
|
||||
|
||||
pub mod progress;
|
||||
pub use progress::{
|
||||
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
|
||||
save_progress_to, xp_for_win,
|
||||
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
|
||||
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
|
||||
};
|
||||
|
||||
pub mod weekly;
|
||||
@@ -144,9 +144,10 @@ pub use settings::{
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_keystore;
|
||||
pub mod android_jni;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android_keystore::init_android_jvm;
|
||||
mod android_keystore;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod auth_tokens;
|
||||
@@ -163,11 +164,14 @@ pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
||||
pub mod replay;
|
||||
pub use replay::{
|
||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
|
||||
ReplayHistory, append_replay_to_history, load_replay_history_from,
|
||||
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
||||
};
|
||||
// `latest_replay_path` is still consumed by the engine's one-shot legacy
|
||||
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
|
||||
// outside `replay.rs` and were dropped from the public surface.
|
||||
#[allow(deprecated)]
|
||||
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
||||
pub use replay::latest_replay_path;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod matomo_client;
|
||||
|
||||
@@ -114,3 +114,62 @@ fn url_encode(s: &str) -> String {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pending(client: &MatomoClient) -> Vec<String> {
|
||||
client.pending.lock().expect("pending lock").clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_buffers_encoded_matomo_query() {
|
||||
let client = MatomoClient::new(
|
||||
"https://analytics.example.com/",
|
||||
7,
|
||||
Some("alice bob".into()),
|
||||
);
|
||||
|
||||
client.event("Game Flow", "Won+Fast", Some("draw three"), Some(42.5));
|
||||
|
||||
let pending = pending(&client);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let query = &pending[0];
|
||||
assert!(query.contains("idsite=7"));
|
||||
assert!(query.contains("rec=1"));
|
||||
assert!(query.contains("e_c=Game%20Flow"));
|
||||
assert!(query.contains("e_a=Won%2BFast"));
|
||||
assert!(query.contains("e_n=draw%20three"));
|
||||
assert!(query.contains("e_v=42.5"));
|
||||
assert!(query.contains("uid=alice%20bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_buffer_drops_oldest_entries_when_capacity_exceeded() {
|
||||
let client = MatomoClient::new("https://analytics.example.com", 1, None);
|
||||
|
||||
for idx in 0..101 {
|
||||
client.event("Game", "Start", Some(&format!("event-{idx}")), None);
|
||||
}
|
||||
|
||||
let pending = pending(&client);
|
||||
assert_eq!(pending.len(), 51);
|
||||
assert!(
|
||||
pending[0].contains("event-50"),
|
||||
"oldest retained event should be event-50, got {}",
|
||||
pending[0]
|
||||
);
|
||||
assert!(
|
||||
pending[50].contains("event-100"),
|
||||
"newest retained event should be event-100, got {}",
|
||||
pending[50]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_leaves_unreserved_bytes_and_escapes_everything_else() {
|
||||
assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~");
|
||||
assert_eq!(url_encode("a b+c/d?"), "a%20b%2Bc%2Fd%3F");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
||||
y * 10_000 + m * 100 + d
|
||||
}
|
||||
|
||||
/// XP awarded for winning a game.
|
||||
/// Component breakdown of the XP awarded for a win.
|
||||
///
|
||||
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
|
||||
/// it for the total, and UI that displays the individual lines (the win-summary
|
||||
/// modal) reads the parts from here so the breakdown can never drift from the
|
||||
/// total.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct XpBreakdown {
|
||||
/// Flat base XP granted for any win.
|
||||
pub base: u64,
|
||||
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
|
||||
pub speed_bonus: u64,
|
||||
/// Bonus for winning without using undo (25, else 0).
|
||||
pub no_undo_bonus: u64,
|
||||
}
|
||||
|
||||
impl XpBreakdown {
|
||||
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
|
||||
pub fn total(self) -> u64 {
|
||||
self.base + self.speed_bonus + self.no_undo_bonus
|
||||
}
|
||||
}
|
||||
|
||||
/// Component breakdown of the XP awarded for a win.
|
||||
///
|
||||
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
||||
/// the player did not use undo.
|
||||
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
let base: u64 = 50;
|
||||
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
|
||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
||||
0
|
||||
} else {
|
||||
@@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
||||
scaled.max(10)
|
||||
};
|
||||
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
||||
base + speed_bonus + no_undo_bonus
|
||||
XpBreakdown {
|
||||
base: 50,
|
||||
speed_bonus,
|
||||
no_undo_bonus: if used_undo { 0 } else { 25 },
|
||||
}
|
||||
}
|
||||
|
||||
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
|
||||
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
xp_breakdown(time_seconds, used_undo).total()
|
||||
}
|
||||
|
||||
/// Platform-specific default path for `progress.json`.
|
||||
|
||||
@@ -12,13 +12,22 @@
|
||||
//! carries any other version so older replays are silently dropped instead
|
||||
//! of crashing the loader.
|
||||
//!
|
||||
//! The recording is intentionally minimal — only [`ReplayMove`] entries
|
||||
//! that successfully advanced the game. `Undo` is **not** recorded: a
|
||||
//! replay represents the canonical path the player ultimately took to win,
|
||||
//! so backed-out missteps simply do not appear in the move list. The
|
||||
//! starting deal is not stored either — the [`seed`](Replay::seed) +
|
||||
//! The recording is intentionally minimal — only the
|
||||
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
||||
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
||||
//! represents the canonical path the player ultimately took to win, so
|
||||
//! backed-out missteps simply do not appear in the move list. The starting
|
||||
//! deal is not stored either — the [`seed`](Replay::seed) +
|
||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||
//!
|
||||
//! Each recorded move is the player's atomic *input*, not its outcome.
|
||||
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||
//! stock state during playback, so the same input always produces the same
|
||||
//! effect on the same starting deal. Runtime-only pile-position types are
|
||||
//! never serialised — the instruction itself serialises via its compact
|
||||
//! upstream serde representation.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -26,8 +35,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
@@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 {
|
||||
/// seeing a broken one.
|
||||
///
|
||||
/// History:
|
||||
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
|
||||
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
|
||||
/// variants which carried the *outcome* of a stock interaction rather
|
||||
/// than the player's atomic input.
|
||||
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
|
||||
/// variant. The engine resolves draw-vs-recycle deterministically from
|
||||
/// the current stock state, so the input alone is sufficient and the
|
||||
/// replay model now stores atomic player inputs end-to-end.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
|
||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||
/// are now stored directly as upstream
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
||||
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
||||
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
||||
/// and are discarded by the loader.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
||||
|
||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||
@@ -81,32 +92,6 @@ fn schema_v0() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// One atomic player input recorded during a winning game, in the order
|
||||
/// it was applied to the live `GameState`.
|
||||
///
|
||||
/// `Undo` is intentionally absent — see the module-level docs.
|
||||
///
|
||||
/// The variants represent *inputs*, not outcomes. `StockClick` covers
|
||||
/// every player click on the stock pile; the engine then resolves
|
||||
/// draw-vs-recycle deterministically from the current state during both
|
||||
/// recording and playback, so the same input always produces the same
|
||||
/// effect on the same starting deal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReplayMove {
|
||||
/// A successful `move_cards(from, to, count)` call.
|
||||
Move {
|
||||
/// Source pile.
|
||||
from: SavedKlondikePile,
|
||||
/// Destination pile.
|
||||
to: SavedKlondikePile,
|
||||
/// Number of cards moved.
|
||||
count: usize,
|
||||
},
|
||||
/// A click on the stock pile. Resolves to a draw when stock is
|
||||
/// non-empty and to a waste→stock recycle when stock is empty.
|
||||
StockClick,
|
||||
}
|
||||
|
||||
/// A complete recording of a single winning game.
|
||||
///
|
||||
/// Replays are reconstructed by rebuilding a fresh
|
||||
@@ -124,7 +109,7 @@ pub struct Replay {
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||
pub seed: u64,
|
||||
/// Draw mode the recorded game was played in.
|
||||
pub draw_mode: DrawMode,
|
||||
pub draw_mode: DrawStockConfig,
|
||||
/// Game mode the recorded game was played in.
|
||||
pub mode: GameMode,
|
||||
/// Total wall-clock seconds the win took. Used for the Stats UI
|
||||
@@ -134,9 +119,11 @@ pub struct Replay {
|
||||
pub final_score: i32,
|
||||
/// ISO-8601 date the win was recorded.
|
||||
pub recorded_at: NaiveDate,
|
||||
/// Ordered move list. Each entry is what the player did, replayable
|
||||
/// against a fresh `GameState` constructed from the seed.
|
||||
pub moves: Vec<ReplayMove>,
|
||||
/// Ordered move list. Each entry is the atomic
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
||||
/// issued, replayable against a fresh `GameState` constructed from the
|
||||
/// seed via `GameState::apply_instruction`.
|
||||
pub moves: Vec<KlondikeInstruction>,
|
||||
/// Public share URL for this replay on the active sync backend, set
|
||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||
/// task resolves. `None` when the player won on a local-only
|
||||
@@ -180,12 +167,12 @@ impl Replay {
|
||||
/// latter directly when the upload task resolves.
|
||||
pub fn new(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
draw_mode: DrawStockConfig,
|
||||
mode: GameMode,
|
||||
time_seconds: u64,
|
||||
final_score: i32,
|
||||
recorded_at: NaiveDate,
|
||||
moves: Vec<ReplayMove>,
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_SCHEMA_VERSION,
|
||||
@@ -442,7 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
#[allow(deprecated)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
||||
};
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
@@ -453,24 +442,22 @@ mod tests {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||
Replay::new(
|
||||
12345,
|
||||
DrawMode::DrawThree,
|
||||
DrawStockConfig::DrawThree,
|
||||
GameMode::Classic,
|
||||
134,
|
||||
5_120,
|
||||
date,
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: SavedKlondikePile::Stock,
|
||||
to: SavedKlondikePile::Tableau(SavedTableau(3)),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: SavedKlondikePile::Tableau(SavedTableau(3)),
|
||||
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
|
||||
count: 1,
|
||||
},
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstTableau(DstTableau {
|
||||
src: KlondikePileStack::Stock,
|
||||
tableau: Tableau::Tableau4,
|
||||
}),
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||
foundation: Foundation::Foundation1,
|
||||
}),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -596,12 +583,12 @@ mod tests {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||
Replay::new(
|
||||
id as u64,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
id,
|
||||
date,
|
||||
vec![ReplayMove::StockClick],
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -837,9 +824,11 @@ mod tests {
|
||||
let path = tmp_path("legacy_no_win_move_index");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
||||
let v2_no_field = r#"{
|
||||
"schema_version": 2,
|
||||
// Hand-rolled minimal current-schema replay JSON with no
|
||||
// win_move_index field — the additive field must still default to None.
|
||||
let no_field = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
@@ -847,8 +836,10 @@ mod tests {
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": []
|
||||
}"#;
|
||||
fs::write(&path, v2_no_field).expect("write fixture");
|
||||
}}"#,
|
||||
schema = REPLAY_SCHEMA_VERSION,
|
||||
);
|
||||
fs::write(&path, no_field).expect("write fixture");
|
||||
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, None);
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
|
||||
@@ -101,7 +101,7 @@ pub struct WindowGeometry {
|
||||
pub struct Settings {
|
||||
/// Draw mode selected for new games.
|
||||
#[serde(default = "default_draw_mode")]
|
||||
pub draw_mode: DrawMode,
|
||||
pub draw_mode: DrawStockConfig,
|
||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
||||
#[serde(default = "default_sfx_volume")]
|
||||
pub sfx_volume: f32,
|
||||
@@ -200,7 +200,7 @@ pub struct Settings {
|
||||
#[serde(default = "default_time_bonus_multiplier")]
|
||||
pub time_bonus_multiplier: f32,
|
||||
/// When `true`, the engine rejects new-game deals the
|
||||
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
||||
/// the solver cannot prove winnable, retrying
|
||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||
/// giving up and using the last tried seed. Off by default —
|
||||
/// the solver adds a few hundred milliseconds of latency on the
|
||||
@@ -288,8 +288,8 @@ pub struct Settings {
|
||||
pub touch_input_mode: TouchInputMode,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
DrawMode::DrawOne
|
||||
fn default_draw_mode() -> DrawStockConfig {
|
||||
DrawStockConfig::DrawOne
|
||||
}
|
||||
|
||||
fn default_sfx_volume() -> f32 {
|
||||
@@ -381,9 +381,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||
/// is willing to attempt before giving up and accepting the latest
|
||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
||||
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
||||
/// deal than spin forever on the main thread.
|
||||
/// every retry comes back provably unwinnable (`Ok(None)` from the
|
||||
/// solver, which would be very unusual) we'd rather hand the player a
|
||||
/// possibly-unwinnable deal than spin forever on the main thread.
|
||||
///
|
||||
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||
/// the upper bound on UI freeze when the toggle is on.
|
||||
@@ -392,7 +392,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
draw_mode: DrawMode::DrawOne,
|
||||
draw_mode: DrawStockConfig::DrawOne,
|
||||
sfx_volume: default_sfx_volume(),
|
||||
music_volume: default_music_volume(),
|
||||
animation_speed: AnimSpeed::Normal,
|
||||
|
||||
+26
-26
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
|
||||
//! This module adds the [`StatsExt`] extension trait, which supplies the
|
||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||
//! `update_on_win` method that depends on [`DrawStockConfig`] from `solitaire_core`.
|
||||
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
|
||||
pub use solitaire_sync::StatsSnapshot;
|
||||
|
||||
@@ -18,9 +18,9 @@ pub trait StatsExt {
|
||||
///
|
||||
/// Tracks lifetime totals only — per-mode best scores and times are
|
||||
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
|
||||
/// long-standing call sites that only know about [`DrawMode`] keep
|
||||
/// long-standing call sites that only know about [`DrawStockConfig`] keep
|
||||
/// compiling.
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig);
|
||||
|
||||
/// Updates the per-mode best score and fastest-win-time fields for the
|
||||
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
|
||||
@@ -37,7 +37,7 @@ pub trait StatsExt {
|
||||
}
|
||||
|
||||
impl StatsExt for StatsSnapshot {
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig) {
|
||||
let prev_wins = self.games_won;
|
||||
self.games_played += 1;
|
||||
self.games_won += 1;
|
||||
@@ -64,8 +64,8 @@ impl StatsExt for StatsSnapshot {
|
||||
};
|
||||
|
||||
match draw_mode {
|
||||
DrawMode::DrawOne => self.draw_one_wins += 1,
|
||||
DrawMode::DrawThree => self.draw_three_wins += 1,
|
||||
DrawStockConfig::DrawOne => self.draw_one_wins += 1,
|
||||
DrawStockConfig::DrawThree => self.draw_three_wins += 1,
|
||||
}
|
||||
|
||||
self.last_modified = Utc::now();
|
||||
@@ -135,7 +135,7 @@ mod tests {
|
||||
#[test]
|
||||
fn first_win_sets_all_fields() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(1500, 120, &DrawMode::DrawOne);
|
||||
s.update_on_win(1500, 120, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.games_played, 1);
|
||||
assert_eq!(s.games_won, 1);
|
||||
assert_eq!(s.win_streak_current, 1);
|
||||
@@ -152,7 +152,7 @@ mod tests {
|
||||
fn streak_tracks_across_wins() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
for _ in 0..3 {
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
}
|
||||
assert_eq!(s.win_streak_current, 3);
|
||||
assert_eq!(s.win_streak_best, 3);
|
||||
@@ -161,8 +161,8 @@ mod tests {
|
||||
#[test]
|
||||
fn record_abandoned_resets_streak_and_increments_played() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.win_streak_current, 2);
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.games_played, 3);
|
||||
@@ -174,35 +174,35 @@ mod tests {
|
||||
#[test]
|
||||
fn fastest_win_takes_minimum() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 120, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 500, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 120, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 500, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.fastest_win_seconds, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avg_time_is_correct_rolling_average() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 100, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 200, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 100, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 200, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.avg_time_seconds, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_score_updates_only_on_higher_score() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(500, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(300, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(500, 60, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(300, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.best_single_score, 500);
|
||||
s.update_on_win(800, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(800, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.best_single_score, 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_score_treated_as_zero() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(-50, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(-50, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.best_single_score, 0);
|
||||
assert_eq!(s.lifetime_score, 0);
|
||||
}
|
||||
@@ -210,8 +210,8 @@ mod tests {
|
||||
#[test]
|
||||
fn draw_three_wins_tracked_separately() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawMode::DrawThree);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawThree);
|
||||
assert_eq!(s.draw_one_wins, 1);
|
||||
assert_eq!(s.draw_three_wins, 1);
|
||||
}
|
||||
@@ -221,7 +221,7 @@ mod tests {
|
||||
let mut s = StatsSnapshot::default();
|
||||
// Build a streak of 5.
|
||||
for _ in 0..5 {
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
}
|
||||
assert_eq!(s.win_streak_best, 5);
|
||||
// Lose (abandon), resetting current.
|
||||
@@ -229,7 +229,7 @@ mod tests {
|
||||
assert_eq!(s.win_streak_current, 0);
|
||||
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
|
||||
// Win once — current becomes 1, best must remain 5.
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(s.win_streak_current, 1);
|
||||
assert_eq!(
|
||||
s.win_streak_best, 5,
|
||||
@@ -243,7 +243,7 @@ mod tests {
|
||||
lifetime_score: u64::MAX - 100,
|
||||
..Default::default()
|
||||
};
|
||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(200, 60, &DrawStockConfig::DrawOne);
|
||||
assert_eq!(
|
||||
s.lifetime_score,
|
||||
u64::MAX,
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
|
||||
@@ -85,16 +85,13 @@ pub fn game_state_file_path() -> Option<PathBuf> {
|
||||
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
if gs.is_won { None } else { Some(gs) }
|
||||
if gs.is_won() { None } else { Some(gs) }
|
||||
}
|
||||
|
||||
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
|
||||
/// because a completed game should not be resumed.
|
||||
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
|
||||
if gs.is_won {
|
||||
if gs.is_won() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -282,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stats::{StatsExt, StatsSnapshot};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
@@ -295,7 +292,7 @@ mod tests {
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut stats = StatsSnapshot::default();
|
||||
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
|
||||
stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne);
|
||||
save_stats_to(&path, &stats).expect("save");
|
||||
|
||||
let loaded = load_stats_from(&path);
|
||||
@@ -380,17 +377,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn game_state_round_trip() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let gs = GameState::new(12345, DrawMode::DrawOne);
|
||||
let gs = GameState::new(12345, DrawStockConfig::DrawOne);
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
|
||||
let loaded = load_game_state_from(&path).expect("load");
|
||||
assert_eq!(loaded.seed, gs.seed);
|
||||
assert_eq!(loaded.draw_mode, gs.draw_mode);
|
||||
assert!(!loaded.is_won);
|
||||
assert_eq!(loaded.draw_mode(), gs.draw_mode());
|
||||
assert!(!loaded.is_won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -409,12 +406,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_game_state_skips_won_games() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("won_skip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut gs = GameState::new(99, DrawMode::DrawOne);
|
||||
gs.is_won = true;
|
||||
let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
|
||||
gs.set_test_won(true);
|
||||
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
|
||||
assert!(
|
||||
!path.exists(),
|
||||
@@ -424,9 +421,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn delete_game_state_removes_file() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("delete");
|
||||
let gs = GameState::new(1, DrawMode::DrawOne);
|
||||
let gs = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
assert!(path.exists());
|
||||
delete_game_state_at(&path).expect("delete");
|
||||
@@ -442,9 +439,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_game_state_is_atomic() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::GameState;
|
||||
let path = gs_path("atomic");
|
||||
let gs = GameState::new(55, DrawMode::DrawThree);
|
||||
let gs = GameState::new(55, DrawStockConfig::DrawThree);
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
@@ -500,22 +497,22 @@ mod tests {
|
||||
/// 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, then
|
||||
/// asserts the full pile layout round-trips exactly.
|
||||
/// test plays several real moves — including an undo — before saving.
|
||||
///
|
||||
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
|
||||
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
|
||||
/// `recycle_count`. Any breakage in the upstream serde or replay path
|
||||
/// will cause at least one pile to disagree.
|
||||
/// 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_v4_mid_game_round_trip() {
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
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, DrawMode::DrawOne);
|
||||
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.
|
||||
@@ -527,11 +524,13 @@ mod tests {
|
||||
|
||||
// Execute the first available DstTableau or DstFoundation move so the
|
||||
// instruction history contains a type other than RotateStock.
|
||||
let moves = gs.possible_instructions();
|
||||
if let Some((from, to, count)) = moves.iter().copied().find(|(_, to, _)| {
|
||||
matches!(to, KlondikePile::Tableau(_) | KlondikePile::Foundation(_))
|
||||
if let Some(instruction) = gs.possible_instructions().into_iter().find(|i| {
|
||||
matches!(
|
||||
i,
|
||||
KlondikeInstruction::DstTableau(_) | KlondikeInstruction::DstFoundation(_)
|
||||
)
|
||||
}) {
|
||||
let _ = gs.move_cards(from, to, count);
|
||||
let _ = gs.apply_instruction(instruction);
|
||||
}
|
||||
|
||||
// Undo once: verifies that `undo_count` is persisted and that the
|
||||
@@ -547,41 +546,53 @@ mod tests {
|
||||
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
|
||||
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
|
||||
// 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('4') && !json.contains(": 3"),
|
||||
"saved file must use schema version 4",
|
||||
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");
|
||||
|
||||
assert_eq!(loaded.schema_version, GAME_STATE_SCHEMA_VERSION);
|
||||
// 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!(
|
||||
loaded, gs,
|
||||
"all pile layouts and counters must be identical after schema-v4 round-trip",
|
||||
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 u8 indices) must load
|
||||
/// successfully and be transparently migrated to schema v4.
|
||||
///
|
||||
/// This verifies the `AnyInstruction` untagged deserialization migration
|
||||
/// path. v3 files with `RotateStock` (unit variant, format-identical in
|
||||
/// v3 and v4) load correctly and report `schema_version == 4` after load.
|
||||
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
|
||||
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
|
||||
/// 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_migrates_to_v4() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
|
||||
let path = gs_path("v3_migrate");
|
||||
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.
|
||||
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
|
||||
// so this exercises the schema version acceptance code path.
|
||||
let v3_json = r#"{
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
@@ -596,19 +607,12 @@ mod tests {
|
||||
}"#;
|
||||
fs::write(&path, v3_json).expect("write v3 fixture");
|
||||
|
||||
let loaded = load_game_state_from(&path)
|
||||
.expect("schema v3 must be accepted and migrated to v4");
|
||||
|
||||
// After migration, the in-memory schema version must be current.
|
||||
assert_eq!(
|
||||
loaded.schema_version, GAME_STATE_SCHEMA_VERSION,
|
||||
"migrated game must report current schema version",
|
||||
assert!(
|
||||
load_game_state_from(&path).is_none(),
|
||||
"schema v3 must be rejected (no migration path)",
|
||||
);
|
||||
|
||||
// The loaded game should match a fresh game that had one draw applied.
|
||||
let mut expected = GameState::new(42, DrawMode::DrawOne);
|
||||
expected.draw().expect("draw must succeed on a fresh game");
|
||||
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
|
||||
/// XP awarded each time a weekly goal is just completed.
|
||||
pub const WEEKLY_GOAL_XP: u64 = 75;
|
||||
@@ -36,7 +36,7 @@ pub struct WeeklyGoalDef {
|
||||
pub struct WeeklyGoalContext {
|
||||
pub time_seconds: u64,
|
||||
pub used_undo: bool,
|
||||
pub draw_mode: DrawMode,
|
||||
pub draw_mode: DrawStockConfig,
|
||||
}
|
||||
|
||||
impl WeeklyGoalDef {
|
||||
@@ -47,7 +47,7 @@ impl WeeklyGoalDef {
|
||||
WeeklyGoalKind::WinGame => true,
|
||||
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
||||
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
||||
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
|
||||
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawStockConfig::DrawThree,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ mod tests {
|
||||
WeeklyGoalContext {
|
||||
time_seconds: time,
|
||||
used_undo: undo,
|
||||
draw_mode: DrawMode::DrawOne,
|
||||
draw_mode: DrawStockConfig::DrawOne,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ mod tests {
|
||||
WeeklyGoalContext {
|
||||
time_seconds: time,
|
||||
used_undo: false,
|
||||
draw_mode: DrawMode::DrawThree,
|
||||
draw_mode: DrawStockConfig::DrawThree,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,3 +52,6 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
|
||||
async-trait = { 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
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
.add_message::<TouchInput>()
|
||||
// Run after GameMutation (so GameWonEvent is available), after
|
||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||
@@ -176,9 +176,9 @@ fn evaluate_on_win(
|
||||
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||
last_win_score: ev.score,
|
||||
last_win_time_seconds: ev.time_seconds,
|
||||
last_win_used_undo: game.0.undo_count > 0,
|
||||
last_win_used_undo: game.0.undo_count() > 0,
|
||||
wall_clock_hour: Some(Local::now().hour()),
|
||||
last_win_recycle_count: game.0.recycle_count,
|
||||
last_win_recycle_count: game.0.recycle_count(),
|
||||
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||
};
|
||||
|
||||
@@ -671,7 +671,7 @@ mod tests {
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||
// MinimalPlugins it isn't auto-registered.
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -779,7 +779,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.undo_count = 1;
|
||||
.force_test_undos(1);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1000,
|
||||
@@ -819,7 +819,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||
.set_test_draw_mode(DrawStockConfig::DrawThree);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -868,7 +868,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||
.set_test_draw_mode(DrawStockConfig::DrawThree);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -912,7 +912,7 @@ mod tests {
|
||||
// Put the active game in Zen mode. evaluate_on_win reads
|
||||
// GameStateResource.mode directly to populate last_win_is_zen.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||
solitaire_core::game_state::GameMode::Zen;
|
||||
GameMode::Zen;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
@@ -946,7 +946,7 @@ mod tests {
|
||||
// Default GameMode is Classic; assert and rely on it.
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.mode,
|
||||
solitaire_core::game_state::GameMode::Classic
|
||||
GameMode::Classic
|
||||
);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
@@ -1250,7 +1250,7 @@ mod tests {
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -1393,8 +1393,8 @@ mod tests {
|
||||
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
use solitaire_data::Replay;
|
||||
|
||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
||||
@@ -1409,12 +1409,12 @@ mod tests {
|
||||
fn dummy_replay() -> Replay {
|
||||
Replay::new(
|
||||
1,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -204,3 +204,61 @@ fn mode_str(mode: GameMode) -> &'static str {
|
||||
GameMode::Difficulty(_) => "difficulty",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use solitaire_core::game_state::DifficultyLevel;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_for_requires_analytics_opt_in() {
|
||||
let settings = Settings {
|
||||
analytics_enabled: false,
|
||||
matomo_url: Some("https://analytics.example.com".into()),
|
||||
..Settings::default()
|
||||
};
|
||||
|
||||
assert!(client_for(&settings).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_for_requires_matomo_url() {
|
||||
let settings = Settings {
|
||||
analytics_enabled: true,
|
||||
matomo_url: None,
|
||||
..Settings::default()
|
||||
};
|
||||
|
||||
assert!(client_for(&settings).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_for_creates_client_when_enabled_and_configured() {
|
||||
let settings = Settings {
|
||||
analytics_enabled: true,
|
||||
matomo_url: Some("https://analytics.example.com".into()),
|
||||
matomo_site_id: 2,
|
||||
sync_backend: SyncBackend::SolitaireServer {
|
||||
url: "https://solitaire.example.com".into(),
|
||||
username: "alice".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
..Settings::default()
|
||||
};
|
||||
|
||||
assert!(client_for(&settings).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_labels_match_analytics_payload_contract() {
|
||||
assert_eq!(mode_str(GameMode::Classic), "classic");
|
||||
assert_eq!(mode_str(GameMode::Zen), "zen");
|
||||
assert_eq!(mode_str(GameMode::Challenge), "challenge");
|
||||
assert_eq!(mode_str(GameMode::TimeAttack), "time_attack");
|
||||
assert_eq!(
|
||||
mode_str(GameMode::Difficulty(DifficultyLevel::Grandmaster)),
|
||||
"difficulty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
/// Android clipboard bridge via JNI.
|
||||
///
|
||||
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
||||
/// through the JNI. Only compiled and linked on `target_os = "android"`.
|
||||
/// through the safe [`solitaire_data::android_jni`] bridge. Only compiled and
|
||||
/// linked on `target_os = "android"`.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn set_text(text: &str) -> Result<(), String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{
|
||||
JavaVM,
|
||||
objects::{JObject, JValueOwned},
|
||||
};
|
||||
use jni::objects::JValueOwned;
|
||||
use solitaire_data::android_jni;
|
||||
|
||||
let app = ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||
|
||||
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
|
||||
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
|
||||
// valid for the lifetime of the process.
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
||||
|
||||
(|| -> jni::errors::Result<()> {
|
||||
android_jni::with_activity_env(|env, activity| {
|
||||
// ClipboardManager cm = activity.getSystemService("clipboard")
|
||||
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
||||
let cm = env
|
||||
.call_method(
|
||||
&activity,
|
||||
activity,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[svc_name.borrow()],
|
||||
@@ -60,6 +42,5 @@ pub fn set_text(text: &str) -> Result<(), String> {
|
||||
&[clip_val.borrow()],
|
||||
)?
|
||||
.v()
|
||||
})()
|
||||
.map_err(|e| format!("clipboard JNI: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ fn handle_win_cascade(
|
||||
end: target.truncate(),
|
||||
elapsed: 0.0,
|
||||
duration,
|
||||
curve: crate::card_animation::MotionCurve::Expressive,
|
||||
curve: MotionCurve::Expressive,
|
||||
delay: i as f32 * step,
|
||||
start_z: start.z,
|
||||
end_z: target.z,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! red/black colour split.
|
||||
|
||||
use bevy::math::UVec2;
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
use solitaire_core::{Rank, Suit};
|
||||
|
||||
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
||||
/// `SvgLoaderSettings` resolution).
|
||||
|
||||
@@ -115,6 +115,10 @@ macro_rules! embed_classic_svg {
|
||||
}
|
||||
|
||||
/// Every Dark-theme SVG file bundled into the binary.
|
||||
// The `as &[u8]` in `embed_dark_svg!` coerces each fixed-size
|
||||
// `&[u8; N]` (N varies per file) to a uniform `&[u8]` so the tuples fit
|
||||
// this array type. The cast is load-bearing, not trivial.
|
||||
#[allow(trivial_casts)]
|
||||
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
embed_dark_svg!("back.svg"),
|
||||
embed_dark_svg!("clubs_ace.svg"),
|
||||
@@ -172,6 +176,8 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
];
|
||||
|
||||
/// Every Classic-theme SVG file bundled into the binary.
|
||||
// See `DARK_THEME_SVGS`: the `as &[u8]` cast is load-bearing.
|
||||
#[allow(trivial_casts)]
|
||||
const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
embed_classic_svg!("back.svg"),
|
||||
embed_classic_svg!("clubs_ace.svg"),
|
||||
|
||||
@@ -192,7 +192,7 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
|
||||
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
||||
use usvg::FontResolver;
|
||||
|
||||
usvg::FontResolver {
|
||||
FontResolver {
|
||||
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
|
||||
select_fallback: FontResolver::default_fallback_selector(),
|
||||
}
|
||||
@@ -282,7 +282,7 @@ mod tests {
|
||||
/// tightens.
|
||||
#[test]
|
||||
fn settings_satisfies_loader_bounds() {
|
||||
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
|
||||
fn assert_loader_settings<T: Default + Serialize + serde::de::DeserializeOwned>() {}
|
||||
assert_loader_settings::<SvgLoaderSettings>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +76,14 @@ fn detect_auto_complete(
|
||||
}
|
||||
changed.clear();
|
||||
|
||||
if game.0.is_won {
|
||||
if game.0.is_won() {
|
||||
state.active = false;
|
||||
return;
|
||||
}
|
||||
if game.0.is_auto_completable && !state.active {
|
||||
if game.0.is_auto_completable() && !state.active {
|
||||
state.active = true;
|
||||
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
||||
} else if !game.0.is_auto_completable && state.active {
|
||||
} 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
|
||||
@@ -168,8 +168,8 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -177,13 +177,13 @@ mod tests {
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(AutoCompletePlugin);
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
||||
let mut g = GameState::new(1, DrawMode::DrawOne);
|
||||
let mut g = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
@@ -207,14 +207,9 @@ mod tests {
|
||||
}
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![solitaire_core::card::Card {
|
||||
id: 7_001,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
}],
|
||||
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||
);
|
||||
g.is_auto_completable = true;
|
||||
g.set_test_auto_completable(true);
|
||||
let expected = (
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
@@ -232,8 +227,8 @@ mod tests {
|
||||
#[test]
|
||||
fn detect_activates_when_auto_completable() {
|
||||
let mut app = headless_app();
|
||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||
g.is_auto_completable = true;
|
||||
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
g.set_test_auto_completable(true);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
@@ -268,7 +263,7 @@ mod tests {
|
||||
let mut app = headless_app();
|
||||
// Inject a won game state — active should not be set.
|
||||
let (mut gs, _) = seeded_state_with_auto_move();
|
||||
gs.is_won = true;
|
||||
gs.set_test_won(true);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
@@ -36,7 +36,7 @@ pub struct AvatarFetchEvent {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl bevy::prelude::Message for AvatarFetchEvent {}
|
||||
impl Message for AvatarFetchEvent {}
|
||||
|
||||
/// In-flight avatar download task. Returns the raw image bytes on success,
|
||||
/// or `None` on any network / decode error.
|
||||
|
||||
@@ -33,6 +33,7 @@ use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::Card;
|
||||
|
||||
use super::animation::CardAnimation;
|
||||
use super::tuning::AnimationTuning;
|
||||
@@ -72,7 +73,7 @@ pub struct HoverState {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BufferedInput {
|
||||
Move {
|
||||
from: crate::events::MoveRequestEvent,
|
||||
from: MoveRequestEvent,
|
||||
},
|
||||
Draw,
|
||||
Undo,
|
||||
@@ -210,12 +211,12 @@ pub(crate) fn apply_drag_visual(
|
||||
|
||||
// Only lift cards that are in a *committed* drag. Pending drags (below
|
||||
// threshold) must stay at scale 1.0 to avoid visible premature lift.
|
||||
let (dragged_ids, committed): (&[u32], bool) = drag
|
||||
let (dragged_cards, committed): (&[Card], bool) = drag
|
||||
.as_ref()
|
||||
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
|
||||
|
||||
for (_, card, mut transform) in &mut cards {
|
||||
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
|
||||
let is_active_drag = committed && dragged_cards.contains(&card.card);
|
||||
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
|
||||
let current = transform.scale.x;
|
||||
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
||||
|
||||
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::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -135,7 +135,7 @@ mod tests {
|
||||
fn challenge_win_advances_index() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -224,7 +224,7 @@ mod tests {
|
||||
.0
|
||||
.challenge_index = 2;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
|
||||
@@ -34,8 +34,9 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
use crate::card_plugin::RightClickHighlight;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
@@ -79,7 +80,7 @@ impl Plugin for CursorPlugin {
|
||||
Update,
|
||||
(
|
||||
update_cursor_icon,
|
||||
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
|
||||
update_drop_highlights.run_if(resource_changed::<DragState>),
|
||||
update_drop_target_overlays,
|
||||
),
|
||||
);
|
||||
@@ -185,7 +186,7 @@ fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> boo
|
||||
let base = layout.pile_positions[&pile];
|
||||
|
||||
for (i, card) in pile_cards.iter().enumerate().rev() {
|
||||
if !card.face_up {
|
||||
if !card.1 {
|
||||
continue;
|
||||
}
|
||||
// Only the topmost card is draggable on non-tableau piles.
|
||||
@@ -436,7 +437,7 @@ fn tableau_or_stack_pos(
|
||||
base.x,
|
||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||
)
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree {
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||
let pile_len = game.waste_cards().len();
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = index.saturating_sub(visible_start) as f32;
|
||||
@@ -446,7 +447,7 @@ fn tableau_or_stack_pos(
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<solitaire_core::card::Card> {
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
if matches!(pile, KlondikePile::Stock) {
|
||||
game.waste_cards()
|
||||
} else {
|
||||
@@ -562,9 +563,9 @@ mod tests {
|
||||
#[test]
|
||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// A cursor far off-screen should never hit anything.
|
||||
assert!(!cursor_over_draggable(
|
||||
@@ -579,8 +580,8 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||
|
||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||
/// registered, plus the resources the system needs. Callers
|
||||
@@ -618,7 +619,7 @@ mod tests {
|
||||
game.0.set_test_waste_cards(vec![dragged.clone()]);
|
||||
}
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![dragged.id];
|
||||
drag.cards = vec![dragged];
|
||||
drag.origin_pile = Some(KlondikePile::Stock);
|
||||
drag.committed = true;
|
||||
}
|
||||
@@ -628,23 +629,13 @@ mod tests {
|
||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||
// — same colour family, illegal. Tableau(2) must NOT be
|
||||
// highlighted.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card {
|
||||
id: 9101,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
},
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
|
||||
);
|
||||
let dragged = Card {
|
||||
id: 9102,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
@@ -362,7 +362,7 @@ mod tests {
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
#[allow(unused_imports)]
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -391,7 +391,7 @@ mod tests {
|
||||
|
||||
// Replace the GameState with one whose seed matches the daily seed.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||
GameState::new(daily_seed, DrawStockConfig::DrawOne);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -419,7 +419,7 @@ mod tests {
|
||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||
// Use a deliberately different seed.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
||||
GameState::new(daily_seed.wrapping_add(7777), DrawStockConfig::DrawOne);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -442,7 +442,7 @@ mod tests {
|
||||
let mut app = headless_app();
|
||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||
GameState::new(daily_seed, DrawStockConfig::DrawOne);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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_data::AchievementRecord;
|
||||
use solitaire_sync::SyncResponse;
|
||||
@@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent {
|
||||
}
|
||||
|
||||
/// Fired when a card's face-up state changes during gameplay.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct CardFlippedEvent(pub u32);
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct CardFlippedEvent(pub Card);
|
||||
|
||||
/// Fired by the flip animation at its midpoint — the instant the card face
|
||||
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
|
||||
@@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub u32);
|
||||
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
||||
/// so the flip sound is synchronised with the visual reveal, not the move
|
||||
/// that triggered the animation.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct CardFaceRevealedEvent(pub u32);
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct CardFaceRevealedEvent(pub Card);
|
||||
|
||||
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||
@@ -299,8 +299,8 @@ pub struct ScanThemesRequestEvent;
|
||||
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct HintVisualEvent {
|
||||
/// The `Card::id` of the source card to be highlighted.
|
||||
pub source_card_id: u32,
|
||||
/// The source card to be highlighted.
|
||||
pub source_card: Card,
|
||||
/// The destination pile whose `PileMarker` should be tinted gold.
|
||||
pub dest_pile: KlondikePile,
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ use std::hash::{Hash, Hasher};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
use solitaire_core::{Foundation, KlondikePile};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::klondike_adapter::foundation_from_slot;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::animation_plugin::CardAnim;
|
||||
@@ -245,16 +247,16 @@ fn start_shake_anim(
|
||||
continue;
|
||||
}
|
||||
let dest_pile = &ev.to;
|
||||
// Collect the card ids that belong to the destination pile.
|
||||
// Collect the cards that belong to the destination pile.
|
||||
let dest_cards = pile_cards(&game.0, dest_pile);
|
||||
let dest_card_ids: Vec<u32> = dest_cards.iter().map(|c| c.id).collect();
|
||||
let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect();
|
||||
|
||||
if dest_card_ids.is_empty() {
|
||||
if dest_card_set.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (entity, card_marker, transform) in card_entities.iter() {
|
||||
if dest_card_ids.contains(&card_marker.card_id) {
|
||||
if dest_card_set.contains(&card_marker.card) {
|
||||
commands.entity(entity).insert(ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: transform.translation.x,
|
||||
@@ -311,27 +313,27 @@ fn start_settle_anim(
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
// Build the list of card ids that should bounce this frame from every
|
||||
// Build the list of cards that should bounce this frame from every
|
||||
// queued request; multiple events can fire in the same frame (e.g. a move
|
||||
// followed by a draw via keyboard accelerators).
|
||||
let mut bounce_ids: Vec<u32> = Vec::new();
|
||||
let mut bounce_ids: Vec<Card> = Vec::new();
|
||||
|
||||
for ev in moves.read() {
|
||||
let pile = pile_cards(&game.0, &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.len());
|
||||
if n > 0 {
|
||||
let start = pile.len() - n;
|
||||
bounce_ids.extend(pile[start..].iter().map(|c| c.id));
|
||||
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if draws.read().next().is_some()
|
||||
&& let Some(top) = game.0.waste_cards().last()
|
||||
&& let Some((top, _)) = game.0.waste_cards().last()
|
||||
{
|
||||
bounce_ids.push(top.id);
|
||||
bounce_ids.push(top.clone());
|
||||
}
|
||||
|
||||
if bounce_ids.is_empty() {
|
||||
@@ -339,7 +341,7 @@ fn start_settle_anim(
|
||||
}
|
||||
|
||||
for (entity, card_marker) in card_entities.iter() {
|
||||
if bounce_ids.contains(&card_marker.card_id) {
|
||||
if bounce_ids.contains(&card_marker.card) {
|
||||
commands.entity(entity).insert(SettleAnim::default());
|
||||
}
|
||||
}
|
||||
@@ -393,7 +395,7 @@ fn start_deal_anim(
|
||||
return;
|
||||
}
|
||||
// Only animate a fresh deal (no moves made yet).
|
||||
if game.0.move_count != 0 {
|
||||
if game.0.move_count() != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else { return };
|
||||
@@ -407,10 +409,14 @@ fn start_deal_anim(
|
||||
|
||||
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
||||
let final_pos = transform.translation;
|
||||
// ±10 % jitter, deterministic per card id, so the deal feels organic
|
||||
// without losing reproducibility (a given seed still produces the
|
||||
// same per-card stagger pattern across runs).
|
||||
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
|
||||
// ±10 % jitter, deterministic per card, so the deal feels organic
|
||||
// without losing reproducibility (a given deal produces the same
|
||||
// per-card stagger pattern across runs). The seed is a hash of the
|
||||
// card's own identity — no separate numeric id needed.
|
||||
let mut card_hasher = DefaultHasher::new();
|
||||
card_marker.card.hash(&mut card_hasher);
|
||||
let per_card_stagger =
|
||||
stagger_secs * (1.0 + deal_stagger_jitter(card_hasher.finish() as u32));
|
||||
commands.entity(entity).insert((
|
||||
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
||||
CardAnim {
|
||||
@@ -524,13 +530,13 @@ fn start_foundation_flourish(
|
||||
let pile_type = KlondikePile::Foundation(foundation);
|
||||
// Top card of the completed foundation is the King.
|
||||
let cards = game.0.pile(pile_type);
|
||||
let Some(king_id) = cards.last().map(|c| c.id) else {
|
||||
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Tag the King's card entity.
|
||||
for (entity, card_marker) in card_entities.iter() {
|
||||
if card_marker.card_id == king_id {
|
||||
if card_marker.card == king_card {
|
||||
commands.entity(entity).insert(FoundationFlourish {
|
||||
foundation_slot: ev.slot,
|
||||
elapsed: 0.0,
|
||||
@@ -633,23 +639,13 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
||||
fn pile_cards(
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
pile: &KlondikePile,
|
||||
) -> Vec<solitaire_core::card::Card> {
|
||||
) -> Vec<(Card, bool)> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (pure functions only — no Bevy world required)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -850,13 +846,13 @@ mod tests {
|
||||
fn shake_anim_skipped_under_reduce_motion() {
|
||||
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;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
@@ -865,19 +861,19 @@ mod tests {
|
||||
|
||||
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
|
||||
let card_id = app
|
||||
let card = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.pile(dest_pile)
|
||||
.last()
|
||||
.map(|c| c.id)
|
||||
.map(|(c, _)| c.clone())
|
||||
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||
|
||||
// Spawn a minimal CardEntity matching that id so the system would
|
||||
// Spawn a minimal CardEntity matching that card so the system would
|
||||
// find it and insert ShakeAnim if the gate were absent.
|
||||
app.world_mut()
|
||||
.spawn((CardEntity { card_id }, Transform::default()));
|
||||
.spawn((CardEntity { card }, Transform::default()));
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||
@@ -904,13 +900,13 @@ mod tests {
|
||||
#[test]
|
||||
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
@@ -921,7 +917,7 @@ mod tests {
|
||||
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||
.write(FoundationCompletedEvent {
|
||||
slot: 0,
|
||||
suit: solitaire_core::card::Suit::Spades,
|
||||
suit: solitaire_core::Suit::Spades,
|
||||
});
|
||||
app.update();
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! Bundling rather than runtime-loading guarantees the canonical UI face is
|
||||
//! always available regardless of install or platform. The bytes are
|
||||
//! validated at startup; a parse failure aborts the program with a clear
|
||||
//! error because it means the binary is corrupt.
|
||||
//! validated at startup; a parse failure logs a warning and continues with
|
||||
//! glyph-less UI rather than aborting, since crashing on a corrupt embed is
|
||||
//! worse than degraded text.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ impl Plugin for HelpPlugin {
|
||||
// plugin under `DefaultPlugins`; register them explicitly so
|
||||
// scroll systems run cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
.add_message::<TouchInput>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||
use solitaire_data::save_settings_to;
|
||||
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
@@ -432,7 +432,7 @@ fn build_home_context<'a>(
|
||||
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||
daily_today,
|
||||
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
|
||||
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
|
||||
font_res,
|
||||
difficulty_expanded,
|
||||
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||
@@ -620,9 +620,9 @@ fn handle_home_draw_mode_buttons(
|
||||
return;
|
||||
};
|
||||
let target = if want_one {
|
||||
DrawMode::DrawOne
|
||||
DrawStockConfig::DrawOne
|
||||
} else {
|
||||
DrawMode::DrawThree
|
||||
DrawStockConfig::DrawThree
|
||||
};
|
||||
if settings.0.draw_mode == target {
|
||||
return; // already in this mode — avoid a redundant respawn.
|
||||
@@ -857,7 +857,7 @@ struct HomeContext<'a> {
|
||||
challenge_best: u32,
|
||||
daily_streak: u32,
|
||||
daily_today: Option<DailyToday>,
|
||||
draw_mode: DrawMode,
|
||||
draw_mode: DrawStockConfig,
|
||||
font_res: Option<&'a FontResource>,
|
||||
/// Whether the difficulty section header is currently expanded.
|
||||
difficulty_expanded: bool,
|
||||
@@ -1038,7 +1038,7 @@ fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>)
|
||||
..default()
|
||||
};
|
||||
|
||||
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
|
||||
let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne);
|
||||
|
||||
parent
|
||||
.spawn(Node {
|
||||
@@ -1878,7 +1878,7 @@ mod tests {
|
||||
|
||||
let states: Vec<(HomeMode, bool)> = app
|
||||
.world_mut()
|
||||
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
|
||||
.query::<(&HomeModeCard, Has<Disabled>)>()
|
||||
.iter(app.world())
|
||||
.map(|(c, d)| (c.0, d))
|
||||
.collect();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,872 @@
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use chrono::Local;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_plugin_registers_without_panic() {
|
||||
let _app = headless_app();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_hud_runs_after_game_mutation_without_panic() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawOne);
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn read_hud_text<M: Component>(app: &mut App) -> String {
|
||||
app.world_mut()
|
||||
.query_filtered::<&Text, With<M>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_reflects_game_state() {
|
||||
let mut app = headless_app();
|
||||
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_reflects_game_state() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(42);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_mode_shows_draw_3_badge() {
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_hides_score() {
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
|
||||
app.update();
|
||||
// Zen mode spec: "No score display" → text must be empty.
|
||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_display_uses_mm_ss_format() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.elapsed_seconds = 125;
|
||||
app.update();
|
||||
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
||||
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_time_limit (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_300_is_5_00() {
|
||||
assert_eq!(format_time_limit(300), "5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_zero() {
|
||||
assert_eq!(format_time_limit(0), "0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_pads_seconds() {
|
||||
assert_eq!(format_time_limit(65), "1:05");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// challenge_hud_text (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_shows_time_limit() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_shows_score_goal() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_empty_when_no_constraints() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_above_60_is_info() {
|
||||
let c = challenge_time_color(61);
|
||||
assert_eq!(c, STATE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_exactly_60_is_info() {
|
||||
let c = challenge_time_color(60);
|
||||
assert_eq!(c, STATE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_59_is_warning() {
|
||||
let c = challenge_time_color(59);
|
||||
assert_eq!(c, STATE_WARNING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_30_is_warning() {
|
||||
let c = challenge_time_color(30);
|
||||
assert_eq!(c, STATE_WARNING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_29_is_danger() {
|
||||
let c = challenge_time_color(29);
|
||||
assert_eq!(c, STATE_DANGER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_zero_is_danger() {
|
||||
let c = challenge_time_color(0);
|
||||
assert_eq!(c, STATE_DANGER);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudChallenge in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_empty_when_no_daily_resource() {
|
||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_shows_time_limit_when_resource_present() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: Some("Win fast".to_string()),
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_shows_score_goal_when_resource_present() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: None,
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_clears_on_win() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
// Mark the game as won — HudChallenge should be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudUndos in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn undos_hud_empty_at_game_start() {
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undos_hud_shows_count_after_undo() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_undos(3);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudAutoComplete in-app tests (Task #56)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn headless_app_with_auto_complete() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.init_resource::<AutoCompleteState>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_badge_shows_auto_when_active() {
|
||||
let mut app = headless_app_with_auto_complete();
|
||||
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
|
||||
// Also trigger game state change so the update fires.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(1);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_badge_empty_when_inactive() {
|
||||
let mut app = headless_app_with_auto_complete();
|
||||
// active is false by default.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(1);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudRecycles in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One, no recycles yet — text must be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawOne);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-Three, no recycles yet — text must also be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawThree);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_three() {
|
||||
let mut app = headless_app();
|
||||
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
|
||||
gs.force_test_recycles(3);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_one() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
||||
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
gs.force_test_recycles(2);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Score-change feedback (G2)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Tells `TimePlugin` to advance by `secs` on every subsequent
|
||||
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
|
||||
/// local to avoid coupling the two test modules.
|
||||
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use std::time::Duration;
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||
secs,
|
||||
)));
|
||||
}
|
||||
|
||||
/// Counts entities matching component `M` currently in the world.
|
||||
fn count_with<M: Component>(app: &mut App) -> usize {
|
||||
app.world_mut().query::<&M>().iter(app.world()).count()
|
||||
}
|
||||
|
||||
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
|
||||
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
|
||||
/// component is also inserted on the score readout — both signals
|
||||
/// fire from the same delta detection.
|
||||
#[test]
|
||||
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||
let mut app = headless_app();
|
||||
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
|
||||
// can be asserted exactly: with Automatic strategy a few ms
|
||||
// of wall-clock time leaks in between updates and the alpha
|
||||
// drifts below 1.0 by `dt / lifetime`.
|
||||
set_manual_time_step(&mut app, 0.0);
|
||||
// Initial state has score=0; bumping by 50 (the threshold)
|
||||
// is the smallest jump that triggers the floater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
|
||||
// One floater should now exist.
|
||||
let count = count_with::<ScoreFloater>(&mut app);
|
||||
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
|
||||
|
||||
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
|
||||
// detect system spawns the floater coloured ACCENT_PRIMARY
|
||||
// and at dt=0 the first advance tick leaves alpha = 1.0.
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
|
||||
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
|
||||
assert_eq!(color.0, ACCENT_PRIMARY);
|
||||
}
|
||||
|
||||
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
|
||||
/// the floater has reached the end of its lifetime and despawned.
|
||||
#[test]
|
||||
fn score_floater_despawns_after_full_lifetime() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||
|
||||
// Advance by a delta well past the floater's lifetime — the
|
||||
// single oversized tick clamps t at 1.0 and the entity is
|
||||
// despawned in the same `Update`.
|
||||
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
|
||||
app.update();
|
||||
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
|
||||
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"floater should have despawned after its full lifetime"
|
||||
);
|
||||
}
|
||||
|
||||
/// A small score change (below the threshold) inserts a pulse on
|
||||
/// the readout but never spawns a floater — keeping the floating
|
||||
/// "+N" reserved for meaningful score jumps.
|
||||
#[test]
|
||||
fn score_increase_below_threshold_does_not_spawn_floater() {
|
||||
let mut app = headless_app();
|
||||
// +5 mirrors a single tableau-to-foundation move; well below
|
||||
// the 50-point threshold so the floater path stays dormant.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"delta of +5 must not spawn a floater"
|
||||
);
|
||||
}
|
||||
|
||||
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
|
||||
/// returns to 1.0 at the endpoints. Pure-function check that
|
||||
/// guards the curve shape against future tweaks.
|
||||
#[test]
|
||||
fn score_pulse_scale_is_triangular() {
|
||||
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
|
||||
// Values outside [0,1] are clamped before the curve runs.
|
||||
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
||||
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
||||
/// curve test — the two animations share a triangular shape so a
|
||||
/// future tweak that desyncs them shows up here.
|
||||
#[test]
|
||||
fn streak_flourish_scale_curves_through_one_one_one() {
|
||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||
assert!(
|
||||
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||
"streak flourish scale at t=0 must be 1.0",
|
||||
);
|
||||
assert!(
|
||||
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
||||
);
|
||||
assert!(
|
||||
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||
"streak flourish scale at t=duration must return to 1.0",
|
||||
);
|
||||
}
|
||||
|
||||
/// Out-of-range values are clamped, not extrapolated. Matches the
|
||||
/// foundation flourish's clamp behaviour so the score readout never
|
||||
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
||||
#[test]
|
||||
fn streak_flourish_scale_clamps_out_of_range() {
|
||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||
/// divides by zero.
|
||||
#[test]
|
||||
fn streak_flourish_scale_zero_duration_is_one() {
|
||||
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||
#[test]
|
||||
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||
use solitaire_data::Settings;
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScorePulse>(&mut app),
|
||||
0,
|
||||
"ScorePulse must not spawn under reduce-motion"
|
||||
);
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"ScoreFloater must not spawn under reduce-motion"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: keyboard focus ring — HUD action bar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Returns the `Focusable` carried by the unique entity matching
|
||||
/// marker `M`. Helper for the HUD focus tests.
|
||||
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
|
||||
app.world_mut()
|
||||
.query_filtered::<&Focusable, With<M>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.copied()
|
||||
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app();
|
||||
// Every action-bar button is in `FocusGroup::Hud`.
|
||||
for f in [
|
||||
focusable_for::<MenuButton>(&mut app),
|
||||
focusable_for::<UndoButton>(&mut app),
|
||||
focusable_for::<PauseButton>(&mut app),
|
||||
focusable_for::<HelpButton>(&mut app),
|
||||
focusable_for::<HintButton>(&mut app),
|
||||
focusable_for::<ModesButton>(&mut app),
|
||||
focusable_for::<NewGameButton>(&mut app),
|
||||
] {
|
||||
assert_eq!(
|
||||
f.group,
|
||||
FocusGroup::Hud,
|
||||
"every HUD action button must be in FocusGroup::Hud"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the tooltip string carried by the unique entity matching
|
||||
/// marker `M`. Panics if zero or more than one such entity exists,
|
||||
/// which is the invariant we want to enforce for HUD readouts and
|
||||
/// action buttons (each marker is spawned exactly once).
|
||||
fn tooltip_for<M: Component>(app: &mut App) -> String {
|
||||
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
|
||||
let world = app.world();
|
||||
let mut iter = q.iter(world);
|
||||
let first = iter
|
||||
.next()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expected a Tooltip on the {} entity",
|
||||
std::any::type_name::<M>()
|
||||
)
|
||||
})
|
||||
.0
|
||||
.clone()
|
||||
.into_owned();
|
||||
assert!(
|
||||
iter.next().is_none(),
|
||||
"expected exactly one Tooltip-bearing entity for {}",
|
||||
std::any::type_name::<M>()
|
||||
);
|
||||
first
|
||||
}
|
||||
|
||||
/// Every HUD readout and action button must spawn with a `Tooltip`
|
||||
/// carrying the approved canonical microcopy. Mirrors the structure
|
||||
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
|
||||
/// invariant — one marker entity, one tooltip, exact text — is
|
||||
/// asserted consistently across every element.
|
||||
#[test]
|
||||
fn hud_elements_carry_expected_tooltip_strings() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// HUD readouts (left column, top to bottom).
|
||||
assert_eq!(
|
||||
tooltip_for::<HudScore>(&mut app),
|
||||
"Points earned this game. Hidden in Zen mode."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudMoves>(&mut app),
|
||||
"Moves you've made this game. Counts placements and stock draws."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudTime>(&mut app),
|
||||
"Time on this game. Counts down in Time Attack."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudMode>(&mut app),
|
||||
"Active game mode. Click Modes to switch."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudChallenge>(&mut app),
|
||||
"Today's daily challenge target. Beat it for bonus XP."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudDrawCycle>(&mut app),
|
||||
"Cards drawn on the next stock click in Draw-Three."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudUndos>(&mut app),
|
||||
"Undos used this game. Any undo blocks the No Undo achievement."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudRecycles>(&mut app),
|
||||
"Times you've recycled the stock. Three or more unlocks Comeback."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudAutoComplete>(&mut app),
|
||||
"Board is solvable from here. Press Enter to auto-finish."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudSelection>(&mut app),
|
||||
"Pile selected with Tab. Use arrows or Enter to act."
|
||||
);
|
||||
|
||||
// Action bar (left to right).
|
||||
assert_eq!(
|
||||
tooltip_for::<MenuButton>(&mut app),
|
||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<UndoButton>(&mut app),
|
||||
"Take back your last move. Costs points and blocks No Undo."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<PauseButton>(&mut app),
|
||||
"Pause the game and freeze the timer."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HelpButton>(&mut app),
|
||||
"Show controls, rules, and keyboard shortcuts."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HintButton>(&mut app),
|
||||
"Highlight a suggested move. Cycles through alternatives on repeat taps."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<ModesButton>(&mut app),
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<NewGameButton>(&mut app),
|
||||
"Start a fresh deal. Confirms first if a game is in progress."
|
||||
);
|
||||
}
|
||||
|
||||
/// Every interior row of the Modes and Menu popovers must carry a
|
||||
/// `Tooltip`. The popovers open from action-bar buttons whose own
|
||||
/// tooltips are already covered above; this test extends the
|
||||
/// invariant inward so hover discoverability is uniform across the
|
||||
/// HUD's nested controls.
|
||||
///
|
||||
/// We invoke the popover spawn helpers directly with a maxed-out
|
||||
/// `ProgressResource` and a `DailyChallengeResource` so every row
|
||||
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
|
||||
/// Headless click simulation isn't needed — the contract under
|
||||
/// test is "every popover row spawns with a tooltip", which is a
|
||||
/// property of the spawn helpers themselves.
|
||||
#[test]
|
||||
fn popover_rows_carry_tooltip_strings() {
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use solitaire_sync::progress::PlayerProgress;
|
||||
|
||||
let mut app = headless_app();
|
||||
|
||||
// Force every mode row to render: level past the challenge
|
||||
// unlock threshold, plus a daily challenge resource so the
|
||||
// Daily row appears.
|
||||
let progress = ProgressResource(PlayerProgress {
|
||||
level: CHALLENGE_UNLOCK_LEVEL,
|
||||
..Default::default()
|
||||
});
|
||||
let daily = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
};
|
||||
|
||||
// Spawn both popovers via their helpers. Mirrors how the click
|
||||
// handlers invoke them in production — we just skip the click.
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
|
||||
spawn_menu_popover(&mut commands, None);
|
||||
world.flush();
|
||||
}
|
||||
app.update();
|
||||
|
||||
// Every ModeOption-tagged entity must also carry a Tooltip,
|
||||
// and the count must match the five canonical modes.
|
||||
let mut mode_q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<ModeOption>>();
|
||||
let mode_tooltips: Vec<String> = mode_q
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
mode_tooltips.len(),
|
||||
5,
|
||||
"expected a tooltip on each of the 5 mode rows, got {}",
|
||||
mode_tooltips.len()
|
||||
);
|
||||
// Every approved mode tooltip string must be present somewhere
|
||||
// among the ModeOption rows. Order isn't asserted — the spawn
|
||||
// order test elsewhere already covers that.
|
||||
for expected in [
|
||||
"Standard Klondike. Score, timer, and full progression.",
|
||||
"Today's seeded deal. Same for every player worldwide.",
|
||||
"No timer, no score, no penalties. Just play.",
|
||||
"Hand-picked hard seeds. No undo allowed.",
|
||||
"Win as many games as you can in ten minutes.",
|
||||
] {
|
||||
assert!(
|
||||
mode_tooltips.iter().any(|s| s == expected),
|
||||
"missing mode tooltip: {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Same contract for MenuOption rows: seven entries, each with a
|
||||
// tooltip, exact strings matching the approved microcopy.
|
||||
let mut menu_q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<MenuOption>>();
|
||||
let menu_tooltips: Vec<String> = menu_q
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
menu_tooltips.len(),
|
||||
7,
|
||||
"expected a tooltip on each of the 7 menu rows, got {}",
|
||||
menu_tooltips.len()
|
||||
);
|
||||
for expected in [
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
"Your level, XP progress, and sync status.",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
] {
|
||||
assert!(
|
||||
menu_tooltips.iter().any(|s| s == expected),
|
||||
"missing menu tooltip: {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_button_order_matches_spawn_order() {
|
||||
let mut app = headless_app();
|
||||
// Visual reading order (left → right): Menu, Undo, Pause, Help,
|
||||
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
|
||||
// that order so Tab cycles them as the player reads them.
|
||||
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
|
||||
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
||||
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
||||
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
||||
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
|
||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
|
||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_focus_only_engages_when_button_hovered() {
|
||||
// Phase 2 declares membership in `FocusGroup::Hud`; the
|
||||
// engagement rule lives in `handle_focus_keys`. Two halves to
|
||||
// this test:
|
||||
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
|
||||
// still holds when nothing is hovered).
|
||||
// (b) no modal + a HUD button hovered ⇒ Tab advances
|
||||
// `FocusedButton` to a Hud-grouped entity.
|
||||
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
|
||||
use crate::ui_modal::UiModalPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
|
||||
// (a) Sanity: HUD buttons exist and are focusable, but no
|
||||
// modal open and no hover ⇒ FocusedButton stays None.
|
||||
assert!(
|
||||
app.world().resource::<FocusedButton>().0.is_none(),
|
||||
"no modal open, no auto-focus"
|
||||
);
|
||||
|
||||
// Press Tab. With no modal and no hover, `handle_focus_keys`
|
||||
// resolves no active group and returns early — Tab must not
|
||||
// advance the HUD focus ring on its own.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(KeyCode::Tab);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<FocusedButton>().0.is_none(),
|
||||
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
|
||||
);
|
||||
|
||||
// (b) Hover the Menu button — the leftmost HUD action — and
|
||||
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
|
||||
let menu_entity = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<MenuButton>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("MenuButton entity should exist");
|
||||
app.world_mut()
|
||||
.entity_mut(menu_entity)
|
||||
.insert(Interaction::Hovered);
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(KeyCode::Tab);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let focused = app
|
||||
.world()
|
||||
.resource::<FocusedButton>()
|
||||
.0
|
||||
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
|
||||
// The focused entity must itself be Hud-grouped (i.e. one of
|
||||
// the action-bar buttons), not anything else in the world.
|
||||
let focusable = app
|
||||
.world()
|
||||
.entity(focused)
|
||||
.get::<Focusable>()
|
||||
.expect("focused entity must carry Focusable");
|
||||
assert_eq!(
|
||||
focusable.group,
|
||||
FocusGroup::Hud,
|
||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
use super::*;
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn clear_test_piles(game: &mut GameState) {
|
||||
game.set_test_stock_cards(Vec::new());
|
||||
game.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
game.set_test_foundation_cards(foundation, Vec::new());
|
||||
}
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
game.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragged_card_z_matches_resting_stack_step() {
|
||||
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
||||
let step = dragged_card_z(1) - dragged_card_z(0);
|
||||
assert!(
|
||||
step > 0.02,
|
||||
"drag step must exceed Android overlay local_z, got {step}"
|
||||
);
|
||||
assert!(
|
||||
step + 1e-4 >= STACK_FAN_FRAC,
|
||||
"drag step must stay aligned with resting stack spacing, got {step}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_inside_returns_true() {
|
||||
let center = Vec2::new(10.0, 20.0);
|
||||
let size = Vec2::new(40.0, 60.0);
|
||||
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_on_edge_returns_true() {
|
||||
let center = Vec2::ZERO;
|
||||
let size = Vec2::new(10.0, 10.0);
|
||||
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_outside_returns_false() {
|
||||
let center = Vec2::ZERO;
|
||||
let size = Vec2::new(10.0, 10.0);
|
||||
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
||||
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
||||
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_top_of_tableau() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
assert_eq!(result.1, 6);
|
||||
assert_eq!(result.2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||
// Reproduces the reported "drags the wrong waste card" bug: with several
|
||||
// cards in the waste, clicking the visible top must pick the actual top
|
||||
// (last index), not the buffer card underneath it.
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
let top_index = waste.len() - 1; // 2 = the visible top
|
||||
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_lone_waste_card() {
|
||||
// "can't play the first card in the stock" — a waste of one card must
|
||||
// still be draggable.
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
||||
game.set_test_waste_cards(vec![card.clone()]);
|
||||
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock);
|
||||
assert_eq!(result.1, 0);
|
||||
assert_eq!(result.2, vec![card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
||||
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
||||
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
||||
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
||||
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
||||
// fanned card's click target lands on the card beneath it — so dragging
|
||||
// the visible top card plays the wrong one.
|
||||
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
||||
let cw = layout.card_size.x;
|
||||
let base = layout.pile_positions[&KlondikePile::Stock];
|
||||
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
layout.pile_positions.insert(
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
Vec2::new(t1.x + cw * 1.03, t1.y),
|
||||
);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
||||
];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
||||
let top_index = waste.len() - 1;
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
|
||||
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
||||
assert!(
|
||||
(pos.x - expected).abs() < 1e-3,
|
||||
"hit-test must use the shared waste fan step"
|
||||
);
|
||||
// The old fixed constant would have drifted from the renderer here.
|
||||
let old = base.x + 2.0 * cw * 0.28;
|
||||
assert!(
|
||||
(pos.x - old).abs() > 1.0,
|
||||
"shared step must differ from the old fixed step under tight spacing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_face_down_cards() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
||||
// the bottom (index 6). Click at the topmost face-down card's
|
||||
// position — its full body is partly visible above the fanned
|
||||
// face-up card, but the iterator should skip face-down cards and
|
||||
// the cursor sits above the face-up card's AABB, so the result
|
||||
// is None.
|
||||
let face_down_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
||||
// Regression test for the bug where input_plugin's hit-testing used
|
||||
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
||||
// at 0.12 — so for any column with face-down cards above the
|
||||
// face-up bottom card, clicking the visible card face missed the
|
||||
// hit-test box and only the bottom strip of the card responded.
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||
let face_up_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
assert_eq!(result.1, 6);
|
||||
assert_eq!(result.2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||
// Manually construct a tableau with three face-up cards all stacked.
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||
game.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![king, queen.clone(), jack.clone()],
|
||||
);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||
let queen_center =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![queen, jack]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_non_top_waste_card() {
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||
// the visually top card (three_hearts), with count = 1.
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![three_hearts]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||
let mut game = game;
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
let target = find_drop_target(
|
||||
pos,
|
||||
&game,
|
||||
&layout,
|
||||
&KlondikePile::Tableau(Tableau::Tableau7),
|
||||
);
|
||||
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_drop_target_returns_none_for_origin() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||
let target = find_drop_target(
|
||||
pos,
|
||||
&game,
|
||||
&layout,
|
||||
&KlondikePile::Tableau(Tableau::Tableau4),
|
||||
);
|
||||
assert_eq!(target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Tableau 6 has 7 cards.
|
||||
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
||||
// Expected: card_height + 6 fan steps.
|
||||
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||
assert!(
|
||||
(size.y - expected).abs() < 1e-3,
|
||||
"expected {expected}, got {}",
|
||||
size.y
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
||||
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
||||
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
||||
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||
let cursor = Vec2::new(top_card_x, waste_base.y);
|
||||
|
||||
let result = find_draggable_at(cursor, &game, &layout);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"top fanned waste card must be hittable at its visual X position"
|
||||
);
|
||||
let (pile, _start, ids) = result.unwrap();
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Clear tableau 0 so it's an empty slot.
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
let result = find_draggable_at(pos, &game, &layout);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"clicking an empty pile must not produce a draggable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for pile in [
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
] {
|
||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||
assert_eq!(size, layout.card_size);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #27 — best_destination pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn best_destination_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Clear everything except one card that has nowhere to go.
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
||||
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||
assert!(best_destination(&card, &game).is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// best_tableau_destination_for_stack pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// Only tableau 0 has anything; every other column is empty.
|
||||
// A King is the only card that can go on an empty tableau column.
|
||||
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
||||
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
||||
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&king,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
// Result must be some other empty tableau column, never the source.
|
||||
if let Some((dest, _)) = result {
|
||||
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
||||
// All other piles are empty — no legal tableau target.
|
||||
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
||||
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&two_clubs,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Two of Clubs has no legal tableau destination on empty piles"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #28 — find_hint pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn find_hint_finds_ace_to_foundation() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Place Ace of Clubs on top of tableau 0.
|
||||
clear_test_piles(&mut game);
|
||||
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
||||
|
||||
let hint = find_hint(&game);
|
||||
assert!(hint.is_some(), "should find a hint");
|
||||
let (from, to) = hint.unwrap();
|
||||
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
||||
/// the "is there actually a game?" gating lives in
|
||||
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
||||
/// "No game to forfeit" toast instead of failing silently.
|
||||
#[test]
|
||||
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
||||
// Build the system param state by hand so we don't rely on a
|
||||
// full Bevy app: the assertion is that the function returns
|
||||
// early on the paused branch without calling write_message.
|
||||
// This is verified by the plain `if paused { return; }` shape;
|
||||
// the body is small enough to inspect by reading.
|
||||
// (Higher-level integration coverage lives in the pause-plugin
|
||||
// tests where `forfeit_app` simulates the full flow.)
|
||||
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// all_hints / new-game window — pure-function tests added during refactor
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
||||
/// are no other moves and the stock is non-empty.
|
||||
#[test]
|
||||
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||
// move exists. Leave one card in the stock.
|
||||
clear_test_piles(&mut game);
|
||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
||||
|
||||
let hints = all_hints(&game);
|
||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||
let (from, to) = &hints[0];
|
||||
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
||||
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
||||
}
|
||||
|
||||
// `all_hints` must be empty when both stock and waste are empty and no
|
||||
// pile-to-pile move exists — the game is truly stuck.
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||
// `ShakeAnim` on the dragged cards. The audio cue
|
||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||
// to a smooth ease-out glide back to the origin pile.
|
||||
//
|
||||
// These tests build the component values exactly as `end_drag` and
|
||||
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||
// and mouse-button input, so we exercise the data path the same way the
|
||||
// legacy `ShakeAnim` tests did.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||
fn build_drag_reject_animation(
|
||||
drag_pos: Vec2,
|
||||
drag_z: f32,
|
||||
target_pos: Vec2,
|
||||
stack_index: usize,
|
||||
) -> CardAnimation {
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||
}
|
||||
|
||||
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||
/// rejection. With the shake → tween migration, the assertion changes
|
||||
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||
/// gets a CardAnimation" — same coverage, new component.
|
||||
#[test]
|
||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
// Simulate a stack drag of two cards.
|
||||
let dragged_cards: Vec<Card> = vec![
|
||||
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
||||
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
||||
];
|
||||
|
||||
let mut animated: Vec<Card> = Vec::new();
|
||||
for card in &dragged_cards {
|
||||
// In `end_drag` we iterate `drag.cards` and look up each card in
|
||||
// `card_entities`. The cards we would insert a `CardAnimation` on
|
||||
// must exactly match the dragged set.
|
||||
animated.push(card.clone());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
animated, dragged_cards,
|
||||
"every card in drag.cards must receive a CardAnimation on rejection"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `end` field of the inserted tween must equal the card's resting
|
||||
/// slot in its origin pile — the position the card belongs at after a
|
||||
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||
/// and `sync_cards` would have to fight it back.
|
||||
#[test]
|
||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||
let anim =
|
||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||
|
||||
assert!(
|
||||
(anim.end - target_pos).length() < 1e-6,
|
||||
"CardAnimation.end must match the origin slot's resting position. \
|
||||
Expected {target_pos:?}, got {:?}",
|
||||
anim.end
|
||||
);
|
||||
}
|
||||
|
||||
/// The `start` field of the inserted tween must equal the card's
|
||||
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||
/// released the card. This is what makes the glide feel like a
|
||||
/// continuous return rather than a teleport-then-shake.
|
||||
#[test]
|
||||
fn rejected_drag_animation_starts_from_drag_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0);
|
||||
let target_pos = Vec2::new(80.0, -120.0);
|
||||
let anim =
|
||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||
|
||||
assert!(
|
||||
(anim.start - drag_pos).length() < 1e-6,
|
||||
"CardAnimation.start must match the drop-time transform position \
|
||||
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||
anim.start
|
||||
);
|
||||
// And the start must be visibly distinct from the origin slot — the
|
||||
// whole point of the tween is that it visibly travels.
|
||||
assert!(
|
||||
(anim.start - anim.end).length() > 1.0,
|
||||
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||
anim.start,
|
||||
anim.end
|
||||
);
|
||||
}
|
||||
|
||||
/// The tween duration is taken from the project-wide motion token so
|
||||
/// designers can retune the feel from one place. Keeps the constant and
|
||||
/// the call site honest.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_correct_duration() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||
anim.duration
|
||||
);
|
||||
}
|
||||
|
||||
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||
/// cleanly into its rest position — overshoot on a rejection feels
|
||||
/// jittery rather than forgiving.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_responsive_curve() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
anim.curve,
|
||||
MotionCurve::Responsive,
|
||||
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||
so the card snaps back without bouncing past the slot"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `start_z` of the tween must equal the card's drop-time z
|
||||
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||
/// travels home, then settles at the correct resting z.
|
||||
#[test]
|
||||
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||
let stack_index = 2_usize;
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
stack_index,
|
||||
);
|
||||
assert!(
|
||||
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||
);
|
||||
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
assert!(
|
||||
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||
anim.end_z
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hint system — async port (v0.18.0+)
|
||||
//
|
||||
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||
// system in `pending_hint` turns into hint visuals one frame
|
||||
// later. The behaviour contract this section pins is "pressing H
|
||||
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||
// covered end-to-end in `pending_hint::tests`.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing H on a non-paused, non-won game with a live
|
||||
/// `GameStateResource` + `LayoutResource` must populate
|
||||
/// `PendingHintTask`. The polling system, exercised in
|
||||
/// `pending_hint::tests`, drives the result to a visual event.
|
||||
#[test]
|
||||
fn pressing_h_spawns_pending_hint_task() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<InfoToastEvent>();
|
||||
app.add_message::<HintVisualEvent>();
|
||||
app.init_resource::<HintCycleIndex>();
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(LayoutResource(
|
||||
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
|
||||
// Simulate the H key being pressed this frame.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<crate::pending_hint::PendingHintTask>()
|
||||
.is_pending(),
|
||||
"pressing H must spawn an async hint task",
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use std::collections::HashMap;
|
||||
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::{Resource, SystemSet};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
/// Schedule labels for layout-related systems so cross-plugin ordering is
|
||||
@@ -91,6 +92,15 @@ const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||
/// this column inside the visible window.
|
||||
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
|
||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||
/// below this band so the HUD doesn't bleed into the play surface.
|
||||
@@ -275,13 +285,12 @@ pub fn compute_layout(
|
||||
);
|
||||
}
|
||||
|
||||
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
|
||||
// height-based sizing already ensures a worst-case 13-card column fits at
|
||||
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
|
||||
// keeps it there — no change from prior behaviour. On width-limited
|
||||
// (portrait phone) windows card_size is small and lots of vertical space
|
||||
// is unused; we solve for the fraction that exactly fills the available
|
||||
// space to the bottom margin.
|
||||
// Adaptive tableau fan fraction. On height-limited windows the height-based
|
||||
// sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
|
||||
// so the formula returns the minimum and the clamp keeps it there. On
|
||||
// width-limited (portrait phone) windows card_size is small and lots of
|
||||
// vertical space is unused; solve for the fraction that fills the available
|
||||
// space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
|
||||
//
|
||||
// avail = distance from the top of the first tableau card to the bottom
|
||||
// margin — i.e. the space available for 12 fan steps.
|
||||
@@ -292,20 +301,87 @@ pub fn compute_layout(
|
||||
} else {
|
||||
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);
|
||||
// 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 tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
|
||||
let available_tableau_height = avail;
|
||||
|
||||
Layout {
|
||||
card_size,
|
||||
pile_positions,
|
||||
tableau_fan_frac,
|
||||
tableau_facedown_fan_frac,
|
||||
available_tableau_height: avail,
|
||||
available_tableau_height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spread the tableau fan so the deepest column fills the available vertical
|
||||
/// height for the *current* deal, mutating `layout.tableau_fan_frac` and its
|
||||
/// face-down companion in place.
|
||||
///
|
||||
/// `compute_layout` is pure geometry and sizes the fan for a worst-case 13-card
|
||||
/// column, so early in a game (shallow columns) a tall or near-square viewport
|
||||
/// — e.g. an unfolded foldable — is left with a large empty band below the
|
||||
/// tableau. This refines the fan once the actual deal is known.
|
||||
///
|
||||
/// Depth is measured across *all* cards in a column, each face-down card
|
||||
/// weighted by the fixed face-down/face-up step ratio. Counting the face-down
|
||||
/// portion (not just the face-up tail) is what fills the lower screen on a fresh
|
||||
/// deal, where the deepest column is several face-down cards under one face-up
|
||||
/// one. Deeper columns drive the fraction down so everything still fits;
|
||||
/// [`TABLEAU_FAN_FRAC`] floors it and [`MAX_DYNAMIC_FAN_FRAC`] caps it.
|
||||
///
|
||||
/// Called from card-sync at startup and on every `StateChangedEvent`, and from
|
||||
/// the resize pipeline after `compute_layout`, so the cold-start deal, ongoing
|
||||
/// play, and fold/unfold all stay filled. `card_position` / `card_positions`
|
||||
/// read the same fractions, so rendering and hit-testing remain in sync.
|
||||
pub(crate) fn apply_dynamic_tableau_fan(game: &GameState, layout: &mut Layout) {
|
||||
let card_h = layout.card_size.y;
|
||||
let avail = layout.available_tableau_height;
|
||||
if card_h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
|
||||
|
||||
// "Step demand" of a column: the vertical offset of its bottom card from its
|
||||
// top card, in units of the face-up fan step. Every card except the last
|
||||
// contributes one step, weighted down to `facedown_ratio` while face-down.
|
||||
let max_demand = [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|tableau| {
|
||||
let pile = game.pile(KlondikePile::Tableau(tableau));
|
||||
let steps = pile.len().saturating_sub(1);
|
||||
pile.iter()
|
||||
.take(steps)
|
||||
.map(|(_, face_up)| if *face_up { 1.0 } else { facedown_ratio })
|
||||
.sum::<f32>()
|
||||
})
|
||||
.fold(0.0_f32, f32::max);
|
||||
|
||||
// No fannable column (every tableau pile has ≤ 1 card) — leave the fractions
|
||||
// at the values compute_layout set.
|
||||
if max_demand <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let ideal = avail / (max_demand * card_h);
|
||||
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, MAX_DYNAMIC_FAN_FRAC);
|
||||
let new_facedown_frac = new_frac * facedown_ratio;
|
||||
|
||||
if (layout.tableau_fan_frac - new_frac).abs() > 1e-4 {
|
||||
layout.tableau_fan_frac = new_frac;
|
||||
}
|
||||
if (layout.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
|
||||
layout.tableau_facedown_fan_frac = new_facedown_frac;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,7 +821,7 @@ mod tests {
|
||||
);
|
||||
// The HUD band top clearance (distance from window top to card top)
|
||||
// must match as well — this is the quantity directly visible in Bug 2.
|
||||
let card_top = |layout: &super::Layout| {
|
||||
let card_top = |layout: &Layout| {
|
||||
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
|
||||
};
|
||||
assert!(
|
||||
|
||||
@@ -862,7 +862,7 @@ fn handle_display_name_confirm(
|
||||
.leaderboard_display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
if let solitaire_data::settings::SyncBackend::SolitaireServer {
|
||||
if let SyncBackend::SolitaireServer {
|
||||
ref username,
|
||||
..
|
||||
} = settings.0.sync_backend
|
||||
@@ -1091,7 +1091,7 @@ mod tests {
|
||||
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
||||
.add_plugins(SyncPlugin::new(NoOpProvider))
|
||||
.add_plugins(LeaderboardPlugin);
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! active opens the overlay as normal.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_data::save_game_state_to;
|
||||
|
||||
use crate::events::{
|
||||
@@ -86,10 +86,10 @@ struct ForfeitConfirmButton;
|
||||
/// Returns the human-readable label for a draw mode.
|
||||
///
|
||||
/// Used on the pause overlay draw-mode toggle button.
|
||||
pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
||||
pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
|
||||
match mode {
|
||||
DrawMode::DrawOne => "Draw 1",
|
||||
DrawMode::DrawThree => "Draw 3",
|
||||
DrawStockConfig::DrawOne => "Draw 1",
|
||||
DrawStockConfig::DrawThree => "Draw 3",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,9 +273,9 @@ fn handle_pause_draw_buttons(
|
||||
}
|
||||
let Some(mut settings) = settings else { return };
|
||||
let new_mode = if pressed_one {
|
||||
DrawMode::DrawOne
|
||||
DrawStockConfig::DrawOne
|
||||
} else {
|
||||
DrawMode::DrawThree
|
||||
DrawStockConfig::DrawThree
|
||||
};
|
||||
if settings.0.draw_mode == new_mode {
|
||||
return;
|
||||
@@ -340,7 +340,7 @@ fn handle_forfeit_request(
|
||||
if !forfeit_screens.is_empty() {
|
||||
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 {
|
||||
toast.write(InfoToastEvent("No game to forfeit".to_string()));
|
||||
return;
|
||||
@@ -477,7 +477,7 @@ fn spawn_pause_screen(
|
||||
commands: &mut Commands,
|
||||
level: Option<u32>,
|
||||
streak: Option<u32>,
|
||||
draw_mode: Option<DrawMode>,
|
||||
draw_mode: Option<DrawStockConfig>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
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.
|
||||
fn spawn_draw_mode_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
mode: DrawMode,
|
||||
mode: DrawStockConfig,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let label_font = TextFont {
|
||||
@@ -530,8 +530,8 @@ fn spawn_draw_mode_row(
|
||||
..default()
|
||||
};
|
||||
let (one_variant, three_variant) = match mode {
|
||||
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
||||
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
||||
DrawStockConfig::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
||||
DrawStockConfig::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
||||
};
|
||||
parent
|
||||
.spawn(Node {
|
||||
@@ -800,20 +800,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn draw_mode_label_draw_one() {
|
||||
assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1");
|
||||
assert_eq!(draw_mode_label(DrawStockConfig::DrawOne), "Draw 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_mode_label_draw_three() {
|
||||
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
|
||||
assert_eq!(draw_mode_label(DrawStockConfig::DrawThree), "Draw 3");
|
||||
}
|
||||
|
||||
/// Both variants are covered so the match is exhaustive — this test would
|
||||
/// fail to compile if a new DrawMode variant were added without updating
|
||||
/// fail to compile if a new DrawStockConfig variant were added without updating
|
||||
/// `draw_mode_label`.
|
||||
#[test]
|
||||
fn draw_mode_label_covers_all_variants() {
|
||||
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
|
||||
for mode in [DrawStockConfig::DrawOne, DrawStockConfig::DrawThree] {
|
||||
let label = draw_mode_label(mode);
|
||||
assert!(
|
||||
!label.is_empty(),
|
||||
@@ -842,7 +842,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.draw_mode = DrawMode::DrawOne;
|
||||
.draw_mode = DrawStockConfig::DrawOne;
|
||||
|
||||
// Set paused so handle_pause_draw_toggle acts.
|
||||
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
||||
@@ -856,7 +856,7 @@ mod tests {
|
||||
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
|
||||
assert_eq!(
|
||||
*mode,
|
||||
DrawMode::DrawThree,
|
||||
DrawStockConfig::DrawThree,
|
||||
"pressing Draw 3 must set mode to DrawThree"
|
||||
);
|
||||
|
||||
@@ -869,7 +869,7 @@ mod tests {
|
||||
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
|
||||
assert_eq!(
|
||||
*mode2,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::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
|
||||
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
|
||||
fn forfeit_app() -> App {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -1020,12 +1020,12 @@ mod tests {
|
||||
/// hotkey was received but is currently a no-op.
|
||||
#[test]
|
||||
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
game.is_won = true;
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
game.set_test_won(true);
|
||||
app.insert_resource(GameStateResource(game));
|
||||
app.update();
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in
|
||||
//! `game_plugin`.
|
||||
//!
|
||||
//! The synchronous version (v0.17.0) called
|
||||
//! `solitaire_core::solver::try_solve_from_state` on the main thread on
|
||||
//! every H press. Median latency was ~2 ms but pathological positions
|
||||
//! 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.
|
||||
//! The synchronous version (v0.17.0) called the solver on the main thread
|
||||
//! on every H press. Median latency was ~2 ms but pathological positions
|
||||
//! can hit the default solve budget 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
|
||||
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
|
||||
@@ -26,13 +24,12 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
|
||||
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint};
|
||||
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint, hint_piles};
|
||||
use crate::resources::{GameStateResource, HintCycleIndex};
|
||||
|
||||
/// In-flight async work for the H-key hint.
|
||||
@@ -60,23 +57,17 @@ impl PendingHintTask {
|
||||
self.inner = None;
|
||||
}
|
||||
|
||||
/// Spawn a new solver task for `state` with `config`. Drops any
|
||||
/// previously in-flight task first (cancel-on-replace).
|
||||
pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
|
||||
let move_count_at_spawn = state.move_count;
|
||||
/// Spawn a new solver task for `state` with the given solve budgets.
|
||||
/// Drops any previously in-flight task first (cancel-on-replace).
|
||||
pub fn spawn(&mut self, state: GameState, moves_budget: u64, states_budget: u64) {
|
||||
let move_count_at_spawn = state.move_count();
|
||||
let handle = AsyncComputeTaskPool::get().spawn(async move {
|
||||
let outcome = try_solve_from_state(&state, &config);
|
||||
match outcome.result {
|
||||
SolverResult::Winnable => outcome
|
||||
.first_move
|
||||
.map(|mv| HintTaskOutput::SolverMove {
|
||||
from: mv.source,
|
||||
to: mv.dest,
|
||||
})
|
||||
.unwrap_or(HintTaskOutput::NeedsHeuristic),
|
||||
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||
HintTaskOutput::NeedsHeuristic
|
||||
}
|
||||
// Winnable (`Ok(Some)`) carries the first move on a winning path;
|
||||
// unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
|
||||
// to the live-state heuristic so H always produces feedback.
|
||||
match state.solve_first_move(moves_budget, states_budget) {
|
||||
Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
|
||||
Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
|
||||
}
|
||||
});
|
||||
self.inner = Some(HintTask {
|
||||
@@ -99,12 +90,10 @@ struct HintTask {
|
||||
|
||||
/// What the solver task carries back to the main thread.
|
||||
enum HintTaskOutput {
|
||||
/// Solver verdict was `Winnable`; here is the first move on the
|
||||
/// solution path.
|
||||
SolverMove {
|
||||
from: KlondikePile,
|
||||
to: KlondikePile,
|
||||
},
|
||||
/// Solver verdict was winnable; here is the first move on the solution
|
||||
/// path. Converted to highlighted `(from, to)` piles by the poll system
|
||||
/// via [`crate::input_plugin::hint_piles`].
|
||||
SolverMove(KlondikeInstruction),
|
||||
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
|
||||
/// runs the legacy heuristic against the live `GameState` so the
|
||||
/// H key always produces feedback while any legal move exists.
|
||||
@@ -156,19 +145,22 @@ pub fn poll_pending_hint_task(
|
||||
pending.inner = None;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let (from, to) = match output {
|
||||
HintTaskOutput::SolverMove { from, to } => (from, to),
|
||||
HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
return;
|
||||
}
|
||||
},
|
||||
// Resolve the solver's first move to highlighted piles; fall back to the
|
||||
// live-state heuristic when there's no solver move or it maps to a no-op.
|
||||
let solver_pair = match output {
|
||||
HintTaskOutput::SolverMove(instruction) => hint_piles(&g.0, instruction),
|
||||
HintTaskOutput::NeedsHeuristic => None,
|
||||
};
|
||||
let (from, to) = match solver_pair.or_else(|| find_heuristic_hint(&g.0, &mut hint_cycle)) {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
emit_hint_visuals(
|
||||
&g.0,
|
||||
@@ -186,9 +178,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::events::HintVisualEvent;
|
||||
use crate::input_plugin::HintSolverConfig;
|
||||
use solitaire_core::{Foundation, Tableau};
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
/// Build a minimal Bevy app exercising only the polling system
|
||||
/// and the resources/messages it touches.
|
||||
@@ -217,7 +209,7 @@ mod tests {
|
||||
/// foundations hold A..Q for each suit, four Kings sit on
|
||||
/// tableau columns 0..3, stock and waste empty.
|
||||
fn near_finished_state() -> GameState {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
game.set_test_stock_cards(Vec::new());
|
||||
game.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
@@ -264,13 +256,8 @@ mod tests {
|
||||
.zip(suits.iter())
|
||||
{
|
||||
let mut cards = Vec::new();
|
||||
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||
cards.push(Card {
|
||||
id: (foundation as u32) * 13 + i as u32,
|
||||
suit: *suit,
|
||||
rank: *rank,
|
||||
face_up: true,
|
||||
});
|
||||
for rank in ranks_below_king.iter() {
|
||||
cards.push(Card::new(Deck::Deck1, *suit, *rank));
|
||||
}
|
||||
game.set_test_foundation_cards(foundation, cards);
|
||||
}
|
||||
@@ -285,12 +272,7 @@ mod tests {
|
||||
{
|
||||
game.set_test_tableau_cards(
|
||||
tableau,
|
||||
vec![Card {
|
||||
id: 100 + tableau as u32,
|
||||
suit: *suit,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
}],
|
||||
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
|
||||
);
|
||||
}
|
||||
game
|
||||
@@ -305,10 +287,10 @@ mod tests {
|
||||
fn winnable_solver_emits_hint_after_async_completes() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||
@@ -344,10 +326,10 @@ mod tests {
|
||||
fn state_change_drops_in_flight_task() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
assert!(
|
||||
app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"task is in flight after spawn",
|
||||
@@ -380,12 +362,12 @@ mod tests {
|
||||
fn second_spawn_drops_first_in_flight_task() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
|
||||
// First spawn.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||
assert!(first_handle_present);
|
||||
|
||||
@@ -394,7 +376,7 @@ mod tests {
|
||||
// in flight.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
// Resource still pending (the second task), but the first
|
||||
// is gone. We can't directly observe the first handle once
|
||||
// it's been overwritten — what we *can* assert is that the
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! 3. `handle_text_input` appends decimal digits / handles Backspace while
|
||||
//! the modal is open, updating [`SeedInputBuffer`] each frame.
|
||||
//! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at
|
||||
//! 60 Hz) of no input before spawning a [`try_solve`] task on
|
||||
//! 60 Hz) of no input before spawning a [`GameState::solve_fresh_deal`] task on
|
||||
//! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task
|
||||
//! by resetting the resource.
|
||||
//! 5. `poll_solver_task` polls the in-flight task each frame and updates the
|
||||
@@ -23,8 +23,9 @@
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
|
||||
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
@@ -83,7 +84,7 @@ struct SeedInputDisplay;
|
||||
#[derive(Resource, Default)]
|
||||
struct PendingVerification {
|
||||
seed: Option<u64>,
|
||||
handle: Option<Task<SolverResult>>,
|
||||
handle: Option<Task<SolveOutcome>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -339,9 +340,15 @@ fn tick_debounce_and_spawn_solver_task(
|
||||
|
||||
let draw_mode = settings
|
||||
.as_ref()
|
||||
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
|
||||
let cfg = SolverConfig::default();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
||||
.map_or(DrawStockConfig::DrawOne, |s| s.0.draw_mode);
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
GameState::solve_fresh_deal(
|
||||
seed,
|
||||
draw_mode,
|
||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||
DEFAULT_SOLVE_STATES_BUDGET,
|
||||
)
|
||||
});
|
||||
|
||||
pending.seed = Some(seed);
|
||||
pending.handle = Some(task);
|
||||
@@ -369,15 +376,15 @@ fn poll_solver_task(
|
||||
return;
|
||||
};
|
||||
match result {
|
||||
SolverResult::Winnable => {
|
||||
Ok(Some(_)) => {
|
||||
text.0 = "\u{2713} Provably winnable".to_string();
|
||||
color.0 = ACCENT_PRIMARY;
|
||||
}
|
||||
SolverResult::Inconclusive => {
|
||||
Err(_) => {
|
||||
text.0 = "? Likely winnable (search timed out)".to_string();
|
||||
color.0 = TEXT_SECONDARY;
|
||||
}
|
||||
SolverResult::Unwinnable => {
|
||||
Ok(None) => {
|
||||
text.0 = "\u{2717} Provably unwinnable".to_string();
|
||||
color.0 = TEXT_DISABLED;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ fn award_xp_on_win(
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
) {
|
||||
for ev in wins.read() {
|
||||
let used_undo = game.0.undo_count > 0;
|
||||
let used_undo = game.0.undo_count() > 0;
|
||||
let amount = xp_for_win(ev.time_seconds, used_undo);
|
||||
let prev_level = progress.0.add_xp(amount);
|
||||
xp_awarded.write(XpAwardedEvent { amount });
|
||||
@@ -151,7 +151,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.undo_count = 1;
|
||||
.force_test_undos(1);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
|
||||
@@ -48,7 +48,7 @@ use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::Card;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||
@@ -113,9 +113,9 @@ pub enum RightClickRadialState {
|
||||
/// radial is built around single-card foundation/tableau
|
||||
/// shortcuts and that matches the right-click highlight set).
|
||||
count: usize,
|
||||
/// Card ids that would be moved (bottom-to-top order). Length
|
||||
/// Cards that would be moved (bottom-to-top order). Length
|
||||
/// always equals `count`. Currently always one element.
|
||||
cards: Vec<u32>,
|
||||
cards: Vec<Card>,
|
||||
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
|
||||
///
|
||||
/// Anchors are evenly spaced around a ring of radius
|
||||
@@ -304,7 +304,7 @@ pub fn find_top_face_up_card_at(
|
||||
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
|
||||
for i in (0..pile_cards.len()).rev() {
|
||||
let card = &pile_cards[i];
|
||||
if !card.face_up {
|
||||
if !card.1 {
|
||||
continue;
|
||||
}
|
||||
// Only the top card is draggable on non-tableau piles.
|
||||
@@ -320,7 +320,7 @@ pub fn find_top_face_up_card_at(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Some((pile, card.clone()));
|
||||
return Some((pile, card.0.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -339,7 +339,7 @@ fn card_position(
|
||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||
let mut y_offset = 0.0_f32;
|
||||
for card in pile_cards(game, pile).iter().take(stack_index) {
|
||||
let step = if card.face_up {
|
||||
let step = if card.1 {
|
||||
TABLEAU_FAN_FRAC
|
||||
} else {
|
||||
TABLEAU_FACEDOWN_FAN_FRAC
|
||||
@@ -352,13 +352,14 @@ fn card_position(
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const fn foundations() -> [Foundation; 4] {
|
||||
[
|
||||
Foundation::Foundation1,
|
||||
@@ -498,7 +499,7 @@ fn radial_open_on_right_click(
|
||||
*state = RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count: 1,
|
||||
cards: vec![card.id],
|
||||
cards: vec![card.clone()],
|
||||
legal_destinations,
|
||||
centre: world,
|
||||
hovered_index: None,
|
||||
@@ -571,7 +572,7 @@ fn radial_open_on_long_press(
|
||||
*state = RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count: 1,
|
||||
cards: vec![card.id],
|
||||
cards: vec![card.clone()],
|
||||
legal_destinations,
|
||||
centre: world,
|
||||
hovered_index: None,
|
||||
@@ -794,8 +795,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::layout::compute_layout;
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::card::{Card as CoreCard, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{Card as CoreCard, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
|
||||
/// resources / messages it depends on. No window, no camera — the
|
||||
@@ -818,7 +819,7 @@ mod tests {
|
||||
/// destination — Foundation(0) — under the standard rules
|
||||
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
|
||||
fn ace_only_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||
// Wipe everything.
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
@@ -844,12 +845,7 @@ mod tests {
|
||||
// Ace of Clubs on Tableau(0).
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
}],
|
||||
vec![CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||
);
|
||||
g
|
||||
}
|
||||
@@ -857,7 +853,7 @@ mod tests {
|
||||
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
|
||||
/// must skip it.
|
||||
fn face_down_only_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
@@ -879,14 +875,9 @@ mod tests {
|
||||
] {
|
||||
g.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
g.set_test_tableau_cards(
|
||||
g.set_test_tableau_cards_with_face(
|
||||
Tableau::Tableau1,
|
||||
vec![CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::King,
|
||||
face_up: false,
|
||||
}],
|
||||
vec![(CoreCard::new(Deck::Deck1, Suit::Spades, Rank::King), false)],
|
||||
);
|
||||
g
|
||||
}
|
||||
@@ -979,12 +970,7 @@ mod tests {
|
||||
#[test]
|
||||
fn legal_destinations_for_ace_includes_only_first_empty_foundation() {
|
||||
let g = ace_only_state();
|
||||
let card = CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace);
|
||||
let dests =
|
||||
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
|
||||
// Ace can be placed on every empty foundation. We only need
|
||||
@@ -999,12 +985,7 @@ mod tests {
|
||||
#[test]
|
||||
fn legal_destinations_excludes_source_pile() {
|
||||
let g = ace_only_state();
|
||||
let card = CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace);
|
||||
let dests = legal_destinations_for_card(
|
||||
&card,
|
||||
&KlondikePile::Foundation(Foundation::Foundation1),
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use super::ReplayPlaybackState;
|
||||
use chrono::Datelike;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||
use solitaire_data::ReplayMove;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
|
||||
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
|
||||
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
|
||||
@@ -60,12 +58,6 @@ pub(crate) fn format_pile(p: &KlondikePile) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
|
||||
KlondikePile::try_from(*p)
|
||||
.map(|pile| format_pile(&pile))
|
||||
.unwrap_or_else(|_| "unknown pile".to_string())
|
||||
}
|
||||
|
||||
fn foundation_number(foundation: Foundation) -> u8 {
|
||||
match foundation {
|
||||
Foundation::Foundation1 => 1,
|
||||
@@ -87,20 +79,36 @@ fn tableau_number(tableau: Tableau) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats a [`ReplayMove`] as the body of a
|
||||
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
|
||||
/// reads as `"{from} → {to}"` using [`format_pile`] for both
|
||||
/// endpoints. The `count` field is omitted from the row body —
|
||||
/// at row scale it adds visual noise without meaningful
|
||||
/// Pure helper — formats a [`KlondikeInstruction`] as the body of a
|
||||
/// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*`
|
||||
/// instruction reads as `"{from} → {to}"` using [`format_pile`] for
|
||||
/// each nameable endpoint. The card count is omitted from the row
|
||||
/// body — at row scale it adds visual noise without meaningful
|
||||
/// information for the typical 1-card moves.
|
||||
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
|
||||
match m {
|
||||
ReplayMove::StockClick => "stock cycle".to_string(),
|
||||
ReplayMove::Move { from, to, .. } => {
|
||||
///
|
||||
/// The destination pile is always recoverable directly from the
|
||||
/// instruction. The source pile is shown when it is statically
|
||||
/// nameable (a `DstFoundation` carries a [`KlondikePile`] source);
|
||||
/// a `DstTableau`'s source is the runtime-only `KlondikePileStack`
|
||||
/// type — not re-exported across the `solitaire_core` boundary and so
|
||||
/// not pattern-matchable here — so its row renders `"→ {to}"` without
|
||||
/// a leading source label. Faithful full-coordinate decoding lives in
|
||||
/// [`GameState::instruction_to_piles`] on the playback path; the
|
||||
/// move-log is a display-only digest.
|
||||
pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => "stock cycle".to_string(),
|
||||
KlondikeInstruction::DstFoundation(dst) => {
|
||||
format!(
|
||||
"{} \u{2192} {}",
|
||||
format_saved_pile(from),
|
||||
format_saved_pile(to)
|
||||
format_pile(&dst.src),
|
||||
format_pile(&KlondikePile::Foundation(dst.foundation))
|
||||
)
|
||||
}
|
||||
KlondikeInstruction::DstTableau(dst) => {
|
||||
format!(
|
||||
"\u{2192} {}",
|
||||
format_pile(&KlondikePile::Tableau(dst.tableau))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -236,9 +244,9 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
|
||||
|
||||
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
|
||||
/// known card, or `"--"` for an absent top card (empty pile).
|
||||
pub(crate) fn format_card_short(card: Option<&Card>) -> String {
|
||||
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
||||
match card {
|
||||
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
|
||||
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
|
||||
None => "--".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::replay_playback::{
|
||||
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
|
||||
stop_replay_playback, toggle_pause_replay_playback,
|
||||
};
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Per-arrow-key time-since-last-fire accumulators that drive the
|
||||
/// continuous-scrub repeat behaviour for held arrow keys. Each
|
||||
@@ -1033,6 +1034,7 @@ pub(crate) fn handle_pause_button(
|
||||
/// guard lives inside `step_replay_playback`.
|
||||
pub(crate) fn handle_step_button(
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
|
||||
@@ -1040,7 +1042,12 @@ pub(crate) fn handle_step_button(
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
||||
step_replay_playback(
|
||||
&mut state,
|
||||
game.as_deref(),
|
||||
&mut moves_writer,
|
||||
&mut draws_writer,
|
||||
);
|
||||
}
|
||||
|
||||
/// Repaints the Pause / Resume button's label whenever
|
||||
@@ -1112,6 +1119,7 @@ pub(crate) fn handle_pause_keyboard(
|
||||
pub(crate) fn handle_arrow_keyboard(
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
time: Res<Time>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut hold: ResMut<ReplayScrubKeyHold>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
@@ -1136,12 +1144,22 @@ pub(crate) fn handle_arrow_keyboard(
|
||||
// Right (forward step) — initial press fires immediately;
|
||||
// held repeats fire when the accumulator crosses the interval.
|
||||
if keys.just_pressed(KeyCode::ArrowRight) {
|
||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
||||
step_replay_playback(
|
||||
&mut state,
|
||||
game.as_deref(),
|
||||
&mut moves_writer,
|
||||
&mut draws_writer,
|
||||
);
|
||||
hold.right_held_secs = 0.0;
|
||||
} else if keys.pressed(KeyCode::ArrowRight) {
|
||||
hold.right_held_secs += dt;
|
||||
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
||||
step_replay_playback(
|
||||
&mut state,
|
||||
game.as_deref(),
|
||||
&mut moves_writer,
|
||||
&mut draws_writer,
|
||||
);
|
||||
hold.right_held_secs = 0.0;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
use solitaire_core::{Rank, Suit};
|
||||
use solitaire_data::Replay;
|
||||
|
||||
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
|
||||
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
|
||||
/// `RotateStock` entries. Tests only ever read `replay.moves.len()`
|
||||
/// (denominator of the progress indicator), so the move kind is
|
||||
/// irrelevant beyond producing the right count.
|
||||
fn synthetic_replay(move_count: usize) -> Replay {
|
||||
Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
120,
|
||||
1_000,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
|
||||
(0..move_count)
|
||||
.map(|_| KlondikeInstruction::RotateStock)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1123,20 +1124,17 @@ fn format_pile_uses_one_indexed_lowercase_names() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Move-body formatter renders `StockClick` as a label and
|
||||
/// `Move` as a `from → to` arrow. The `count` field is
|
||||
/// deliberately omitted — at row scale it adds noise.
|
||||
/// Move-body formatter renders `RotateStock` as a label. The
|
||||
/// `Dst*` variants render as a `→ to` arrow, but their pile-stack
|
||||
/// source types are runtime-only and not constructible from this
|
||||
/// crate, so only the stock-cycle label is asserted here; the
|
||||
/// arrow path is exercised end-to-end through the move-log
|
||||
/// integration tests.
|
||||
#[test]
|
||||
fn format_move_body_handles_both_variants() {
|
||||
assert_eq!(format_move_body(&ReplayMove::StockClick), "stock cycle");
|
||||
fn format_move_body_handles_stock_cycle() {
|
||||
assert_eq!(
|
||||
format_move_body(&ReplayMove::Move {
|
||||
from: SavedKlondikePile::Stock,
|
||||
to: SavedKlondikePile::Tableau(SavedTableau(4)),
|
||||
count: 1,
|
||||
}),
|
||||
"waste \u{2192} tableau 5",
|
||||
"Move variant must render as `{{from}} → {{to}}` with 1-indexed pile numbers",
|
||||
format_move_body(&KlondikeInstruction::RotateStock),
|
||||
"stock cycle"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2314,8 +2312,8 @@ fn format_suit_glyph_all_suits() {
|
||||
fn format_foundations_row_empty_board() {
|
||||
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||
42,
|
||||
solitaire_core::game_state::DrawMode::DrawOne,
|
||||
solitaire_core::game_state::GameMode::Classic,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
);
|
||||
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
|
||||
}
|
||||
@@ -2326,8 +2324,8 @@ fn format_foundations_row_empty_board() {
|
||||
fn format_stock_waste_row_initial_state() {
|
||||
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||
42,
|
||||
solitaire_core::game_state::DrawMode::DrawOne,
|
||||
solitaire_core::game_state::GameMode::Classic,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
);
|
||||
let text = format_stock_waste_row(&game);
|
||||
assert!(
|
||||
|
||||
@@ -8,8 +8,7 @@ use super::*;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_data::ReplayMove;
|
||||
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||
|
||||
/// Overwrites the banner label whenever the resource changes — covers the
|
||||
/// `Playing → Completed` transition by swapping "▌ replay" for
|
||||
@@ -85,9 +84,15 @@ pub(crate) fn update_floating_progress_chip(
|
||||
// the most-recently-applied move sits at `cursor - 1`.
|
||||
let dest_pile = match state.as_ref() {
|
||||
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
||||
// The destination pile is recoverable directly from the
|
||||
// instruction — no live state needed. `RotateStock` has no
|
||||
// destination (the chip hides over the stock pile).
|
||||
match &replay.moves[cursor - 1] {
|
||||
ReplayMove::Move { to, .. } => Some(*to),
|
||||
ReplayMove::StockClick => None,
|
||||
KlondikeInstruction::DstFoundation(dst) => {
|
||||
Some(KlondikePile::Foundation(dst.foundation))
|
||||
}
|
||||
KlondikeInstruction::DstTableau(dst) => Some(KlondikePile::Tableau(dst.tableau)),
|
||||
KlondikeInstruction::RotateStock => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
@@ -95,8 +100,7 @@ pub(crate) fn update_floating_progress_chip(
|
||||
|
||||
let Some(world_pos) = dest_pile
|
||||
.as_ref()
|
||||
.and_then(|p| KlondikePile::try_from(*p).ok())
|
||||
.and_then(|p| layout.0.pile_positions.get(&p).copied())
|
||||
.and_then(|p| layout.0.pile_positions.get(p).copied())
|
||||
else {
|
||||
// Nothing to point at — hide every chip and exit.
|
||||
for (_, mut visibility, _) in chips.iter_mut() {
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
//! flag is threaded through, no every-callsite gate is added.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
use solitaire_data::Replay;
|
||||
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||
@@ -94,7 +94,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
||||
/// replay's recorded deal.
|
||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||
/// for each [`ReplayMove`].
|
||||
/// for each [`KlondikeInstruction`].
|
||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||
/// [`Completed`](Self::Completed). It lingers for
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||
@@ -251,6 +251,7 @@ pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) ->
|
||||
/// normal advance loop takes.
|
||||
pub fn step_replay_playback(
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
game: Option<&GameStateResource>,
|
||||
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||
) -> bool {
|
||||
@@ -266,31 +267,49 @@ pub fn step_replay_playback(
|
||||
if *cursor >= replay.moves.len() {
|
||||
return false;
|
||||
}
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
||||
else {
|
||||
warn!(
|
||||
"skipping replay move with invalid pile encoding at cursor {}",
|
||||
*cursor
|
||||
);
|
||||
*cursor += 1;
|
||||
return false;
|
||||
};
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from,
|
||||
to,
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
let instruction = replay.moves[*cursor];
|
||||
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||
*cursor += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Translates one recorded [`KlondikeInstruction`] into the canonical
|
||||
/// engine event that drives the live animation pipeline.
|
||||
///
|
||||
/// `RotateStock` fires a [`DrawRequestEvent`]; a `Dst*` instruction is
|
||||
/// decoded back to its runtime `(from, to, count)` pile coordinates via
|
||||
/// [`GameState::instruction_to_piles`] against the *current* live state
|
||||
/// (decoded before the event mutates it, so the source pile's face-up
|
||||
/// run length is the one in effect when the move applies) and fires a
|
||||
/// [`MoveRequestEvent`]. A decode that returns `None` (e.g. a malformed
|
||||
/// instruction loaded from disk) is skipped with a warning rather than
|
||||
/// panicking — the cursor still advances so playback never stalls.
|
||||
///
|
||||
/// `game` is `None` only in headless fixtures that install no
|
||||
/// [`GameStateResource`]; in that case only `RotateStock` (which needs
|
||||
/// no live state) is dispatched and `Dst*` instructions are skipped.
|
||||
fn dispatch_instruction(
|
||||
instruction: KlondikeInstruction,
|
||||
cursor: usize,
|
||||
game: Option<&GameStateResource>,
|
||||
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||
) {
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
}
|
||||
_ => match game.and_then(|g| g.0.instruction_to_piles(instruction)) {
|
||||
Some((from, to, count)) => {
|
||||
moves_writer.write(MoveRequestEvent { from, to, count });
|
||||
}
|
||||
None => {
|
||||
warn!("skipping replay move that did not decode to piles at cursor {cursor}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps the replay **backwards** by exactly one move while paused.
|
||||
///
|
||||
/// Strategy: the live game's undo system is the source of truth for
|
||||
@@ -355,6 +374,7 @@ pub fn step_backwards_replay_playback(
|
||||
fn tick_replay_playback(
|
||||
time: Res<Time>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||
@@ -378,27 +398,14 @@ fn tick_replay_playback(
|
||||
if !*paused {
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
if let (Ok(from), Ok(to)) =
|
||||
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
||||
{
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from,
|
||||
to,
|
||||
count: *count,
|
||||
});
|
||||
} else {
|
||||
warn!(
|
||||
"skipping replay move with invalid pile encoding at cursor {}",
|
||||
*cursor
|
||||
);
|
||||
}
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
let instruction = replay.moves[*cursor];
|
||||
dispatch_instruction(
|
||||
instruction,
|
||||
*cursor,
|
||||
game.as_deref(),
|
||||
&mut moves_writer,
|
||||
&mut draws_writer,
|
||||
);
|
||||
*cursor += 1;
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
@@ -555,9 +562,8 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
@@ -592,25 +598,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3-move replay covering both `Move` and `StockClick` variants.
|
||||
/// Seed 12345 is arbitrary — the test asserts on event counts and
|
||||
/// move shapes, not on board positions.
|
||||
/// A 3-move replay of `RotateStock` inputs. Pile-position types are
|
||||
/// runtime-only and intentionally not constructible from the engine
|
||||
/// crate, so a `Dst*` fixture can't be hand-built here; `RotateStock`
|
||||
/// exercises the dispatch path (it fires a `DrawRequestEvent` without
|
||||
/// needing a live state to decode piles). Seed 12345 is arbitrary —
|
||||
/// the test asserts on event counts, not board positions.
|
||||
fn sample_replay_three_moves() -> Replay {
|
||||
Replay::new(
|
||||
12345,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
500,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: SavedKlondikePile::Stock,
|
||||
to: SavedKlondikePile::Tableau(SavedTableau(3)),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::RotateStock,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -748,20 +753,17 @@ mod tests {
|
||||
let captured_moves = app.world().resource::<CapturedMoves>();
|
||||
let captured_draws = app.world().resource::<CapturedDraws>();
|
||||
|
||||
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
|
||||
// Sample replay: three `RotateStock` inputs — each dispatches a
|
||||
// `DrawRequestEvent` and never a `MoveRequestEvent`.
|
||||
assert_eq!(
|
||||
captured_draws.0, 2,
|
||||
"expected 2 DrawRequestEvent (two StockClicks)",
|
||||
captured_draws.0, 3,
|
||||
"expected 3 DrawRequestEvent (one per RotateStock)",
|
||||
);
|
||||
assert_eq!(
|
||||
captured_moves.0.len(),
|
||||
1,
|
||||
"expected 1 MoveRequestEvent (the single Move variant)",
|
||||
0,
|
||||
"RotateStock inputs must not produce MoveRequestEvent",
|
||||
);
|
||||
let m = &captured_moves.0[0];
|
||||
assert!(matches!(m.from, KlondikePile::Stock));
|
||||
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
|
||||
assert_eq!(m.count, 1);
|
||||
}
|
||||
|
||||
/// Driving past one interval on a single-move replay must
|
||||
@@ -771,12 +773,12 @@ mod tests {
|
||||
let mut app = headless_app();
|
||||
let one_move = Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
);
|
||||
start_playback(&mut app, one_move);
|
||||
app.update();
|
||||
@@ -822,7 +824,7 @@ mod tests {
|
||||
// Replay — their in-flight recording must not get clobbered.
|
||||
{
|
||||
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
rec.moves.push(ReplayMove::StockClick);
|
||||
rec.moves.push(KlondikeInstruction::RotateStock);
|
||||
}
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
@@ -880,12 +882,12 @@ mod tests {
|
||||
fn ten_draws_replay() -> Replay {
|
||||
Replay::new(
|
||||
7,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick; 10],
|
||||
vec![KlondikeInstruction::RotateStock; 10],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use bevy::math::Vec2;
|
||||
use bevy::prelude::Resource;
|
||||
use chrono::{DateTime, Utc};
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||
@@ -27,8 +28,8 @@ pub struct GameStateResource(pub GameState);
|
||||
/// This prevents accidental drags on quick taps, especially on touch screens.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct DragState {
|
||||
/// IDs of the cards being dragged (bottom-to-top stacking order).
|
||||
pub cards: Vec<u32>,
|
||||
/// Cards being dragged (bottom-to-top stacking order).
|
||||
pub cards: Vec<Card>,
|
||||
/// Pile the drag originated from.
|
||||
pub origin_pile: Option<KlondikePile>,
|
||||
/// World-space offset from the cursor/touch to the bottom card's centre.
|
||||
|
||||
@@ -191,10 +191,12 @@ fn apply_safe_area_to_modal_scrims(
|
||||
/// whatever `SafeAreaInsets` are current at that moment.
|
||||
///
|
||||
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||||
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
|
||||
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
|
||||
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
|
||||
/// synthetic `WindowResized` and the layout converges to the right position.
|
||||
/// and resets `SafeAreaPollTries` (the cached `SafeAreaInsets` keep their
|
||||
/// last-known values), causing `refresh_insets` to re-poll JNI over the next
|
||||
/// few frames. If the insets changed while backgrounded, `on_safe_area_changed`
|
||||
/// in `table_plugin` emits a second synthetic `WindowResized` and the layout
|
||||
/// converges to the right position; if they didn't, nothing is rewritten and
|
||||
/// the layout stays put.
|
||||
///
|
||||
/// On non-Android targets this handler still fires — it ensures that a resume
|
||||
/// event always refreshes the layout (e.g., after a minimise/restore on
|
||||
@@ -232,9 +234,14 @@ mod android {
|
||||
pub(super) struct SafeAreaPollTries(pub u32);
|
||||
|
||||
/// Polls Android for safe-area insets until we get a non-zero
|
||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
||||
/// is typically frame 1–3 of a fresh launch.
|
||||
/// reading, then settles until [`rearm_on_resumed`] re-arms it on the
|
||||
/// next foreground resume — insets can change while backgrounded
|
||||
/// (rotation, fold/unfold, gesture ↔ 3-button nav). The poll counter
|
||||
/// (not `insets.is_populated()`) gates the loop, so a re-armed cycle
|
||||
/// re-queries JNI even though cached values are already populated.
|
||||
/// `getRootWindowInsets()` returns `null` (or all-zero `Insets`)
|
||||
/// until the decor view has been laid out, which is typically frame
|
||||
/// 1–3 of a fresh launch.
|
||||
pub(super) fn refresh_insets(
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
@@ -243,18 +250,25 @@ mod android {
|
||||
// devices that genuinely report zero insets.
|
||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||
|
||||
if poll.0 >= MAX_TRIES || insets.is_populated() {
|
||||
if poll.0 >= MAX_TRIES {
|
||||
return;
|
||||
}
|
||||
poll.0 += 1;
|
||||
|
||||
match query_insets() {
|
||||
Ok(v) if v.is_populated() => {
|
||||
info!(
|
||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||
v.top, v.bottom, v.left, v.right, poll.0
|
||||
);
|
||||
*insets = v;
|
||||
if *insets != v {
|
||||
info!(
|
||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||
v.top, v.bottom, v.left, v.right, poll.0
|
||||
);
|
||||
*insets = v;
|
||||
}
|
||||
// Settled for this poll cycle; `rearm_on_resumed` re-arms on
|
||||
// the next resume. Writing `insets` only on an actual change
|
||||
// keeps resource change detection (and the relayout it
|
||||
// triggers) quiet on resumes where nothing moved.
|
||||
poll.0 = MAX_TRIES;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Layout not ready yet; try again next frame.
|
||||
@@ -291,30 +305,12 @@ mod android {
|
||||
}
|
||||
|
||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{JavaVM, objects::JObject};
|
||||
use solitaire_data::android_jni;
|
||||
|
||||
let app = ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||
|
||||
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
|
||||
// runtime; valid for the lifetime of the process.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
|
||||
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
|
||||
// pointer — valid for the lifetime of the process.
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
||||
|
||||
(|| -> jni::errors::Result<SafeAreaInsets> {
|
||||
android_jni::with_activity_env(|env, activity| {
|
||||
// Window window = activity.getWindow();
|
||||
let window = env
|
||||
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||
.l()?;
|
||||
|
||||
// View decor = window.getDecorView();
|
||||
@@ -366,8 +362,7 @@ mod android {
|
||||
left,
|
||||
right,
|
||||
})
|
||||
})()
|
||||
.map_err(|e| format!("safe-area JNI: {e}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::Card;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::card_plugin::CardEntityIndex;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
|
||||
@@ -91,9 +91,9 @@ pub enum KeyboardDragState {
|
||||
/// Number of cards lifted (1 for waste / foundation, full face-up
|
||||
/// run length for a tableau column).
|
||||
count: usize,
|
||||
/// Card ids being lifted, in the same bottom-to-top order
|
||||
/// Cards being lifted, in the same bottom-to-top order
|
||||
/// `DragState.cards` expects.
|
||||
cards: Vec<u32>,
|
||||
cards: Vec<Card>,
|
||||
/// Pre-computed list of piles the lifted stack can legally be
|
||||
/// placed on. Always at least one entry while in this variant —
|
||||
/// if no legal destinations exist the state machine refuses to
|
||||
@@ -147,8 +147,12 @@ pub struct SelectionPlugin;
|
||||
|
||||
impl Plugin for SelectionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// `CardEntityIndex` is owned and kept current by `CardPlugin`; this
|
||||
// call is a no-op there. It is declared here so `update_selection_highlight`
|
||||
// can read it via `Res<>` even in harnesses that omit `CardPlugin`.
|
||||
app.init_resource::<SelectionState>()
|
||||
.init_resource::<KeyboardDragState>()
|
||||
.init_resource::<CardEntityIndex>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -159,7 +163,7 @@ impl Plugin for SelectionPlugin {
|
||||
update_selection_highlight.after(GameMutation).run_if(
|
||||
resource_changed::<SelectionState>
|
||||
.or(resource_changed::<KeyboardDragState>)
|
||||
.or(resource_changed::<crate::GameStateResource>),
|
||||
.or(resource_changed::<GameStateResource>),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -393,7 +397,7 @@ fn handle_selection_keys(
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
];
|
||||
all.into_iter()
|
||||
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
|
||||
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.1))
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -424,7 +428,7 @@ fn handle_selection_keys(
|
||||
&& let Some(ref pile) = selection.selected_pile
|
||||
{
|
||||
let selected_cards = pile_cards(&game.0, pile);
|
||||
let Some(card) = selected_cards.last().filter(|c| c.face_up) else {
|
||||
let Some((card, _)) = selected_cards.last().filter(|c| c.1) else {
|
||||
return;
|
||||
};
|
||||
// Priority 1: foundation move (single card).
|
||||
@@ -441,7 +445,7 @@ fn handle_selection_keys(
|
||||
let run_len = face_up_run_len(&selected_cards);
|
||||
let bottom_card = selected_cards
|
||||
.get(selected_cards.len().saturating_sub(run_len))
|
||||
.cloned();
|
||||
.map(|(c, _)| c.clone());
|
||||
if let Some(bottom) = bottom_card
|
||||
&& let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len)
|
||||
@@ -483,8 +487,9 @@ fn handle_selection_keys(
|
||||
1
|
||||
};
|
||||
let start = source_cards.len().saturating_sub(count);
|
||||
let lifted_cards: Vec<u32> = source_cards[start..].iter().map(|c| c.id).collect();
|
||||
let Some(bottom) = source_cards.get(start) else {
|
||||
let lifted_cards: Vec<Card> =
|
||||
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
|
||||
let Some((bottom, _)) = source_cards.get(start) else {
|
||||
return;
|
||||
};
|
||||
let legal = legal_destinations_for(bottom, source, &game.0, count);
|
||||
@@ -529,7 +534,7 @@ fn handle_selection_keys(
|
||||
/// destination after a lift. Players who want a different column simply
|
||||
/// press the right-arrow key once or twice.
|
||||
pub(crate) fn legal_destinations_for(
|
||||
_bottom: &solitaire_core::card::Card,
|
||||
_bottom: &Card,
|
||||
source: &KlondikePile,
|
||||
game: &GameState,
|
||||
stack_count: usize,
|
||||
@@ -574,10 +579,10 @@ pub(crate) fn legal_destinations_for(
|
||||
/// Walks backwards from the last element and stops at the first face-down card
|
||||
/// (or when the slice is exhausted). Returns at least `1` when the top card is
|
||||
/// face-up; returns `0` for an empty slice or when the top card is face-down.
|
||||
fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
|
||||
fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
|
||||
let mut count = 0;
|
||||
for card in cards.iter().rev() {
|
||||
if card.face_up {
|
||||
for (_, face_up) in cards.iter().rev() {
|
||||
if *face_up {
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
@@ -593,10 +598,10 @@ fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
|
||||
/// handler can attempt a foundation move first and fall through to a
|
||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||
fn try_foundation_dest(
|
||||
card: &solitaire_core::card::Card,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
card: &Card,
|
||||
game: &GameState,
|
||||
) -> Option<KlondikePile> {
|
||||
let source = game.pile_containing_card(card.id)?;
|
||||
let source = game.pile_containing_card(card.clone())?;
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
@@ -656,7 +661,7 @@ fn update_selection_highlight(
|
||||
kbd_drag: Res<KeyboardDragState>,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
card_index: Res<CardEntityIndex>,
|
||||
highlights: Query<Entity, With<SelectionHighlight>>,
|
||||
) {
|
||||
// Always despawn any existing highlight first.
|
||||
@@ -694,8 +699,8 @@ fn update_selection_highlight(
|
||||
{
|
||||
spawn_highlight_on_card(
|
||||
&mut commands,
|
||||
&card_entities,
|
||||
card.id,
|
||||
&card_index,
|
||||
&card,
|
||||
card_size,
|
||||
source_color,
|
||||
);
|
||||
@@ -711,8 +716,8 @@ fn update_selection_highlight(
|
||||
if let Some(card) = top_face_up_card(dest, &game.0) {
|
||||
spawn_highlight_on_card(
|
||||
&mut commands,
|
||||
&card_entities,
|
||||
card.id,
|
||||
&card_index,
|
||||
&card,
|
||||
card_size,
|
||||
dest_color,
|
||||
);
|
||||
@@ -723,10 +728,13 @@ fn update_selection_highlight(
|
||||
/// Returns the top face-up card on `pile`, or `None` if the pile is
|
||||
/// empty or its top card is face-down.
|
||||
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
|
||||
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
|
||||
pile_cards(game, pile)
|
||||
.last()
|
||||
.filter(|(_, up)| *up)
|
||||
.map(|(c, _)| c.clone())
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
@@ -734,30 +742,27 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
|
||||
}
|
||||
|
||||
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
|
||||
/// the matching `CardEntity::card_id`. No-op if no entity matches.
|
||||
/// the matching `CardEntity::card`. No-op if no entity matches.
|
||||
fn spawn_highlight_on_card(
|
||||
commands: &mut Commands,
|
||||
card_entities: &Query<(Entity, &CardEntity)>,
|
||||
card_id: u32,
|
||||
card_index: &CardEntityIndex,
|
||||
card: &Card,
|
||||
card_size: Vec2,
|
||||
color: Color,
|
||||
) {
|
||||
for (entity, card_entity) in card_entities {
|
||||
if card_entity.card_id == card_id {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
SelectionHighlight,
|
||||
Sprite {
|
||||
color,
|
||||
custom_size: Some(card_size + Vec2::splat(4.0)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||
Visibility::default(),
|
||||
));
|
||||
});
|
||||
break;
|
||||
}
|
||||
if let Some(entity) = card_index.get(card) {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
SelectionHighlight,
|
||||
Sprite {
|
||||
color,
|
||||
custom_size: Some(card_size + Vec2::splat(4.0)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||
Visibility::default(),
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,58 +886,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_all_face_up() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 2,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Jack,
|
||||
face_up: true,
|
||||
},
|
||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
|
||||
(Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_mixed_stops_at_face_down() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: false,
|
||||
},
|
||||
Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: false,
|
||||
},
|
||||
Card {
|
||||
id: 2,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Jack,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 3,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Ten,
|
||||
face_up: true,
|
||||
},
|
||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
|
||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
||||
(Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
|
||||
(Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ten), true),
|
||||
];
|
||||
// Only the top two cards are face-up.
|
||||
assert_eq!(face_up_run_len(&cards), 2);
|
||||
@@ -940,33 +910,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_top_card_face_down_is_zero() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: false,
|
||||
},
|
||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_single_face_up_card() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
}];
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
|
||||
assert_eq!(face_up_run_len(&cards), 1);
|
||||
}
|
||||
|
||||
@@ -979,8 +934,8 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
|
||||
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
|
||||
@@ -1013,7 +968,7 @@ mod tests {
|
||||
/// Ace first). It cannot go to an empty tableau (only Kings).
|
||||
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
|
||||
fn deterministic_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||
// Clear stock, waste, all tableaus.
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
@@ -1031,30 +986,15 @@ mod tests {
|
||||
// Place test cards.
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![Card {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
}],
|
||||
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)],
|
||||
);
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau2,
|
||||
vec![Card {
|
||||
id: 101,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
}],
|
||||
vec![Card::new(Deck::Deck1, Suit::Hearts, Rank::Six)],
|
||||
);
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau3,
|
||||
vec![Card {
|
||||
id: 102,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
}],
|
||||
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::Six)],
|
||||
);
|
||||
g
|
||||
}
|
||||
@@ -1150,7 +1090,7 @@ mod tests {
|
||||
} => {
|
||||
assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(cards, vec![100]);
|
||||
assert_eq!(cards, vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]);
|
||||
assert!(
|
||||
!legal_destinations.is_empty(),
|
||||
"lifted stack must have at least one legal destination"
|
||||
@@ -1162,7 +1102,10 @@ mod tests {
|
||||
|
||||
// DragState must mirror the lifted cards and carry the keyboard sentinel.
|
||||
let drag = app.world().resource::<DragState>();
|
||||
assert_eq!(drag.cards, vec![100]);
|
||||
assert_eq!(
|
||||
drag.cards,
|
||||
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]
|
||||
);
|
||||
assert_eq!(
|
||||
drag.origin_pile,
|
||||
Some(KlondikePile::Tableau(Tableau::Tableau1))
|
||||
@@ -1267,7 +1210,7 @@ mod tests {
|
||||
// keyboard sentinel.
|
||||
{
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![100];
|
||||
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)];
|
||||
drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
drag.committed = true;
|
||||
drag.active_touch_id = None;
|
||||
|
||||
+12
-668
@@ -15,7 +15,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
use bevy::window::{WindowMoved, WindowResized};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_data::{
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
|
||||
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
|
||||
@@ -241,7 +241,7 @@ enum SettingsButton {
|
||||
ToggleTouchInputMode,
|
||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||
/// random Classic-mode deals are filtered through
|
||||
/// [`solitaire_core::solver::try_solve`] until one is provably
|
||||
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||
/// winnable (or the retry cap is hit). Off by default.
|
||||
ToggleWinnableDealsOnly,
|
||||
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
|
||||
@@ -252,8 +252,7 @@ enum SettingsButton {
|
||||
/// player's last window size always wins.
|
||||
ToggleSmartDefaultSize,
|
||||
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
|
||||
/// sync server is configured — there is no server to send to in
|
||||
/// local-only mode.
|
||||
/// Matomo URL is configured.
|
||||
ToggleAnalytics,
|
||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -380,8 +379,8 @@ impl Plugin for SettingsPlugin {
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<TouchInput>()
|
||||
// `WindowResized` / `WindowMoved` are real Bevy window events
|
||||
// and emitted by the windowing backend under `DefaultPlugins`,
|
||||
// but we register them explicitly here so the geometry watcher
|
||||
@@ -1087,8 +1086,8 @@ fn handle_settings_buttons(
|
||||
}
|
||||
SettingsButton::ToggleDrawMode => {
|
||||
settings.0.draw_mode = match settings.0.draw_mode {
|
||||
DrawMode::DrawOne => DrawMode::DrawThree,
|
||||
DrawMode::DrawThree => DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||||
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
|
||||
};
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
@@ -1311,10 +1310,10 @@ fn handle_sync_buttons(
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_mode_label(mode: &DrawMode) -> String {
|
||||
fn draw_mode_label(mode: &DrawStockConfig) -> String {
|
||||
match mode {
|
||||
DrawMode::DrawOne => "Draw 1".into(),
|
||||
DrawMode::DrawThree => "Draw 3".into(),
|
||||
DrawStockConfig::DrawOne => "Draw 1".into(),
|
||||
DrawStockConfig::DrawThree => "Draw 3".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2663,7 +2662,7 @@ fn handle_scan_themes(
|
||||
|
||||
let themes_dir = user_theme_dir();
|
||||
|
||||
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) {
|
||||
let zips: Vec<PathBuf> = match std::fs::read_dir(&themes_dir) {
|
||||
Ok(entries) => entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
@@ -2855,659 +2854,4 @@ fn icon_button(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(SettingsPlugin::headless());
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn press(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
input.clear();
|
||||
input.press(key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_loaded() {
|
||||
let app = headless_app();
|
||||
assert_eq!(
|
||||
app.world().resource::<SettingsResource>().0,
|
||||
Settings::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
||||
let mut app = headless_app();
|
||||
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
|
||||
press(&mut app, KeyCode::BracketLeft);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!(after < before);
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(cursor.read(events).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_right_bracket_increases_volume() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 0.5;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!((after - 0.6).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_change_does_not_emit_event() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 1.0;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(cursor.read(events).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_clamped_at_zero_does_not_emit_event() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 0.0;
|
||||
|
||||
press(&mut app, KeyCode::BracketLeft);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!(after >= 0.0, "volume must not go below zero");
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(
|
||||
cursor.read(events).count(),
|
||||
0,
|
||||
"no event when clamped at floor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_o_toggles_settings_screen_flag() {
|
||||
let mut app = headless_app();
|
||||
assert!(
|
||||
!app.world().resource::<SettingsScreen>().0,
|
||||
"screen is closed initially"
|
||||
);
|
||||
|
||||
press(&mut app, KeyCode::KeyO);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SettingsScreen>().0,
|
||||
"O opens settings"
|
||||
);
|
||||
|
||||
press(&mut app, KeyCode::KeyO);
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<SettingsScreen>().0,
|
||||
"second O closes settings"
|
||||
);
|
||||
}
|
||||
|
||||
// cycle_unlocked pure-function tests
|
||||
#[test]
|
||||
fn cycle_unlocked_wraps_at_end() {
|
||||
// [0, 1, 2] → cycling from 2 wraps to 0
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_advances_normally() {
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_single_element_stays() {
|
||||
// Only one unlockable — cycling always returns it.
|
||||
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
||||
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_empty_returns_zero() {
|
||||
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_is_noop_when_settings_panel_closed() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||||
.id();
|
||||
// Send a downward scroll event while the panel is closed.
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -3.0,
|
||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
// ScrollPosition must remain at 0.0 — panel was closed.
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_moves_offset_when_panel_open() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition(Vec2::new(0.0, 100.0)),
|
||||
))
|
||||
.id();
|
||||
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -2.0,
|
||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert!(
|
||||
(offset - 200.0).abs() < 1e-3,
|
||||
"scrolling down should increase offset_y; got {offset}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
||||
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
||||
/// auto-tag systems fire end-to-end without writing to disk.
|
||||
fn headless_app_with_focus() -> App {
|
||||
use crate::ui_focus::UiFocusPlugin;
|
||||
use crate::ui_modal::UiModalPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(SettingsPlugin {
|
||||
// No persistence — keep the test isolated.
|
||||
storage_path: None,
|
||||
ui_enabled: true,
|
||||
});
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||
// and queues the spawn commands; the second flushes them and
|
||||
// runs `attach_focusable_to_settings_buttons`.
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||
// `ModalButton`) must carry a `Focusable`.
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
untagged.is_empty(),
|
||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||||
untagged
|
||||
);
|
||||
|
||||
// And there must be at least one tagged `SettingsButton` so the
|
||||
// assertion above isn't vacuously true (the panel really did
|
||||
// spawn).
|
||||
let tagged_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tagged_count >= 6,
|
||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
||||
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
||||
/// indexed swatches carry hover-reveal context. Mirrors
|
||||
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
||||
/// the invariant — every interactive Settings element except the
|
||||
/// `Done` modal button has a tooltip — is asserted consistently.
|
||||
#[test]
|
||||
fn settings_buttons_carry_tooltip() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel and let spawn + child-flush run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
||||
let untipped: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
untipped.is_empty(),
|
||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||
untipped
|
||||
);
|
||||
|
||||
// And there must be at least 6 tipped buttons so the assertion
|
||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
||||
// card-back and one background swatch — well over the floor.
|
||||
let tipped_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tipped_count >= 6,
|
||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||
);
|
||||
|
||||
// Spot-check: with default (Local) settings the Connect button
|
||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||
let connect_tip = app
|
||||
.world_mut()
|
||||
.query::<(&SettingsButton, &Tooltip)>()
|
||||
.iter(app.world())
|
||||
.find_map(|(btn, tip)| {
|
||||
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
||||
})
|
||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||
assert_eq!(
|
||||
connect_tip.as_ref(),
|
||||
"Connect to a self-hosted Ferrous Solitaire sync server.",
|
||||
"ConnectSync tooltip must use the canonical microcopy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_picker_rows_get_focus_row_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Two picker rows are spawned (card-back + background); each
|
||||
// must carry the FocusRow marker.
|
||||
let row_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<FocusRow>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
row_count >= 2,
|
||||
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
|
||||
/// `ImageNode` for each preview slot.
|
||||
#[test]
|
||||
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
|
||||
|
||||
let mut app = headless_app_with_focus();
|
||||
// Prime an Assets<Image> resource so we can mint stable handles
|
||||
// for the synthetic thumbnail pair.
|
||||
app.init_resource::<Assets<Image>>();
|
||||
let (ace_handle, back_handle) = {
|
||||
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
|
||||
let ace = images.add(Image::default());
|
||||
let back = images.add(Image::default());
|
||||
(ace, back)
|
||||
};
|
||||
// Inject one theme entry + a matching thumbnail pair.
|
||||
app.insert_resource(ThemeRegistry {
|
||||
entries: vec![ThemeEntry {
|
||||
id: "test_theme".into(),
|
||||
display_name: "Test Theme".into(),
|
||||
manifest_url: "themes://test_theme/theme.ron".into(),
|
||||
meta: crate::theme::ThemeMeta {
|
||||
id: "test_theme".into(),
|
||||
name: "Test Theme".into(),
|
||||
author: "x".into(),
|
||||
version: "x".into(),
|
||||
card_aspect: (2, 3),
|
||||
},
|
||||
}],
|
||||
});
|
||||
let mut cache = ThemeThumbnailCache::default();
|
||||
cache.entries.insert(
|
||||
"test_theme".into(),
|
||||
ThemeThumbnailPair {
|
||||
ace: ace_handle.clone(),
|
||||
back: back_handle.clone(),
|
||||
},
|
||||
);
|
||||
app.insert_resource(cache);
|
||||
|
||||
// Open the panel and let the spawn + child-flush systems run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||
// theme picker chip for "test_theme" must contribute exactly
|
||||
// two of them (ace + back).
|
||||
let thumbnail_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
thumbnail_count >= 2,
|
||||
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
|
||||
);
|
||||
|
||||
// Spot-check: at least one thumbnail's image handle matches one
|
||||
// of the ones we inserted into the cache. This guards against a
|
||||
// future refactor that accidentally clones the wrong handle.
|
||||
let any_matches = app
|
||||
.world_mut()
|
||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||
.iter(app.world())
|
||||
.any(|node| node.image == ace_handle || node.image == back_handle);
|
||||
assert!(
|
||||
any_matches,
|
||||
"at least one rendered thumbnail must reuse the cached handle"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Window geometry persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn should_persist_geometry_respects_debounce_window() {
|
||||
// Within the debounce window: not yet.
|
||||
assert!(!should_persist_geometry(10.0, 9.7));
|
||||
assert!(!should_persist_geometry(
|
||||
10.0,
|
||||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
||||
));
|
||||
// Exactly the debounce window: allowed (>= comparison).
|
||||
assert!(should_persist_geometry(
|
||||
10.0,
|
||||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||||
));
|
||||
// Well past the debounce window: allowed.
|
||||
assert!(should_persist_geometry(20.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_geometry_uses_existing_when_event_components_missing() {
|
||||
let existing = WindowGeometry {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
x: 100,
|
||||
y: 50,
|
||||
};
|
||||
// Position-only event keeps existing size.
|
||||
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
||||
assert_eq!(merged.width, 1280);
|
||||
assert_eq!(merged.height, 800);
|
||||
assert_eq!(merged.x, 200);
|
||||
assert_eq!(merged.y, 75);
|
||||
// Size-only event keeps existing position.
|
||||
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
||||
assert_eq!(merged.width, 1024);
|
||||
assert_eq!(merged.height, 768);
|
||||
assert_eq!(merged.x, 100);
|
||||
assert_eq!(merged.y, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_geometry_returns_none_when_size_unknown() {
|
||||
// No existing geometry, no size in the event → can't fabricate one.
|
||||
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
||||
}
|
||||
|
||||
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
||||
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
||||
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
||||
/// slices and run enough ticks to comfortably exceed the debounce
|
||||
/// window after the first record tick has set `last_changed_secs`.
|
||||
fn advance_past_geometry_debounce(app: &mut App) {
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use std::time::Duration;
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||
0.15,
|
||||
)));
|
||||
// Tick 1 sets last_changed_secs from any pending events. Each
|
||||
// subsequent tick advances the clock by 150 ms; five ticks total
|
||||
// buys 0.75 s of elapsed time relative to the record tick — well
|
||||
// past the 0.5 s debounce window.
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
||||
app.world_mut().write_message(WindowResized {
|
||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
fn fire_move(app: &mut App, x: i32, y: i32) {
|
||||
app.world_mut().write_message(WindowMoved {
|
||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
position: IVec2::new(x, y),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_event_then_quiet_persists_window_geometry() {
|
||||
let mut app = headless_app();
|
||||
// Sanity: geometry starts unset (default).
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Fire a resize, then go quiet for past the debounce.
|
||||
fire_resize(&mut app, 1500.0, 950.0);
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
|
||||
let geom = app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.expect("geometry should be persisted after debounce");
|
||||
assert_eq!(geom.width, 1500);
|
||||
assert_eq!(geom.height, 950);
|
||||
// Position not yet observed → defaults to 0, 0 since there was
|
||||
// no existing geometry to fall back on.
|
||||
assert_eq!(geom.x, 0);
|
||||
assert_eq!(geom.y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_event_after_resize_updates_position_only() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// First, establish a baseline geometry via a resize event.
|
||||
fire_resize(&mut app, 1280.0, 800.0);
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
let baseline = app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.unwrap();
|
||||
assert_eq!(baseline.width, 1280);
|
||||
|
||||
// Now fire a move-only event — size must be preserved from the
|
||||
// existing geometry.
|
||||
fire_move(&mut app, 250, 175);
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
|
||||
let geom = app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
geom.width, 1280,
|
||||
"size must be preserved across a move-only update"
|
||||
);
|
||||
assert_eq!(geom.height, 800);
|
||||
assert_eq!(geom.x, 250);
|
||||
assert_eq!(geom.y, 175);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_resize_storm_only_persists_final_size() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Burst of resize events on a single frame — only the last one
|
||||
// should be the eventually-persisted size.
|
||||
fire_resize(&mut app, 900.0, 600.0);
|
||||
fire_resize(&mut app, 1100.0, 700.0);
|
||||
fire_resize(&mut app, 1400.0, 850.0);
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
|
||||
let geom = app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.unwrap();
|
||||
assert_eq!((geom.width, geom.height), (1400, 850));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_window_events_no_geometry_change() {
|
||||
let mut app = headless_app();
|
||||
// Just advance time — without any events, settings must stay clean.
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_clamps_offset_to_zero_at_top() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
// Entity starts at 10 px offset.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition(Vec2::new(0.0, 10.0)),
|
||||
))
|
||||
.id();
|
||||
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: 5.0,
|
||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert_eq!(
|
||||
offset, 0.0,
|
||||
"scrolling past top must clamp to 0, got {offset}"
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,654 @@
|
||||
use super::*;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(SettingsPlugin::headless());
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn press(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
input.clear();
|
||||
input.press(key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_loaded() {
|
||||
let app = headless_app();
|
||||
assert_eq!(
|
||||
app.world().resource::<SettingsResource>().0,
|
||||
Settings::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
||||
let mut app = headless_app();
|
||||
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
|
||||
press(&mut app, KeyCode::BracketLeft);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!(after < before);
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(cursor.read(events).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_right_bracket_increases_volume() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 0.5;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!((after - 0.6).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_change_does_not_emit_event() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 1.0;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(cursor.read(events).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_clamped_at_zero_does_not_emit_event() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 0.0;
|
||||
|
||||
press(&mut app, KeyCode::BracketLeft);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!(after >= 0.0, "volume must not go below zero");
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(
|
||||
cursor.read(events).count(),
|
||||
0,
|
||||
"no event when clamped at floor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_o_toggles_settings_screen_flag() {
|
||||
let mut app = headless_app();
|
||||
assert!(
|
||||
!app.world().resource::<SettingsScreen>().0,
|
||||
"screen is closed initially"
|
||||
);
|
||||
|
||||
press(&mut app, KeyCode::KeyO);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SettingsScreen>().0,
|
||||
"O opens settings"
|
||||
);
|
||||
|
||||
press(&mut app, KeyCode::KeyO);
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<SettingsScreen>().0,
|
||||
"second O closes settings"
|
||||
);
|
||||
}
|
||||
|
||||
// cycle_unlocked pure-function tests
|
||||
#[test]
|
||||
fn cycle_unlocked_wraps_at_end() {
|
||||
// [0, 1, 2] → cycling from 2 wraps to 0
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_advances_normally() {
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_single_element_stays() {
|
||||
// Only one unlockable — cycling always returns it.
|
||||
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
||||
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_empty_returns_zero() {
|
||||
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_is_noop_when_settings_panel_closed() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||||
.id();
|
||||
// Send a downward scroll event while the panel is closed.
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -3.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
// ScrollPosition must remain at 0.0 — panel was closed.
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_moves_offset_when_panel_open() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition(Vec2::new(0.0, 100.0)),
|
||||
))
|
||||
.id();
|
||||
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -2.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert!(
|
||||
(offset - 200.0).abs() < 1e-3,
|
||||
"scrolling down should increase offset_y; got {offset}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
||||
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
||||
/// auto-tag systems fire end-to-end without writing to disk.
|
||||
fn headless_app_with_focus() -> App {
|
||||
use crate::ui_focus::UiFocusPlugin;
|
||||
use crate::ui_modal::UiModalPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(SettingsPlugin {
|
||||
// No persistence — keep the test isolated.
|
||||
storage_path: None,
|
||||
ui_enabled: true,
|
||||
});
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||
// and queues the spawn commands; the second flushes them and
|
||||
// runs `attach_focusable_to_settings_buttons`.
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||
// `ModalButton`) must carry a `Focusable`.
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
untagged.is_empty(),
|
||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||||
untagged
|
||||
);
|
||||
|
||||
// And there must be at least one tagged `SettingsButton` so the
|
||||
// assertion above isn't vacuously true (the panel really did
|
||||
// spawn).
|
||||
let tagged_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tagged_count >= 6,
|
||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
||||
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
||||
/// indexed swatches carry hover-reveal context. Mirrors
|
||||
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
||||
/// the invariant — every interactive Settings element except the
|
||||
/// `Done` modal button has a tooltip — is asserted consistently.
|
||||
#[test]
|
||||
fn settings_buttons_carry_tooltip() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel and let spawn + child-flush run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
||||
let untipped: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
untipped.is_empty(),
|
||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||
untipped
|
||||
);
|
||||
|
||||
// And there must be at least 6 tipped buttons so the assertion
|
||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
||||
// card-back and one background swatch — well over the floor.
|
||||
let tipped_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tipped_count >= 6,
|
||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||
);
|
||||
|
||||
// Spot-check: with default (Local) settings the Connect button
|
||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||
let connect_tip = app
|
||||
.world_mut()
|
||||
.query::<(&SettingsButton, &Tooltip)>()
|
||||
.iter(app.world())
|
||||
.find_map(|(btn, tip)| {
|
||||
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
||||
})
|
||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||
assert_eq!(
|
||||
connect_tip.as_ref(),
|
||||
"Connect to a self-hosted Ferrous Solitaire sync server.",
|
||||
"ConnectSync tooltip must use the canonical microcopy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_picker_rows_get_focus_row_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Two picker rows are spawned (card-back + background); each
|
||||
// must carry the FocusRow marker.
|
||||
let row_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<FocusRow>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
row_count >= 2,
|
||||
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
|
||||
/// `ImageNode` for each preview slot.
|
||||
#[test]
|
||||
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
|
||||
|
||||
let mut app = headless_app_with_focus();
|
||||
// Prime an Assets<Image> resource so we can mint stable handles
|
||||
// for the synthetic thumbnail pair.
|
||||
app.init_resource::<Assets<Image>>();
|
||||
let (ace_handle, back_handle) = {
|
||||
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
|
||||
let ace = images.add(Image::default());
|
||||
let back = images.add(Image::default());
|
||||
(ace, back)
|
||||
};
|
||||
// Inject one theme entry + a matching thumbnail pair.
|
||||
app.insert_resource(ThemeRegistry {
|
||||
entries: vec![ThemeEntry {
|
||||
id: "test_theme".into(),
|
||||
display_name: "Test Theme".into(),
|
||||
manifest_url: "themes://test_theme/theme.ron".into(),
|
||||
meta: crate::theme::ThemeMeta {
|
||||
id: "test_theme".into(),
|
||||
name: "Test Theme".into(),
|
||||
author: "x".into(),
|
||||
version: "x".into(),
|
||||
card_aspect: (2, 3),
|
||||
},
|
||||
}],
|
||||
});
|
||||
let mut cache = ThemeThumbnailCache::default();
|
||||
cache.entries.insert(
|
||||
"test_theme".into(),
|
||||
ThemeThumbnailPair {
|
||||
ace: ace_handle.clone(),
|
||||
back: back_handle.clone(),
|
||||
},
|
||||
);
|
||||
app.insert_resource(cache);
|
||||
|
||||
// Open the panel and let the spawn + child-flush systems run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||
// theme picker chip for "test_theme" must contribute exactly
|
||||
// two of them (ace + back).
|
||||
let thumbnail_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
thumbnail_count >= 2,
|
||||
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
|
||||
);
|
||||
|
||||
// Spot-check: at least one thumbnail's image handle matches one
|
||||
// of the ones we inserted into the cache. This guards against a
|
||||
// future refactor that accidentally clones the wrong handle.
|
||||
let any_matches = app
|
||||
.world_mut()
|
||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||
.iter(app.world())
|
||||
.any(|node| node.image == ace_handle || node.image == back_handle);
|
||||
assert!(
|
||||
any_matches,
|
||||
"at least one rendered thumbnail must reuse the cached handle"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Window geometry persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn should_persist_geometry_respects_debounce_window() {
|
||||
// Within the debounce window: not yet.
|
||||
assert!(!should_persist_geometry(10.0, 9.7));
|
||||
assert!(!should_persist_geometry(
|
||||
10.0,
|
||||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
||||
));
|
||||
// Exactly the debounce window: allowed (>= comparison).
|
||||
assert!(should_persist_geometry(
|
||||
10.0,
|
||||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||||
));
|
||||
// Well past the debounce window: allowed.
|
||||
assert!(should_persist_geometry(20.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_geometry_uses_existing_when_event_components_missing() {
|
||||
let existing = WindowGeometry {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
x: 100,
|
||||
y: 50,
|
||||
};
|
||||
// Position-only event keeps existing size.
|
||||
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
||||
assert_eq!(merged.width, 1280);
|
||||
assert_eq!(merged.height, 800);
|
||||
assert_eq!(merged.x, 200);
|
||||
assert_eq!(merged.y, 75);
|
||||
// Size-only event keeps existing position.
|
||||
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
||||
assert_eq!(merged.width, 1024);
|
||||
assert_eq!(merged.height, 768);
|
||||
assert_eq!(merged.x, 100);
|
||||
assert_eq!(merged.y, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_geometry_returns_none_when_size_unknown() {
|
||||
// No existing geometry, no size in the event → can't fabricate one.
|
||||
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
||||
}
|
||||
|
||||
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
||||
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
||||
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
||||
/// slices and run enough ticks to comfortably exceed the debounce
|
||||
/// window after the first record tick has set `last_changed_secs`.
|
||||
fn advance_past_geometry_debounce(app: &mut App) {
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use std::time::Duration;
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||
0.15,
|
||||
)));
|
||||
// Tick 1 sets last_changed_secs from any pending events. Each
|
||||
// subsequent tick advances the clock by 150 ms; five ticks total
|
||||
// buys 0.75 s of elapsed time relative to the record tick — well
|
||||
// past the 0.5 s debounce window.
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
||||
app.world_mut().write_message(WindowResized {
|
||||
window: Entity::PLACEHOLDER,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
fn fire_move(app: &mut App, x: i32, y: i32) {
|
||||
app.world_mut().write_message(WindowMoved {
|
||||
window: Entity::PLACEHOLDER,
|
||||
position: IVec2::new(x, y),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_event_then_quiet_persists_window_geometry() {
|
||||
let mut app = headless_app();
|
||||
// Sanity: geometry starts unset (default).
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Fire a resize, then go quiet for past the debounce.
|
||||
fire_resize(&mut app, 1500.0, 950.0);
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
|
||||
let geom = app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.expect("geometry should be persisted after debounce");
|
||||
assert_eq!(geom.width, 1500);
|
||||
assert_eq!(geom.height, 950);
|
||||
// Position not yet observed → defaults to 0, 0 since there was
|
||||
// no existing geometry to fall back on.
|
||||
assert_eq!(geom.x, 0);
|
||||
assert_eq!(geom.y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_event_after_resize_updates_position_only() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// First, establish a baseline geometry via a resize event.
|
||||
fire_resize(&mut app, 1280.0, 800.0);
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
let baseline = app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.unwrap();
|
||||
assert_eq!(baseline.width, 1280);
|
||||
|
||||
// Now fire a move-only event — size must be preserved from the
|
||||
// existing geometry.
|
||||
fire_move(&mut app, 250, 175);
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
|
||||
let geom = app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
geom.width, 1280,
|
||||
"size must be preserved across a move-only update"
|
||||
);
|
||||
assert_eq!(geom.height, 800);
|
||||
assert_eq!(geom.x, 250);
|
||||
assert_eq!(geom.y, 175);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_resize_storm_only_persists_final_size() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Burst of resize events on a single frame — only the last one
|
||||
// should be the eventually-persisted size.
|
||||
fire_resize(&mut app, 900.0, 600.0);
|
||||
fire_resize(&mut app, 1100.0, 700.0);
|
||||
fire_resize(&mut app, 1400.0, 850.0);
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
|
||||
let geom = app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.unwrap();
|
||||
assert_eq!((geom.width, geom.height), (1400, 850));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_window_events_no_geometry_change() {
|
||||
let mut app = headless_app();
|
||||
// Just advance time — without any events, settings must stay clean.
|
||||
advance_past_geometry_debounce(&mut app);
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.window_geometry
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_clamps_offset_to_zero_at_top() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
// Entity starts at 10 px offset.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition(Vec2::new(0.0, 10.0)),
|
||||
))
|
||||
.id();
|
||||
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: 5.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert_eq!(
|
||||
offset, 0.0,
|
||||
"scrolling past top must clamp to 0, got {offset}"
|
||||
);
|
||||
}
|
||||
@@ -1010,8 +1010,8 @@ mod tests {
|
||||
use solitaire_data::Settings;
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(bevy::asset::AssetPlugin::default())
|
||||
.init_asset::<bevy::image::Image>()
|
||||
.add_plugins(AssetPlugin::default())
|
||||
.init_asset::<Image>()
|
||||
.add_plugins(SplashPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<MouseButton>>();
|
||||
|
||||
@@ -203,7 +203,7 @@ impl Plugin for StatsPlugin {
|
||||
// `DefaultPlugins`; register it explicitly so the stats-scroll
|
||||
// system also runs cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
.add_message::<TouchInput>()
|
||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
||||
// StatsUpdate (as a set) is ordered after GameMutation by external
|
||||
@@ -464,7 +464,7 @@ fn repaint_replay_selector_detail(
|
||||
/// Pure helper: render the detail line for the selected replay. Returns
|
||||
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
|
||||
/// when a share URL is present. Empty when the history slice is empty.
|
||||
pub fn replay_selector_detail(replays: &[solitaire_data::Replay], index: usize) -> String {
|
||||
pub fn replay_selector_detail(replays: &[Replay], index: usize) -> String {
|
||||
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
|
||||
return String::new();
|
||||
};
|
||||
@@ -534,7 +534,7 @@ fn update_stats_on_win(
|
||||
let prev_streak = stats.0.win_streak_current;
|
||||
stats
|
||||
.0
|
||||
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
||||
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode());
|
||||
// Per-mode best score / fastest win — additive on top of the
|
||||
// lifetime totals tracked by `update_on_win`. TimeAttack is a
|
||||
// no-op inside the helper because it has its own session-level
|
||||
@@ -588,7 +588,7 @@ fn update_stats_on_new_game(
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
if game.0.move_count > 0 && !game.0.is_won {
|
||||
if game.0.move_count() > 0 && !game.0.is_won() {
|
||||
let streak = stats.0.win_streak_current;
|
||||
stats.0.record_abandoned();
|
||||
persist(&path, &stats.0, "abandoned game");
|
||||
@@ -614,7 +614,7 @@ fn handle_forfeit(
|
||||
mut auto_complete: Option<ResMut<AutoCompleteState>>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
if game.0.move_count > 0 && !game.0.is_won {
|
||||
if game.0.move_count() > 0 && !game.0.is_won() {
|
||||
let streak = stats.0.win_streak_current;
|
||||
stats.0.record_abandoned();
|
||||
persist(&path, &stats.0, "forfeit");
|
||||
@@ -1325,9 +1325,9 @@ mod tests {
|
||||
fn draw_three_win_increments_draw_three_wins_only() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -1371,9 +1371,9 @@ mod tests {
|
||||
let mut app = headless_app();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.move_count = 3;
|
||||
.set_test_move_count(3);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: Some(999),
|
||||
@@ -1501,7 +1501,7 @@ mod tests {
|
||||
fn zen_win_event_updates_zen_best_score_only() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
||||
|
||||
@@ -1697,9 +1697,9 @@ mod tests {
|
||||
stats.0.win_streak_current = 3;
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.move_count = 1;
|
||||
.set_test_move_count(1);
|
||||
|
||||
app.world_mut().write_message(ForfeitEvent);
|
||||
app.update();
|
||||
@@ -1723,9 +1723,9 @@ mod tests {
|
||||
stats.0.win_streak_current = 1;
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.move_count = 1;
|
||||
.set_test_move_count(1);
|
||||
|
||||
app.world_mut().write_message(ForfeitEvent);
|
||||
app.update();
|
||||
@@ -1948,11 +1948,11 @@ mod tests {
|
||||
///
|
||||
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
|
||||
/// `time_seconds` and `share_url` are the only varying fields across tests.
|
||||
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay {
|
||||
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> Replay {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
|
||||
let mut r = solitaire_data::Replay::new(
|
||||
let mut r = Replay::new(
|
||||
1,
|
||||
solitaire_core::game_state::DrawMode::DrawOne,
|
||||
solitaire_core::DrawStockConfig::DrawOne,
|
||||
solitaire_core::game_state::GameMode::Classic,
|
||||
time_seconds,
|
||||
0,
|
||||
|
||||
@@ -331,7 +331,7 @@ fn push_replay_on_win(
|
||||
}
|
||||
let replay = Replay::new(
|
||||
game.0.seed,
|
||||
game.0.draw_mode,
|
||||
game.0.draw_mode(),
|
||||
game.0.mode,
|
||||
ev.time_seconds,
|
||||
ev.score,
|
||||
@@ -496,7 +496,7 @@ mod tests {
|
||||
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
||||
.add_plugins(SyncPlugin::new(provider));
|
||||
// MinimalPlugins does not register keyboard input.
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -604,7 +604,7 @@ mod tests {
|
||||
/// would silently drop the link.
|
||||
#[test]
|
||||
fn upload_result_writes_share_url_into_replay_and_persists() {
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_data::{
|
||||
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
|
||||
};
|
||||
@@ -617,7 +617,7 @@ mod tests {
|
||||
// share_url — the upload-poll path must populate it.
|
||||
let initial = Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
500,
|
||||
@@ -629,8 +629,8 @@ mod tests {
|
||||
replays: vec![initial],
|
||||
};
|
||||
save_replay_history_to(&path, &history).expect("seed history on disk");
|
||||
app.insert_resource(crate::stats_plugin::ReplayHistoryResource(history));
|
||||
app.insert_resource(crate::stats_plugin::LatestReplayPath(Some(path.clone())));
|
||||
app.insert_resource(ReplayHistoryResource(history));
|
||||
app.insert_resource(LatestReplayPath(Some(path.clone())));
|
||||
|
||||
// Pre-resolved task carrying the URL the production path would
|
||||
// get back from the server.
|
||||
@@ -659,7 +659,7 @@ mod tests {
|
||||
// In-memory contract: replays[0].share_url is now Some(url).
|
||||
let live = app
|
||||
.world()
|
||||
.resource::<crate::stats_plugin::ReplayHistoryResource>();
|
||||
.resource::<ReplayHistoryResource>();
|
||||
assert_eq!(
|
||||
live.0.replays.first().and_then(|r| r.share_url.clone()),
|
||||
Some(url.clone()),
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::Suit;
|
||||
|
||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||
use crate::hud_plugin::HudVisibility;
|
||||
#[cfg(test)]
|
||||
use crate::layout::TABLE_COLOUR;
|
||||
use crate::layout::{Layout, LayoutResource, LayoutSystem, compute_layout};
|
||||
use crate::layout::{
|
||||
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
||||
};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::safe_area::SafeAreaInsets;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
@@ -177,9 +177,9 @@ fn setup_table(
|
||||
Camera2d,
|
||||
Camera {
|
||||
clear_color: ClearColorConfig::Custom(Color::srgb(
|
||||
crate::layout::TABLE_COLOUR[0],
|
||||
crate::layout::TABLE_COLOUR[1],
|
||||
crate::layout::TABLE_COLOUR[2],
|
||||
TABLE_COLOUR[0],
|
||||
TABLE_COLOUR[1],
|
||||
TABLE_COLOUR[2],
|
||||
)),
|
||||
..default()
|
||||
},
|
||||
@@ -350,12 +350,13 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn on_window_resized(
|
||||
mut events: MessageReader<WindowResized>,
|
||||
safe_area: Option<Res<SafeAreaInsets>>,
|
||||
windows: Query<&Window>,
|
||||
hud_vis: Option<Res<HudVisibility>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut layout_res: Option<ResMut<LayoutResource>>,
|
||||
mut backgrounds: Query<
|
||||
(&mut Sprite, &mut Transform),
|
||||
@@ -372,7 +373,16 @@ fn on_window_resized(
|
||||
let safe_area_top = insets.top / scale;
|
||||
let safe_area_bottom = insets.bottom / scale;
|
||||
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
|
||||
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
|
||||
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
|
||||
|
||||
// compute_layout sizes the fan for a worst-case column; refine it to the
|
||||
// current deal so a resize (incl. the Android safe-area-inset resize that
|
||||
// fires in the first few frames, and fold/unfold on foldables) keeps the
|
||||
// tableau filling the viewport instead of snapping back to the sparse
|
||||
// worst-case fan.
|
||||
if let Some(game) = game.as_ref() {
|
||||
apply_dynamic_tableau_fan(&game.0, &mut new_layout);
|
||||
}
|
||||
|
||||
if let Some(layout_res) = layout_res.as_deref_mut() {
|
||||
layout_res.0 = new_layout.clone();
|
||||
@@ -520,7 +530,7 @@ fn sync_pile_marker_visibility(
|
||||
fn pile_cards(
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
pile: &KlondikePile,
|
||||
) -> Vec<solitaire_core::card::Card> {
|
||||
) -> Vec<(solitaire_core::Card, bool)> {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
let stock = game.stock_cards();
|
||||
|
||||
@@ -236,7 +236,7 @@ pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId,
|
||||
/// Sums every entry's declared uncompressed size and rejects archives
|
||||
/// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central
|
||||
/// directory only — does not actually decompress anything.
|
||||
fn enforce_archive_size_limit<R: io::Read + io::Seek>(
|
||||
fn enforce_archive_size_limit<R: Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
) -> Result<(), ImportError> {
|
||||
let mut total: u64 = 0;
|
||||
@@ -257,7 +257,7 @@ fn enforce_archive_size_limit<R: io::Read + io::Seek>(
|
||||
/// (after normalisation) escapes its root. Catches `..`, absolute
|
||||
/// paths, drive prefixes on Windows, and the awkward case where
|
||||
/// `enclosed_name` returns `None` because the entry is suspicious.
|
||||
fn enforce_zip_slip_safe<R: io::Read + io::Seek>(
|
||||
fn enforce_zip_slip_safe<R: Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
) -> Result<(), ImportError> {
|
||||
for i in 0..archive.len() {
|
||||
@@ -291,7 +291,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
|
||||
/// `theme.ron` entry at its root.
|
||||
/// - [`ImportError::ManifestParse`] when the bytes don't form valid
|
||||
/// RON for `ThemeManifest`.
|
||||
fn read_manifest<R: io::Read + io::Seek>(
|
||||
fn read_manifest<R: Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
) -> Result<ThemeManifest, ImportError> {
|
||||
// We can't use `?` directly across `by_name` because a missing
|
||||
@@ -318,7 +318,7 @@ fn read_manifest<R: io::Read + io::Seek>(
|
||||
///
|
||||
/// Returns [`ImportError::MissingFile`] when the archive has no entry
|
||||
/// matching the path.
|
||||
fn read_archive_entry<R: io::Read + io::Seek>(
|
||||
fn read_archive_entry<R: Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
path: &Path,
|
||||
) -> Result<Vec<u8>, ImportError> {
|
||||
@@ -351,7 +351,7 @@ fn archive_key(path: &Path) -> String {
|
||||
/// parent directories as needed. The destination path is rebuilt from
|
||||
/// the safe components we already vetted in
|
||||
/// [`enforce_zip_slip_safe`], not from the raw entry name.
|
||||
fn write_archive_entry<R: io::Read + io::Seek>(
|
||||
fn write_archive_entry<R: Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
name: &str,
|
||||
staging: &Path,
|
||||
@@ -384,7 +384,7 @@ fn write_archive_entry<R: io::Read + io::Seek>(
|
||||
|
||||
/// Variant of [`write_archive_entry`] keyed by `Path` for the
|
||||
/// manifest-declared face/back paths.
|
||||
fn write_archive_entry_pathbuf<R: io::Read + io::Seek>(
|
||||
fn write_archive_entry_pathbuf<R: Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
path: &Path,
|
||||
staging: &Path,
|
||||
|
||||
@@ -27,7 +27,7 @@ use bevy::reflect::TypePath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
use solitaire_core::{Rank, Suit};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
|
||||
@@ -43,11 +43,11 @@ pub use registry::{
|
||||
|
||||
/// Hashable lookup key into [`CardTheme::faces`].
|
||||
///
|
||||
/// Distinct from `solitaire_core::Card`: the core type carries an `id`
|
||||
/// and a `face_up` flag that vary per deal, neither of which is
|
||||
/// relevant to image lookup. `CardKey` is just the (suit, rank) pair
|
||||
/// that uniquely identifies which artwork to draw.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
/// Distinct from `card_game::Card`, which also encodes a deck id: `CardKey`
|
||||
/// is just the (suit, rank) pair that uniquely identifies which artwork to
|
||||
/// draw. Serialised theme manifests address faces by
|
||||
/// [`CardKey::manifest_name`] strings, not by serialising `CardKey` itself.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct CardKey {
|
||||
pub suit: Suit,
|
||||
pub rank: Rank,
|
||||
|
||||
@@ -12,7 +12,7 @@ use bevy::asset::AssetEvent;
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::math::UVec2;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
use solitaire_core::{Rank, Suit};
|
||||
|
||||
use crate::assets::{
|
||||
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
|
||||
@@ -484,7 +484,7 @@ mod tests {
|
||||
let mut image_set = empty_card_image_set();
|
||||
// Snapshot the legacy back ids so we can prove they don't
|
||||
// change when a theme is applied.
|
||||
let legacy_ids_before: [bevy::asset::AssetId<bevy::image::Image>; 5] =
|
||||
let legacy_ids_before: [AssetId<Image>; 5] =
|
||||
std::array::from_fn(|i| image_set.backs[i].id());
|
||||
let theme = empty_theme();
|
||||
assert!(image_set.theme_back.is_none(), "theme_back starts empty");
|
||||
|
||||
@@ -182,7 +182,7 @@ fn advance_time_attack(
|
||||
// No shared screen-state enum currently covers every overlay. Pause the
|
||||
// countdown whenever gameplay is blocked by a modal, the pause flag, or a
|
||||
// just-won board state.
|
||||
if paused.is_some_and(|p| p.0) || game.0.is_won || !modal_scrims.is_empty() {
|
||||
if paused.is_some_and(|p| p.0) || game.0.is_won() || !modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0);
|
||||
@@ -299,7 +299,7 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -430,7 +430,7 @@ mod tests {
|
||||
};
|
||||
// The current game must be in TimeAttack mode for auto-deal to fire.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -454,7 +454,7 @@ mod tests {
|
||||
let mut app = headless_app();
|
||||
// Default session is inactive. Game is TimeAttack mode — still no count.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
@@ -521,7 +521,7 @@ mod tests {
|
||||
// the session timer or the running win count.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn tmp_ta_path(name: &str) -> std::path::PathBuf {
|
||||
fn tmp_ta_path(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("engine_test_ta_{name}.json"))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::Card;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::StateChangedEvent;
|
||||
@@ -49,8 +50,8 @@ use crate::ui_theme::ACCENT_PRIMARY;
|
||||
/// card ids that will be moved (1 for a single card, multiple for a face-up run).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct TouchSelectionState {
|
||||
/// Currently selected source pile and the card ids to move (bottom-to-top).
|
||||
pub selected: Option<(KlondikePile, Vec<u32>)>,
|
||||
/// Currently selected source pile and the cards to move (bottom-to-top).
|
||||
pub selected: Option<(KlondikePile, Vec<Card>)>,
|
||||
}
|
||||
|
||||
impl TouchSelectionState {
|
||||
@@ -60,12 +61,12 @@ impl TouchSelectionState {
|
||||
}
|
||||
|
||||
/// Takes the current selection, leaving `selected` as `None`.
|
||||
pub fn take(&mut self) -> Option<(KlondikePile, Vec<u32>)> {
|
||||
pub fn take(&mut self) -> Option<(KlondikePile, Vec<Card>)> {
|
||||
self.selected.take()
|
||||
}
|
||||
|
||||
/// Sets the current selection.
|
||||
pub fn set(&mut self, pile: KlondikePile, cards: Vec<u32>) {
|
||||
pub fn set(&mut self, pile: KlondikePile, cards: Vec<Card>) {
|
||||
self.selected = Some((pile, cards));
|
||||
}
|
||||
|
||||
@@ -142,7 +143,7 @@ pub(crate) fn update_touch_selection_highlight(
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
|
||||
let Some((_, ref card_ids)) = selection.selected else {
|
||||
let Some((_, ref cards)) = selection.selected else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout else {
|
||||
@@ -154,8 +155,8 @@ pub(crate) fn update_touch_selection_highlight(
|
||||
// but highlighting the whole run gives the player clear confirmation
|
||||
// of how many cards are involved in the move.
|
||||
let card_size = layout.0.card_size;
|
||||
for &card_id in card_ids {
|
||||
spawn_touch_highlight(&mut commands, &card_entities, card_id, card_size);
|
||||
for card in cards {
|
||||
spawn_touch_highlight(&mut commands, &card_entities, card, card_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +164,11 @@ pub(crate) fn update_touch_selection_highlight(
|
||||
fn spawn_touch_highlight(
|
||||
commands: &mut Commands,
|
||||
card_entities: &Query<(Entity, &CardEntity)>,
|
||||
card_id: u32,
|
||||
card: &Card,
|
||||
card_size: Vec2,
|
||||
) {
|
||||
for (entity, card_entity) in card_entities {
|
||||
if card_entity.card_id == card_id {
|
||||
if card_entity.card == *card {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
TouchSelectionHighlight,
|
||||
@@ -193,6 +194,17 @@ fn spawn_touch_highlight(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::Tableau;
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
|
||||
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
|
||||
/// numeric ids. Identity is now the `Card` value.
|
||||
fn test_cards() -> [Card; 3] {
|
||||
[
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Three),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_state_default_is_idle() {
|
||||
@@ -204,20 +216,24 @@ mod tests {
|
||||
#[test]
|
||||
fn set_and_take_roundtrip() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1, 2, 3]);
|
||||
let cards = test_cards().to_vec();
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau1), cards.clone());
|
||||
assert!(state.has_selection());
|
||||
let taken = state.take();
|
||||
assert!(taken.is_some());
|
||||
let (pile, cards) = taken.unwrap();
|
||||
let (pile, taken_cards) = taken.unwrap();
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(cards, vec![1, 2, 3]);
|
||||
assert_eq!(taken_cards, cards);
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_selection() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(KlondikePile::Stock, vec![42]);
|
||||
state.set(
|
||||
KlondikePile::Stock,
|
||||
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
|
||||
);
|
||||
state.clear();
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
@@ -232,10 +248,17 @@ mod tests {
|
||||
#[test]
|
||||
fn set_overwrites_previous_selection() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1]);
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau4), vec![7, 8]);
|
||||
state.set(
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||
);
|
||||
let second = vec![
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Seven),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Eight),
|
||||
];
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau4), second.clone());
|
||||
let (pile, cards) = state.take().unwrap();
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4));
|
||||
assert_eq!(cards, vec![7, 8]);
|
||||
assert_eq!(cards, second);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,7 +1324,7 @@ mod tests {
|
||||
let row = world.spawn((FocusRow, Node::default())).id();
|
||||
world.entity_mut(scrim).add_child(row);
|
||||
|
||||
let make_swatch = |w: &mut World, marker: fn(&mut bevy::ecs::world::EntityWorldMut)| {
|
||||
let make_swatch = |w: &mut World, marker: fn(&mut EntityWorldMut)| {
|
||||
let mut e = w.spawn((
|
||||
Button,
|
||||
Node::default(),
|
||||
|
||||
@@ -982,9 +982,9 @@ mod tests {
|
||||
outline_width: 0.0,
|
||||
outline_offset: 0.0,
|
||||
unrounded_size: card_size,
|
||||
border: bevy::sprite::BorderRect::default(),
|
||||
border_radius: bevy::ui::ResolvedBorderRadius::default(),
|
||||
padding: bevy::sprite::BorderRect::default(),
|
||||
border: BorderRect::default(),
|
||||
border_radius: ResolvedBorderRadius::default(),
|
||||
padding: BorderRect::default(),
|
||||
inverse_scale_factor: 1.0,
|
||||
};
|
||||
// `is_empty` guard inside Bevy treats zero-size
|
||||
|
||||
@@ -249,12 +249,12 @@ pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0);
|
||||
pub struct HighContrastBorder {
|
||||
/// Border colour to use when high-contrast mode is *off* — the
|
||||
/// site's normal idle / active-state colour.
|
||||
pub default_color: bevy::prelude::Color,
|
||||
pub default_color: Color,
|
||||
}
|
||||
|
||||
impl HighContrastBorder {
|
||||
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
|
||||
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
||||
pub const fn with_default(default_color: Color) -> Self {
|
||||
Self { default_color }
|
||||
}
|
||||
}
|
||||
@@ -282,18 +282,18 @@ impl HighContrastBorder {
|
||||
pub struct HighContrastBackground {
|
||||
/// Background colour to use when high-contrast mode is *off* —
|
||||
/// the site's normal idle / active-state colour.
|
||||
pub default_color: bevy::prelude::Color,
|
||||
pub default_color: Color,
|
||||
/// Background colour to use when high-contrast mode is *on*.
|
||||
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
|
||||
///
|
||||
/// [`with_default`]: HighContrastBackground::with_default
|
||||
pub hc_color: bevy::prelude::Color,
|
||||
pub hc_color: Color,
|
||||
}
|
||||
|
||||
impl HighContrastBackground {
|
||||
/// Convenience constructor — HC colour defaults to
|
||||
/// [`BORDER_SUBTLE_HC`].
|
||||
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
||||
pub const fn with_default(default_color: Color) -> Self {
|
||||
Self {
|
||||
default_color,
|
||||
hc_color: BORDER_SUBTLE_HC,
|
||||
@@ -305,8 +305,8 @@ impl HighContrastBackground {
|
||||
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
|
||||
/// than to a neutral gray.
|
||||
pub const fn with_hc(
|
||||
default_color: bevy::prelude::Color,
|
||||
hc_color: bevy::prelude::Color,
|
||||
default_color: Color,
|
||||
hc_color: Color,
|
||||
) -> Self {
|
||||
Self {
|
||||
default_color,
|
||||
|
||||
@@ -82,8 +82,8 @@ fn evaluate_weekly_goals(
|
||||
for ev in events.drain(..) {
|
||||
let ctx = WeeklyGoalContext {
|
||||
time_seconds: ev.time_seconds,
|
||||
used_undo: game.0.undo_count > 0,
|
||||
draw_mode: game.0.draw_mode,
|
||||
used_undo: game.0.undo_count() > 0,
|
||||
draw_mode: game.0.draw_mode(),
|
||||
};
|
||||
for def in WEEKLY_GOALS {
|
||||
if !def.matches(&ctx) {
|
||||
@@ -177,7 +177,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.undo_count = 1;
|
||||
.force_test_undos(1);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user