Compare commits

..

5 Commits

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:28:44 -07:00
124 changed files with 12702 additions and 17984 deletions
-5
View File
@@ -1,5 +0,0 @@
[registries.Quaternions]
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
-46
View File
@@ -6,14 +6,10 @@ on:
branches: [master] branches: [master]
paths: paths:
- 'solitaire_server/**' - 'solitaire_server/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_sync/**' - 'solitaire_sync/**'
- 'solitaire_core/**' - 'solitaire_core/**'
- 'solitaire_engine/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- 'solitaire_server/Dockerfile'
- '.gitea/workflows/docker-build.yml' - '.gitea/workflows/docker-build.yml'
env: env:
@@ -36,48 +32,6 @@ jobs:
id: meta id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: Check wasm pkg drift
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
- name: Log in to Gitea registry - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
-49
View File
@@ -1,49 +0,0 @@
name: Web E2E
on:
push:
branches: [master]
paths:
- 'solitaire_server/web/**'
- 'solitaire_server/src/**'
- 'solitaire_server/e2e/**'
- 'solitaire_wasm/**'
- 'solitaire_core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/web-e2e.yml'
workflow_dispatch:
jobs:
web-e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: solitaire_server/e2e/package-lock.json
- name: Install e2e dependencies
working-directory: solitaire_server/e2e
run: npm ci
- name: Install Playwright browser
working-directory: solitaire_server/e2e
run: npx playwright install --with-deps chromium
- name: Run web e2e tests
working-directory: solitaire_server/e2e
run: npm test
- name: Run cycle regression gate
working-directory: solitaire_server/e2e
run: npm run review:cycles:regression
-10
View File
@@ -15,11 +15,6 @@ agentdb.rvf.lock
# IDE project files # IDE project files
.idea/ .idea/
# Browser e2e harness artifacts
solitaire_server/e2e/node_modules/
solitaire_server/e2e/playwright-report/
solitaire_server/e2e/test-results/
# Android signing keystores — never commit # Android signing keystores — never commit
*.jks *.jks
*.jks.bak *.jks.bak
@@ -30,8 +25,3 @@ solitaire_server/e2e/test-results/
deploy/matomo-secret.yaml deploy/matomo-secret.yaml
deploy/*-secret.yaml deploy/*-secret.yaml
deploy/*-auth-secret.yaml deploy/*-auth-secret.yaml
# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo
/.agents/
/.codex/
/AGENTS.md
-276
View File
@@ -6,282 +6,6 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added
- **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
- **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
- **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.
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
file paths to async I/O where needed, and validated `JWT_SECRET` at startup.
- **CI and deployment workflow.** Fixed deploy-branch handling, Docker registry
secret usage, and related release automation issues.
### Tests
- Ran an Android AVD `Pixel_7` launch smoke for the x86_64 debug APK,
including install, NativeActivity launch, safe-area log validation, screenshot
render check, onboarding input, and crash-log review.
- Added direct coverage for Android/touch card corner labels using Unicode suit
glyphs.
- Added schema-v3 persistence round-trip coverage, foundation-to-tableau
instruction coverage, expanded WASM unit tests, and Playwright E2E specs for
browser routes and game-canvas behavior.
## [0.39.0] — 2026-05-19
### Fixed
- **No-legal-moves detection and banner.** Corrected no-move detection across
engine, WASM, and web paths, then surfaced the state to players with an
in-game banner instead of silently leaving the board stuck.
- **Release/deploy automation.** Updated deployment automation so kustomization
changes are pushed to the deploy branch instead of the main development
branch.
## [0.38.0] — 2026-05-19
### Added
- **Klondike scoring parity.** Added tableau flip bonuses and stock recycle
penalties to align scoring with standard Klondike expectations.
### Fixed
- **Core rule enforcement.** Auto-complete now requires an empty waste pile,
waste-origin moves reject multi-card transfers, foundation-to-foundation moves
are blocked, and undo restores score from the snapshot baseline.
- **Modal lifecycle guards.** Added missing `ModalScrim` guards to New Game,
restore prompt, and no-moves modal spawn sites.
- **Runtime and server robustness.** Tokio runtime setup degrades gracefully
instead of panicking; web replay submission casing/date formatting now matches
server expectations; avatar routes are publicly reachable when intended.
- **Android token and sync merge correctness.** Android tokens are namespaced
under the application directory, stored per user, and migrated safely; sync
merges preserve draw-one / draw-three win invariants.
## [0.37.0] — 2026-05-19
### Fixed
- **Foundation-to-tableau default.** Made `take_from_foundation` default to true
across clients so restored, startup, and web games use the same supported move
rules.
## [0.36.12] — 2026-05-19
### Fixed
- **Foundation-to-tableau default.** Set `take_from_foundation` true by default
in core so every client inherits the intended house rule without special-case
setup.
## [0.36.11] — 2026-05-19
### Fixed
- **Web foundation moves.** Enabled take-from-foundation moves in the web game
client.
## [0.36.10] — 2026-05-19
### Added
- **Web resume flow.** Browser games now persist state across page refreshes and
can resume through a dialog instead of starting over.
## [0.36.9] — 2026-05-19
### Fixed
- **Settings sync connection flow.** Clicking Connect from Settings now opens the
sync-setup modal.
## [0.36.8] — 2026-05-19
### Fixed
- **Restored/startup foundation moves.** Enabled take-from-foundation behavior
for restored and startup games, not only newly-created sessions.
## [0.36.7] — 2026-05-19
### Fixed
- **Remaining Android UI issues.** Resolved the final Android UI defects from
the review pass, including action-bar/tableau interaction and safe visual
spacing.
## [0.36.6] — 2026-05-19
### Fixed
- **Action-bar layout reservation.** Reserved action-bar height in layout so
tableau columns do not extend behind bottom controls.
## [0.36.5] — 2026-05-19
### Added
- **Responsive Android action-bar glyphs.** Action-bar glyph font size now scales
dynamically on Android to fit available space.
## [0.36.4] — 2026-05-19
### Fixed
- **Classic card labels and HUD overlap.** Corrected classic-card corner-label
colors and fixed HUD-band overlap in the Android layout.
## [0.36.3] — 2026-05-19
### Fixed
- **Core, animation, and modal review fixes.** Added the foundation-to-tableau
score penalty, hardened solver win validation, guarded zero-duration card
animations, aligned initial and dynamic tableau fan spacing, and added missing
modal guards for play-by-seed and win-summary paths.
- **Pause, messages, credentials, and server validation.** Auto-complete respects
pause state, standalone plugins register their events, sync passwords are
cleared from ECS buffers after auth task spawn, and avatar MIME validation uses
exact matches.
- **Foundation pile rendering.** Raised stack fan z-order above corner labels to
prevent bleed-through.
- **Android release workflow.** Added a manual `workflow_dispatch` trigger to
the Android release workflow.
## [0.36.2] — 2026-05-19
### Fixed
- **Comprehensive review fixes.** Addressed 26 issues across core rules, replay
controls, modal guards, sync payload timing, server replay casing, time-attack
overlays, theme refresh, auth overlays, stats ordering, animations, cursor
fallbacks, achievements, server temp-file cleanup, and runtime fallback paths.
- **Animation and Android label polish.** Cancelled stale win-cascade animations
on new game, refreshed Android corner labels on resize, lifted animating cards
above lower z-layers, and froze the web timer when auto-complete starts.
- **Web package and tooling updates.** Rebuilt the WASM package for
foundation-to-tableau moves, added ruflo scaffolding, and ignored ruflo runtime
state files.
- **Leaderboard test stability.** Made opt-in / opt-out tests robust under
parallel test execution.
## [0.36.1] — 2026-05-18
### Fixed
- **Android HUD gesture conflict.** Stock taps no longer toggle HUD visibility on
Android.
## [0.36.0] — 2026-05-18
### Changed
- **Rank model cleanup.** `Rank` now uses explicit discriminants and checked
arithmetic, making rank conversions and sequencing more robust.
- **Instruction generation.** Refined `possible_instructions` alongside the rank
arithmetic cleanup.
- **Session handoff.** Recreated `SESSION_HANDOFF.md` to reflect the `0.35.1`
state.
## [0.35.1] — 2026-05-17
### Fixed
- **Leaderboard profile sync.** Fixed three leaderboard/profile issues: wrong
toast type for failures, stale display-name label after update, and display
name not syncing to the server.
## [0.35.0] — 2026-05-17
### Added
- **Reduced-motion support.** Decorative motion animations are now gated behind
`reduce_motion_mode`.
### Changed
- **Performance and runtime cleanup.** Shared a single Tokio runtime across
network tasks and gated frame-hot ECS systems on resource changes.
- **Core/data refactors.** Consolidated the application directory name, added
`#[must_use]` to pure helpers, derived `Copy` for `DrawMode`, removed
redundant clones, added missing derives to `AchievementContext`, and used
saturating move-count arithmetic.
- **HUD z-layer naming.** Replaced raw HUD popover z-index arithmetic with named
layer constants.
### Fixed
- **Android UI and font safety.** Wired FiraMono to stock-empty labels, removed
raw physical safe-area pixels from HUD spawns, replaced unsupported chevrons,
corrected the Android help hint label, and fixed touch/drop-zone behavior.
- **Engine modal and panic hardening.** Eliminated several runtime panics, added
required transforms to modal scrims, constrained dismiss hit-tests, and guarded
home overlay respawns.
- **Sync/data/server correctness.** Deterministic pile serialization, undo skip
handling, byte URL encoding, merge timestamp handling, auth-guarded avatar
serving, atomic server writes, and user-id assertions were corrected.
- **Display-name and token-file boundaries.** Enforced the 32-character display
name limit in the sync client and aligned Android keystore temp-file cleanup
with the cleanup glob.
- **WASM error reporting.** `state()` and `step()` now return `Result` so errors
surface as JavaScript exceptions.
- **Sync and leaderboard toasts.** Pull failures and leaderboard opt-in /
opt-out failures now produce the intended warning/error feedback.
### Documentation
- Corrected stale focus-ring color documentation.
## [0.34.0] — 2026-05-17
### Fixed
- **Android waste fan and resume layout.** Corrected Android waste-pile fan
overlap and a layout desynchronization after resume.
- **Card-face artwork.** Fixed the wrong bottom-right suit symbol on the jack,
queen, and king of spades.
- **Android corner-label font coverage.** Wired FiraMono into Android corner
labels and added `CardImageSet` tests to guard the asset path behavior.
## [0.33.0] — 2026-05-16 ## [0.33.0] — 2026-05-16
### Fixed ### Fixed
+3 -5
View File
@@ -430,11 +430,9 @@ explicitly replacing the current one (despawn first, then spawn).
## 14.3 Safe area ## 14.3 Safe area
Every `ModalScrim` automatically receives `padding.top` equal to the logical Every `ModalScrim` automatically receives `padding.bottom` equal to the
status-bar height and `padding.bottom` equal to the logical gesture-bar height logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
via `apply_safe_area_to_modal_scrims` in `SafeAreaInsetsPlugin`. This centres `SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
the modal card within the usable area between both system bars. Do not manually
add top or bottom padding to scrim nodes.
## 14.4 Z-ordering ## 14.4 Z-ordering
Generated
+15 -418
View File
@@ -364,12 +364,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4"
[[package]] [[package]]
name = "as-raw-xcb-connection" name = "as-raw-xcb-connection"
version = "1.0.1" version = "1.0.1"
@@ -723,28 +717,6 @@ dependencies = [
"android-activity", "android-activity",
] ]
[[package]]
name = "bevy_anti_alias"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_core_pipeline",
"bevy_derive",
"bevy_diagnostic",
"bevy_ecs",
"bevy_image",
"bevy_math",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_utils",
"tracing",
]
[[package]] [[package]]
name = "bevy_app" name = "bevy_app"
version = "0.18.1" version = "0.18.1"
@@ -906,35 +878,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "bevy_dev_tools"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_diagnostic",
"bevy_ecs",
"bevy_image",
"bevy_input",
"bevy_math",
"bevy_picking",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_state",
"bevy_text",
"bevy_time",
"bevy_transform",
"bevy_ui",
"bevy_ui_render",
"bevy_window",
"tracing",
]
[[package]] [[package]]
name = "bevy_diagnostic" name = "bevy_diagnostic"
version = "0.18.1" version = "0.18.1"
@@ -958,7 +901,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d" checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
dependencies = [ dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"bevy_ecs_macros", "bevy_ecs_macros",
"bevy_platform", "bevy_platform",
"bevy_ptr", "bevy_ptr",
@@ -1002,36 +945,6 @@ dependencies = [
"encase_derive_impl", "encase_derive_impl",
] ]
[[package]]
name = "bevy_feathers"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6"
dependencies = [
"accesskit",
"bevy_a11y",
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_derive",
"bevy_ecs",
"bevy_input_focus",
"bevy_log",
"bevy_math",
"bevy_picking",
"bevy_platform",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_text",
"bevy_ui",
"bevy_ui_render",
"bevy_ui_widgets",
"bevy_window",
"smol_str",
]
[[package]] [[package]]
name = "bevy_gizmos" name = "bevy_gizmos"
version = "0.18.1" version = "0.18.1"
@@ -1154,17 +1067,14 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754"
dependencies = [ dependencies = [
"bevy_a11y", "bevy_a11y",
"bevy_android", "bevy_android",
"bevy_anti_alias",
"bevy_app", "bevy_app",
"bevy_asset", "bevy_asset",
"bevy_camera", "bevy_camera",
"bevy_color", "bevy_color",
"bevy_core_pipeline", "bevy_core_pipeline",
"bevy_derive", "bevy_derive",
"bevy_dev_tools",
"bevy_diagnostic", "bevy_diagnostic",
"bevy_ecs", "bevy_ecs",
"bevy_feathers",
"bevy_gizmos_render", "bevy_gizmos_render",
"bevy_image", "bevy_image",
"bevy_input", "bevy_input",
@@ -1172,7 +1082,6 @@ dependencies = [
"bevy_log", "bevy_log",
"bevy_math", "bevy_math",
"bevy_mesh", "bevy_mesh",
"bevy_pbr",
"bevy_platform", "bevy_platform",
"bevy_ptr", "bevy_ptr",
"bevy_reflect", "bevy_reflect",
@@ -1192,27 +1101,6 @@ dependencies = [
"bevy_winit", "bevy_winit",
] ]
[[package]]
name = "bevy_light"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_ecs",
"bevy_image",
"bevy_math",
"bevy_mesh",
"bevy_platform",
"bevy_reflect",
"bevy_transform",
"bevy_utils",
"tracing",
]
[[package]] [[package]]
name = "bevy_log" name = "bevy_log"
version = "0.18.1" version = "0.18.1"
@@ -1250,7 +1138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84" checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
dependencies = [ dependencies = [
"approx", "approx",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"bevy_reflect", "bevy_reflect",
"derive_more", "derive_more",
"glam 0.30.10", "glam 0.30.10",
@@ -1273,9 +1161,7 @@ dependencies = [
"bevy_asset", "bevy_asset",
"bevy_derive", "bevy_derive",
"bevy_ecs", "bevy_ecs",
"bevy_image",
"bevy_math", "bevy_math",
"bevy_mikktspace",
"bevy_platform", "bevy_platform",
"bevy_reflect", "bevy_reflect",
"bevy_transform", "bevy_transform",
@@ -1288,71 +1174,6 @@ dependencies = [
"wgpu-types", "wgpu-types",
] ]
[[package]]
name = "bevy_mikktspace"
version = "0.17.0-dev"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59"
[[package]]
name = "bevy_pbr"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_core_pipeline",
"bevy_derive",
"bevy_diagnostic",
"bevy_ecs",
"bevy_image",
"bevy_light",
"bevy_log",
"bevy_math",
"bevy_mesh",
"bevy_platform",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_transform",
"bevy_utils",
"bitflags 2.11.1",
"bytemuck",
"derive_more",
"fixedbitset",
"nonmax",
"offset-allocator",
"smallvec",
"static_assertions",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "bevy_picking"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10"
dependencies = [
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_derive",
"bevy_ecs",
"bevy_input",
"bevy_math",
"bevy_platform",
"bevy_reflect",
"bevy_time",
"bevy_transform",
"bevy_window",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "bevy_platform" name = "bevy_platform"
version = "0.18.1" version = "0.18.1"
@@ -1679,7 +1500,6 @@ dependencies = [
"bevy_input", "bevy_input",
"bevy_input_focus", "bevy_input_focus",
"bevy_math", "bevy_math",
"bevy_picking",
"bevy_platform", "bevy_platform",
"bevy_reflect", "bevy_reflect",
"bevy_sprite", "bevy_sprite",
@@ -1692,7 +1512,6 @@ dependencies = [
"taffy", "taffy",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]
@@ -1726,26 +1545,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "bevy_ui_widgets"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712"
dependencies = [
"accesskit",
"bevy_a11y",
"bevy_app",
"bevy_camera",
"bevy_ecs",
"bevy_input",
"bevy_input_focus",
"bevy_log",
"bevy_math",
"bevy_picking",
"bevy_reflect",
"bevy_ui",
]
[[package]] [[package]]
name = "bevy_utils" name = "bevy_utils"
version = "0.18.1" version = "0.18.1"
@@ -1873,7 +1672,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [ dependencies = [
"bytemuck",
"serde_core", "serde_core",
] ]
@@ -1905,7 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"cc", "cc",
"cfg-if", "cfg-if",
"constant_time_eq", "constant_time_eq",
@@ -2081,17 +1879,6 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "card_game"
version = "0.4.1"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "983728ead19f51d96931725706e62293bd133ac3d836097dd7d745e929f7811b"
dependencies = [
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
"serde",
"serde_derive",
]
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.1.2" version = "0.1.2"
@@ -2152,17 +1939,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -3681,17 +3457,6 @@ dependencies = [
"weezl", "weezl",
] ]
[[package]]
name = "gl_generator"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
dependencies = [
"khronos_api",
"log",
"xml-rs",
]
[[package]] [[package]]
name = "glam" name = "glam"
version = "0.30.10" version = "0.30.10"
@@ -3720,27 +3485,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "glow"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
dependencies = [
"js-sys",
"slotmap",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
dependencies = [
"gl_generator",
]
[[package]] [[package]]
name = "governor" name = "governor"
version = "0.10.4" version = "0.10.4"
@@ -4307,7 +4051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [ dependencies = [
"byteorder-lite", "byteorder-lite",
"quick-error 2.0.1", "quick-error",
] ]
[[package]] [[package]]
@@ -4565,23 +4309,6 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "khronos-egl"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
"libloading",
"pkg-config",
]
[[package]]
name = "khronos_api"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]] [[package]]
name = "kira" name = "kira"
version = "0.12.0" version = "0.12.0"
@@ -4599,25 +4326,13 @@ dependencies = [
"triple_buffer", "triple_buffer",
] ]
[[package]]
name = "klondike"
version = "0.4.0"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "d5c82b0c3abd7da07b4a1c4221a809e6e2ffd475ae0e67180fbfef35a9cfe769"
dependencies = [
"card_game",
"rand 0.10.1",
"serde",
"serde_derive",
]
[[package]] [[package]]
name = "kurbo" name = "kurbo"
version = "0.13.0" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
dependencies = [ dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"euclid", "euclid",
"smallvec", "smallvec",
] ]
@@ -5025,7 +4740,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
dependencies = [ dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"bit-set", "bit-set",
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
@@ -6063,25 +5778,6 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags 2.11.1",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]] [[package]]
name = "prost" name = "prost"
version = "0.14.3" version = "0.14.3"
@@ -6126,12 +5822,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "2.0.1" version = "2.0.1"
@@ -6257,16 +5947,6 @@ dependencies = [
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.3.1" version = "0.3.1"
@@ -6305,12 +5985,6 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]] [[package]]
name = "rand_distr" name = "rand_distr"
version = "0.5.1" version = "0.5.1"
@@ -6330,15 +6004,6 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "range-alloc" name = "range-alloc"
version = "0.1.5" version = "0.1.5"
@@ -6828,18 +6493,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]] [[package]]
name = "rustybuzz" name = "rustybuzz"
version = "0.20.1" version = "0.20.1"
@@ -7327,9 +6980,7 @@ dependencies = [
name = "solitaire_core" name = "solitaire_core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"card_game", "rand 0.9.4",
"klondike",
"proptest",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -7340,13 +6991,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"card_game", "bevy",
"chrono", "chrono",
"dirs", "dirs",
"jni 0.21.1", "jni 0.21.1",
"jsonwebtoken", "jsonwebtoken",
"keyring-core", "keyring-core",
"klondike",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -7440,18 +7090,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "solitaire_web"
version = "0.1.0"
dependencies = [
"bevy",
"console_error_panic_hook",
"getrandom 0.3.4",
"solitaire_data",
"solitaire_engine",
"wasm-bindgen",
]
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.8"
@@ -7864,7 +7502,7 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [ dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"bitflags 1.3.2", "bitflags 1.3.2",
"bytemuck", "bytemuck",
"lazy_static", "lazy_static",
@@ -7963,7 +7601,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
dependencies = [ dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"grid", "grid",
"serde", "serde",
"slotmap", "slotmap",
@@ -8232,7 +7870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
"log", "log",
@@ -8246,7 +7884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
"log", "log",
@@ -8895,12 +8533,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]] [[package]]
name = "uncased" name = "uncased"
version = "0.9.10" version = "0.9.10"
@@ -9107,15 +8739,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -9421,13 +9044,12 @@ version = "27.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
dependencies = [ dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"document-features", "document-features",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"js-sys",
"log", "log",
"naga", "naga",
"portable-atomic", "portable-atomic",
@@ -9435,8 +9057,6 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
"smallvec", "smallvec",
"static_assertions", "static_assertions",
"wasm-bindgen",
"web-sys",
"wgpu-core", "wgpu-core",
"wgpu-hal", "wgpu-hal",
"wgpu-types", "wgpu-types",
@@ -9448,7 +9068,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
dependencies = [ dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"bit-set", "bit-set",
"bit-vec", "bit-vec",
"bitflags 2.11.1", "bitflags 2.11.1",
@@ -9468,7 +9088,6 @@ dependencies = [
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"wgpu-core-deps-apple", "wgpu-core-deps-apple",
"wgpu-core-deps-wasm",
"wgpu-core-deps-windows-linux-android", "wgpu-core-deps-windows-linux-android",
"wgpu-hal", "wgpu-hal",
"wgpu-types", "wgpu-types",
@@ -9483,15 +9102,6 @@ dependencies = [
"wgpu-hal", "wgpu-hal",
] ]
[[package]]
name = "wgpu-core-deps-wasm"
version = "27.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7"
dependencies = [
"wgpu-hal",
]
[[package]] [[package]]
name = "wgpu-core-deps-windows-linux-android" name = "wgpu-core-deps-windows-linux-android"
version = "27.0.0" version = "27.0.0"
@@ -9508,7 +9118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec",
"ash", "ash",
"bit-set", "bit-set",
"bitflags 2.11.1", "bitflags 2.11.1",
@@ -9517,20 +9127,15 @@ dependencies = [
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"core-graphics-types 0.2.0", "core-graphics-types 0.2.0",
"glow",
"glutin_wgl_sys",
"gpu-alloc", "gpu-alloc",
"gpu-allocator", "gpu-allocator",
"gpu-descriptor", "gpu-descriptor",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"js-sys",
"khronos-egl",
"libc", "libc",
"libloading", "libloading",
"log", "log",
"metal", "metal",
"naga", "naga",
"ndk-sys",
"objc", "objc",
"once_cell", "once_cell",
"ordered-float", "ordered-float",
@@ -9543,8 +9148,6 @@ dependencies = [
"renderdoc-sys", "renderdoc-sys",
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
"wgpu-types", "wgpu-types",
"windows 0.58.0", "windows 0.58.0",
"windows-core 0.58.0", "windows-core 0.58.0",
@@ -10427,12 +10030,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]] [[package]]
name = "xmlwriter" name = "xmlwriter"
version = "0.1.0" version = "0.1.0"
+1 -4
View File
@@ -8,7 +8,6 @@ members = [
"solitaire_app", "solitaire_app",
"solitaire_assetgen", "solitaire_assetgen",
"solitaire_wasm", "solitaire_wasm",
"solitaire_web",
] ]
resolver = "2" resolver = "2"
@@ -22,7 +21,7 @@ rust-version = "1.95"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde", "wasmbind"] } chrono = { version = "0.4", features = ["serde"] }
thiserror = "2" thiserror = "2"
rand = "0.9" rand = "0.9"
async-trait = "0.1" async-trait = "0.1"
@@ -38,8 +37,6 @@ solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" } solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" } solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" } solitaire_engine = { path = "solitaire_engine" }
klondike = { version = "0.4.0", registry = "Quaternions", features = ["serde"] }
card_game = { version = "0.4.1", registry = "Quaternions", features = ["serde"] }
# Bevy with `default-features = false` to avoid the unused # Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain. # `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
-20
View File
@@ -118,28 +118,8 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
# Lint # Lint
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
# Browser e2e smoke (starts solitaire_server automatically)
cd solitaire_server/e2e
npm ci
npx playwright install chromium
npm test
# Seed-batch cycle regression gate (thresholded)
npm run review:cycles:regression
# Loop-aware candidate benchmark (writes test-results/cycle-candidate.json)
npm run review:cycles:candidate
``` ```
For layered engine-vs-UI automation design (Rust unit tests, wasm debug-API
integration tests, and Playwright UI validation), see
[docs/testing-architecture.md](docs/testing-architecture.md).
For Quaternions (`klondike` / `card_game`) dependency upgrades, use
[`scripts/update_quaternions_deps.sh`](scripts/update_quaternions_deps.sh) and
the runbook in [docs/card-game-integration.md](docs/card-game-integration.md).
## Credits ## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
+27 -59
View File
@@ -1,38 +1,16 @@
# Ferrous Solitaire — Session Handoff # Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-09 — AVD Android launch smoke passed; physical-device gate remains. **Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
--- ---
## Current state ## Current state
- **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes. - **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
- **Latest tag:** `v0.39.0` - **Latest tag:** `v0.35.1`
- **Working tree:** clean. Local `scripts/` helpers are excluded through `.git/info/exclude` and intentionally not committed. - **Working tree:** clean
- **Latest verification in this follow-up:** `cargo test -p solitaire_core`; `cargo test -p solitaire_data matomo_client`; `cargo test -p solitaire_engine analytics_plugin`; `cargo test -p solitaire_engine settings_plugin`; `cargo test -p solitaire_engine card_plugin`; `cargo apk build -p solitaire_app --target x86_64-linux-android --lib`; AVD `Pixel_7` install/launch/input smoke. - **Build:** `cargo clippy --workspace -- -D warnings` clean
- **Full previous gate:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up. - **Tests:** 1277 passing / 0 failing across the workspace
---
## What shipped since v0.39.0
- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`.
- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up:
- `5e87358` integrates upstream deps cleanly.
- `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types.
- `d864d98` routes klondike/card imports through `solitaire_core`.
- `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs.
- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed.
- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`.
- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels.
- Native analytics and Android physical-device validation now have runbooks in
`docs/analytics-validation.md` and `docs/ANDROID.md`.
---
## Historical notes before v0.39.0
See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work.
--- ---
@@ -103,27 +81,32 @@ Three bugs fixed:
## Open punch list ## Open punch list
### 1. Android APK launch verification (Option A) ### 1. CHANGELOG documentation debt
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
are missing. Low priority (git log is authoritative) but worth closing before the
next release.
### 2. Android APK launch verification (Option A)
Physical device test: install the latest APK on a real Android device (not AVD), Physical device test: install the latest APK on a real Android device (not AVD),
and run the checklist in `docs/ANDROID.md`. This has never been gated in CI. confirm:
AVD `adb shell input tap` doesn't deliver real touch events, so physical-device - App launches without crash
smoke testing is the only gate. - Safe area insets arrive and shift HUD correctly after ~3 frames
- All modal Done buttons are above the gesture bar
- Drag-and-drop works on all pile types
- Leaderboard panel opens and the "Public name" label updates correctly after
using "Set Name"
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed touch events, so physical-device smoke testing is the only gate.
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR.
### 2. Matomo analytics live validation ### 3. Matomo analytics wiring
`Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine `Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live engine code consumes them — the analytics toggle in Settings is a no-op. If
validation against the deployed Matomo instance. Use analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
`docs/analytics-validation.md` for the native validation checklist and the and wired to `GameStateResource` events.
current web/WASM decision notes.
--- ---
@@ -145,18 +128,3 @@ current web/WASM decision notes.
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so - **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared `ButtonInput::just_pressed` state persists across frames unless explicitly cleared
with `input.release(key); input.clear()` between updates. with `input.release(key); input.clear()` between updates.
- **`/play` debug bridge design:** `play.html` runs two independent WASM instances in
`Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__`
(logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge
operates its own `SolitaireGame` — moves applied through the bridge do NOT affect
the Bevy visual game. This is intentional for automation/invariant checking.
- **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)`
is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit
on HiDPI displays, causing an immediate wgpu panic on the first resize event.
- **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js`
target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play`
using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js`
supports both via `--route play-classic|play`.
+7 -48
View File
@@ -1,21 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Rebuild WASM artifacts and install them into solitaire_server/web/pkg/. # Rebuild the solitaire_wasm crate and install the output into
# # solitaire_server/web/pkg/ so the server can serve the replay viewer.
# Two artifacts are produced:
# solitaire_wasm.* — thin replay-viewer + interactive JS API (wasm-pack)
# canvas.* — full Bevy WASM app for play.html (cargo + wasm-bindgen)
# #
# Prerequisites: # Prerequisites:
# cargo install wasm-pack wasm-bindgen-cli # cargo install wasm-pack
# rustup target add wasm32-unknown-unknown # rustup target add wasm32-unknown-unknown
# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm
# #
# Run from the repo root: # Run from the repo root:
# ./build_wasm.sh # ./build_wasm.sh
# #
# The generated pkg/ files are committed to git so self-hosters who don't # The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
# touch the WASM crates can skip this step. Regenerate after any change to # committed to git so self-hosters who don't touch the WASM crate can
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/. # skip this step. Regenerate after any change to solitaire_wasm/ or
# solitaire_core/.
set -euo pipefail set -euo pipefail
@@ -39,43 +36,5 @@ wasm-pack build \
# Remove them — we manage the output directory ourselves. # Remove them — we manage the output directory ourselves.
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore" rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
# ---------------------------------------------------------------------------
# Bevy WASM app (solitaire_web → canvas.js + canvas_bg.wasm)
# ---------------------------------------------------------------------------
if ! command -v wasm-bindgen &> /dev/null; then
echo "error: wasm-bindgen not found." >&2
echo " Install with: cargo install wasm-bindgen-cli" >&2
echo " The CLI version must match the wasm-bindgen crate dep." >&2
exit 1
fi
echo "Building solitaire_web (Bevy WASM app)..."
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
echo "Running wasm-bindgen for solitaire_web..."
wasm-bindgen \
--out-dir "$OUT_DIR" \
--out-name canvas \
--target web \
--no-typescript \
"$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm"
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
# wasm-opt passes are skipped silently when the tool is not installed.
if command -v wasm-opt &> /dev/null; then
echo "Running wasm-opt on canvas_bg.wasm..."
# Use -O2 (not -Oz): Bevy's render pipeline uses deep call stacks and
# complex memory patterns that wasm-opt -Oz can miscompile, resulting
# in a grey screen on first load. -O2 is speed-optimised and avoids
# the size-focused transforms that trigger the regression.
wasm-opt -O2 \
-o "$OUT_DIR/canvas_bg.wasm" \
"$OUT_DIR/canvas_bg.wasm"
else
echo "note: wasm-opt not found; skipping size optimisation."
echo " Install with: cargo install wasm-opt (or via binaryen)"
fi
echo "Done. Output:" echo "Done. Output:"
ls -lh "$OUT_DIR" ls -lh "$OUT_DIR"
+20 -50
View File
@@ -2,13 +2,13 @@
This doc captures the toolchain install + build invocation for the This doc captures the toolchain install + build invocation for the
Android target. Steps are runnable on a fresh Debian 13 (trixie) box; Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
later sections document physical-device validation, supported platform later sections document what's known to compile, what's stubbed, and
surfaces, and remaining Android follow-ups. the next milestones.
> **Status (2026-06-09):** Android build plumbing, app-directory storage, > **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
> JNI keystore wiring, and safe-area layout fixes have landed. The remaining > debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
> release gate is a physical-device smoke test; AVD tap injection does not > NOT yet been verified to launch on a device or emulator — that's
> exercise the real touch path reliably enough for launch verification. > the next milestone.
--- ---
@@ -163,8 +163,8 @@ accepted workaround.
Physical device: Physical device:
```bash ```bash
adb devices # confirm connection adb devices # confirm connection
adb install -r target/debug/apk/ferrous-solitaire.apk adb install target/debug/apk/ferrous-solitaire.apk
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic" adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
``` ```
@@ -185,65 +185,35 @@ AVD.
--- ---
## 4. Physical-device smoke test ## 4. What's wired vs. what's stubbed
Run this on a real phone, preferably a modern 64-bit ARM device with gesture The first build pass (commit `fb8b2ac`) gates four desktop-only
navigation enabled. crates / call sites so the workspace cross-compiles. Each gate is
documented at its call site.
Build and install:
```bash
cargo apk build -p solitaire_app --target aarch64-linux-android --lib
adb install -r target/debug/apk/ferrous-solitaire.apk
adb logcat -c
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic|WindowInsets"
```
Pass criteria:
- App launches without panic or ANR.
- Safe-area insets arrive after the first few frames and shift HUD/modal
content away from the status and gesture bars.
- Every modal's Done button remains above the gesture bar:
Settings, Help, Pause, Win Summary, and Leaderboard-related dialogs.
- Drag-and-drop works on tableau, waste, foundation, and stock/recycle paths.
- Tap-to-select and one-tap modes both respond correctly on card stacks.
- Leaderboard panel opens, "Set Name" saves, and the "Public name" label updates
while the panel remains open.
- Rotate the device once, then repeat one modal and one drag operation.
- Close and relaunch the app; settings/progress still load.
Record the device model, Android version, APK commit, and pass/fail notes in the
release notes or session handoff. If a failure occurs, keep the filtered logcat
and note the exact screen/control path that reproduced it.
---
## 5. Platform support matrix
Desktop-only crates and call sites are gated so the workspace cross-compiles.
Each gate is documented at its call site.
| Surface | Desktop | Android | | Surface | Desktop | Android |
|---------|---------|---------| |---------|---------|---------|
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) | | Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline | | Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Android Keystore via JNI | | OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
| Data directory | Platform data dir | Android app files dir |
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity | | App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
Remaining Android follow-ups: What's NOT yet ported / not yet measured:
- `dirs::data_dir()` returns `None` on Android. Callers in
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
`achievements.rs`, `settings.rs` all need an Android-aware
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
- Touch UX pass — hit-target sizes, modal scaling on small screens, - Touch UX pass — hit-target sizes, modal scaling on small screens,
app lifecycle (suspend / resume), font scaling. app lifecycle (suspend / resume), font scaling.
- Android Keystore via JNI for `auth_tokens`.
- JNI ClipboardManager for share links. - JNI ClipboardManager for share links.
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced - Google Play Games sign-in (the `solitaire_gpgs` crate referenced
in older docs doesn't yet exist). in older docs doesn't yet exist).
--- ---
## 6. Iteration loop ## 5. Iteration loop
```bash ```bash
# Edit code… # Edit code…
-67
View File
@@ -1,67 +0,0 @@
# Analytics Validation Runbook
Ferrous Solitaire currently has two analytics paths:
- Native desktop/Android gameplay events use `solitaire_engine::AnalyticsPlugin`
and `solitaire_data::MatomoClient`.
- Hosted web pages include Matomo page-view snippets in
`solitaire_server/web/*.html`.
The Bevy `/play` WASM canvas does not emit the native gameplay events because
`AnalyticsPlugin` is intentionally gated out on `wasm32`; it depends on the
native Tokio/reqwest stack.
## Native Matomo Validation
Use this when a deployed Matomo instance and a native build are available.
1. Configure `settings.json` with a Matomo URL and site ID:
```json
{
"analytics_enabled": true,
"matomo_url": "https://analytics.example.com",
"matomo_site_id": 1
}
```
2. Launch the native app and open Settings.
3. Confirm the Privacy section appears and "Share usage data" is `ON`.
4. Start a new confirmed game.
5. Win or forfeit the game.
6. Unlock an achievement if practical, or use an existing achievement path that
is easy to trigger in a test profile.
7. Wait at least 60 seconds, or close after the win/forfeit path has fired its
immediate flush.
8. In Matomo, confirm the following custom events arrived:
| Category | Action | Name |
| --- | --- | --- |
| `Game` | `Start` | `classic`, `zen`, `challenge`, `time_attack`, or `difficulty` |
| `Game` | `Won` | empty |
| `Game` | `Forfeit` | empty |
| `Achievement` | `Unlocked` | achievement id |
## Web/WASM Decision
Keep the current split unless the project explicitly needs in-canvas gameplay
events for `/play`.
Current behavior:
- `/`, `/play-classic`, `/account`, `/leaderboard`, and `/replays` emit Matomo
page views through the hosted HTML snippets.
- `/play` hosts the Bevy canvas but does not emit gameplay events from the
engine.
- The browser Content-Security-Policy already allows the deployed Matomo host
for scripts, images, and connections.
If gameplay events are needed on `/play`, add a small `wasm32`-only analytics
bridge instead of trying to compile the native plugin:
- keep the same event contract as native (`Game / Start`, `Game / Won`,
`Game / Forfeit`, `Achievement / Unlocked`);
- read `Settings::analytics_enabled`, `matomo_url`, and `matomo_site_id`;
- send through browser APIs or the existing `_paq` queue;
- keep the Settings opt-in behavior identical to native;
- add Playwright coverage that stubs Matomo and verifies emitted payloads.
+19 -63
View File
@@ -2,10 +2,7 @@
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires. **Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
**Approach:** Integration is complete. Upstream `card_game` / `klondike` now owns **Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
authoritative Klondike rules, session history, undo snapshots, and solving.
Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes,
and typed UI errors in `solitaire_core`.
--- ---
@@ -45,12 +42,10 @@ and typed UI errors in `solitaire_core`.
--- ---
## What Ferrous Solitaire's `solitaire_core` Still Owns ## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
### 1. Scoring — remaining adapter responsibilities ### 1. Scoring — remaining adapter responsibilities
Ferrous uses **Windows XP Standard** scoring. The upstream library handles the Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
per-move counters and configurable deltas; Ferrous adds the product-specific
parts in `GameState` / `KlondikeAdapter`.
| Event | Delta | Handled by | | Event | Delta | Handled by |
|---|---|---| |---|---|---|
@@ -66,13 +61,11 @@ parts in `GameState` / `KlondikeAdapter`.
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html> Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty. **Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The 15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance. **Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
**In our wrapper:** `KlondikeAdapter::config_for` configures the upstream rules **In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor,
time bonus, game-mode suppression, and undo score restoration.
### 2. Game Modes ### 2. Game Modes
Ferrous has three modes that alter scoring and undo behaviour: Ferrous has three modes that alter scoring and undo behaviour:
@@ -85,9 +78,7 @@ Ferrous has three modes that alter scoring and undo behaviour:
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic. Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
**In our wrapper:** `GameMode` lives on `solitaire_core::GameState`; undo and **In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
scoring behavior are applied before/after delegating legal moves to the upstream
session.
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)* ### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants: `card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
@@ -96,33 +87,23 @@ session.
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each). `Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
The old local DFS has been replaced. `solitaire_core::solver` is now a small Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
adapter around `Session::solve()` that preserves the engine-facing
`SolverResult`, `SolverConfig`, and first-move payload contract.
**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested **In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget
errors → Inconclusive.
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)* ### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions. `MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
**Default behaviour:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire **also defaults to Allowed** (`take_from_foundation: true` in `GameState`, `Settings`). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (`take_from_foundation = false`), which maps to `Disallowed`. **Important:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in. Our adapter explicitly sets `Disallowed` in the default `KlondikeConfig` and switches to `Allowed` only when the user toggles the house-rule option.
**In our wrapper:** `KlondikeAdapter::config_for(draw_mode, take_from_foundation)` constructs `KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }`. No custom intercept needed — `klondike` enforces the rule automatically. **In our wrapper:** Construct `KlondikeConfig { move_from_foundation: MoveFromFoundationConfig::Disallowed, .. }` by default; mirror the user's settings toggle to `Allowed`. No custom intercept needed — `klondike` enforces the rule automatically.
### 5. JSON Serialisation / Persistence ### 5. JSON Serialisation / Persistence
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
**Upstream serde status (rev 99b49e62):** At this revision, `klondike` and `card_game` both enable a `serde` feature. All nine instruction/pile types (`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`, `TableauStack`, `Foundation`, `Tableau`, `SkipCards`) derive `serde::Serialize` + `serde::Deserialize` under that feature. The workspace `Cargo.toml` enables `features = ["serde"]`. **Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
**Schema v4 (current):** `saved_moves` serialises as `Vec<KlondikeInstruction>` using upstream named-variant serde. Example: `{"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}`. **In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
**Schema v3 (legacy, auto-migrated):** `saved_moves` used local `SavedInstruction` mirror types with u8 indices. Example: `{"DstFoundation": {"src": "Stock", "foundation": 0}}`. On load, an `AnyInstruction` untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The `SavedInstruction` bridge types are retained in `solitaire_core::klondike_adapter` for this migration path and for backward-compatible `solitaire_data::ReplayMove` / WASM replay formats.
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
**In our wrapper:** `GameState::Serialize` emits schema v4 (upstream instruction types). `GameState::Deserialize` accepts v3 (auto-migrates) and v4 (direct). Schema version field lives on our wrapper.
### 6. Typed Move Errors ### 6. Typed Move Errors
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.): `solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
@@ -148,9 +129,7 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
### 8. Undo Stack Approach *(resolved — not an issue)* ### 8. Undo Stack Approach *(resolved — not an issue)*
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`. `card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
**Resolution:** `GameState` uses `Session`'s built-in snapshot history. Ferrous **Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
keeps parallel score/recycle metadata so undo can restore product-specific score
state that upstream snapshots do not own.
--- ---
@@ -159,35 +138,12 @@ state that upstream snapshots do not own.
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged. Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
1.**Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers. 1.**Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
2. **Map pile types** — project `klondike`'s stock face-up half as the engine's waste pile and expose renderer-facing pile snapshots. 2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
3.**Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (gap 4, upstream). 3.**Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
4.**Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1). 4.**Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
5.**Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2). 5.**Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
6. **Replace solver** — call `session.solve()` with budgets from `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream). 6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
7. **Implement `serde`**serialise schema v4 with upstream `KlondikeInstruction`; auto-migrate schema v3 via `SavedInstruction` compatibility types. 7. **Implement `serde`**define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
---
## Quaternions Upgrade Runbook
Use this sequence whenever upgrading `klondike` / `card_game` from the
Quaternions registry:
1. Review upstream changes/releases:
- <https://git.aleshym.co/Quaternions/card_game>
- <https://git.aleshym.co/Quaternions/klondike>
2. Run:
```bash
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
```
3. If the script passes, inspect the resulting `Cargo.lock` diff and land the
upgrade with the normal PR flow.
The script enforces:
- lockfile update to requested versions
- `cargo test --workspace`
- `cargo clippy --workspace -- -D warnings`
- deterministic replay/debug-API smoke tests in `solitaire_wasm`
--- ---
@@ -207,5 +163,5 @@ The script enforces:
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10) - Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
- Upstream solver PR: #14 - Upstream solver PR: #14
- `solitaire_core` source: `solitaire_core/src/` - `solitaire_core` source: `solitaire_core/src/`
- Scoring implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs` - Scoring spec: `solitaire_core/src/scoring.rs`
- Architecture overview: `ARCHITECTURE.md` - Architecture overview: `ARCHITECTURE.md`
-408
View File
@@ -1,408 +0,0 @@
# In-Place card_game / klondike Rewrite Plan
**Date:** 2026-06-08
**Upstream rev:** `99b49e62`
**Status:** All phases complete (03). recycle_count drift and score compound error on undo fixed in `56e3b62`.
---
## 1. What Is Already Integrated
The integration is substantially complete. `solitaire_core` already delegates all
authoritative Klondike logic to the upstream crates.
| Area | Status | Location |
|---|---|---|
| `Session<Klondike>` ownership | ✅ complete | `GameState.session` |
| `draw()``session.process_instruction(RotateStock)` | ✅ complete | `game_state.rs` |
| `move_cards()``session.process_instruction(KlondikeInstruction)` | ✅ complete | `game_state.rs` |
| `undo()``session.undo()` | ✅ complete | `game_state.rs` |
| `possible_instructions()``session.state().state().get_sorted_moves()` | ✅ complete | `game_state.rs` |
| `can_move_cards()``session.state().state().is_instruction_valid()` | ✅ complete | `game_state.rs` |
| `solver.rs``session.solve()` | ✅ complete | `solver.rs` |
| `Suit`, `Rank` → re-export from `card_game` | ✅ complete | `card.rs` |
| `Foundation`, `Klondike`, `KlondikePile`, `Session`, `Tableau``solitaire_core::lib` | ✅ complete | `lib.rs` |
| Move legality enforcement | ✅ upstream (`is_instruction_valid`) | `klondike/src/lib.rs` |
| Foundation placement rules (Ace start, suit match) | ✅ upstream | `klondike/src/lib.rs` |
| Tableau placement rules (alternating colour, King on empty) | ✅ upstream | `klondike/src/lib.rs` |
| Multi-card stack moves via `SkipCards` | ✅ upstream | `klondike/src/lib.rs` |
| Session history / snapshot undo | ✅ upstream | `card_game/src/lib.rs` |
| DFS solver with budget limits | ✅ upstream | `card_game/src/lib.rs` |
| Instruction history → `SavedInstruction` serde mirrors | ✅ in adapter | `klondike_adapter.rs` |
| Schema v3 save/load (instruction replay) | ✅ complete | `game_state.rs`, `storage.rs` |
| `take_from_foundation` house rule → `MoveFromFoundationConfig` | ✅ complete | `klondike_adapter.rs` |
---
## 2. Duplicated / Replaceable Logic
These are local implementations that either replicate upstream or could be removed.
### 2a. `SavedInstruction` mirror types (~300 lines, `klondike_adapter.rs`)
**What:** A full hand-written serde mirror for every upstream klondike instruction type
(`SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
`SavedSkipCards`, `InvalidSavedInstruction`) plus ~20 `From`/`TryFrom` conversion impls.
**Why written:** At the time, upstream klondike had no serde feature.
**Current upstream status:** At rev `99b49e62`, the `serde` feature is present and active.
`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`,
`TableauStack`, `Tableau`, `Foundation`, `SkipCards` all derive
`#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]`.
**Blocker — JSON format incompatibility:**
| Field | Local `SavedInstruction` JSON | Upstream `KlondikeInstruction` JSON |
|---|---|---|
| Tableau index | `{ "Tableau": 0 }` (u8) | `{ "Tableau": "Tableau1" }` (named) |
| Foundation slot | `{ "Foundation": 0 }` (u8) | `{ "Foundation": "Foundation1" }` (named) |
| Skip count | `{ "skip_cards": 0 }` (u8) | `{ "skip_cards": "Skip0" }` (named) |
Switching to direct upstream serde **changes the `saved_moves` JSON shape** stored in
`game_state.json`. Any existing v3 save file would fail to deserialize after the switch.
This requires either:
- A schema bump to v4 **with a migration** (deserialize v3 manually then re-save as v4), or
- A schema bump to v4 **with graceful fallback** (v3 files rejected → fresh game).
**Recommendation:** Schema v4 with graceful fallback (v3 saves start fresh). Migration
is feasible but adds ~100 lines of throwaway code; the in-progress game loss is modest
since schema v3 was never shipped to users (it landed in the current dev branch, not a
release).
### 2b. `GameState::check_win()` (~15 lines)
**What:** Iterates all four foundation slots checking 13-card A→K sequences.
**Upstream equivalent:** `session.state().state().is_win()` on `Klondike`.
**Status:** Local check is correct but redundant. Trivially replaceable with no format change.
**Risk:** None — only affects `is_won` flag update path.
### 2c. `GameState::check_auto_complete()` (~15 lines)
**What:** Checks stock empty, waste empty, all tableau cards face-up.
**Upstream equivalent:** `session.state().state().is_win_trivial()` on `Klondike`.
**Semantic difference:** Upstream `is_win_trivial` checks `stock.is_empty()` (both faces)
and all `tableau.face_down().is_empty()`. Ferrous additionally checks `waste.is_empty()`.
These are logically equivalent for a valid game state (waste = stock face-up half).
**Risk:** Low — validated by existing auto-complete engine tests.
### 2c. `recycle_count` drift on undo (existing bug, not new)
**What:** `GameState.recycle_count` is incremented in `draw()` when stock is empty.
`undo()` does not decrement it. After undoing a recycle, `recycle_count` is stale and
may cause incorrect future penalty application.
**Upstream:** `KlondikeStats.recycle_count()` has the same problem — it is cumulative
and not restored on undo (stats are not part of the session snapshot, only game state is).
**Fix approach:** After each undo, recompute `recycle_count` by scanning
`session.history()` for `RotateStock` instructions that caused recycling.
**Priority:** Medium — affects scoring correctness in rare paths. File as a separate bug.
---
## 3. What Must Remain Ferrous-Specific
These responsibilities are product-layer, not Klondike-rules-layer, and must stay in `solitaire_core`.
| Responsibility | Why upstream cannot own it |
|---|---|
| WXP recycle penalties (free allowance + -100/-20) | `ScoringConfig::recycle` is a flat delta; no free-allowance concept exists upstream |
| Score floor (`score.max(0)`) | Not modelled upstream |
| Time bonus (`700_000 / elapsed_seconds`) | Not modelled upstream |
| `DrawMode` / `GameMode` enums | Product concept; not in upstream |
| Challenge mode undo block | Product rule |
| Zen mode scoring suppression | Product rule |
| `MoveError` variants for UI feedback | Upstream returns `bool`; Ferrous needs typed errors |
| `card::Card` projection (adds `id`, `face_up`) | Renderer requires stable `id` and face orientation |
| `Pile` DTO for engine sync | Renderer-facing snapshot type |
| `stock_cards()` / `waste_cards()` distinction | Engine models waste as a separate pile; upstream uses stock face-up half |
| `recycle_count` tracking | Needed for free-allowance penalty calculation |
| Persistence format + schema versioning | Product concern |
| `SavedInstruction` (currently) or upstream serde (after migration) | Either way, Ferrous owns the save contract |
---
## 4. Key Audit Findings
### Finding 1 — Upstream serde claim in docs is stale
`docs/card-game-integration.md` (last section "JSON Serialisation") states:
> Current verification (2026-06-01): klondike v0.3.0 and card_game v0.4.0 crate manifests
> expose no serde dependency/feature.
**This is wrong at rev 99b49e62.** The `serde` feature is present and active. All nine
instruction/pile types have `#[cfg_attr(feature = "serde", derive(...))]`. The doc must
be updated.
### Finding 2 — `take_from_foundation` default: docs vs code
`docs/card-game-integration.md` says:
> Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the
> default, with the house rule as an opt-in.
**The code and settings say the opposite:** `Settings::take_from_foundation` defaults to
`true` (Allowed); `GameState.take_from_foundation` also initializes to `true`. Multiple
tests assert this is the intended behavior. The upstream default is also `Allowed`.
**Resolution:** The docs are wrong. Default = Allowed (house rule on by default for
beginner-friendliness) is intentional. Update the docs; do not change the code.
### Finding 3 — `KlondikeStats` cumulative vs session-history-aware counts
`KlondikeStats.moves()` and `KlondikeStats.recycle_count()` accumulate monotonically.
They are NOT restored when `Session::undo()` is called (only `Klondike` game state is
restored from the snapshot, not the stats). Ferrous correctly uses
`session.history().len()` for `move_count` (history-aware). But `recycle_count` is
stored separately in `GameState` and also not decremented on undo — making them
equivalent in this one bug.
### Finding 4 — `SkipCards as usize` cast is correct
Upstream `SkipCards` has no explicit discriminants, so `Skip0 = 0 .. Skip12 = 12`.
`skip_cards as usize` in `solver.rs` and `game_state.rs` is correct.
---
## 5. Staged Migration
### Phase 0 — Doc fixes only (no code change)
Files: `docs/card-game-integration.md`
- Correct the serde claim (upstream has serde at rev 99b49e62).
- Correct the `take_from_foundation` default description.
- Update integration status table.
### Phase 1 — Delegate `is_win` / `is_win_trivial` (safe, no format change)
Files: `solitaire_core/src/game_state.rs`
Replace local `check_win()` and `check_auto_complete()` with upstream delegation:
```rust
// before
pub fn check_win(&self) -> bool { ... 40 lines ... }
// after
pub fn check_win(&self) -> bool {
self.session.state().state().is_win()
}
```
```rust
// before
pub fn check_auto_complete(&self) -> bool { ... 15 lines ... }
// after
pub fn check_auto_complete(&self) -> bool {
self.session.state().state().is_win_trivial()
}
```
**Risk:** Very low. Both methods are tested by existing integration tests. The semantic
difference in `check_auto_complete` (upstream vs Ferrous definition) is equivalent for
valid game states.
### Phase 2 — Replace `SavedInstruction` with upstream serde (schema v4)
Files:
- `solitaire_core/src/klondike_adapter.rs` (remove ~300 lines)
- `solitaire_core/src/game_state.rs` (update `Serialize`/`Deserialize` impls)
- `solitaire_core/src/proptest_tests.rs` (remove now-redundant SavedInstruction tests)
- `solitaire_data/src/storage.rs` (add schema v4 rejection test)
- `solitaire_data/src/replay.rs` (no change — uses `SavedKlondikePile` independently)
- `solitaire_wasm/src/lib.rs` (uses `SavedKlondikePileStack` in its own mirror — evaluate)
**Steps:**
1. In `game_state.rs`, change `PersistedGameState.saved_moves` from
`Vec<SavedInstruction>` to `Vec<KlondikeInstruction>` (upstream serde now works).
2. Update `GameState::Serialize` to emit `KlondikeInstruction` directly.
3. Update `GameState::Deserialize` to parse `KlondikeInstruction` directly.
4. Increment `GAME_STATE_SCHEMA_VERSION` to 4.
5. In `GameState::Deserialize`, reject schema != 4 with graceful fallback (already
handled by `load_game_state_from` returning `None` on serde error or wrong version).
6. Delete `SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
`SavedSkipCards`, `InvalidSavedInstruction` from `klondike_adapter.rs`.
7. Delete the 20 `From`/`TryFrom` impls.
8. Remove `SavedInstruction` proptest and boundary tests (no longer needed).
9. Add schema v4 round-trip test and v3 rejection test.
**Note on `solitaire_data::replay.rs`:**
`replay.rs` uses `SavedKlondikePile` independently (for `ReplayMove`). This is a
separate type from the game-state save format and is NOT changed by this phase.
`ReplayMove` has its own schema (`REPLAY_SCHEMA_VERSION`) and can keep using the local
mirror types.
**Note on `solitaire_wasm/src/lib.rs`:**
Uses `SavedKlondikePileStack` in its own `ReplayMove` mirror. Same as above — separate
type, not affected.
### Pre-Phase 3 — Undo Field Audit (completed 2026-06-08)
Full audit of every Ferrous-owned field in `GameState` for undo correctness.
| Field | Correctly updated by `undo()`? | Notes |
|---|---|---|
| `score` | ✅ By design | 15 WXP undo penalty applied; Zen: stays 0 |
| `move_count` | ✅ Correct | Recomputed from `session.history().len()` |
| `is_won` | ✅ Correct | Recomputed; undo blocked on won game |
| `is_auto_completable` | ✅ Correct | Recomputed |
| `undo_count` | ✅ By design | Total undos ever, intentionally non-reversible |
| `elapsed_seconds` | ✅ Intentional | Timer is independent of moves |
| `seed` / `draw_mode` / `mode` / `take_from_foundation` | ✅ Immutable | |
| **`recycle_count`** | ❌ **Bug** | Not decremented — see below |
**`recycle_count` drift bug:**
`draw()` increments `recycle_count` when `stock.face_down().is_empty()` (the rotation
is a recycle, not just a draw). `undo()` calls `session.undo()` which restores the
`Klondike` card state, but does NOT decrement `recycle_count`.
Consequence: if the player recycles, undoes it, then recycles again, `recycle_count`
is `2` instead of `1` — the free-recycle allowance is consumed even though the first
recycle was undone. On Draw-1, the 2nd recycle costs 100; after the undo-and-replay
bug the player pays 100 for what should be their still-free recycle.
**Score compound effect:** When `undo()` is applied to a recycle that incurred a
penalty, the penalty amount (`score_after_recycle - 100`) is already in `self.score`.
`apply_undo_score` then adds `15` on top. The recycle penalty is never reversed.
**Fix approach for Phase 3:**
- After `session.undo()`, recompute `recycle_count` by scanning the new
`session.history()` for `RotateStock` snapshots where
`snapshot.state().state().stock().face_down().is_empty()` (indicating the rotation
was a recycle, not a draw from a populated stock).
- Restore `score` to `snapshot_score` **before** the undone move, then apply only
the 15 undo penalty. This requires reading the score stored in `StateSnapshot`
or keeping a pre-move score stack alongside the session history.
**Simpler alternative:** Store `(score_before, recycle_count_before)` in `GameState`
alongside each `session.process_instruction` call, mirroring the snapshot stack.
Undo pops this alongside the session undo.
### Phase 3 — Fix `recycle_count` drift on undo (optional, post-approval)
Files: `solitaire_core/src/game_state.rs`
After `session.undo()`, recompute `recycle_count` by scanning `session.history()` for
`RotateStock` snapshots where the pre-instruction stock face-down was empty (indicating
a recycle). Also correct the score: restore to the pre-undone-move score and apply only
the 15 undo penalty.
**Tests to add:**
- `recycle_count_decrements_when_recycle_is_undone`
- `score_recycle_penalty_is_reversed_on_undo`
**Risk:** Medium — changes observable scoring behavior. The fix is strictly more
correct, but any golden-file or regression test that recorded the old (buggy) score
after undo-of-recycle will need updating.
---
## 6. Files Likely to Change Per Phase
| Phase | Files |
|---|---|
| Phase 0 | `docs/card-game-integration.md` |
| Phase 1 | `solitaire_core/src/game_state.rs` |
| Phase 2 | `solitaire_core/src/klondike_adapter.rs`, `solitaire_core/src/game_state.rs`, `solitaire_core/src/proptest_tests.rs`, `solitaire_data/src/storage.rs` |
| Phase 3 | `solitaire_core/src/game_state.rs`, new test module |
---
## 7. Risks
### R1 — Save file format break (Phase 2, HIGH)
Users with v3 saves lose their in-progress game. Mitigated by the fact that v3 is
not in any shipped release (dev branch only). Graceful fallback (start fresh) is
acceptable; a migration shim is possible but not required.
### R2 — `solitaire_wasm` / `solitaire_data::replay` breakage (Phase 2, MEDIUM)
`SavedKlondikePile` and `SavedKlondikePileStack` are also used in `replay.rs` and
`wasm/src/lib.rs`. These are separate from the game-state save format and must be
left in place. Plan is to keep them in `klondike_adapter.rs` (or relocate to
`replay.rs`) after the game-state mirror types are deleted.
### R3 — `check_auto_complete` semantic drift (Phase 1, LOW)
Upstream `is_win_trivial` checks `stock.is_empty()` (no cards at all in stock)
whereas Ferrous also checks waste. These are equivalent for a valid game state but
could differ under test-support pile overrides. Existing auto-complete tests will
catch any regression.
### R4 — `SkipCards as usize` cast correctness
Already verified: enums have implicit 0..12 discriminants. No risk.
### R5 — Upstream changes after rev pin
The workspace is pinned to `rev = "99b49e62"`. No upstream drift risk until explicitly
re-pinned.
---
## 8. Test Plan
### Phase 1 tests (all currently pass)
- `game_state::tests::take_from_foundation_allows_legal_return_move`
- `game_state::tests::take_from_foundation_disabled_blocks_return_move_everywhere`
- `proptest_tests::*` (card conservation, deal determinism, undo invariant, legal moves)
### Phase 2 tests to add
- `storage::tests::game_state_v4_mid_game_round_trip` — verify upstream serde round-trip
after migrating to `KlondikeInstruction` directly
- `storage::tests::save_format_v3_is_rejected` — v3 files must return `None`
- Update `game_state::tests::*` — all existing tests must continue to pass
### Phase 2 tests to remove
- `proptest_tests::saved_instruction_round_trip` — no longer needed (no mirror types)
- `proptest_tests::saved_instruction_boundary_tests::*` — no longer needed
### Phase 3 tests to add
- `game_state::tests::recycle_count_decrements_on_undo` — after recycling and undoing,
`recycle_count` must reflect the correct post-undo count
---
## 9. Validation Commands
Run after each phase:
```bash
# Targeted (fast)
cargo test -p solitaire_core
cargo clippy -p solitaire_core -- -D warnings
# Broader
cargo test -p solitaire_wasm
cargo test -p solitaire_data
# Full workspace (run before declaring phase complete)
cargo test --workspace
cargo clippy --workspace -- -D warnings
```
---
## Summary: What Would Be Removed vs Kept
### Removed after all phases complete
| Code | Lines est. | Reason |
|---|---|---|
| `SavedInstruction` + 8 mirror types | ~150 | Upstream serde now available |
| 20 `From`/`TryFrom` impls | ~150 | Upstream serde now available |
| `InvalidSavedInstruction` error type | ~10 | Upstream serde now available |
| `check_win()` local impl | ~20 | Replaced by `is_win()` delegation |
| `check_auto_complete()` local impl | ~15 | Replaced by `is_win_trivial()` delegation |
| `SavedInstruction` proptest + boundary tests | ~60 | Mirror types removed |
**Total: ~400 lines removed from `solitaire_core`**
### Remains Ferrous-specific
- `KlondikeAdapter` scoring helpers (recycle penalties, score floor, time bonus, Zen/mode suppression)
- `DrawMode`, `GameMode`, `DifficultyLevel`
- `MoveError` and all boundary-checking logic
- `card::Card` (id + face_up projection)
- `Pile` DTO
- `stock_cards()` / `waste_cards()` projections
- Persistence format (`GameState` serde, schema version, `PersistedGameState`)
- `solitaire_data::replay` types (`ReplayMove`, `SavedKlondikePile` mirror — unchanged)
- `solitaire_wasm` replay mirror types (unchanged)
-115
View File
@@ -1,115 +0,0 @@
# Testing Architecture — Engine-first Validation
Ferrous Solitaire validation is split into three layers with clear ownership:
1. **Rust unit tests (`solitaire_core`)**
- move generation and legality
- deal generation determinism
- scoring and penalties
- undo semantics
- win detection
2. **Engine integration tests (`solitaire_wasm` debug API)**
- autonomous game execution without UI/pointer simulation
- invariant checks after every move
- deterministic seed replay
- high-volume seeded runs (including long-running soak tests)
3. **Playwright UI tests**
- verify rendering vs engine state
- drag/drop and keyboard UX behavior
- responsive layout behavior
- browser-compatibility checks
## Source of truth
The Rust engine is authoritative. Browser tests must interact with the game via
debug API hooks, not via pixel/OCR solving or hardcoded screen coordinates.
## Debug API surfaces
Two automation surfaces are exposed:
- `solitaire_wasm::SolitaireGame` methods:
- `debug_snapshot()`
- `debug_legal_moves()`
- `debug_move_history()`
- `debug_apply_legal_move(index)`
- `debug_apply_move_json(json)`
- Browser bridge on `game.html`:
- `window.__FERROUS_DEBUG__.snapshot()`
- `window.__FERROUS_DEBUG__.legalMoves()`
- `window.__FERROUS_DEBUG__.moveHistory()`
- `window.__FERROUS_DEBUG__.applyLegalMove(index)`
- `window.__FERROUS_DEBUG__.applyMove(move)`
- `window.__FERROUS_DEBUG__.failureReport()`
- `window.__FERROUS_DEBUG__.runAutoplay(options)`
## Required failure payload
Every automation failure should capture:
- seed
- move history
- current game state
- screenshot
- browser trace
- console logs
`failureReport()` provides the engine-side fields (`seed`, `moveHistory`,
`currentState`) so UI harnesses only need to attach browser artifacts.
## Execution guidance
- Fast verification:
- `cargo test -p solitaire_core -p solitaire_wasm`
- Full verification:
- `cargo test --workspace`
- `cargo clippy --workspace -- -D warnings`
- Long unattended soak:
- `cargo test -p solitaire_wasm debug_api_autonomous_thousands_seed_soak -- --ignored`
### Browser e2e harness
The Playwright suite lives under `solitaire_server/e2e/` and boots
`solitaire_server` via Playwright `webServer` config.
- Install + run:
- `cd solitaire_server/e2e`
- `npm ci`
- `npx playwright install chromium`
- `npm test`
- Cycle metrics batch run:
- `cd solitaire_server/e2e`
- `npm run review:cycles -- --games 1000 --steps 350 --policy baseline --max-visits 1 --out /tmp/cycle-baseline.json`
- `npm run review:cycles -- --games 1000 --steps 350 --policy loop_aware --max-visits 2 --out /tmp/cycle-loop-aware.json`
- `npm run review:cycles:regression` (thresholded gate, writes `test-results/cycle-regression.json`)
- `npm run review:cycles:candidate` (loop-aware candidate run, writes `test-results/cycle-candidate.json`)
### Cycle-risk regression baseline and guardrails
- Current regression gate command:
- `npm run review:cycles:regression`
- config: `games=240`, `steps=350`, `policy=baseline`, `max-visits=1`
- Current guardrail thresholds:
- `all.cycle_rate_pct <= 86`
- `draw1.cycle_rate_pct <= 76`
- `draw3.cycle_rate_pct <= 95`
- `all.win_rate_pct >= 14`
- zero invariant/apply/page/console issue counts
- Baseline sample (240 games):
- overall: `win_rate=15.8%`, `cycle_rate=84.2%`
- draw-one: `win_rate=25.8%`, `cycle_rate=74.2%`
- draw-three: `win_rate=5.8%`, `cycle_rate=94.2%`
- Candidate loop-aware sample (240 games, lookahead via simulated move + restore):
- overall: `win_rate=20.4%`, `cycle_rate=32.5%`
- draw-one: `win_rate=33.3%`, `cycle_rate=16.7%`
- draw-three: `win_rate=7.5%`, `cycle_rate=48.3%`
- no invariant/apply/page/console issues in the sampled run
- Additional 500-game candidate soak:
- overall: `win_rate=20.2%`, `cycle_rate=28.6%`, `step_budget=51.2%`
- draw-three remains the dominant risk (`cycle_rate=45.2%`)
- Fix applied: cycle metrics regression now supports explicit
`max_step_budget_rate_*` thresholds. Candidate command now enforces
`max_step_budget_rate_all <= 60` to prevent silent drift from cycles into
step-budget stalls.
-228
View File
@@ -1,228 +0,0 @@
# Android testing
This directory contains lightweight Android test helpers for Ferrous Solitaire.
They are intended to run against either a physical Android device or an emulator
connected through `adb`. When no device is connected the smoke script can
automatically launch an AVD for you.
## Prerequisites
- Android SDK and NDK installed.
- `adb` available on `PATH`.
- One device/emulator visible in `adb devices`, **or** at least one AVD created
(the script will launch one automatically if `LAUNCH_AVD=1`, which is the default).
- If multiple devices are connected, set `ADB_SERIAL` to the target device serial.
- Environment variables required by `scripts/build_android_apk.sh` when building:
```sh
export ANDROID_HOME=/path/to/android-sdk
export ANDROID_NDK_HOME=/path/to/android-ndk
export BUILD_TOOLS_VERSION=34.0.0
export PLATFORM=android-34
```
## Smoke test
From the workspace root (`Rusty_Solitaire/`):
```sh
scripts/android_smoke.sh
```
The smoke test first checks whether `adb` can see a ready device. If no device
is connected and `LAUNCH_AVD=1` (default), it:
1. locates the `emulator` binary under `ANDROID_HOME` or `PATH`,
2. picks the first available AVD (or uses `AVD_NAME`),
3. launches the emulator in the foreground (or headless with `AVD_HEADLESS=1`),
4. waits for `sys.boot_completed=1` before proceeding,
5. dismisses the lock screen so the screenshot shows the app.
Once a device is ready (auto-launched or pre-existing) the script:
1. builds the APK using `scripts/build_android_apk.sh`,
2. installs it with `adb install -r -d` so debug smoke builds can replace newer local builds,
3. force-stops the package by default for a clean launch,
4. clears `logcat`,
5. launches `com.ferrousapp.solitaire/android.app.NativeActivity`,
6. waits for the app to settle,
7. verifies the process is still running,
8. captures a screenshot and `logcat`, and
9. fails on fatal log patterns such as native crashes, JNI fatal errors, real ANRs,
and Rust panics.
On exit the script kills any emulator it launched (`SHUTDOWN_AVD_ON_EXIT=1` by
default). Set `SHUTDOWN_AVD_ON_EXIT=0` to keep the emulator open for inspection.
Artifacts are written to `target/android-smoke/<timestamp>/` by default. A successful run includes:
- `device.txt` — selected device and display metadata,
- `df-data-before.txt` / `df-data-after.txt` — emulator/device storage snapshots,
- `emulator.log` — stdout/stderr from the emulator process (AVD runs only),
- `emulator.pid` — PID of the emulator process (AVD runs only),
- `launch.png` — screenshot after the wait period,
- `logcat.txt` — full captured log,
- `log-summary.txt` — grep summary for warnings, errors, JNI, safe-area, and crash terms, and
- `pid.txt` — running app process id.
## Creating an AVD
If no AVDs exist, create one before running the smoke test:
```sh
# Install a system image
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
'system-images;android-34;google_apis;x86_64'
# Create the AVD
"$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd \
-n Pixel_7_API_34 \
-k 'system-images;android-34;google_apis;x86_64' \
--device 'pixel_7'
```
Then run the smoke test — it will pick `Pixel_7_API_34` automatically:
```sh
scripts/android_smoke.sh
```
## Faster iteration
If you already built the APK and only want to reinstall/relaunch:
```sh
BUILD_APK=0 scripts/android_smoke.sh
```
If the APK is already installed and you only want to relaunch/capture logs:
```sh
BUILD_APK=0 INSTALL_APK=0 scripts/android_smoke.sh
```
By default the script force-stops the package before launch so logcat and screenshots represent a clean app start. To test warm-launch behavior instead:
```sh
BUILD_APK=0 INSTALL_APK=0 FORCE_STOP=0 scripts/android_smoke.sh
```
This is also useful when an already-installed build is good enough for launch/log checks. On install failure, the script writes `adb-install.txt`, storage snapshots, and installed-package diagnostics to the output directory.
If install fails with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, the smoke script uninstalls the package and retries once by default (`RESET_ON_SIGNATURE_MISMATCH=1`). This resets app data on the device/emulator. Disable it with:
```sh
RESET_ON_SIGNATURE_MISMATCH=0 scripts/android_smoke.sh
```
To write artifacts to a stable path:
```sh
OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
```
When reusing an output directory, previous files are removed by default so stale artifacts do not contaminate the latest result. To keep existing files:
```sh
CLEAN_OUT_DIR=0 OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
```
To target a specific device when more than one is attached:
```sh
ADB_SERIAL=emulator-5554 scripts/android_smoke.sh
```
To wait longer for safe-area inset polling or slow devices:
```sh
WAIT_SECS=8 scripts/android_smoke.sh
```
## AVD options
To pick a specific AVD by name instead of auto-selecting the first one:
```sh
AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
```
To run headless (no emulator window) — useful in CI or on a display-less machine:
```sh
AVD_HEADLESS=1 scripts/android_smoke.sh
```
To give a slow machine more time to boot the emulator (default is 120 s):
```sh
AVD_BOOT_TIMEOUT=180 scripts/android_smoke.sh
```
To keep the emulator running after the test (useful for manual inspection):
```sh
SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh
```
To pass extra flags to the emulator (e.g. disable snapshot for a completely
cold boot, or change GPU mode):
```sh
AVD_EXTRA_ARGS="-gpu swiftshader_indirect" scripts/android_smoke.sh
```
To disable AVD auto-launch entirely and fail immediately if no device is
connected:
```sh
LAUNCH_AVD=0 scripts/android_smoke.sh
```
For build-only validation without requiring a connected device, use the lower-level APK builder directly:
```sh
scripts/build_android_apk.sh
```
For smoke testing, `scripts/android_smoke.sh` defaults to the connected device's primary ABI when `BUILD_APK=1`, which keeps emulator APKs much smaller than the full multi-ABI default. You can still override it explicitly:
```sh
ABIS=x86_64 scripts/android_smoke.sh
```
For build-only validation, `scripts/build_android_apk.sh` still defaults to all configured ABIs unless you set `ABIS` yourself:
```sh
ABIS=x86_64 scripts/build_android_apk.sh
```
The APK builder signs debug builds with a persistent keystore at `target/android/debug.keystore` by default. This avoids signature churn across smoke-test runs.
The APK builder also strips native debug symbols by default before packaging (`STRIP_NATIVE_LIBS=1`). This keeps debug APKs installable on emulators with limited `/data` storage. To preserve native debug symbols for low-level debugging:
```sh
STRIP_NATIVE_LIBS=0 ABIS=x86_64 scripts/build_android_apk.sh
```
## Device checklist
The script is only a smoke test. Before shipping Android builds, also verify:
- safe-area insets arrive and shift the HUD after a few seconds,
- HUD does not overlap the top status bar,
- modal Done buttons are above the gesture/navigation bar,
- stock tap works,
- drag-and-drop works on tableau, waste, and foundation piles,
- Settings/Help/Profile modals open and close,
- login tokens persist after app restart, and
- `target/android-smoke/.../logcat.txt` contains no fatal JNI/native crash output.
## Notes
- `adb shell input tap X Y` uses physical pixels, not Bevy logical pixels.
- The projects common test device mapping is physical `1080×2400`, Bevy logical
`900×2000`, scale factor `1.20`; multiply logical coordinates by `1.20` for
scripted `adb shell input` commands on that device.
- Keep generated screenshots/logs under `target/android-smoke/` so they stay out
of source control.
-362
View File
@@ -1,362 +0,0 @@
#!/usr/bin/env bash
# Android smoke test for Ferrous Solitaire.
#
# Builds (optional), installs, launches, captures logcat + screenshot, and
# fails on fatal Android log patterns. Designed as a lightweight device/emulator
# sanity check rather than a full UI automation suite.
#
# Required:
# adb on PATH
# Android SDK/NDK env required by scripts/build_android_apk.sh when BUILD_APK=1
#
# Optional environment:
# BUILD_APK=1|0 Build APK before install (default: 1)
# INSTALL_APK=1|0 Install APK before launch (default: 1)
# RESET_ON_SIGNATURE_MISMATCH=1|0
# Uninstall/retry if debug signatures differ (default: 1)
# LAUNCH_APP=1|0 Launch app before checks (default: 1)
# FORCE_STOP=1|0 Force-stop package before launch for clean logs (default: 1)
# CAPTURE_SCREENSHOT=1|0 Capture screenshot (default: 1)
# ADB_SERIAL=... Device serial to use when multiple devices are connected
# APK_PATH=... APK to install (default: target/debug/apk/ferrous-solitaire.apk)
# PACKAGE=... Android package (default: com.ferrousapp.solitaire)
# ACTIVITY=... Activity class (default: android.app.NativeActivity)
# OUT_DIR=... Artifact directory (default: target/android-smoke/<timestamp>)
# CLEAN_OUT_DIR=1|0 Remove prior artifacts from OUT_DIR first (default: 1)
# WAIT_SECS=... Seconds to wait after launch (default: 5)
# ABIS=... Passed to build script. If unset and BUILD_APK=1,
# defaults to the connected device's primary ABI.
#
# AVD auto-launch (used when no device/emulator is already connected):
# LAUNCH_AVD=1|0 Auto-launch an AVD when no device is ready (default: 1)
# AVD_NAME=... AVD name to launch (default: first from `emulator -list-avds`)
# AVD_BOOT_TIMEOUT=... Seconds to wait for the emulator to finish booting (default: 120)
# AVD_HEADLESS=1|0 Run with -no-window -no-audio for CI/no-display environments (default: 0)
# AVD_EXTRA_ARGS=... Extra arguments appended verbatim to the emulator command line
# SHUTDOWN_AVD_ON_EXIT=1|0
# Kill the AVD this script launched on exit (default: 1).
# Set to 0 to leave the emulator running after the test.
#
# Examples:
# scripts/android_smoke.sh
# BUILD_APK=0 scripts/android_smoke.sh
# LAUNCH_AVD=0 scripts/android_smoke.sh # error out if no device, never auto-launch
# AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
# AVD_HEADLESS=1 scripts/android_smoke.sh # CI / no-display
# SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh # keep emulator open after test
# OUT_DIR=target/android-smoke/latest WAIT_SECS=8 scripts/android_smoke.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
BUILD_APK="${BUILD_APK:-1}"
INSTALL_APK="${INSTALL_APK:-1}"
RESET_ON_SIGNATURE_MISMATCH="${RESET_ON_SIGNATURE_MISMATCH:-1}"
LAUNCH_APP="${LAUNCH_APP:-1}"
FORCE_STOP="${FORCE_STOP:-1}"
CAPTURE_SCREENSHOT="${CAPTURE_SCREENSHOT:-1}"
APK_PATH="${APK_PATH:-target/debug/apk/ferrous-solitaire.apk}"
PACKAGE="${PACKAGE:-com.ferrousapp.solitaire}"
ACTIVITY="${ACTIVITY:-android.app.NativeActivity}"
WAIT_SECS="${WAIT_SECS:-5}"
OUT_DIR="${OUT_DIR:-target/android-smoke/$(date +%Y%m%d-%H%M%S)}"
CLEAN_OUT_DIR="${CLEAN_OUT_DIR:-1}"
REMOTE_SCREENSHOT="/sdcard/ferrous-solitaire-smoke.png"
LAUNCH_AVD="${LAUNCH_AVD:-1}"
AVD_NAME="${AVD_NAME:-}"
AVD_BOOT_TIMEOUT="${AVD_BOOT_TIMEOUT:-120}"
AVD_HEADLESS="${AVD_HEADLESS:-0}"
AVD_EXTRA_ARGS="${AVD_EXTRA_ARGS:-}"
SHUTDOWN_AVD_ON_EXIT="${SHUTDOWN_AVD_ON_EXIT:-1}"
ADB=(adb)
if [ -n "${ADB_SERIAL:-}" ]; then
ADB+=( -s "$ADB_SERIAL" )
fi
# PID of any emulator we start so the EXIT trap can clean it up.
_LAUNCHED_EMULATOR_PID=""
_cleanup_emulator() {
if [ -n "$_LAUNCHED_EMULATOR_PID" ] && [ "$SHUTDOWN_AVD_ON_EXIT" = "1" ]; then
echo ">>> shutdown emulator (PID $_LAUNCHED_EMULATOR_PID)"
kill "$_LAUNCHED_EMULATOR_PID" 2>/dev/null || true
fi
}
trap _cleanup_emulator EXIT
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 1
}
}
mkdir -p "$OUT_DIR"
if [ "$CLEAN_OUT_DIR" = "1" ]; then
find "$OUT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
fi
require_cmd adb
# ---------------------------------------------------------------------------
# Device / emulator availability
# ---------------------------------------------------------------------------
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$DEVICE_STATE" != "device" ]; then
if [ "$LAUNCH_AVD" != "1" ]; then
adb devices > "$OUT_DIR/adb-devices.txt" 2>&1 || true
if [ -n "${ADB_SERIAL:-}" ]; then
echo "Android device '$ADB_SERIAL' is not connected/ready (state: ${DEVICE_STATE:-unknown})." >&2
else
echo "No Android device/emulator is connected and ready." >&2
fi
echo "Run 'adb devices' or start an emulator, then retry." >&2
echo "Device list saved to $OUT_DIR/adb-devices.txt" >&2
exit 1
fi
# --- locate emulator binary -----------------------------------------------
# Priority: ANDROID_HOME env → PATH → common SDK install locations.
_find_sdk_root() {
for candidate in \
"$HOME/Android/Sdk" \
"$HOME/Library/Android/sdk" \
"/opt/android-sdk" \
"/usr/lib/android-sdk"; do
[ -d "$candidate" ] && echo "$candidate" && return
done
}
EMULATOR_BIN=""
if [ -n "${ANDROID_HOME:-}" ] && [ -x "$ANDROID_HOME/emulator/emulator" ]; then
EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
elif command -v emulator >/dev/null 2>&1; then
EMULATOR_BIN="$(command -v emulator)"
else
_SDK_ROOT="$(_find_sdk_root)"
if [ -n "$_SDK_ROOT" ] && [ -x "$_SDK_ROOT/emulator/emulator" ]; then
EMULATOR_BIN="$_SDK_ROOT/emulator/emulator"
fi
fi
if [ -z "$EMULATOR_BIN" ]; then
echo "No Android device found and 'emulator' binary is not available." >&2
echo " • Install the Android SDK emulator component, or" >&2
echo " • Set ANDROID_HOME to your SDK root, or" >&2
echo " • Start a device/emulator manually then retry with LAUNCH_AVD=0." >&2
exit 1
fi
echo ">>> emulator binary: $EMULATOR_BIN"
# --- select AVD -----------------------------------------------------------
if [ -z "$AVD_NAME" ]; then
AVD_NAME="$("$EMULATOR_BIN" -list-avds 2>/dev/null | head -n 1 | tr -d '\r')"
if [ -z "$AVD_NAME" ]; then
echo "No AVDs found. Create one first, for example:" >&2
echo " sdkmanager 'system-images;android-34;google_apis;x86_64'" >&2
echo " avdmanager create avd -n Pixel_7_API_34 \\" >&2
echo " -k 'system-images;android-34;google_apis;x86_64' --device 'pixel_7'" >&2
exit 1
fi
echo ">>> auto-selected AVD: $AVD_NAME"
fi
# --- launch emulator -------------------------------------------------------
EMULATOR_ARGS=( -avd "$AVD_NAME" -no-snapshot-load )
[ "$AVD_HEADLESS" = "1" ] && EMULATOR_ARGS+=( -no-window -no-audio )
# Split AVD_EXTRA_ARGS on whitespace only (disable glob expansion).
set -f
# shellcheck disable=SC2206
[ -n "$AVD_EXTRA_ARGS" ] && EMULATOR_ARGS+=( $AVD_EXTRA_ARGS )
set +f
echo ">>> launch emulator: $AVD_NAME"
"$EMULATOR_BIN" "${EMULATOR_ARGS[@]}" > "$OUT_DIR/emulator.log" 2>&1 &
_LAUNCHED_EMULATOR_PID=$!
echo "$_LAUNCHED_EMULATOR_PID" > "$OUT_DIR/emulator.pid"
echo " emulator PID: $_LAUNCHED_EMULATOR_PID"
echo " emulator log: $OUT_DIR/emulator.log"
# --- wait for adb transport -----------------------------------------------
# Poll adb get-state (≠ wait-for-device which blocks indefinitely) so we can
# honour AVD_BOOT_TIMEOUT for the whole boot sequence.
echo ">>> waiting for device to appear in adb (timeout: ${AVD_BOOT_TIMEOUT}s)"
_ELAPSED=0
while true; do
_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$_STATE" = "device" ] || [ "$_STATE" = "offline" ]; then
break
fi
if [ "$_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
echo "Device did not appear in adb within ${AVD_BOOT_TIMEOUT}s" >&2
echo "emulator log:" >&2
tail -20 "$OUT_DIR/emulator.log" >&2 || true
exit 1
fi
sleep 3
_ELAPSED=$(( _ELAPSED + 3 ))
echo " ... ${_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s"
done
# Capture emulator serial (emulator-5554 etc.) so all subsequent adb calls
# target the right device when ADB_SERIAL was not set by the caller.
if [ -z "${ADB_SERIAL:-}" ]; then
_EMU_SERIAL="$(adb devices 2>/dev/null | awk '/^emulator-/{print $1; exit}' | tr -d '\r')"
if [ -n "$_EMU_SERIAL" ]; then
ADB_SERIAL="$_EMU_SERIAL"
ADB=(adb -s "$ADB_SERIAL")
echo ">>> detected emulator serial: $ADB_SERIAL"
fi
fi
# --- wait for full Android boot -------------------------------------------
# adb get-state returning "device" means the transport is up, but the
# Android framework may still be initialising. Poll sys.boot_completed.
echo ">>> waiting for boot_completed (timeout: ${AVD_BOOT_TIMEOUT}s)"
_BOOT_ELAPSED=0
_BOOT_INTERVAL=5
while true; do
_BOOT="$("${ADB[@]}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
if [ "$_BOOT" = "1" ]; then
echo ">>> emulator boot complete"
break
fi
if [ "$_BOOT_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
echo "Emulator did not finish booting within ${AVD_BOOT_TIMEOUT}s" >&2
echo "emulator log:" >&2
tail -20 "$OUT_DIR/emulator.log" >&2 || true
exit 1
fi
sleep "$_BOOT_INTERVAL"
_BOOT_ELAPSED=$(( _BOOT_ELAPSED + _BOOT_INTERVAL ))
echo " ... ${_BOOT_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s (boot_completed='${_BOOT}')"
done
# Dismiss the lock screen so later screencap shows the app, not the keyguard.
"${ADB[@]}" shell input keyevent 82 2>/dev/null || true
# Final sanity check — device must be fully ready before we proceed.
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$DEVICE_STATE" != "device" ]; then
echo "Emulator is running but adb state is '${DEVICE_STATE:-unknown}'." >&2
exit 1
fi
fi
# ---------------------------------------------------------------------------
# Device metadata
# ---------------------------------------------------------------------------
{
echo "adb_serial=${ADB_SERIAL:-default}"
echo "package=$PACKAGE"
echo "activity=$ACTIVITY"
echo "device_state=$DEVICE_STATE"
"${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' | sed 's/^/product_model=/'
"${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' | sed 's/^/android_release=/'
"${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' | sed 's/^/android_sdk=/'
"${ADB[@]}" shell wm size 2>/dev/null | tr -d '\r' | sed 's/^/wm_size=/'
"${ADB[@]}" shell wm density 2>/dev/null | tr -d '\r' | sed 's/^/wm_density=/'
} > "$OUT_DIR/device.txt"
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-before.txt" 2>&1 || true
if [ "$BUILD_APK" = "1" ]; then
if [ -z "${ABIS:-}" ]; then
DEVICE_ABI="$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')"
case "$DEVICE_ABI" in
x86_64|arm64-v8a|armeabi-v7a)
export ABIS="$DEVICE_ABI"
;;
armeabi*)
export ABIS="armeabi-v7a"
;;
*)
echo "Could not map device ABI '$DEVICE_ABI'; using build script default ABIS." >&2
;;
esac
fi
echo ">>> build Android APK${ABIS:+ (ABIS=$ABIS)}"
scripts/build_android_apk.sh
fi
if [ "$INSTALL_APK" = "1" ]; then
[ -f "$APK_PATH" ] || {
echo "APK not found: $APK_PATH" >&2
echo "Set APK_PATH or run with BUILD_APK=1." >&2
exit 1
}
ls -lh "$APK_PATH" > "$OUT_DIR/apk.txt"
echo ">>> install $APK_PATH"
if ! "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install.txt" 2>&1; then
if [ "$RESET_ON_SIGNATURE_MISMATCH" = "1" ] && grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE" "$OUT_DIR/adb-install.txt"; then
echo ">>> signature mismatch; uninstalling $PACKAGE and retrying install"
"${ADB[@]}" uninstall "$PACKAGE" > "$OUT_DIR/adb-uninstall-before-retry.txt" 2>&1 || true
if "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install-retry.txt" 2>&1; then
cat "$OUT_DIR/adb-install-retry.txt" >> "$OUT_DIR/adb-install.txt"
else
cat "$OUT_DIR/adb-install.txt" >&2
cat "$OUT_DIR/adb-install-retry.txt" >&2
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
echo "APK install retry failed. Diagnostics saved in $OUT_DIR" >&2
exit 1
fi
else
cat "$OUT_DIR/adb-install.txt" >&2
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
echo "APK install failed. Diagnostics saved in $OUT_DIR" >&2
echo "If the package is already installed and you only need launch/log checks, retry with INSTALL_APK=0." >&2
exit 1
fi
fi
fi
if [ "$FORCE_STOP" = "1" ]; then
echo ">>> force-stop $PACKAGE"
"${ADB[@]}" shell am force-stop "$PACKAGE" || true
fi
echo ">>> clear logcat"
"${ADB[@]}" logcat -c
if [ "$LAUNCH_APP" = "1" ]; then
echo ">>> launch $PACKAGE/$ACTIVITY"
"${ADB[@]}" shell am start -n "$PACKAGE/$ACTIVITY" > "$OUT_DIR/am-start.txt"
fi
echo ">>> wait ${WAIT_SECS}s"
sleep "$WAIT_SECS"
PID="$("${ADB[@]}" shell pidof "$PACKAGE" | tr -d '\r' || true)"
if [ -z "$PID" ]; then
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt" || true
echo "app process is not running after launch: $PACKAGE" >&2
echo "logcat saved to $OUT_DIR/logcat.txt" >&2
exit 1
fi
echo "$PID" > "$OUT_DIR/pid.txt"
if [ "$CAPTURE_SCREENSHOT" = "1" ]; then
echo ">>> capture screenshot"
"${ADB[@]}" shell screencap -p "$REMOTE_SCREENSHOT"
"${ADB[@]}" pull "$REMOTE_SCREENSHOT" "$OUT_DIR/launch.png" >/dev/null
"${ADB[@]}" shell rm -f "$REMOTE_SCREENSHOT" >/dev/null 2>&1 || true
fi
echo ">>> capture logcat"
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt"
grep -iE "panic|fatal|jni|native crash|\bANR\b|exception|error|warn|keystore|safe_area" "$OUT_DIR/logcat.txt" > "$OUT_DIR/log-summary.txt" || true
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after.txt" 2>&1 || true
# Fatal patterns only. Avoid matching generic "error" because Android logs are
# noisy and many non-fatal framework lines contain that word.
if grep -iE "fatal exception|jni detected error|native crash|signal [0-9]+|ANR in|Application Not Responding|Input dispatching timed out|thread exiting with uncaught exception|panicked at" "$OUT_DIR/logcat.txt"; then
echo "Android smoke test found fatal log output" >&2
echo "Artifacts saved in $OUT_DIR" >&2
exit 1
fi
echo ">>> Android smoke test passed"
echo "Artifacts saved in $OUT_DIR"
+15 -87
View File
@@ -6,15 +6,11 @@
# ndk-build crate that we couldn't isolate; running each Android toolchain # ndk-build crate that we couldn't isolate; running each Android toolchain
# step explicitly gives us a debuggable pipeline. # step explicitly gives us a debuggable pipeline.
# #
# Environment: # Required environment:
# ANDROID_HOME Path to Android SDK root. If unset, common SDK # ANDROID_HOME Path to Android SDK root
# locations such as ~/Android/Sdk are tried. # ANDROID_NDK_HOME Path to the specific NDK version
# ANDROID_NDK_HOME Path to the specific NDK version. If unset, the # BUILD_TOOLS_VERSION e.g. "34.0.0"
# newest $ANDROID_HOME/ndk/* directory is used. # PLATFORM e.g. "android-34"
# BUILD_TOOLS_VERSION e.g. "34.0.0". If unset, newest installed build-tools
# version is used.
# PLATFORM e.g. "android-34". If unset, newest installed
# $ANDROID_HOME/platforms/android-* platform is used.
# #
# Optional environment: # Optional environment:
# PROFILE "debug" (default) | "release" # PROFILE "debug" (default) | "release"
@@ -23,8 +19,7 @@
# fit the runner's disk budget — a full three-ABI # fit the runner's disk budget — a full three-ABI
# debug build can exceed 25 GB of target/ output. # debug build can exceed 25 GB of target/ output.
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk) # APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
# STRIP_NATIVE_LIBS 1 to strip .so files before packaging (default: 1) # KEYSTORE Path to keystore for signing (default: generates a debug keystore)
# KEYSTORE Path to keystore for signing (default: target/android/debug.keystore)
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore) # KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
# KEY_ALIAS Key alias (default: "androiddebugkey") # KEY_ALIAS Key alias (default: "androiddebugkey")
# KEY_PASS Key password (default: same as KEYSTORE_PASS) # KEY_PASS Key password (default: same as KEYSTORE_PASS)
@@ -33,63 +28,18 @@
# $APK_OUT Signed, zipaligned APK # $APK_OUT Signed, zipaligned APK
set -euo pipefail set -euo pipefail
infer_latest_dir_name() { : "${ANDROID_HOME:?ANDROID_HOME must be set}"
local pattern="$1" : "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
local latest="" : "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
shopt -s nullglob : "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
local dirs=( $pattern )
shopt -u nullglob
if [ ${#dirs[@]} -gt 0 ]; then
latest="$(printf '%s\n' "${dirs[@]}" | sort -V | tail -n 1)"
basename "$latest"
fi
}
if [ -z "${ANDROID_HOME:-}" ]; then
for candidate in "$HOME/Android/Sdk" "$HOME/Library/Android/sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do
if [ -d "$candidate" ]; then
ANDROID_HOME="$candidate"
export ANDROID_HOME
break
fi
done
fi
: "${ANDROID_HOME:?ANDROID_HOME must be set or discoverable under a common SDK path}"
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
NDK_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/ndk/*")"
if [ -n "$NDK_VERSION" ]; then
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
export ANDROID_NDK_HOME
fi
fi
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set or discoverable under ANDROID_HOME/ndk}"
if [ -z "${BUILD_TOOLS_VERSION:-}" ]; then
BUILD_TOOLS_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/build-tools/*")"
export BUILD_TOOLS_VERSION
fi
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set or discoverable under ANDROID_HOME/build-tools}"
if [ -z "${PLATFORM:-}" ]; then
PLATFORM="$(infer_latest_dir_name "$ANDROID_HOME/platforms/android-*")"
export PLATFORM
fi
: "${PLATFORM:?PLATFORM must be set or discoverable under ANDROID_HOME/platforms}"
PROFILE="${PROFILE:-debug}" PROFILE="${PROFILE:-debug}"
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}" ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}" APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
STRIP_NATIVE_LIBS="${STRIP_NATIVE_LIBS:-1}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT" cd "$REPO_ROOT"
echo ">>> Android SDK: $ANDROID_HOME"
echo ">>> Android NDK: $ANDROID_NDK_HOME"
echo ">>> Build tools: $BUILD_TOOLS_VERSION"
echo ">>> Platform: $PLATFORM"
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION" BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar" PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
MANIFEST="solitaire_app/android/AndroidManifest.xml" MANIFEST="solitaire_app/android/AndroidManifest.xml"
@@ -119,24 +69,6 @@ fi
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}" echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
cargo ndk "${CARGO_NDK_ARGS[@]}" cargo ndk "${CARGO_NDK_ARGS[@]}"
if [ "$STRIP_NATIVE_LIBS" = "1" ]; then
LLVM_STRIP=""
shopt -s nullglob
STRIP_CANDIDATES=( "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/llvm-strip )
shopt -u nullglob
if [ ${#STRIP_CANDIDATES[@]} -gt 0 ]; then
LLVM_STRIP="${STRIP_CANDIDATES[0]}"
fi
if [ -z "$LLVM_STRIP" ]; then
echo "llvm-strip not found under ANDROID_NDK_HOME; native libraries will remain unstripped" >&2
else
echo ">>> strip native libraries with $LLVM_STRIP"
find "$STAGING/lib" -name '*.so' -print0 | while IFS= read -r -d '' so; do
"$LLVM_STRIP" --strip-debug "$so"
done
fi
fi
# --- 2. compile + link resources and manifest ------------------------------ # --- 2. compile + link resources and manifest ------------------------------
if [ -d "$RES_DIR" ]; then if [ -d "$RES_DIR" ]; then
echo ">>> aapt2 compile resources" echo ">>> aapt2 compile resources"
@@ -188,15 +120,11 @@ rm -f "$STAGING/app-unsigned.apk"
# --- 5. sign --------------------------------------------------------------- # --- 5. sign ---------------------------------------------------------------
if [ -z "${KEYSTORE:-}" ]; then if [ -z "${KEYSTORE:-}" ]; then
KEYSTORE="target/android/debug.keystore" # Generate a deterministic debug keystore on the fly.
fi KEYSTORE="$STAGING/debug.keystore"
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEYSTORE_PASS="${KEYSTORE_PASS:-android}" KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}" KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
if [ ! -f "$KEYSTORE" ]; then
mkdir -p "$(dirname "$KEYSTORE")"
echo ">>> generating debug keystore at $KEYSTORE" echo ">>> generating debug keystore at $KEYSTORE"
keytool -genkeypair -v \ keytool -genkeypair -v \
-keystore "$KEYSTORE" \ -keystore "$KEYSTORE" \
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env bash
# Update Quaternions registry dependencies and run the full safety gate.
#
# Usage:
# scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
#
# Example:
# scripts/update_quaternions_deps.sh 0.3.1 0.4.1
#
# This script updates Cargo.lock to the requested versions (within the semver
# ranges already declared in Cargo.toml), then runs the project's required
# verification steps plus deterministic replay checks.
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "usage: $0 <klondike_version> <card_game_version>"
exit 2
fi
KLONDIKE_VERSION="$1"
CARD_GAME_VERSION="$2"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
echo ">>> Quaternions registry:"
echo " https://git.aleshym.co/api/packages/Quaternions/cargo/"
echo
echo ">>> Review upstream release notes / changelogs before proceeding:"
echo " - https://git.aleshym.co/Quaternions/card_game"
echo " - https://git.aleshym.co/Quaternions/klondike"
echo
echo ">>> Updating lockfile to klondike=$KLONDIKE_VERSION card_game=$CARD_GAME_VERSION"
cargo update -p klondike --precise "$KLONDIKE_VERSION"
cargo update -p card_game --precise "$CARD_GAME_VERSION"
echo ">>> Verifying dependency graph"
cargo tree -p solitaire_core --depth 2 | cat
echo ">>> Running workspace tests"
cargo test --workspace
echo ">>> Running workspace clippy"
cargo clippy --workspace -- -D warnings
echo ">>> Running deterministic replay / debug-api smoke checks"
cargo test -p solitaire_wasm debug_snapshot_exposes_replayable_seed_and_history -- --exact
cargo test -p solitaire_wasm debug_api_autonomous_seed_batch_smoke -- --exact
echo ">>> Quaternions dependency upgrade gate passed"
+10 -25
View File
@@ -25,10 +25,7 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use bevy::winit::WinitWindows; use bevy::winit::WinitWindows;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use bevy::winit::{UpdateMode, WinitSettings}; use bevy::winit::{UpdateMode, WinitSettings};
use solitaire_data::{ use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
settings_file_path,
};
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources}; use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
fn load_settings() -> Settings { fn load_settings() -> Settings {
@@ -52,12 +49,6 @@ pub fn run() {
// and any debugger attached still sees the panic). // and any debugger attached still sees the panic).
install_crash_log_hook(); install_crash_log_hook();
// Remove any *.tmp files left behind by a crash between an atomic write
// and its rename. Safe to call unconditionally — missing data dir is a
// no-op. Must run before GamePlugin loads saved state so orphaned files
// don't accumulate across launches.
let _ = cleanup_orphaned_tmp_files();
// Initialise the platform keyring store before any token operations. // Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on // On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store. // macOS it uses the Keychain; on Windows it uses the Credential store.
@@ -65,9 +56,10 @@ pub fn run() {
// operations will fail gracefully with TokenError::KeychainUnavailable. // operations will fail gracefully with TokenError::KeychainUnavailable.
// //
// Android: `keyring` isn't compiled in (its `rpassword` transitive // Android: `keyring` isn't compiled in (its `rpassword` transitive
// pulls a libc symbol Android's bionic doesn't expose). The Android // pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
// auth-token path uses Android Keystore via JNI; `android_main` passes // ships an Android stub that returns KeychainUnavailable for every
// the process JavaVM pointer into `solitaire_data` before `run()`. // call — the runtime behaviour is "session login required each launch"
// until we wire Android Keystore via JNI in the Phase-Android round.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
if let Err(e) = keyring::use_native_store(true) { if let Err(e) = keyring::use_native_store(true) {
eprintln!( eprintln!(
@@ -180,16 +172,13 @@ fn build_app_with_settings(
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency // a 1-second ceiling when the app is backgrounded cuts wake-up frequency
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain. // from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
// //
// focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when // The focused mode stays Continuous so that card-slide animations remain
// an event arrives (touch, resize, etc.) or an animation system writes // smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the
// RequestRedraw. The 100 ms ceiling is a fallback that ensures the game // display refresh rate (~60 Hz) when foregrounded, which already prevents
// timer ticks at least 10×/s even with no input, while keeping the GPU // the GPU from spinning at 200+ fps between vsync intervals.
// completely idle between frames when the board is static.
// PresentMode::AutoVsync (set above) still caps the GPU at the display
// refresh rate when frames do render.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
app.insert_resource(WinitSettings { app.insert_resource(WinitSettings {
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)), focused_mode: UpdateMode::Continuous,
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)), unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
}); });
@@ -365,10 +354,6 @@ fn set_window_icon(
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) { fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let vm_ptr = android_app.vm_as_ptr().cast();
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) {
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
}
let _ = bevy::android::ANDROID_APP.set(android_app); let _ = bevy::android::ANDROID_APP.set(android_app);
run(); run();
} }
@@ -2,10 +2,10 @@
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in //! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
//! `solitaire_data/src/difficulty_seeds.rs`. //! `solitaire_data/src/difficulty_seeds.rs`.
//! //!
//! A seed's tier is determined by the **smallest** solve budget at which it is //! A seed's tier is determined by the **smallest** `SolverConfig` budget that
//! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget //! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
//! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded //! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
//! (we only emit provably-winnable seeds). //! provably-winnable seeds).
//! //!
//! # Usage //! # Usage
//! //!
@@ -19,12 +19,12 @@
//! --per-tier Seeds to emit per tier (default 40) //! --per-tier Seeds to emit per tier (default 40)
//! --help Print this message //! --help Print this message
use solitaire_core::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::solver::try_solve; use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
// Budget boundaries defining each tier. A seed belongs to the lowest tier // Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable. // whose budget proves it Winnable.
const BUDGETS: &[(&str, u64, u64)] = &[ const BUDGETS: &[(&str, u64, usize)] = &[
("Easy", 1_000, 1_000), ("Easy", 1_000, 1_000),
("Medium", 5_000, 5_000), ("Medium", 5_000, 5_000),
("Hard", 25_000, 25_000), ("Hard", 25_000, 25_000),
@@ -99,8 +99,12 @@ fn main() {
if buckets[i].len() >= per_tier { if buckets[i].len() >= per_tier {
continue; continue;
} }
match try_solve(seed, draw_mode, move_budget, state_budget) { let cfg = SolverConfig {
Ok(Some(_)) => { move_budget,
state_budget,
};
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable => {
buckets[i].push(seed); buckets[i].push(seed);
eprintln!( eprintln!(
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})", " [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
@@ -109,13 +113,13 @@ fn main() {
); );
break 'tier; // assign to the cheapest tier that proves it winnable break 'tier; // assign to the cheapest tier that proves it winnable
} }
Ok(None) => { SolverResult::Unwinnable => {
// Definitely unsolvable — skip all remaining tiers. // Definitely unsolvable — skip all remaining tiers.
break 'tier; break 'tier;
} }
Err(_) => { SolverResult::Inconclusive => {
// Budget exhausted without proof — try the next larger tier. // Budget exhausted without proof — try the next larger tier.
// If this is the last tier, the seed is discarded (inconclusive // If this is the last tier, the seed is discarded (Inconclusive
// at max budget means "probably but not provably winnable"). // at max budget means "probably but not provably winnable").
if i == num_tiers - 1 { if i == num_tiers - 1 {
break 'tier; break 'tier;
+5 -12
View File
@@ -1,7 +1,7 @@
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`. //! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
//! //!
//! Walks seeds incrementally from `--start`, calls the solver on each, and //! Walks seeds incrementally from `--start`, calls the solver on each, and
//! collects only those proven winnable (`Ok(Some(_))`; inconclusive is //! collects only those that return `SolverResult::Winnable` (Inconclusive is
//! rejected — the curated list wants proof). Prints Rust source suitable for //! rejected — the curated list wants proof). Prints Rust source suitable for
//! pasting into `solitaire_data/src/challenge.rs`. //! pasting into `solitaire_data/src/challenge.rs`.
//! //!
@@ -17,8 +17,8 @@
//! --count Number of Winnable seeds to emit (default 75) //! --count Number of Winnable seeds to emit (default 75)
//! --help Print this message //! --help Print this message
use solitaire_core::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::solver::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve}; use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
fn main() { fn main() {
let mut args = std::env::args().skip(1).peekable(); let mut args = std::env::args().skip(1).peekable();
@@ -67,6 +67,7 @@ fn main() {
std::process::exit(1); std::process::exit(1);
} }
let cfg = SolverConfig::default();
let draw_mode = DrawMode::DrawOne; let draw_mode = DrawMode::DrawOne;
let mut found: Vec<u64> = Vec::with_capacity(count); let mut found: Vec<u64> = Vec::with_capacity(count);
let mut tried: u64 = 0; let mut tried: u64 = 0;
@@ -76,15 +77,7 @@ fn main() {
while found.len() < count { while found.len() < count {
tried += 1; tried += 1;
if matches!( if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) {
try_solve(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET
),
Ok(Some(_))
) {
found.push(seed); found.push(seed);
eprintln!( eprintln!(
" [{:>3}/{}] 0x{:016X} ({} tried so far)", " [{:>3}/{}] 0x{:016X} ({} tried so far)",
+1 -9
View File
@@ -4,15 +4,7 @@ version.workspace = true
license.workspace = true license.workspace = true
edition.workspace = true edition.workspace = true
[features]
default = []
test-support = []
[dev-dependencies]
proptest = "1"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
klondike = { workspace = true } rand = { workspace = true }
card_game = { workspace = true }
+167 -21
View File
@@ -1,23 +1,169 @@
pub use card_game::{Card, Deck, Rank, Suit}; use serde::{Deserialize, Serialize};
/// Maps a [`Card`] to a stable `0..=51` numeric identity, independent of the /// Card suit.
/// upstream `card_game::Card` bit-packing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
/// pub enum Suit {
/// Encoding: `suit_index * 13 + (rank as u32 - 1)`, where `suit_index` is Clubs,
/// Clubs=0, Diamonds=1, Hearts=2, Spades=3 and `rank` is 1 (Ace) ..= 13 (King). Diamonds,
/// The deck id is intentionally ignored so the id depends only on the visible Hearts,
/// face. Spades,
/// }
/// This is the single source of truth shared by `CardEntity` numeric tracking,
/// deterministic per-card animation jitter, and the WASM replay layer — those impl Suit {
/// must agree byte-for-byte so replay snapshots are identical across the /// All four suits in declaration order.
/// desktop and browser builds. pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
pub fn card_to_id(card: &Card) -> u32 {
let suit_index: u32 = match card.suit() { /// Returns `true` for red suits (Diamonds, Hearts).
Suit::Clubs => 0, pub fn is_red(self) -> bool {
Suit::Diamonds => 1, matches!(self, Suit::Diamonds | Suit::Hearts)
Suit::Hearts => 2, }
Suit::Spades => 3,
}; /// Returns `true` for black suits (Clubs, Spades).
suit_index * 13 + (card.rank() as u32 - 1) pub fn is_black(self) -> bool {
!self.is_red()
}
}
/// Card rank, Ace through King.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Rank {
Ace = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
Six = 6,
Seven = 7,
Eight = 8,
Nine = 9,
Ten = 10,
Jack = 11,
Queen = 12,
King = 13,
}
impl Rank {
/// All thirteen ranks in ascending order.
pub const RANKS: [Self; 13] = [
Self::Ace,
Self::Two,
Self::Three,
Self::Four,
Self::Five,
Self::Six,
Self::Seven,
Self::Eight,
Self::Nine,
Self::Ten,
Self::Jack,
Self::Queen,
Self::King,
];
/// Numeric value: Ace = 1, King = 13.
pub fn value(self) -> u8 {
self as u8
}
const fn new(n: u8) -> Option<Self> {
match n {
1 => Some(Self::Ace),
2 => Some(Self::Two),
3 => Some(Self::Three),
4 => Some(Self::Four),
5 => Some(Self::Five),
6 => Some(Self::Six),
7 => Some(Self::Seven),
8 => Some(Self::Eight),
9 => Some(Self::Nine),
10 => Some(Self::Ten),
11 => Some(Self::Jack),
12 => Some(Self::Queen),
13 => Some(Self::King),
_ => None,
}
}
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
pub const fn checked_add(self, n: u8) -> Option<Self> {
Self::new((self as u8).saturating_add(n))
}
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
pub const fn checked_sub(self, n: u8) -> Option<Self> {
match (self as u8).checked_sub(n) {
Some(v) => Self::new(v),
None => None,
}
}
}
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card {
/// Unique identifier for this card within the deal. Stable across moves and undo.
pub id: u32,
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
pub suit: Suit,
/// The card's rank (Ace through King).
pub rank: Rank,
/// Whether the card is visible to the player. Face-down cards may not be moved.
pub face_up: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rank_values_are_sequential() {
for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(r.value(), (i + 1) as u8);
}
}
#[test]
fn rank_as_u8_matches_value() {
for r in Rank::RANKS {
assert_eq!(r as u8, r.value());
}
}
#[test]
fn rank_checked_add_boundary() {
assert_eq!(Rank::King.checked_add(1), None);
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
}
#[test]
fn rank_checked_sub_boundary() {
assert_eq!(Rank::Ace.checked_sub(1), None);
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
}
#[test]
fn suit_suits_contains_all_four() {
assert_eq!(Suit::SUITS.len(), 4);
assert!(Suit::SUITS.contains(&Suit::Clubs));
assert!(Suit::SUITS.contains(&Suit::Diamonds));
assert!(Suit::SUITS.contains(&Suit::Hearts));
assert!(Suit::SUITS.contains(&Suit::Spades));
}
#[test]
fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
assert_ne!(
suit.is_red(),
suit.is_black(),
"{suit:?} must be exactly one of red/black"
);
}
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
}
} }
+193
View File
@@ -0,0 +1,193 @@
use crate::card::{Card, Rank, Suit};
use crate::pile::{Pile, PileType};
use rand::rngs::StdRng;
use rand::{SeedableRng, seq::SliceRandom};
const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const ALL_RANKS: [Rank; 13] = [
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
];
/// A standard 52-card deck.
pub struct Deck {
/// All 52 cards in the deck, in deal order.
pub cards: Vec<Card>,
}
impl Deck {
/// Creates an unshuffled deck with all 52 unique cards (id 051).
pub fn new() -> Self {
let mut cards = Vec::with_capacity(52);
let mut id = 0u32;
for &suit in &ALL_SUITS {
for &rank in &ALL_RANKS {
cards.push(Card {
id,
suit,
rank,
face_up: false,
});
id += 1;
}
}
Self { cards }
}
/// Shuffles the deck in-place using Fisher-Yates with a seeded `StdRng`.
/// The same seed always produces the same order on any platform.
pub fn shuffle(&mut self, seed: u64) {
let mut rng = StdRng::seed_from_u64(seed);
self.cards.shuffle(&mut rng);
}
}
impl Default for Deck {
fn default() -> Self {
Self::new()
}
}
/// Deals a standard Klondike layout from a pre-shuffled deck.
///
/// Returns 7 tableau piles and the remaining stock pile.
/// Column `i` contains `i + 1` cards; only the top card is face-up.
/// Stock receives the remaining 24 cards, all face-down.
pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) {
debug_assert_eq!(
deck.cards.len(),
52,
"deal_klondike requires a full 52-card deck"
);
let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i)));
// Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded.
let mut idx = 0usize;
for (col, pile) in tableau.iter_mut().enumerate() {
for row in 0..=col {
let mut card = deck.cards[idx].clone();
card.face_up = row == col;
pile.cards.push(card);
idx += 1;
}
}
let mut stock = Pile::new(PileType::Stock);
stock.cards.extend(deck.cards.into_iter().skip(idx));
(tableau, stock)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deck_new_has_52_cards() {
assert_eq!(Deck::new().cards.len(), 52);
}
#[test]
fn deck_new_has_unique_ids() {
let deck = Deck::new();
let mut ids: Vec<u32> = deck.cards.iter().map(|c| c.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 52);
}
#[test]
fn deck_new_has_all_suits_and_ranks() {
let deck = Deck::new();
for suit in ALL_SUITS {
for rank in ALL_RANKS {
assert!(
deck.cards.iter().any(|c| c.suit == suit && c.rank == rank),
"missing {rank:?} {suit:?}"
);
}
}
}
#[test]
fn same_seed_produces_same_order() {
let mut d1 = Deck::new();
d1.shuffle(42);
let mut d2 = Deck::new();
d2.shuffle(42);
assert_eq!(d1.cards, d2.cards);
}
#[test]
fn different_seeds_produce_different_orders() {
let mut d1 = Deck::new();
d1.shuffle(1);
let mut d2 = Deck::new();
d2.shuffle(2);
assert_ne!(d1.cards, d2.cards);
}
#[test]
fn deal_klondike_correct_tableau_sizes() {
let mut deck = Deck::new();
deck.shuffle(0);
let (tableau, stock) = deal_klondike(deck);
for (i, pile) in tableau.iter().enumerate() {
assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size");
}
assert_eq!(stock.cards.len(), 24);
}
#[test]
fn deal_klondike_top_cards_are_face_up() {
let mut deck = Deck::new();
deck.shuffle(0);
let (tableau, _) = deal_klondike(deck);
for pile in &tableau {
assert!(pile.cards.last().unwrap().face_up);
}
}
#[test]
fn deal_klondike_non_top_cards_are_face_down() {
let mut deck = Deck::new();
deck.shuffle(0);
let (tableau, _) = deal_klondike(deck);
for pile in &tableau {
for card in &pile.cards[..pile.cards.len().saturating_sub(1)] {
assert!(!card.face_up);
}
}
}
#[test]
fn deal_klondike_stock_is_face_down() {
let mut deck = Deck::new();
deck.shuffle(0);
let (_, stock) = deal_klondike(deck);
assert!(stock.cards.iter().all(|c| !c.face_up));
}
#[test]
fn deal_klondike_all_52_cards_present() {
let mut deck = Deck::new();
deck.shuffle(99);
let (tableau, stock) = deal_klondike(deck);
let mut ids: Vec<u32> = stock.cards.iter().map(|c| c.id).collect();
for pile in &tableau {
ids.extend(pile.cards.iter().map(|c| c.id));
}
ids.sort_unstable();
assert_eq!(ids, (0u32..52).collect::<Vec<_>>());
}
}
File diff suppressed because it is too large Load Diff
-478
View File
@@ -1,478 +0,0 @@
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
//!
//! [`KlondikeAdapter`] is a pure helper namespace for:
//! - building [`KlondikeConfig`] from Ferrous settings
//! - translating between local and upstream types
//! - applying Ferrous-specific scoring policy on top of upstream defaults
//!
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
//! upstream `card_game` / `klondike` types live here so that the product modules
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
TableauStack,
};
use serde::{Deserialize, Serialize};
use crate::game_state::GameMode;
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree,
}
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
///
/// This type is intentionally zero-sized: it does not carry mutable runtime
/// state, and exists only as a namespace for configuration, conversion, and
/// scoring 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 {
KlondikeConfig {
draw_stock: match draw_mode {
DrawMode::DrawOne => DrawStockConfig::DrawOne,
DrawMode::DrawThree => DrawStockConfig::DrawThree,
},
move_from_foundation: if take_from_foundation {
MoveFromFoundationConfig::Allowed
} else {
MoveFromFoundationConfig::Disallowed
},
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`].
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
match index {
0 => Some(Tableau::Tableau1),
1 => Some(Tableau::Tableau2),
2 => Some(Tableau::Tableau3),
3 => Some(Tableau::Tableau4),
4 => Some(Tableau::Tableau5),
5 => Some(Tableau::Tableau6),
6 => Some(Tableau::Tableau7),
_ => None,
}
}
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
match slot {
0 => Some(Foundation::Foundation1),
1 => Some(Foundation::Foundation2),
2 => Some(Foundation::Foundation3),
3 => Some(Foundation::Foundation4),
_ => None,
}
}
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
match skip {
0 => Some(SkipCards::Skip0),
1 => Some(SkipCards::Skip1),
2 => Some(SkipCards::Skip2),
3 => Some(SkipCards::Skip3),
4 => Some(SkipCards::Skip4),
5 => Some(SkipCards::Skip5),
6 => Some(SkipCards::Skip6),
7 => Some(SkipCards::Skip7),
8 => Some(SkipCards::Skip8),
9 => Some(SkipCards::Skip9),
10 => Some(SkipCards::Skip10),
11 => Some(SkipCards::Skip11),
12 => Some(SkipCards::Skip12),
_ => None,
}
}
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
//
// These types were introduced when upstream `klondike` had no serde feature.
// Mainline `klondike` now provides full serde support (with a hand-written
// compact `KlondikeInstruction` impl), and `GameState` serialises
// `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
//
// The mirror types are retained for three reasons:
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
// files with u8 indices and replay them.
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
// type; changing it would break the on-disk replay format (schema v2).
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
// replay JSON is cross-compatible between the desktop and browser builds.
//
// These types should not be used for new serialisation concerns. If the
// ReplayMove format is ever bumped to a new schema, migrate those callers to
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableau(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedFoundation(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedSkipCards(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePile {
Tableau(SavedTableau),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableauStack {
pub tableau: SavedTableau,
pub skip_cards: SavedSkipCards,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePileStack {
Tableau(SavedTableauStack),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstFoundation {
pub src: SavedKlondikePile,
pub foundation: SavedFoundation,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstTableau {
pub src: SavedKlondikePileStack,
pub tableau: SavedTableau,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
///
/// Convert to/from the upstream type with:
/// ```ignore
/// let saved = SavedInstruction::from(instruction);
/// let instruction = KlondikeInstruction::try_from(saved)?;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedInstruction {
DstFoundation(SavedDstFoundation),
DstTableau(SavedDstTableau),
RotateStock,
}
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSavedInstruction {
#[error("invalid tableau index {0} (expected 06)")]
Tableau(u8),
#[error("invalid foundation index {0} (expected 03)")]
Foundation(u8),
#[error("invalid skip_cards value {0} (expected 012)")]
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
}
+5 -16
View File
@@ -1,20 +1,9 @@
pub mod achievement; pub mod achievement;
pub mod card; pub mod card;
pub mod deck;
pub mod error; pub mod error;
pub mod game_state; pub mod game_state;
pub mod klondike_adapter; pub mod pile;
pub mod rules;
// Re-export the upstream types that cross the solitaire_core API boundary so pub mod scoring;
// downstream crates (engine, wasm) can import from one place without a direct pub mod solver;
// `klondike` / `card_game` dep.
//
// `KlondikePileStack`, `SkipCards` and `TableauStack` are intentionally NOT
// re-exported — they are only used internally (in `klondike_adapter.rs` and
// when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature.
pub use card_game::{Card, Session};
pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
pub use klondike_adapter::DrawMode;
#[cfg(test)]
mod proptest_tests;
+133
View File
@@ -0,0 +1,133 @@
use crate::card::{Card, Suit};
use serde::{Deserialize, Serialize};
/// Identifies which pile on the board a set of cards belongs to.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum PileType {
/// The face-down draw pile.
Stock,
/// The face-up discard pile drawn to.
Waste,
/// One of the four foundation slots (0..=3). The claimed suit, if any,
/// is derived from the bottom card of the pile (always an Ace by
/// construction).
Foundation(u8),
/// One of the seven tableau columns (06).
Tableau(usize),
}
/// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
pub cards: Vec<Card>,
}
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: PileType) -> Self {
Self {
pile_type,
cards: Vec::new(),
}
}
/// Returns a reference to the top (last) card, or `None` if empty.
pub fn top(&self) -> Option<&Card> {
self.cards.last()
}
/// For foundation piles: returns `Some(suit)` once at least one card has
/// landed (the bottom card is always an Ace of the claimed suit).
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(PileType::Stock);
assert!(pile.cards.is_empty());
}
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(PileType::Waste);
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
});
pile.cards.push(Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
});
assert_eq!(pile.top().unwrap().id, 1);
}
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(PileType::Waste);
assert!(pile.top().is_none());
}
#[test]
fn pile_type_foundation_uses_slot_index() {
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
}
#[test]
fn pile_type_tableau_uses_index() {
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
}
#[test]
fn claimed_suit_is_none_for_empty_foundation() {
let pile = Pile::new(PileType::Foundation(0));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_is_none_for_non_foundation() {
let mut pile = Pile::new(PileType::Tableau(0));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
});
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(PileType::Foundation(2));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
});
pile.cards.push(Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Two,
face_up: true,
});
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
}
-377
View File
@@ -1,377 +0,0 @@
use card_game::{Card, Game};
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
use proptest::prelude::*;
use crate::game_state::GameState;
use crate::klondike_adapter::DrawMode;
use crate::klondike_adapter::{
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
SavedTableauStack,
};
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Collect all cards across every pile in a fixed traversal order:
/// stock → waste → foundations 14 → tableaux 17.
///
/// The order is deterministic for a given game state, so two calls on
/// equivalent states produce identical Vec outputs — the right fingerprint
/// for undo-reversibility checks.
fn all_cards(game: &GameState) -> Vec<Card> {
let foundations = [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
];
let tableaux = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
let mut cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
for f in &foundations {
cards.extend(
game.pile(KlondikePile::Foundation(*f))
.iter()
.map(|(c, _)| c.clone()),
);
}
for t in &tableaux {
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
}
cards
}
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
}
/// Apply a sequence of random actions to a game, silently ignoring errors.
///
/// Each action is `(draw_flag, move_index)`:
/// - `draw_flag = true` → call `game.draw()`
/// - `draw_flag = false` → pick the `move_index % len`th legal instruction
/// from `possible_instructions()` and apply it via `apply_instruction()`.
///
/// `possible_instructions()` may return `RotateStock`, which
/// `apply_instruction()` dispatches to `game.draw()`; ordinary instructions
/// are equivalent to `move_cards(from, to, count)`.
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
for &(do_draw, idx) in actions {
if do_draw {
let _ = game.draw();
} else {
let moves = game.possible_instructions();
if moves.is_empty() {
continue;
}
let instruction = moves[idx % moves.len()];
let _ = game.apply_instruction(instruction);
}
}
}
/// Apply one move from `possible_instructions()` (or a draw if no move is
/// available), using `move_idx` to select among the legal options.
/// Returns `true` when a move was successfully applied.
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
if game.is_won() {
return false;
}
let moves = game.possible_instructions();
if moves.is_empty() {
return game.draw().is_ok();
}
let instruction = moves[move_idx % moves.len()];
game.apply_instruction(instruction).is_ok()
}
// ---------------------------------------------------------------------------
// Properties
// ---------------------------------------------------------------------------
proptest! {
/// `check_auto_complete()` and `is_win_trivial()` must agree on every
/// reachable game state.
///
/// The upstream `Klondike::is_win_trivial()` checks that the stock pile
/// (both face-down and face-up halves) is completely empty AND that all
/// tableau columns have no face-down cards. Ferrous `check_auto_complete()`
/// checks the same three conditions individually (stock empty, waste empty,
/// all tableau cards face-up). This property guards against any semantic
/// drift between the two implementations so that delegating to upstream is
/// safe.
///
/// If this property ever fails, `check_auto_complete()` must NOT be fully
/// replaced — the Ferrous conditions must be preserved and `is_win_trivial()`
/// used only as a supplementary guard.
#[test]
fn check_auto_complete_agrees_with_is_win_trivial(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
) {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &actions);
prop_assert_eq!(
game.check_auto_complete(),
game.session().state().state().is_win_trivial(),
"check_auto_complete() disagreed with is_win_trivial() after {:?} actions",
actions.len(),
);
}
/// `check_win()` and `is_win()` must agree on every reachable game state.
#[test]
fn check_win_agrees_with_is_win(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
) {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &actions);
prop_assert_eq!(
game.check_win(),
game.session().state().state().is_win(),
"check_win() disagreed with is_win()",
);
}
/// All 52 card IDs must be present exactly once across every pile after
/// any reachable sequence of draw + move_cards actions.
///
/// Catches two bug classes at once:
/// - Card loss (fewer than 52 unique IDs after the sequence).
/// - Card duplication (52 total but deduplication reduces the set).
#[test]
fn all_52_cards_always_present(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
) {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &actions);
let cards = all_cards(&game);
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
prop_assert_eq!(
unique.len(), 52,
"duplicate cards found after dedup — a card was cloned"
);
}
/// `GameState::new(seed, draw_mode)` must be deterministic: two calls
/// with the same arguments must produce identical initial pile layouts.
///
/// Pins that the deal is seeded from `seed` alone and not from any
/// implicit source like wall-clock time or global state.
#[test]
fn deal_is_deterministic(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
) {
let a = GameState::new(seed, draw_mode);
let b = GameState::new(seed, draw_mode);
prop_assert_eq!(
all_cards(&a),
all_cards(&b),
"same seed + draw_mode produced different deals",
);
}
/// After applying any single legal move and immediately undoing it, the
/// pile layout and move_count must be identical to their pre-move values.
///
/// `setup_actions` drives the game to an arbitrary mid-game position;
/// `move_idx` selects which legal move to apply and then undo.
///
/// The score is intentionally excluded: `undo()` applies a 15 penalty
/// that is by design, not a regression.
#[test]
fn undo_restores_pile_layout_and_move_count(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
move_idx in 0usize..200,
) {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &setup_actions);
// Snapshot the state before the move.
let before_ids = all_cards(&game);
let before_move_count = game.move_count();
// Apply one move.
if !apply_one_move(&mut game, move_idx) || game.is_won() {
return Ok(()); // nothing to undo
}
// Undo and verify.
prop_assert!(
game.undo().is_ok(),
"undo must succeed immediately after a successful move",
);
prop_assert_eq!(
all_cards(&game),
before_ids,
"pile layout after undo differs from the pre-move snapshot",
);
prop_assert_eq!(
game.move_count(),
before_move_count,
"move_count after undo must equal the pre-move value",
);
}
/// Every move returned by `possible_instructions()` must succeed when
/// applied via `move_cards()`.
///
/// `possible_instructions()` and `move_cards()` both validate moves
/// through the same upstream rule engine. This property ensures no
/// drift has opened up between what the engine reports as legal and
/// what it actually accepts.
#[test]
fn legal_moves_always_succeed(
seed in any::<u64>(),
draw_mode in draw_mode_strategy(),
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
) {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &setup_actions);
for instruction in game.possible_instructions() {
// Clone so each move is tried from the same starting state.
let mut trial = game.clone();
let result = trial.apply_instruction(instruction);
prop_assert!(
result.is_ok(),
"possible_instructions() reported {instruction:?} \
as legal but the call returned Err: {result:?}",
);
}
}
// -------------------------------------------------------------------------
// 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`: 06
/// - `SavedFoundation`: 03
/// - `SavedSkipCards`: 012
#[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)));
}
}
+228
View File
@@ -0,0 +1,228 @@
use crate::card::{Card, Rank};
use crate::pile::Pile;
/// Returns `true` if `card` can be placed on the foundation `pile`.
///
/// Foundation rules:
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
/// becomes the pile's claimed suit (derived from the bottom card via
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
/// - When the pile is non-empty, the next card must match the top card's
/// suit and be exactly one rank higher.
#[must_use]
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank == Rank::Ace,
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
}
}
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
///
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
#[must_use]
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank == Rank::King,
Some(top) => {
top.face_up
&& card.rank.checked_add(1) == Some(top.rank)
&& card.suit.is_red() != top.suit.is_red()
}
}
}
/// Returns `true` if `cards` is a legal tableau run on its own — every
/// adjacent pair descends by one rank and alternates colour. A single
/// card is trivially valid. The destination check is separate; this
/// only validates the sequence's *internal* structure, which the tableau
/// move path must enforce so a player can't smuggle an arbitrary stack
/// onto another column when the bottom card happens to land legally.
#[must_use]
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
cards.windows(2).all(|w| {
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
use crate::pile::{Pile, PileType};
fn card(suit: Suit, rank: Rank) -> Card {
Card {
id: 0,
suit,
rank,
face_up: true,
}
}
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
Pile { pile_type, cards }
}
// Foundation tests
#[test]
fn foundation_ace_on_empty_is_valid() {
// Every suit's Ace must land on an empty foundation slot regardless of
// its slot index; the slot claims the suit only after the Ace lands.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
let c = card(suit, Rank::Ace);
let p = Pile::new(PileType::Foundation(0));
assert!(
can_place_on_foundation(&c, &p),
"Ace of {suit:?} must land on empty slot 0",
);
}
}
#[test]
fn foundation_non_ace_on_empty_is_invalid() {
let c = card(Suit::Hearts, Rank::Two);
let p = Pile::new(PileType::Foundation(0));
assert!(!can_place_on_foundation(&c, &p));
}
#[test]
fn foundation_two_on_ace_same_suit_is_valid() {
let c = card(Suit::Clubs, Rank::Two);
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
assert!(can_place_on_foundation(&c, &p));
}
#[test]
fn foundation_second_card_must_match_claimed_suit() {
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
// because the slot's claimed suit is Hearts after the Ace lands.
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
let c = card(Suit::Spades, Rank::Two);
assert!(!can_place_on_foundation(&c, &p));
}
#[test]
fn foundation_skipping_rank_is_invalid() {
let c = card(Suit::Diamonds, Rank::Three);
let p = pile_with(
PileType::Foundation(0),
vec![card(Suit::Diamonds, Rank::Ace)],
);
assert!(!can_place_on_foundation(&c, &p));
}
// Tableau tests
#[test]
fn tableau_king_on_empty_is_valid() {
let c = card(Suit::Hearts, Rank::King);
let p = Pile::new(PileType::Tableau(0));
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_non_king_on_empty_is_invalid() {
let c = card(Suit::Hearts, Rank::Queen);
let p = Pile::new(PileType::Tableau(0));
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_red_on_black_one_lower_is_valid() {
let c = card(Suit::Hearts, Rank::Nine);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_same_color_is_invalid() {
let c = card(Suit::Clubs, Rank::Nine);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_wrong_rank_difference_is_invalid() {
let c = card(Suit::Hearts, Rank::Eight);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_black_on_red_one_lower_is_valid() {
let c = card(Suit::Clubs, Rank::Six);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn foundation_king_on_queen_completes_suit() {
// The last card placed to complete a foundation is always King on Queen.
let c = card(Suit::Spades, Rank::King);
let p = pile_with(
PileType::Foundation(0),
vec![card(Suit::Spades, Rank::Queen)],
);
assert!(can_place_on_foundation(&c, &p));
}
#[test]
fn foundation_king_wrong_suit_is_invalid() {
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
let c = card(Suit::Hearts, Rank::King);
let p = pile_with(
PileType::Foundation(0),
vec![card(Suit::Spades, Rank::Queen)],
);
assert!(!can_place_on_foundation(&c, &p));
}
#[test]
fn tableau_ace_on_two_different_color_is_valid() {
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
let c = card(Suit::Hearts, Rank::Ace);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_same_rank_different_color_is_invalid() {
// Two cards of the same rank cannot be stacked regardless of colour.
let c = card(Suit::Hearts, Rank::Nine);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_face_down_destination_top_is_invalid() {
// A face-down top card must never be a valid placement target.
let c = card(Suit::Hearts, Rank::Nine);
let mut top = card(Suit::Spades, Rank::Ten);
top.face_up = false;
let p = pile_with(PileType::Tableau(0), vec![top]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_sequence_validation() {
// Single card is trivially a valid sequence.
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
// Valid descending alternating-colour run K♠ Q♥ J♣.
assert!(is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Hearts, Rank::Queen),
card(Suit::Clubs, Rank::Jack),
]));
// Same colour twice (Q♠ on K♠) — invalid.
assert!(!is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Spades, Rank::Queen),
]));
// Rank gap (K♠ → J♥) — invalid.
assert!(!is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Hearts, Rank::Jack),
]));
}
}
+152
View File
@@ -0,0 +1,152 @@
use crate::pile::PileType;
/// Score delta for moving cards from `from` to `to`.
///
/// Windows XP Standard scoring:
/// - +10 for any card reaching a foundation pile
/// - +5 for a waste → tableau move
/// - -15 for a foundation → tableau (take-from-foundation) move
/// - 0 for all other moves
///
/// Note: the +5 flip bonus for exposing a face-down tableau card is applied
/// separately in `game_state::move_cards` because it depends on post-move state.
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
match to {
PileType::Foundation(_) => 10,
PileType::Tableau(_) => match from {
PileType::Waste => 5,
PileType::Foundation(_) => -15,
_ => 0,
},
_ => 0,
}
}
/// Score penalty applied when the player uses undo: -15.
pub fn score_undo() -> i32 {
-15
}
/// Score bonus awarded when a face-down tableau card is flipped face-up: +5.
pub fn score_flip() -> i32 {
5
}
/// Score penalty for recycling the waste pile back to stock.
///
/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3).
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
/// `recycle_count` is the new total count **after** this recycle.
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
let (free, penalty) = if is_draw_three {
(3_u32, -20_i32)
} else {
(1_u32, -100_i32)
};
if recycle_count > free { penalty } else { 0 }
}
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn move_to_foundation_scores_ten() {
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
assert_eq!(
score_move(&PileType::Tableau(0), &PileType::Foundation(0)),
10
);
}
#[test]
fn waste_to_tableau_scores_five() {
assert_eq!(score_move(&PileType::Waste, &PileType::Tableau(3)), 5);
}
#[test]
fn tableau_to_tableau_scores_zero() {
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Tableau(1)), 0);
}
#[test]
fn undo_penalty_is_negative_fifteen() {
assert_eq!(score_undo(), -15);
}
#[test]
fn time_bonus_at_100_seconds() {
assert_eq!(compute_time_bonus(100), 7000);
}
#[test]
fn time_bonus_at_zero_is_zero() {
assert_eq!(compute_time_bonus(0), 0);
}
#[test]
fn time_bonus_at_one_second() {
assert_eq!(compute_time_bonus(1), 700_000);
}
#[test]
fn foundation_to_tableau_penalises_fifteen() {
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
assert_eq!(
score_move(&PileType::Foundation(0), &PileType::Tableau(0)),
-15
);
}
#[test]
fn move_to_stock_or_waste_scores_zero() {
// These destinations are illegal moves in practice, but the function
// must not panic and should return 0.
assert_eq!(score_move(&PileType::Waste, &PileType::Stock), 0);
assert_eq!(score_move(&PileType::Waste, &PileType::Waste), 0);
}
#[test]
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
// Very short elapsed time would overflow without the .min() guard.
let bonus = compute_time_bonus(1);
assert!(
bonus >= 0,
"time bonus must be non-negative after u64→i32 cast"
);
}
#[test]
fn flip_bonus_is_five() {
assert_eq!(score_flip(), 5);
}
#[test]
fn recycle_draw1_first_pass_free() {
assert_eq!(score_recycle(1, false), 0);
}
#[test]
fn recycle_draw1_second_pass_penalised() {
assert_eq!(score_recycle(2, false), -100);
}
#[test]
fn recycle_draw3_third_pass_free() {
assert_eq!(score_recycle(3, true), 0);
}
#[test]
fn recycle_draw3_fourth_pass_penalised() {
assert_eq!(score_recycle(4, true), -20);
}
}
File diff suppressed because it is too large Load Diff
+6 -11
View File
@@ -7,23 +7,15 @@ edition.workspace = true
[dependencies] [dependencies]
solitaire_core = { workspace = true } solitaire_core = { workspace = true }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
uuid = { workspace = true }
# These deps are not available / not needed on wasm32:
# dirs — platform data directories (no filesystem on browser)
# reqwest — native HTTP client (sync/analytics gated out on wasm32)
# tokio — OS-threaded async runtime (mio doesn't compile on wasm32)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
dirs = { workspace = true } dirs = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by # `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in # `auth_tokens`. The crate's own dependency tree pulls in
@@ -32,14 +24,17 @@ tokio = { workspace = true }
# on bionic). On Android `auth_tokens` falls back to a stub # on bionic). On Android `auth_tokens` falls back to a stub
# implementation that always returns `KeychainUnavailable`; the # implementation that always returns `KeychainUnavailable`; the
# real backend lands when we wire Android Keystore via JNI. # real backend lands when we wire Android Keystore via JNI.
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
keyring-core = { workspace = true } keyring-core = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true } jni = { workspace = true }
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
# process-wide JavaVM handle for JNI. Must be listed here so the
# symbol resolves when cross-compiling for Android targets.
bevy = { workspace = true }
[dev-dependencies] [dev-dependencies]
solitaire_core = { workspace = true, features = ["test-support"] }
solitaire_server = { path = "../solitaire_server" } solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
+6 -29
View File
@@ -19,14 +19,11 @@ use jni::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::c_void;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock;
use crate::auth_tokens::TokenError; use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "ferrous_solitaire_token_key"; const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct TokenBlob { struct TokenBlob {
@@ -39,37 +36,17 @@ struct TokenBlob {
// JVM helper // 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(())
}
fn with_jvm<F, R>(f: F) -> Result<R, TokenError> fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
where where
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>, F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
{ {
let vm = ANDROID_JVM let app = bevy::android::ANDROID_APP
.get() .get()
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?; .ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?;
// SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
let mut env = vm let mut env = vm
.attach_current_thread_permanently() .attach_current_thread_permanently()
+6 -4
View File
@@ -14,13 +14,15 @@
//! the Bevy `App`). If no default store is set, all operations in this module //! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`]. //! will return [`TokenError::KeychainUnavailable`].
//! //!
//! # Android //! # Android stub
//! //!
//! `keyring-core` cannot compile for the android target (its `rpassword` //! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic //! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android this module delegates to an Android Keystore //! doesn't expose). On Android every function in this module returns
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm` //! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
//! from Android startup before token operations can succeed. //! the same way they handle a Linux box without Secret Service. The
//! real Android backend will arrive in the Phase-Android round when we
//! wire Android Keystore via JNI.
//! //!
//! # Note: no unit tests — requires live OS keychain. //! # Note: no unit tests — requires live OS keychain.
+184 -184
View File
@@ -26,227 +26,227 @@ use solitaire_core::game_state::DifficultyLevel;
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states). /// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
pub const EASY_SEEDS: &[u64] = &[ pub const EASY_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-06-04) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
0xD1FF_0000_0000_0001,
0xD1FF_0000_0000_0002,
0xD1FF_0000_0000_0007,
0xD1FF_0000_0000_0008,
0xD1FF_0000_0000_0009, 0xD1FF_0000_0000_0009,
0xD1FF_0000_0000_0087, 0xD1FF_0000_0000_000E,
0xD1FF_0000_0000_00EB, 0xD1FF_0000_0000_0013,
0xD1FF_0000_0000_017F, 0xD1FF_0000_0000_0015,
0xD1FF_0000_0000_01CE, 0xD1FF_0000_0000_0018,
0xD1FF_0000_0000_020F, 0xD1FF_0000_0000_001D,
0xD1FF_0000_0000_0251, 0xD1FF_0000_0000_0021,
0xD1FF_0000_0000_0275, 0xD1FF_0000_0000_0022,
0xD1FF_0000_0000_029C, 0xD1FF_0000_0000_0026,
0xD1FF_0000_0000_02BD, 0xD1FF_0000_0000_002C,
0xD1FF_0000_0000_02ED, 0xD1FF_0000_0000_002E,
0xD1FF_0000_0000_038F, 0xD1FF_0000_0000_002F,
0xD1FF_0000_0000_03C9, 0xD1FF_0000_0000_0035,
0xD1FF_0000_0000_0415, 0xD1FF_0000_0000_0036,
0xD1FF_0000_0000_045F, 0xD1FF_0000_0000_003C,
0xD1FF_0000_0000_04C4, 0xD1FF_0000_0000_0045,
0xD1FF_0000_0000_04CC, 0xD1FF_0000_0000_0046,
0xD1FF_0000_0000_04EE, 0xD1FF_0000_0000_0048,
0xD1FF_0000_0000_0631, 0xD1FF_0000_0000_0049,
0xD1FF_0000_0000_0651, 0xD1FF_0000_0000_004D,
0xD1FF_0000_0000_0689, 0xD1FF_0000_0000_004F,
0xD1FF_0000_0000_0735, 0xD1FF_0000_0000_0050,
0xD1FF_0000_0000_0748, 0xD1FF_0000_0000_0051,
0xD1FF_0000_0000_0801, 0xD1FF_0000_0000_0053,
0xD1FF_0000_0000_0820, 0xD1FF_0000_0000_0054,
0xD1FF_0000_0000_08F9, 0xD1FF_0000_0000_0057,
0xD1FF_0000_0000_091C, 0xD1FF_0000_0000_0058,
0xD1FF_0000_0000_0937, 0xD1FF_0000_0000_005A,
0xD1FF_0000_0000_09A6, 0xD1FF_0000_0000_005B,
0xD1FF_0000_0000_09C3, 0xD1FF_0000_0000_005C,
0xD1FF_0000_0000_09DD, 0xD1FF_0000_0000_005D,
0xD1FF_0000_0000_0BD9, 0xD1FF_0000_0000_005F,
0xD1FF_0000_0000_0BEC, 0xD1FF_0000_0000_0061,
0xD1FF_0000_0000_0BF2, 0xD1FF_0000_0000_0062,
0xD1FF_0000_0000_0C1B, 0xD1FF_0000_0000_0063,
0xD1FF_0000_0000_0C26, 0xD1FF_0000_0000_0069,
0xD1FF_0000_0000_0C36,
0xD1FF_0000_0000_0C4B,
0xD1FF_0000_0000_0C78,
0xD1FF_0000_0000_0CBC,
]; ];
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states). /// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
pub const MEDIUM_SEEDS: &[u64] = &[ pub const MEDIUM_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-06-04) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
0xD1FF_0000_0000_0000,
0xD1FF_0000_0000_0012, 0xD1FF_0000_0000_0012,
0xD1FF_0000_0000_002C, 0xD1FF_0000_0000_0016,
0xD1FF_0000_0000_004B, 0xD1FF_0000_0000_001B,
0xD1FF_0000_0000_0052, 0xD1FF_0000_0000_001C,
0xD1FF_0000_0000_0058, 0xD1FF_0000_0000_0020,
0xD1FF_0000_0000_005E, 0xD1FF_0000_0000_002A,
0xD1FF_0000_0000_0063, 0xD1FF_0000_0000_0034,
0xD1FF_0000_0000_003A,
0xD1FF_0000_0000_0041,
0xD1FF_0000_0000_0043,
0xD1FF_0000_0000_0060,
0xD1FF_0000_0000_006A,
0xD1FF_0000_0000_006C,
0xD1FF_0000_0000_006E,
0xD1FF_0000_0000_006F,
0xD1FF_0000_0000_0071,
0xD1FF_0000_0000_0072,
0xD1FF_0000_0000_0075,
0xD1FF_0000_0000_0076,
0xD1FF_0000_0000_007B,
0xD1FF_0000_0000_007E,
0xD1FF_0000_0000_0081,
0xD1FF_0000_0000_0083,
0xD1FF_0000_0000_0084,
0xD1FF_0000_0000_0087,
0xD1FF_0000_0000_0090,
0xD1FF_0000_0000_0092,
0xD1FF_0000_0000_0093,
0xD1FF_0000_0000_0098,
0xD1FF_0000_0000_0099, 0xD1FF_0000_0000_0099,
0xD1FF_0000_0000_00A9, 0xD1FF_0000_0000_009A,
0xD1FF_0000_0000_009E,
0xD1FF_0000_0000_00A5,
0xD1FF_0000_0000_00A8,
0xD1FF_0000_0000_00AA,
0xD1FF_0000_0000_00AB,
0xD1FF_0000_0000_00AE,
0xD1FF_0000_0000_00AF, 0xD1FF_0000_0000_00AF,
0xD1FF_0000_0000_00BB, 0xD1FF_0000_0000_00B0,
0xD1FF_0000_0000_00D1,
0xD1FF_0000_0000_00E3,
0xD1FF_0000_0000_0108,
0xD1FF_0000_0000_010D,
0xD1FF_0000_0000_0110,
0xD1FF_0000_0000_012F,
0xD1FF_0000_0000_0139,
0xD1FF_0000_0000_013C,
0xD1FF_0000_0000_0148,
0xD1FF_0000_0000_015E,
0xD1FF_0000_0000_016A,
0xD1FF_0000_0000_016F,
0xD1FF_0000_0000_0179,
0xD1FF_0000_0000_019E,
0xD1FF_0000_0000_01A8,
0xD1FF_0000_0000_01AB,
0xD1FF_0000_0000_01B5,
0xD1FF_0000_0000_01B8,
0xD1FF_0000_0000_01D3,
0xD1FF_0000_0000_01EE,
0xD1FF_0000_0000_01F3,
0xD1FF_0000_0000_0202,
0xD1FF_0000_0000_0203,
0xD1FF_0000_0000_021E,
0xD1FF_0000_0000_022C,
0xD1FF_0000_0000_022D,
0xD1FF_0000_0000_0233,
0xD1FF_0000_0000_0245,
0xD1FF_0000_0000_024E,
]; ];
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states). /// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
pub const HARD_SEEDS: &[u64] = &[ pub const HARD_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-06-04) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
0xD1FF_0000_0000_0006, 0xD1FF_0000_0000_001F,
0xD1FF_0000_0000_0008, 0xD1FF_0000_0000_0024,
0xD1FF_0000_0000_000F, 0xD1FF_0000_0000_0025,
0xD1FF_0000_0000_0011, 0xD1FF_0000_0000_0031,
0xD1FF_0000_0000_0022, 0xD1FF_0000_0000_0032,
0xD1FF_0000_0000_0023, 0xD1FF_0000_0000_003E,
0xD1FF_0000_0000_002A, 0xD1FF_0000_0000_004A,
0xD1FF_0000_0000_002D, 0xD1FF_0000_0000_006D,
0xD1FF_0000_0000_0040,
0xD1FF_0000_0000_0042,
0xD1FF_0000_0000_0050,
0xD1FF_0000_0000_005B,
0xD1FF_0000_0000_005D,
0xD1FF_0000_0000_0067,
0xD1FF_0000_0000_0069,
0xD1FF_0000_0000_006E,
0xD1FF_0000_0000_0072,
0xD1FF_0000_0000_0079, 0xD1FF_0000_0000_0079,
0xD1FF_0000_0000_007C, 0xD1FF_0000_0000_007C,
0xD1FF_0000_0000_0080, 0xD1FF_0000_0000_0080,
0xD1FF_0000_0000_0081, 0xD1FF_0000_0000_008A,
0xD1FF_0000_0000_0083, 0xD1FF_0000_0000_0097,
0xD1FF_0000_0000_0091,
0xD1FF_0000_0000_009B,
0xD1FF_0000_0000_00A1,
0xD1FF_0000_0000_00B1, 0xD1FF_0000_0000_00B1,
0xD1FF_0000_0000_00B2,
0xD1FF_0000_0000_00B3,
0xD1FF_0000_0000_00B5,
0xD1FF_0000_0000_00B7,
0xD1FF_0000_0000_00B8,
0xD1FF_0000_0000_00B9,
0xD1FF_0000_0000_00BA,
0xD1FF_0000_0000_00BB,
0xD1FF_0000_0000_00BC,
0xD1FF_0000_0000_00BD,
0xD1FF_0000_0000_00C2,
0xD1FF_0000_0000_00C3, 0xD1FF_0000_0000_00C3,
0xD1FF_0000_0000_00C5,
0xD1FF_0000_0000_00CC,
0xD1FF_0000_0000_00CE,
0xD1FF_0000_0000_00D1,
0xD1FF_0000_0000_00D2,
0xD1FF_0000_0000_00D6, 0xD1FF_0000_0000_00D6,
0xD1FF_0000_0000_00DD, 0xD1FF_0000_0000_00D7,
0xD1FF_0000_0000_00E8, 0xD1FF_0000_0000_00DC,
0xD1FF_0000_0000_00F2, 0xD1FF_0000_0000_00DF,
0xD1FF_0000_0000_0101, 0xD1FF_0000_0000_00E0,
0xD1FF_0000_0000_010F, 0xD1FF_0000_0000_00E1,
0xD1FF_0000_0000_0113, 0xD1FF_0000_0000_00E4,
0xD1FF_0000_0000_0118, 0xD1FF_0000_0000_00E6,
0xD1FF_0000_0000_0119, 0xD1FF_0000_0000_00E7,
0xD1FF_0000_0000_012D,
0xD1FF_0000_0000_0133,
0xD1FF_0000_0000_0144,
0xD1FF_0000_0000_0147,
]; ];
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states). /// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
pub const EXPERT_SEEDS: &[u64] = &[ pub const EXPERT_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-06-04) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
0xD1FF_0000_0000_0000, 0xD1FF_0000_0000_0006,
0xD1FF_0000_0000_0002, 0xD1FF_0000_0000_000B,
0xD1FF_0000_0000_000A, 0xD1FF_0000_0000_0019,
0xD1FF_0000_0000_0013,
0xD1FF_0000_0000_0017,
0xD1FF_0000_0000_001C,
0xD1FF_0000_0000_001F,
0xD1FF_0000_0000_0021,
0xD1FF_0000_0000_0024,
0xD1FF_0000_0000_0029,
0xD1FF_0000_0000_002E,
0xD1FF_0000_0000_0035,
0xD1FF_0000_0000_0045,
0xD1FF_0000_0000_0048,
0xD1FF_0000_0000_0049,
0xD1FF_0000_0000_004F,
0xD1FF_0000_0000_0062,
0xD1FF_0000_0000_006D,
0xD1FF_0000_0000_0074,
0xD1FF_0000_0000_0076,
0xD1FF_0000_0000_0082, 0xD1FF_0000_0000_0082,
0xD1FF_0000_0000_008F, 0xD1FF_0000_0000_00CB,
0xD1FF_0000_0000_0090, 0xD1FF_0000_0000_00D5,
0xD1FF_0000_0000_0097, 0xD1FF_0000_0000_00D8,
0xD1FF_0000_0000_009A, 0xD1FF_0000_0000_00E8,
0xD1FF_0000_0000_009F, 0xD1FF_0000_0000_00EA,
0xD1FF_0000_0000_00A5, 0xD1FF_0000_0000_00EB,
0xD1FF_0000_0000_00A8, 0xD1FF_0000_0000_00EC,
0xD1FF_0000_0000_00AD,
0xD1FF_0000_0000_00AE,
0xD1FF_0000_0000_00B8,
0xD1FF_0000_0000_00B9,
0xD1FF_0000_0000_00BC,
0xD1FF_0000_0000_00C5,
0xD1FF_0000_0000_00CA,
0xD1FF_0000_0000_00CE,
0xD1FF_0000_0000_00DE,
0xD1FF_0000_0000_00ED, 0xD1FF_0000_0000_00ED,
0xD1FF_0000_0000_00EE, 0xD1FF_0000_0000_00F2,
0xD1FF_0000_0000_00EF, 0xD1FF_0000_0000_00F3,
0xD1FF_0000_0000_00F4,
0xD1FF_0000_0000_00FE,
0xD1FF_0000_0000_00FF,
0xD1FF_0000_0000_0102,
0xD1FF_0000_0000_0103,
0xD1FF_0000_0000_0104,
0xD1FF_0000_0000_0105,
0xD1FF_0000_0000_0106,
0xD1FF_0000_0000_0109,
0xD1FF_0000_0000_010B,
0xD1FF_0000_0000_010C,
0xD1FF_0000_0000_0110,
0xD1FF_0000_0000_0113,
0xD1FF_0000_0000_0114,
0xD1FF_0000_0000_011B,
0xD1FF_0000_0000_011C,
0xD1FF_0000_0000_011E,
0xD1FF_0000_0000_0120,
0xD1FF_0000_0000_0121,
0xD1FF_0000_0000_0122,
0xD1FF_0000_0000_0123,
0xD1FF_0000_0000_0124,
0xD1FF_0000_0000_0126,
0xD1FF_0000_0000_012B,
0xD1FF_0000_0000_012C,
0xD1FF_0000_0000_012E,
]; ];
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states). /// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
pub const GRANDMASTER_SEEDS: &[u64] = &[ pub const GRANDMASTER_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-06-04) // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
0xD1FF_0000_0000_003C, 0xD1FF_0000_0000_0027,
0xD1FF_0000_0000_0047, 0xD1FF_0000_0000_00A0,
0xD1FF_0000_0000_005A, 0xD1FF_0000_0000_00C4,
0xD1FF_0000_0000_009C, 0xD1FF_0000_0000_00D4,
0xD1FF_0000_0000_00D2, 0xD1FF_0000_0000_00DE,
0xD1FF_0000_0000_00F4, 0xD1FF_0000_0000_00F9,
0xD1FF_0000_0000_00F6, 0xD1FF_0000_0000_0107,
0xD1FF_0000_0000_0104, 0xD1FF_0000_0000_0108,
0xD1FF_0000_0000_0106, 0xD1FF_0000_0000_0130,
0xD1FF_0000_0000_0111, 0xD1FF_0000_0000_0132,
0xD1FF_0000_0000_0112, 0xD1FF_0000_0000_0133,
0xD1FF_0000_0000_0116, 0xD1FF_0000_0000_0134,
0xD1FF_0000_0000_0117,
0xD1FF_0000_0000_011A,
0xD1FF_0000_0000_0123,
0xD1FF_0000_0000_012B,
0xD1FF_0000_0000_012E,
0xD1FF_0000_0000_0135, 0xD1FF_0000_0000_0135,
0xD1FF_0000_0000_0137,
0xD1FF_0000_0000_0139,
0xD1FF_0000_0000_013A, 0xD1FF_0000_0000_013A,
0xD1FF_0000_0000_013B, 0xD1FF_0000_0000_013D,
0xD1FF_0000_0000_013F,
0xD1FF_0000_0000_0140,
0xD1FF_0000_0000_0141, 0xD1FF_0000_0000_0141,
0xD1FF_0000_0000_0142,
0xD1FF_0000_0000_0143,
0xD1FF_0000_0000_0145,
0xD1FF_0000_0000_0146,
0xD1FF_0000_0000_014A, 0xD1FF_0000_0000_014A,
0xD1FF_0000_0000_014B, 0xD1FF_0000_0000_014B,
0xD1FF_0000_0000_014E, 0xD1FF_0000_0000_014C,
0xD1FF_0000_0000_014D,
0xD1FF_0000_0000_014F,
0xD1FF_0000_0000_0150, 0xD1FF_0000_0000_0150,
0xD1FF_0000_0000_0155, 0xD1FF_0000_0000_0151,
0xD1FF_0000_0000_0152,
0xD1FF_0000_0000_0153,
0xD1FF_0000_0000_0157, 0xD1FF_0000_0000_0157,
0xD1FF_0000_0000_0158, 0xD1FF_0000_0000_0158,
0xD1FF_0000_0000_0159, 0xD1FF_0000_0000_015B,
0xD1FF_0000_0000_015A,
0xD1FF_0000_0000_015C, 0xD1FF_0000_0000_015C,
0xD1FF_0000_0000_015D, 0xD1FF_0000_0000_015E,
0xD1FF_0000_0000_015F, 0xD1FF_0000_0000_0162,
0xD1FF_0000_0000_0166, 0xD1FF_0000_0000_0164,
0xD1FF_0000_0000_0173,
0xD1FF_0000_0000_0174,
0xD1FF_0000_0000_0178,
0xD1FF_0000_0000_017D,
0xD1FF_0000_0000_0182,
0xD1FF_0000_0000_0187,
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+4 -21
View File
@@ -99,12 +99,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
} }
} }
pub mod solver;
pub use solver::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
try_solve_from_state,
};
pub mod stats; pub mod stats;
pub use stats::{StatsExt, StatsSnapshot}; pub use stats::{StatsExt, StatsSnapshot};
@@ -124,8 +118,8 @@ pub use achievements::{
pub mod progress; pub mod progress;
pub use progress::{ pub use progress::{
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from, PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
progress_file_path, save_progress_to, xp_breakdown, xp_for_win, save_progress_to, xp_for_win,
}; };
pub mod weekly; pub mod weekly;
@@ -151,20 +145,14 @@ pub use settings::{
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod android_keystore; mod android_keystore;
#[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens; pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{ pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
}; };
pub mod sync_client; pub mod sync_client;
pub use sync_client::LocalOnlyProvider; pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend};
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub mod replay; pub mod replay;
pub use replay::{ pub use replay::{
@@ -172,15 +160,10 @@ pub use replay::{
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from, ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to, migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
}; };
// `latest_replay_path` is still consumed by the engine's one-shot legacy
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
// outside `replay.rs` and were dropped from the public surface.
#[allow(deprecated)] #[allow(deprecated)]
pub use replay::latest_replay_path; pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
#[cfg(not(target_arch = "wasm32"))]
pub mod matomo_client; pub mod matomo_client;
#[cfg(not(target_arch = "wasm32"))]
pub use matomo_client::MatomoClient; pub use matomo_client::MatomoClient;
pub mod platform; pub mod platform;
-59
View File
@@ -114,62 +114,3 @@ fn url_encode(s: &str) -> String {
}) })
.collect() .collect()
} }
#[cfg(test)]
mod tests {
use super::*;
fn pending(client: &MatomoClient) -> Vec<String> {
client.pending.lock().expect("pending lock").clone()
}
#[test]
fn event_buffers_encoded_matomo_query() {
let client = MatomoClient::new(
"https://analytics.example.com/",
7,
Some("alice bob".into()),
);
client.event("Game Flow", "Won+Fast", Some("draw three"), Some(42.5));
let pending = pending(&client);
assert_eq!(pending.len(), 1);
let query = &pending[0];
assert!(query.contains("idsite=7"));
assert!(query.contains("rec=1"));
assert!(query.contains("e_c=Game%20Flow"));
assert!(query.contains("e_a=Won%2BFast"));
assert!(query.contains("e_n=draw%20three"));
assert!(query.contains("e_v=42.5"));
assert!(query.contains("uid=alice%20bob"));
}
#[test]
fn event_buffer_drops_oldest_entries_when_capacity_exceeded() {
let client = MatomoClient::new("https://analytics.example.com", 1, None);
for idx in 0..101 {
client.event("Game", "Start", Some(&format!("event-{idx}")), None);
}
let pending = pending(&client);
assert_eq!(pending.len(), 51);
assert!(
pending[0].contains("event-50"),
"oldest retained event should be event-50, got {}",
pending[0]
);
assert!(
pending[50].contains("event-100"),
"newest retained event should be event-100, got {}",
pending[50]
);
}
#[test]
fn url_encode_leaves_unreserved_bytes_and_escapes_everything_else() {
assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~");
assert_eq!(url_encode("a b+c/d?"), "a%20b%2Bc%2Fd%3F");
}
}
+1 -9
View File
@@ -55,15 +55,7 @@ pub fn data_dir() -> Option<PathBuf> {
{ {
Some(PathBuf::from(ANDROID_APP_FILES_DIR)) Some(PathBuf::from(ANDROID_APP_FILES_DIR))
} }
#[cfg(target_arch = "wasm32")] #[cfg(not(target_os = "android"))]
{
// No filesystem on the browser; all persistence goes through
// WasmStorage (localStorage-backed). Return None so every caller
// degrades gracefully (the same path they take on a
// misconfigured desktop environment).
None
}
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
{ {
dirs::data_dir() dirs::data_dir()
} }
+5 -35
View File
@@ -25,34 +25,12 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
y * 10_000 + m * 100 + d y * 10_000 + m * 100 + d
} }
/// Component breakdown of the XP awarded for a win. /// XP awarded for winning a game.
///
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
/// it for the total, and UI that displays the individual lines (the win-summary
/// modal) reads the parts from here so the breakdown can never drift from the
/// total.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct XpBreakdown {
/// Flat base XP granted for any win.
pub base: u64,
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
pub speed_bonus: u64,
/// Bonus for winning without using undo (25, else 0).
pub no_undo_bonus: u64,
}
impl XpBreakdown {
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
pub fn total(self) -> u64 {
self.base + self.speed_bonus + self.no_undo_bonus
}
}
/// Component breakdown of the XP awarded for a win.
/// ///
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if /// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
/// the player did not use undo. /// the player did not use undo.
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown { pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
let base: u64 = 50;
let speed_bonus: u64 = if time_seconds >= 120 { let speed_bonus: u64 = if time_seconds >= 120 {
0 0
} else { } else {
@@ -61,16 +39,8 @@ pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120); let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
scaled.max(10) scaled.max(10)
}; };
XpBreakdown { let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
base: 50, base + speed_bonus + no_undo_bonus
speed_bonus,
no_undo_bonus: if used_undo { 0 } else { 25 },
}
}
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
xp_breakdown(time_seconds, used_undo).total()
} }
/// Platform-specific default path for `progress.json`. /// Platform-specific default path for `progress.json`.
+8 -9
View File
@@ -26,8 +26,8 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{DrawMode, game_state::GameMode}; use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::klondike_adapter::SavedKlondikePile; use solitaire_core::pile::PileType;
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json"; const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -96,9 +96,9 @@ pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call. /// A successful `move_cards(from, to, count)` call.
Move { Move {
/// Source pile. /// Source pile.
from: SavedKlondikePile, from: PileType,
/// Destination pile. /// Destination pile.
to: SavedKlondikePile, to: PileType,
/// Number of cards moved. /// Number of cards moved.
count: usize, count: usize,
}, },
@@ -442,7 +442,6 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
#[allow(deprecated)] #[allow(deprecated)]
mod tests { mod tests {
use super::*; use super::*;
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
use std::env; use std::env;
fn tmp_path(name: &str) -> PathBuf { fn tmp_path(name: &str) -> PathBuf {
@@ -461,14 +460,14 @@ mod tests {
vec![ vec![
ReplayMove::StockClick, ReplayMove::StockClick,
ReplayMove::Move { ReplayMove::Move {
from: SavedKlondikePile::Stock, from: PileType::Waste,
to: SavedKlondikePile::Tableau(SavedTableau(3)), to: PileType::Tableau(3),
count: 1, count: 1,
}, },
ReplayMove::StockClick, ReplayMove::StockClick,
ReplayMove::Move { ReplayMove::Move {
from: SavedKlondikePile::Tableau(SavedTableau(3)), from: PileType::Tableau(3),
to: SavedKlondikePile::Foundation(SavedFoundation(0)), to: PileType::Foundation(0),
count: 1, count: 1,
}, },
], ],
+5 -5
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{DrawMode, game_state::DifficultyLevel}; use solitaire_core::game_state::{DifficultyLevel, DrawMode};
const SETTINGS_FILE_NAME: &str = "settings.json"; const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -200,7 +200,7 @@ pub struct Settings {
#[serde(default = "default_time_bonus_multiplier")] #[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32, pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the /// When `true`, the engine rejects new-game deals the
/// [`solitaire_data::solver`] cannot prove winnable, retrying /// [`solitaire_core::solver`] cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before /// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default — /// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the /// the solver adds a few hundred milliseconds of latency on the
@@ -381,9 +381,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`] /// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest /// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If /// candidate seed when [`Settings::winnable_deals_only`] is on. If
/// every retry comes back provably unwinnable (`Ok(None)` from the /// every retry comes back [`SolverResult::Unwinnable`] (which would
/// solver, which would be very unusual) we'd rather hand the player a /// be very unusual) we'd rather hand the player a possibly-unwinnable
/// possibly-unwinnable deal than spin forever on the main thread. /// deal than spin forever on the main thread.
/// ///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall — /// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on. /// the upper bound on UI freeze when the toggle is on.
-140
View File
@@ -1,140 +0,0 @@
//! Klondike solvability check using upstream `card_game::Session::solve()`.
//!
//! Backs the **Settings → Gameplay → "Winnable deals only"** toggle, the
//! Play-by-seed verdict badge, and the hint system (which wants the first
//! move on a winning path). All search is delegated to `card_game`; this
//! module only adapts the inputs (a seed or a live [`GameState`]) and extracts
//! the first move from the returned solution.
use card_game::{Session, SessionConfig, SolveError};
use klondike::KlondikeInstruction;
use solitaire_core::DrawMode;
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::KlondikeAdapter;
/// Default move budget for a solve. Matches the winnable-deal retry loop.
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
/// Default unique-state budget for a solve.
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
/// Outcome of a solvability check:
///
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first move on a
/// winning path (used by the hint system).
/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or
/// the game is already won so no next move exists).
/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded
/// before a verdict was reached.
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;
/// Solves a fresh Classic-mode game dealt from `seed` + `draw_mode`.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
/// take-from-foundation house rule stays disabled here.
pub fn try_solve(
seed: u64,
draw_mode: DrawMode,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
let mut game = GameState::new(seed, draw_mode);
game.take_from_foundation = false;
try_solve_from_state(&game, moves_budget, states_budget)
}
/// Solves from an existing in-progress [`GameState`], returning the first move
/// on a winning path when one exists.
pub fn try_solve_from_state(
state: &GameState,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
// An already-won game has no "next move"; report it as unwinnable so the
// winnable contract (`Some(_)` ⇒ a real move exists) holds.
if state.is_won() {
return Ok(None);
}
let config = SessionConfig {
inner: KlondikeAdapter::config_for(state.draw_mode(), state.take_from_foundation),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let session = Session::new(state.session().state().state().clone(), config);
session.solve().map(|solution| {
solution.and_then(|solution| {
solution
.raw_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.find(|instruction| !instruction.is_useless())
})
})
}
#[cfg(test)]
mod tests {
use super::*;
/// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
/// extracted first move (both `Eq`) rather than the whole `Result`.
fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
(outcome.is_err(), outcome.clone().ok().flatten())
}
#[test]
fn try_solve_is_deterministic() {
let a = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
let b = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
assert_eq!(verdict_key(&a), verdict_key(&b));
}
#[test]
fn winnable_verdict_carries_a_first_move() {
// Contract: a first move is present iff the verdict is winnable.
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[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 outcome = try_solve_from_state(&game, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn zero_state_budget_is_inconclusive() {
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving
// the budget reaches the solver unchanged.
let easy = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
let medium = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
#[test]
fn budget_above_five_thousand_is_not_clamped() {
let below_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 5_000, 5_000);
let above_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 50_000, 50_000);
assert!(below_cap.is_err(), "seed must be Inconclusive at 5 000 states");
assert!(
matches!(above_cap, Ok(Some(_))),
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this"
);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`. //! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
use chrono::Utc; use chrono::Utc;
use solitaire_core::{DrawMode, game_state::GameMode}; use solitaire_core::game_state::{DrawMode, GameMode};
pub use solitaire_sync::StatsSnapshot; pub use solitaire_sync::StatsSnapshot;
+38 -157
View File
@@ -3,13 +3,13 @@
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power //! All saves go through `filename.json.tmp` → `rename()` so a crash or power
//! loss during a write never corrupts the saved data. //! loss during a write never corrupts the saved data.
use chrono::Utc;
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::game_state::GameState; use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
use crate::stats::StatsSnapshot; use crate::stats::StatsSnapshot;
@@ -85,13 +85,16 @@ pub fn game_state_file_path() -> Option<PathBuf> {
pub fn load_game_state_from(path: &Path) -> Option<GameState> { pub fn load_game_state_from(path: &Path) -> Option<GameState> {
let data = fs::read(path).ok()?; let data = fs::read(path).ok()?;
let gs: GameState = serde_json::from_slice(&data).ok()?; let gs: GameState = serde_json::from_slice(&data).ok()?;
if gs.is_won() { None } else { Some(gs) } if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
if gs.is_won { None } else { Some(gs) }
} }
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won` /// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
/// because a completed game should not be resumed. /// because a completed game should not be resumed.
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> { pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
if gs.is_won() { if gs.is_won {
return Ok(()); return Ok(());
} }
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -231,7 +234,9 @@ pub fn load_time_attack_session_from_at(
/// See [`load_time_attack_session_from_at`] for the rules under which /// See [`load_time_attack_session_from_at`] for the rules under which
/// the call returns `None` (missing file, corrupt JSON, expired window). /// the call returns `None` (missing file, corrupt JSON, expired window).
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> { pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
let now = Utc::now().timestamp().max(0) as u64; let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
load_time_attack_session_from_at(path, now) load_time_attack_session_from_at(path, now)
} }
@@ -249,7 +254,9 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
/// current wall-clock time. Equivalent to constructing the struct /// current wall-clock time. Equivalent to constructing the struct
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`. /// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession { pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
let now = Utc::now().timestamp().max(0) as u64; let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
TimeAttackSession { TimeAttackSession {
remaining_secs, remaining_secs,
wins, wins,
@@ -279,7 +286,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
mod tests { mod tests {
use super::*; use super::*;
use crate::stats::{StatsExt, StatsSnapshot}; use crate::stats::{StatsExt, StatsSnapshot};
use solitaire_core::DrawMode; use solitaire_core::game_state::DrawMode;
use std::env; use std::env;
fn tmp_path(name: &str) -> PathBuf { fn tmp_path(name: &str) -> PathBuf {
@@ -377,7 +384,7 @@ mod tests {
#[test] #[test]
fn game_state_round_trip() { fn game_state_round_trip() {
use solitaire_core::game_state::GameState; use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("round_trip"); let path = gs_path("round_trip");
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
@@ -386,8 +393,8 @@ mod tests {
let loaded = load_game_state_from(&path).expect("load"); let loaded = load_game_state_from(&path).expect("load");
assert_eq!(loaded.seed, gs.seed); assert_eq!(loaded.seed, gs.seed);
assert_eq!(loaded.draw_mode(), gs.draw_mode()); assert_eq!(loaded.draw_mode, gs.draw_mode);
assert!(!loaded.is_won()); assert!(!loaded.is_won);
} }
#[test] #[test]
@@ -406,12 +413,12 @@ mod tests {
#[test] #[test]
fn save_game_state_skips_won_games() { fn save_game_state_skips_won_games() {
use solitaire_core::game_state::GameState; use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("won_skip"); let path = gs_path("won_skip");
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
let mut gs = GameState::new(99, DrawMode::DrawOne); let mut gs = GameState::new(99, DrawMode::DrawOne);
gs.set_test_won(true); gs.is_won = true;
save_game_state_to(&path, &gs).expect("save should be no-op, not error"); save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!( assert!(
!path.exists(), !path.exists(),
@@ -419,9 +426,26 @@ mod tests {
); );
} }
#[test]
fn load_game_state_ignores_won_games() {
use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("won_load");
let _ = fs::remove_file(&path);
// Write a won game directly (bypassing save_game_state_to's guard).
let mut gs = GameState::new(77, DrawMode::DrawOne);
gs.is_won = true;
let json = serde_json::to_string_pretty(&gs).unwrap();
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes()).unwrap();
fs::rename(&tmp, &path).unwrap();
assert!(load_game_state_from(&path).is_none());
}
#[test] #[test]
fn delete_game_state_removes_file() { fn delete_game_state_removes_file() {
use solitaire_core::game_state::GameState; use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("delete"); let path = gs_path("delete");
let gs = GameState::new(1, DrawMode::DrawOne); let gs = GameState::new(1, DrawMode::DrawOne);
save_game_state_to(&path, &gs).expect("save"); save_game_state_to(&path, &gs).expect("save");
@@ -439,7 +463,7 @@ mod tests {
#[test] #[test]
fn save_game_state_is_atomic() { fn save_game_state_is_atomic() {
use solitaire_core::game_state::GameState; use solitaire_core::game_state::{DrawMode, GameState};
let path = gs_path("atomic"); let path = gs_path("atomic");
let gs = GameState::new(55, DrawMode::DrawThree); let gs = GameState::new(55, DrawMode::DrawThree);
save_game_state_to(&path, &gs).expect("save"); save_game_state_to(&path, &gs).expect("save");
@@ -492,149 +516,6 @@ mod tests {
assert_eq!(loaded, StatsSnapshot::default()); assert_eq!(loaded, StatsSnapshot::default());
} }
/// Schema v4 serialises the instruction history using upstream
/// `KlondikeInstruction` serde (named enum variants). The deserialiser
/// replays all `saved_moves` to reconstruct every pile.
///
/// A fresh-game test (zero moves) never exercises that replay path, so this
/// test plays several real moves — including an undo — before saving, then
/// asserts the full pile layout round-trips exactly.
///
/// `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.
#[test]
fn game_state_v4_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);
// Draw several times to populate the instruction history with
// RotateStock entries and expose waste cards for further moves.
for _ in 0..6 {
if gs.draw().is_err() {
break;
}
}
// Execute the first available DstTableau or DstFoundation move so the
// instruction history contains a type other than RotateStock.
if let Some(instruction) = gs.possible_instructions().into_iter().find(|i| {
matches!(
i,
KlondikeInstruction::DstTableau(_) | KlondikeInstruction::DstFoundation(_)
)
}) {
let _ = gs.apply_instruction(instruction);
}
// Undo once: verifies that `undo_count` is persisted and that the
// truncated history (post-undo) replays back to the correct state.
if gs.undo_stack_len() > 0 {
let _ = gs.undo();
}
assert!(
gs.undo_stack_len() > 0,
"instruction history must be non-empty (seed 42 always produces draws)",
);
save_game_state_to(&path, &gs).expect("save");
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
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",
);
let loaded = load_game_state_from(&path)
.expect("a valid in-progress game must load without error");
assert_eq!(
loaded, gs,
"all pile layouts and counters must be identical after schema-v4 round-trip",
);
}
/// 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.
#[test]
fn game_state_v3_migrates_to_v4() {
use solitaire_core::game_state::GameState;
let path = gs_path("v3_migrate");
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",
"score": 0,
"elapsed_seconds": 0,
"seed": 42,
"undo_count": 0,
"recycle_count": 0,
"take_from_foundation": true,
"schema_version": 3,
"saved_moves": ["RotateStock"]
}"#;
fs::write(&path, v3_json).expect("write v3 fixture");
let loaded = load_game_state_from(&path)
.expect("schema v3 must be accepted and migrated to v4");
// 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");
}
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
/// history). Any file claiming `schema_version: 2` must be rejected so
/// players upgrading from an older build start with a fresh game rather
/// than a half-reconstructed state.
#[test]
fn save_format_v2_is_rejected() {
let path = gs_path("schema_v2");
let _ = fs::remove_file(&path);
// Structurally valid JSON for `PersistedGameState` but with
// `schema_version: 2`. The schema-version gate in
// `GameState::deserialize` must reject this before replay starts.
let v2_json = r#"{
"draw_mode": "DrawOne",
"mode": "Classic",
"score": 0,
"elapsed_seconds": 0,
"seed": 42,
"undo_count": 0,
"recycle_count": 0,
"take_from_foundation": true,
"schema_version": 2,
"saved_moves": []
}"#;
fs::write(&path, v2_json).expect("write v2 fixture");
assert!(
load_game_state_from(&path).is_none(),
"schema v2 game_state.json must be rejected — player must start a fresh game",
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Time Attack session persistence // Time Attack session persistence
// //
+3 -19
View File
@@ -12,14 +12,10 @@
//! without matching on [`SyncBackend`] anywhere else in the codebase. //! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait; use async_trait::async_trait;
#[cfg(not(target_arch = "wasm32"))] use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
use solitaire_sync::{SyncPayload, SyncResponse};
use crate::{SyncError, SyncProvider};
#[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
SyncError, SyncProvider,
auth_tokens::{load_access_token, load_refresh_token, store_tokens}, auth_tokens::{load_access_token, load_refresh_token, store_tokens},
replay::Replay, replay::Replay,
settings::SyncBackend, settings::SyncBackend,
@@ -58,17 +54,12 @@ impl SyncProvider for LocalOnlyProvider {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SolitaireServerClient // SolitaireServerClient
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Native-only: HTTP sync client and factory function.
// On wasm32 these are gated out because reqwest uses native OS networking
// (mio + hyper) which does not compile for wasm32-unknown-unknown.
// ---------------------------------------------------------------------------
/// HTTP sync client for the self-hosted Ferrous Solitaire server. /// HTTP sync client for the self-hosted Ferrous Solitaire server.
/// ///
/// Authenticates via JWT stored in the OS keychain. On a 401 response the /// Authenticates via JWT stored in the OS keychain. On a 401 response the
/// client automatically attempts a token refresh and retries the request once /// client automatically attempts a token refresh and retries the request once
/// before returning an error. /// before returning an error.
#[cfg(not(target_arch = "wasm32"))]
pub struct SolitaireServerClient { pub struct SolitaireServerClient {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`. /// Base URL of the server, e.g. `"https://solitaire.example.com"`.
/// Trailing slashes are stripped on construction. /// Trailing slashes are stripped on construction.
@@ -79,7 +70,6 @@ pub struct SolitaireServerClient {
client: reqwest::Client, client: reqwest::Client,
} }
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient { impl SolitaireServerClient {
/// Construct a new client for the given server URL and username. /// Construct a new client for the given server URL and username.
/// ///
@@ -211,7 +201,6 @@ impl SolitaireServerClient {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
#[async_trait] #[async_trait]
impl SyncProvider for SolitaireServerClient { impl SyncProvider for SolitaireServerClient {
/// Fetch the latest sync payload from the server. /// Fetch the latest sync payload from the server.
@@ -497,7 +486,6 @@ impl SyncProvider for SolitaireServerClient {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient { impl SolitaireServerClient {
/// Pulled out of `push_replay` so both the first attempt and the /// Pulled out of `push_replay` so both the first attempt and the
/// post-401-retry attempt go through the same parse path. /// post-401-retry attempt go through the same parse path.
@@ -593,10 +581,9 @@ impl SolitaireServerClient {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Response extraction helpers (native-only, use reqwest::Response) // Response extraction helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a pull response body as [`SyncResponse`] and return its /// Deserialize a pull response body as [`SyncResponse`] and return its
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`]. /// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
/// ///
@@ -620,7 +607,6 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
} }
} }
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`. /// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
async fn extract_leaderboard_body( async fn extract_leaderboard_body(
resp: reqwest::Response, resp: reqwest::Response,
@@ -635,7 +621,6 @@ async fn extract_leaderboard_body(
} }
} }
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a push response body as [`SyncResponse`], or map non-200 /// Deserialize a push response body as [`SyncResponse`], or map non-200
/// statuses to the appropriate [`SyncError`]. /// statuses to the appropriate [`SyncError`].
/// ///
@@ -667,7 +652,6 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
/// This is the **one** place in the codebase that matches on [`SyncBackend`] /// This is the **one** place in the codebase that matches on [`SyncBackend`]
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>` /// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
/// and remains backend-agnostic. /// and remains backend-agnostic.
#[cfg(not(target_arch = "wasm32"))]
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> { pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
match backend { match backend {
SyncBackend::Local => Box::new(LocalOnlyProvider), SyncBackend::Local => Box::new(LocalOnlyProvider),
+1 -1
View File
@@ -4,7 +4,7 @@
//! increments matching counters in `PlayerProgress::weekly_goal_progress`. //! increments matching counters in `PlayerProgress::weekly_goal_progress`.
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate};
use solitaire_core::DrawMode; use solitaire_core::game_state::DrawMode;
/// XP awarded each time a weekly goal is just completed. /// XP awarded each time a weekly goal is just completed.
pub const WEEKLY_GOAL_XP: u64 = 75; pub const WEEKLY_GOAL_XP: u64 = 75;
+11 -16
View File
@@ -7,11 +7,14 @@ edition.workspace = true
[dependencies] [dependencies]
bevy = { workspace = true } bevy = { workspace = true }
image = { workspace = true } image = { workspace = true }
reqwest = { workspace = true }
kira = { workspace = true }
solitaire_core = { workspace = true } solitaire_core = { workspace = true }
solitaire_data = { workspace = true } solitaire_data = { workspace = true }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@@ -19,24 +22,17 @@ usvg = { workspace = true }
resvg = { workspace = true } resvg = { workspace = true }
tiny-skia = { workspace = true } tiny-skia = { workspace = true }
ron = { workspace = true } ron = { workspace = true }
# These deps are not available / not needed on wasm32:
# reqwest — uses mio/hyper native networking (sync plugin is gated out)
# kira — uses cpal OS audio (audio plugin is gated out)
# tokio — multi-threaded runtime (TokioRuntimeResource is gated out)
# dirs — platform data directories (storage uses WasmStorage instead)
# zip — theme ZIP importer (importer is gated out on wasm32)
# arboard — clipboard (no wasm backend; stats copy-link uses localStorage)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
reqwest = { workspace = true }
kira = { workspace = true }
tokio = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
zip = { workspace = true } zip = { workspace = true }
# `arboard` has no Android backend and no wasm32 backend. Gate it out for # `arboard` provides clipboard access for the Stats overlay's
# both; the copy-share-link button surfaces an informational toast instead. # "Copy share link" button. The crate has no Android backend
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies] # (its `platform::Clipboard` module is unimplemented for the
# android target — `cargo apk build` fails with E0433 if this is
# left unconditional). On Android the same button surfaces an
# informational toast instead; see
# `stats_plugin::handle_copy_share_link_button`.
[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = { workspace = true } arboard = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
@@ -51,4 +47,3 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
[dev-dependencies] [dev-dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] }
+3 -3
View File
@@ -819,7 +819,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree); .draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree); .draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -1393,7 +1393,7 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState; use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{DrawMode, game_state::GameMode}; use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove}; use solitaire_data::{Replay, ReplayMove};
/// Headless app variant that injects a default `ReplayPlaybackState` /// Headless app variant that injects a default `ReplayPlaybackState`
-58
View File
@@ -204,61 +204,3 @@ fn mode_str(mode: GameMode) -> &'static str {
GameMode::Difficulty(_) => "difficulty", GameMode::Difficulty(_) => "difficulty",
} }
} }
#[cfg(test)]
mod tests {
use solitaire_core::game_state::DifficultyLevel;
use super::*;
#[test]
fn client_for_requires_analytics_opt_in() {
let settings = Settings {
analytics_enabled: false,
matomo_url: Some("https://analytics.example.com".into()),
..Settings::default()
};
assert!(client_for(&settings).is_none());
}
#[test]
fn client_for_requires_matomo_url() {
let settings = Settings {
analytics_enabled: true,
matomo_url: None,
..Settings::default()
};
assert!(client_for(&settings).is_none());
}
#[test]
fn client_for_creates_client_when_enabled_and_configured() {
let settings = Settings {
analytics_enabled: true,
matomo_url: Some("https://analytics.example.com".into()),
matomo_site_id: 2,
sync_backend: SyncBackend::SolitaireServer {
url: "https://solitaire.example.com".into(),
username: "alice".into(),
avatar_url: None,
},
..Settings::default()
};
assert!(client_for(&settings).is_some());
}
#[test]
fn mode_labels_match_analytics_payload_contract() {
assert_eq!(mode_str(GameMode::Classic), "classic");
assert_eq!(mode_str(GameMode::Zen), "zen");
assert_eq!(mode_str(GameMode::Challenge), "challenge");
assert_eq!(mode_str(GameMode::TimeAttack), "time_attack");
assert_eq!(
mode_str(GameMode::Difficulty(DifficultyLevel::Grandmaster)),
"difficulty"
);
}
}
+3 -5
View File
@@ -13,7 +13,6 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_data::{AnimSpeed, Settings}; use solitaire_data::{AnimSpeed, Settings};
use crate::achievement_plugin::display_name_for; use crate::achievement_plugin::display_name_for;
@@ -181,7 +180,6 @@ impl Plugin for AnimationPlugin {
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_message::<RequestRedraw>()
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
.init_resource::<ActiveToast>() .init_resource::<ActiveToast>()
@@ -1078,7 +1076,7 @@ mod tests {
// Pairs the existing audio (`card_invalid.wav`) and visual // Pairs the existing audio (`card_invalid.wav`) and visual
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback // (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
// with an accessibility-focused readable text cue. // with an accessibility-focused readable text cue.
use solitaire_core::{KlondikePile, Tableau}; use solitaire_core::pile::PileType;
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
@@ -1090,8 +1088,8 @@ mod tests {
.count(); .count();
app.world_mut().write_message(MoveRejectedEvent { app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1), from: PileType::Tableau(0),
to: KlondikePile::Tableau(Tableau::Tableau2), to: PileType::Tableau(1),
count: 1, count: 1,
}); });
app.update(); app.update();
+5 -14
View File
@@ -47,16 +47,12 @@
//! comments on each call out the pairing so a future reader doesn't //! comments on each call out the pairing so a future reader doesn't
//! accidentally drop one half. //! accidentally drop one half.
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::AssetApp; use bevy::asset::AssetApp;
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::io::AssetSourceBuilder; use bevy::asset::io::AssetSourceBuilder;
use bevy::asset::io::embedded::EmbeddedAssetRegistry; use bevy::asset::io::embedded::EmbeddedAssetRegistry;
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::io::file::FileAssetReader; use bevy::asset::io::file::FileAssetReader;
use bevy::prelude::*; use bevy::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_dir::user_theme_dir; use crate::assets::user_dir::user_theme_dir;
/// `AssetSourceId` of the user-themes asset source. Use it as /// `AssetSourceId` of the user-themes asset source. Use it as
@@ -239,16 +235,11 @@ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
/// Returns the `&mut App` so the call can be chained from the binary /// Returns the `&mut App` so the call can be chained from the binary
/// entry point. /// entry point.
pub fn register_theme_asset_sources(app: &mut App) -> &mut App { pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
// User themes are stored on the filesystem; wasm32 has no filesystem and let root = user_theme_dir();
// `FileAssetReader` is not available on that target. app.register_asset_source(
#[cfg(not(target_arch = "wasm32"))] USER_THEMES,
{ AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
let root = user_theme_dir(); );
app.register_asset_source(
USER_THEMES,
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
);
}
app app
} }
+7 -17
View File
@@ -82,23 +82,13 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
/// the panic message names the supported workaround. /// the panic message names the supported workaround.
fn detected_platform_data_dir() -> PathBuf { fn detected_platform_data_dir() -> PathBuf {
solitaire_data::data_dir().unwrap_or_else(|| { solitaire_data::data_dir().unwrap_or_else(|| {
// On wasm32, data_dir() always returns None — there is no filesystem. panic!(
// User themes are not supported in the browser build; return an empty "user_theme_dir(): platform data directory is unavailable. \
// path so callers produce a benign empty dir rather than panicking. On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
#[cfg(target_arch = "wasm32")] the OS reported no Application Support / AppData path. \
{ As a workaround call solitaire_engine::assets::user_dir::\
PathBuf::new() set_user_theme_dir() before App::run()."
} )
#[cfg(not(target_arch = "wasm32"))]
{
panic!(
"user_theme_dir(): platform data directory is unavailable. \
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
the OS reported no Application Support / AppData path. \
As a workaround call solitaire_engine::assets::user_dir::\
set_user_theme_dir() before App::run()."
)
}
}) })
} }
+5 -1
View File
@@ -34,6 +34,7 @@ use crate::events::{
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use solitaire_core::pile::PileType;
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0). /// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
const RECYCLE_VOLUME: f64 = 0.5; const RECYCLE_VOLUME: f64 = 0.5;
@@ -373,7 +374,10 @@ fn play_on_draw(
// When the stock pile is empty the draw action recycles the waste pile // When the stock pile is empty the draw action recycles the waste pile
// back to stock. Play the flip sound at half volume to give audible // back to stock. Play the flip sound at half volume to give audible
// feedback that distinguishes a recycle from a normal draw. // feedback that distinguishes a recycle from a normal draw.
let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound let stock_len = game
.as_ref()
.and_then(|g| g.0.piles.get(&PileType::Stock))
.map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound
if is_recycle(stock_len) { if is_recycle(stock_len) {
let mut data = lib.flip.clone(); let mut data = lib.flip.clone();
+55 -94
View File
@@ -9,9 +9,7 @@
//! returns `None` (e.g. a transient state), the plugin retries next tick. //! returns `None` (e.g. a transient state), the plugin retries next tick.
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw;
#[cfg(not(target_arch = "wasm32"))]
use crate::audio_plugin::{AudioState, SoundLibrary}; use crate::audio_plugin::{AudioState, SoundLibrary};
use crate::events::{MoveRequestEvent, StateChangedEvent}; use crate::events::{MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
@@ -22,18 +20,11 @@ use crate::resources::GameStateResource;
/// ///
/// Plays the win fanfare at half volume so it is clearly distinguishable from /// Plays the win fanfare at half volume so it is clearly distinguishable from
/// both normal card-place sounds and the full win fanfare that fires later. /// both normal card-place sounds and the full win fanfare that fires later.
#[cfg(not(target_arch = "wasm32"))]
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5; const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
/// Seconds between consecutive auto-complete moves. /// Seconds between consecutive auto-complete moves.
const STEP_INTERVAL: f32 = 0.12; const STEP_INTERVAL: f32 = 0.12;
/// Seconds to wait after detection before firing the first auto-complete move.
///
/// This pause gives the player a moment to register that the game is
/// transitioning into auto-complete mode before cards start moving.
const AUTO_COMPLETE_INITIAL_DELAY: f32 = 0.75;
/// Tracks whether auto-complete is active and when the next move fires. /// Tracks whether auto-complete is active and when the next move fires.
#[derive(Resource, Default, Debug)] #[derive(Resource, Default, Debug)]
pub struct AutoCompleteState { pub struct AutoCompleteState {
@@ -48,18 +39,16 @@ pub struct AutoCompletePlugin;
impl Plugin for AutoCompletePlugin { impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>() app.init_resource::<AutoCompleteState>().add_systems(
.add_message::<RequestRedraw>() Update,
.add_systems( (
Update, detect_auto_complete,
( on_auto_complete_start,
detect_auto_complete, drive_auto_complete,
on_auto_complete_start, )
drive_auto_complete, .chain()
) .after(GameMutation),
.chain() );
.after(GameMutation),
);
} }
} }
@@ -76,28 +65,21 @@ fn detect_auto_complete(
} }
changed.clear(); changed.clear();
if game.0.is_won() { if game.0.is_won {
state.active = false; state.active = false;
return; return;
} }
if game.0.is_auto_completable() && !state.active { if game.0.is_auto_completable && !state.active {
state.active = true; state.active = true;
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY; state.cooldown = 0.0; // fire first move immediately
} else if !game.0.is_auto_completable() && state.active {
// `is_auto_completable` only becomes false after an explicit undo
// (which puts a card back on the tableau or re-fills the stock/waste)
// or a new-game reset — never as a transient gap during a normal
// auto-complete sequence. Deactivate here so `drive_auto_complete`
// does not keep retrying indefinitely after the player undoes out of
// the sequence.
//
// Note: the transient-`None` case mentioned in older versions of this
// comment referred to `next_auto_complete_move()` returning `None`, not
// to `is_auto_completable` being false. Those are independent fields;
// `drive_auto_complete` still retries on a transient `None` return from
// `next_auto_complete_move` because that check happens there, not here.
state.active = false;
} }
// Intentionally no `else if !is_auto_completable` branch here.
// Deactivating on every frame where `is_auto_completable` is false
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
// transiently returns `None` (e.g. while the previous move is still
// in-flight). The `is_won` check above already handles the definitive
// end-of-game case; `drive_auto_complete` simply retries next tick
// when no move is available yet.
} }
/// Plays a distinct chime the moment auto-complete first activates. /// Plays a distinct chime the moment auto-complete first activates.
@@ -106,7 +88,6 @@ fn detect_auto_complete(
/// exactly once on the `false → true` edge. The win fanfare is played at half /// exactly once on the `false → true` edge. The win fanfare is played at half
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does /// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
/// not overwhelm the card-place sounds that follow immediately. /// not overwhelm the card-place sounds that follow immediately.
#[cfg(not(target_arch = "wasm32"))]
fn on_auto_complete_start( fn on_auto_complete_start(
state: Res<AutoCompleteState>, state: Res<AutoCompleteState>,
mut was_active: Local<bool>, mut was_active: Local<bool>,
@@ -127,12 +108,6 @@ fn on_auto_complete_start(
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME); audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
} }
// No audio on wasm — stub keeps the system registration unconditional.
#[cfg(target_arch = "wasm32")]
fn on_auto_complete_start(state: Res<AutoCompleteState>, mut was_active: Local<bool>) {
*was_active = state.active;
}
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active. /// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
fn drive_auto_complete( fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>, mut state: ResMut<AutoCompleteState>,
@@ -167,9 +142,9 @@ mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Deck, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::pile::PileType;
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -182,40 +157,31 @@ mod tests {
app app
} }
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) { /// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other
let mut g = GameState::new(1, DrawMode::DrawOne); /// tableau piles empty, stock/waste empty, Clubs foundation empty.
g.set_test_stock_cards(Vec::new()); fn nearly_won_state() -> GameState {
g.set_test_waste_cards(Vec::new()); let mut g = GameState::new(42, DrawMode::DrawOne);
for foundation in [ g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
Foundation::Foundation1, g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
Foundation::Foundation2, for i in 0..7 {
Foundation::Foundation3, g.piles
Foundation::Foundation4, .get_mut(&PileType::Tableau(i))
] { .unwrap()
g.set_test_foundation_cards(foundation, Vec::new()); .cards
.clear();
} }
for tableau in [ g.piles
Tableau::Tableau1, .get_mut(&PileType::Tableau(0))
Tableau::Tableau2, .unwrap()
Tableau::Tableau3, .cards
Tableau::Tableau4, .push(Card {
Tableau::Tableau5, id: 99,
Tableau::Tableau6, suit: Suit::Clubs,
Tableau::Tableau7, rank: Rank::Ace,
] { face_up: true,
g.set_test_tableau_cards(tableau, Vec::new()); });
} g.is_auto_completable = true;
g.set_test_tableau_cards( g
Tableau::Tableau1,
vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
g.set_test_auto_completable(true);
let expected = (
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Foundation(Foundation::Foundation1),
);
assert_eq!(g.next_auto_complete_move(), Some(expected));
(g, expected)
} }
#[test] #[test]
@@ -227,9 +193,8 @@ mod tests {
#[test] #[test]
fn detect_activates_when_auto_completable() { fn detect_activates_when_auto_completable() {
let mut app = headless_app(); let mut app = headless_app();
let mut g = GameState::new(42, DrawMode::DrawOne); // Install a nearly-won state and fire StateChangedEvent.
g.set_test_auto_completable(true); app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent); app.world_mut().write_message(StateChangedEvent);
app.update(); app.update();
@@ -239,14 +204,9 @@ mod tests {
#[test] #[test]
fn drive_fires_move_request_when_active() { fn drive_fires_move_request_when_active() {
let mut app = headless_app(); let mut app = headless_app();
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move(); app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent); app.world_mut().write_message(StateChangedEvent);
app.update(); // detect runs, sets active app.update(); // detect runs, sets active
// Zero out the cooldown so drive fires on the next update regardless
// of the initial delay constant.
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
app.update(); // drive fires the move app.update(); // drive fires the move
let events = app.world().resource::<Messages<MoveRequestEvent>>(); let events = app.world().resource::<Messages<MoveRequestEvent>>();
@@ -254,16 +214,17 @@ mod tests {
let fired: Vec<_> = cursor.read(events).collect(); let fired: Vec<_> = cursor.read(events).collect();
// At least one MoveRequestEvent should have been fired. // At least one MoveRequestEvent should have been fired.
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent"); assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
assert_eq!(fired[0].from, expected_from); assert_eq!(fired[0].from, PileType::Tableau(0));
assert_eq!(fired[0].to, expected_to); // First empty foundation slot wins on a fresh nearly-won board.
assert_eq!(fired[0].to, PileType::Foundation(0));
} }
#[test] #[test]
fn drive_deactivates_on_win() { fn drive_deactivates_on_win() {
let mut app = headless_app(); let mut app = headless_app();
// Inject a won game state — active should not be set. // Inject a won game state — active should not be set.
let (mut gs, _) = seeded_state_with_auto_move(); let mut gs = nearly_won_state();
gs.set_test_won(true); gs.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0 = gs; app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().write_message(StateChangedEvent); app.world_mut().write_message(StateChangedEvent);
app.update(); app.update();
@@ -33,7 +33,6 @@ use std::collections::VecDeque;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::card::Card;
use super::animation::CardAnimation; use super::animation::CardAnimation;
use super::tuning::AnimationTuning; use super::tuning::AnimationTuning;
@@ -211,12 +210,12 @@ pub(crate) fn apply_drag_visual(
// Only lift cards that are in a *committed* drag. Pending drags (below // Only lift cards that are in a *committed* drag. Pending drags (below
// threshold) must stay at scale 1.0 to avoid visible premature lift. // threshold) must stay at scale 1.0 to avoid visible premature lift.
let (dragged_cards, committed): (&[Card], bool) = drag let (dragged_ids, committed): (&[u32], bool) = drag
.as_ref() .as_ref()
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed)); .map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
for (_, card, mut transform) in &mut cards { for (_, card, mut transform) in &mut cards {
let is_active_drag = committed && dragged_cards.contains(&card.card); let is_active_drag = committed && dragged_ids.contains(&card.card_id);
let target_scale = if is_active_drag { drag_scale } else { 1.0 }; let target_scale = if is_active_drag { drag_scale } else { 1.0 };
let current = transform.scale.x; let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0); let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
@@ -92,7 +92,6 @@ pub use timing::{
pub use tuning::{AnimationTuning, InputPlatform}; pub use tuning::{AnimationTuning, InputPlatform};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
@@ -126,7 +125,6 @@ impl Plugin for CardAnimationPlugin {
.add_message::<DrawRequestEvent>() .add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>() .add_message::<UndoRequestEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<RequestRedraw>()
.init_resource::<DragState>() .init_resource::<DragState>()
.init_resource::<HoverState>() .init_resource::<HoverState>()
.init_resource::<InputBuffer>() .init_resource::<InputBuffer>()
@@ -100,7 +100,7 @@ impl AnimationTuning {
platform: InputPlatform::Mouse, platform: InputPlatform::Mouse,
duration_scale: 1.0, duration_scale: 1.0,
overshoot_scale: 1.0, overshoot_scale: 1.0,
drag_threshold_px: 6.0, drag_threshold_px: 4.0,
drag_scale: 1.08, drag_scale: 1.08,
hover_scale: 1.04, hover_scale: 1.04,
hover_lerp_speed: 14.0, hover_lerp_speed: 14.0,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -117,7 +117,7 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin; use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
+16 -24
View File
@@ -13,18 +13,16 @@ use crate::platform::{
default_storage_backend, default_storage_backend,
}; };
use crate::{ use crate::{
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
}; TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
#[cfg(not(target_arch = "wasm32"))] WinSummaryPlugin,
use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
}; };
/// Groups all Ferrous Solitaire gameplay plugins. /// Groups all Ferrous Solitaire gameplay plugins.
@@ -47,7 +45,6 @@ impl Plugin for CoreGamePlugin {
Ok(guard) => guard, Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(), Err(poisoned) => poisoned.into_inner(),
}; };
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
let sync_provider = sync_provider let sync_provider = sync_provider
.take() .take()
.expect("CoreGamePlugin::build called twice"); .expect("CoreGamePlugin::build called twice");
@@ -107,26 +104,21 @@ impl Plugin for CoreGamePlugin {
.add_plugins(HudPlugin) .add_plugins(HudPlugin)
.add_plugins(HelpPlugin) .add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default()) .add_plugins(HomePlugin::default())
.add_plugins(AvatarPlugin)
.add_plugins(ProfilePlugin) .add_plugins(ProfilePlugin)
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default()) .add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin) .add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin) .add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin); .add_plugins(DiagnosticsHudPlugin);
// Plugins that use kira/cpal audio or multi-threaded Tokio are not
// compatible with the single-threaded wasm32 runtime. Gate them out
// so the browser build boots silently and without a sync backend.
#[cfg(not(target_arch = "wasm32"))]
app.add_plugins(AvatarPlugin)
.add_plugins(AudioPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin);
} }
} }
+233 -78
View File
@@ -34,9 +34,9 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Card; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::pile::PileType;
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::RightClickHighlight; use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
@@ -66,10 +66,10 @@ const MARKER_VALID: Color = Color::srgba(0.675, 0.761, 0.404, 0.55);
/// Marker component on a parent entity that owns one drop-target overlay /// Marker component on a parent entity that owns one drop-target overlay
/// (a translucent fill plus four outline edges as children). The wrapped /// (a translucent fill plus four outline edges as children). The wrapped
/// `KlondikePile` identifies which pile this overlay highlights, so test /// `PileType` identifies which pile this overlay highlights, so test
/// queries and the despawn-on-target-change logic can filter by pile. /// queries and the despawn-on-target-change logic can filter by pile.
#[derive(Component, Debug, Clone, PartialEq, Eq)] #[derive(Component, Debug, Clone, PartialEq, Eq)]
pub struct DropTargetOverlay(pub KlondikePile); pub struct DropTargetOverlay(pub PileType);
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress. /// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
pub struct CursorPlugin; pub struct CursorPlugin;
@@ -163,34 +163,33 @@ fn update_cursor_icon(
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card. /// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool { fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
let piles = [ let piles = [
KlondikePile::Stock, PileType::Waste,
KlondikePile::Foundation(Foundation::Foundation1), PileType::Foundation(0),
KlondikePile::Foundation(Foundation::Foundation2), PileType::Foundation(1),
KlondikePile::Foundation(Foundation::Foundation3), PileType::Foundation(2),
KlondikePile::Foundation(Foundation::Foundation4), PileType::Foundation(3),
KlondikePile::Tableau(Tableau::Tableau1), PileType::Tableau(0),
KlondikePile::Tableau(Tableau::Tableau2), PileType::Tableau(1),
KlondikePile::Tableau(Tableau::Tableau3), PileType::Tableau(2),
KlondikePile::Tableau(Tableau::Tableau4), PileType::Tableau(3),
KlondikePile::Tableau(Tableau::Tableau5), PileType::Tableau(4),
KlondikePile::Tableau(Tableau::Tableau6), PileType::Tableau(5),
KlondikePile::Tableau(Tableau::Tableau7), PileType::Tableau(6),
]; ];
for pile in piles { for pile in piles {
let pile_cards = pile_cards(game, &pile); let Some(pile_cards) = game.piles.get(&pile) else {
if pile_cards.is_empty() {
continue; continue;
} };
let is_tableau = matches!(pile, KlondikePile::Tableau(_)); let is_tableau = matches!(pile, PileType::Tableau(_));
let base = layout.pile_positions[&pile]; let base = layout.pile_positions[&pile];
for (i, card) in pile_cards.iter().enumerate().rev() { for (i, card) in pile_cards.cards.iter().enumerate().rev() {
if !card.1 { if !card.face_up {
continue; continue;
} }
// Only the topmost card is draggable on non-tableau piles. // Only the topmost card is draggable on non-tableau piles.
if !is_tableau && i != pile_cards.len() - 1 { if !is_tableau && i != pile_cards.cards.len() - 1 {
continue; continue;
} }
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau); let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
@@ -227,14 +226,38 @@ fn update_drop_highlights(
let Some(game) = game else { return }; let Some(game) = game else { return };
let drag_count = drag.cards.len(); // The first element of drag.cards is the bottom card that lands on the target.
let Some(&bottom_id) = drag.cards.first() else {
let Some(origin) = drag.origin_pile.as_ref() else {
return; return;
}; };
let bottom_card = game
.0
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == bottom_id)
.cloned();
let Some(bottom_card) = bottom_card else {
return;
};
let drag_count = drag.cards.len();
for (marker, mut sprite, _rch) in &mut markers { for (marker, mut sprite, _rch) in &mut markers {
let valid = game.0.can_move_cards(origin, &marker.0, drag_count); let valid = match &marker.0 {
PileType::Foundation(slot) => {
if drag_count != 1 {
false
} else {
let pile = game.0.piles.get(&PileType::Foundation(*slot));
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
}
}
PileType::Tableau(idx) => {
let pile = game.0.piles.get(&PileType::Tableau(*idx));
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
}
_ => false,
};
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT }; sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
} }
} }
@@ -274,7 +297,20 @@ fn update_drop_target_overlays(
return; return;
}; };
let Some(origin) = drag.origin_pile.as_ref() else { // Resolve the bottom card of the dragged stack — same logic as
// `update_drop_highlights` so rules can't drift between the marker
// tint and the overlay.
let Some(&bottom_id) = drag.cards.first() else {
return;
};
let bottom_card = game
.0
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == bottom_id)
.cloned();
let Some(bottom_card) = bottom_card else {
return; return;
}; };
let drag_count = drag.cards.len(); let drag_count = drag.cards.len();
@@ -282,24 +318,44 @@ fn update_drop_target_overlays(
// Iterate the same pile list as `update_drop_highlights`. Stock and // Iterate the same pile list as `update_drop_highlights`. Stock and
// Waste are excluded because they are never legal drop targets. // Waste are excluded because they are never legal drop targets.
let candidates = [ let candidates = [
KlondikePile::Foundation(Foundation::Foundation1), PileType::Foundation(0),
KlondikePile::Foundation(Foundation::Foundation2), PileType::Foundation(1),
KlondikePile::Foundation(Foundation::Foundation3), PileType::Foundation(2),
KlondikePile::Foundation(Foundation::Foundation4), PileType::Foundation(3),
KlondikePile::Tableau(Tableau::Tableau1), PileType::Tableau(0),
KlondikePile::Tableau(Tableau::Tableau2), PileType::Tableau(1),
KlondikePile::Tableau(Tableau::Tableau3), PileType::Tableau(2),
KlondikePile::Tableau(Tableau::Tableau4), PileType::Tableau(3),
KlondikePile::Tableau(Tableau::Tableau5), PileType::Tableau(4),
KlondikePile::Tableau(Tableau::Tableau6), PileType::Tableau(5),
KlondikePile::Tableau(Tableau::Tableau7), PileType::Tableau(6),
]; ];
// Compute the new set of valid piles for this frame. // Compute the new set of valid piles for this frame.
let mut valid: Vec<KlondikePile> = Vec::new(); let mut valid: Vec<PileType> = Vec::new();
for pile in &candidates { for pile in &candidates {
if game.0.can_move_cards(origin, pile, drag_count) { let is_valid = match pile {
valid.push(*pile); PileType::Foundation(_) => {
if drag_count != 1 {
false
} else {
game.0
.piles
.get(pile)
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
}
}
PileType::Tableau(_) => game
.0
.piles
.get(pile)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
_ => false,
};
// Don't highlight the origin pile — dropping onto the source is
// a no-op.
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
valid.push(pile.clone());
} }
} }
@@ -311,9 +367,9 @@ fn update_drop_target_overlays(
} }
// Spawn overlays for piles that are now valid but don't yet have one. // Spawn overlays for piles that are now valid but don't yet have one.
let already_overlaid: Vec<KlondikePile> = overlays let already_overlaid: Vec<PileType> = overlays
.iter() .iter()
.map(|(_, m)| m.0) .map(|(_, m)| m.0.clone())
.filter(|p| valid.contains(p)) .filter(|p| valid.contains(p))
.collect(); .collect();
@@ -332,14 +388,10 @@ fn update_drop_target_overlays(
/// for everything else it is card-sized. Replicated here rather than /// for everything else it is card-sized. Replicated here rather than
/// imported because `pile_drop_rect` is private to `input_plugin` and /// imported because `pile_drop_rect` is private to `input_plugin` and
/// this overlay is the only other consumer. /// this overlay is the only other consumer.
fn drop_overlay_rect( fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
pile: &KlondikePile,
layout: &Layout,
game: &GameState,
) -> Option<(Vec2, Vec2)> {
let centre = layout.pile_positions.get(pile).copied()?; let centre = layout.pile_positions.get(pile).copied()?;
if matches!(pile, KlondikePile::Tableau(_)) { if matches!(pile, PileType::Tableau(_)) {
let card_count = game.pile(*pile).len(); let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
if card_count > 1 { if card_count > 1 {
let fan = -layout.card_size.y * layout.tableau_fan_frac; let fan = -layout.card_size.y * layout.tableau_fan_frac;
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32; let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
@@ -360,7 +412,7 @@ fn drop_overlay_rect(
/// the appropriate world position for `pile`. /// the appropriate world position for `pile`.
fn spawn_drop_target_overlay( fn spawn_drop_target_overlay(
commands: &mut Commands, commands: &mut Commands,
pile: &KlondikePile, pile: &PileType,
layout: &Layout, layout: &Layout,
game: &GameState, game: &GameState,
) { ) {
@@ -378,7 +430,7 @@ fn spawn_drop_target_overlay(
..default() ..default()
}, },
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY), Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
DropTargetOverlay(*pile), DropTargetOverlay(pile.clone()),
)) ))
.with_children(|parent| { .with_children(|parent| {
// Top edge. // Top edge.
@@ -427,7 +479,7 @@ fn spawn_drop_target_overlay(
fn tableau_or_stack_pos( fn tableau_or_stack_pos(
game: &GameState, game: &GameState,
layout: &Layout, layout: &Layout,
pile: &KlondikePile, pile: &PileType,
index: usize, index: usize,
base: Vec2, base: Vec2,
is_tableau: bool, is_tableau: bool,
@@ -437,8 +489,8 @@ fn tableau_or_stack_pos(
base.x, base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
) )
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree { } else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
let pile_len = game.waste_cards().len(); let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32; let slot = index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
@@ -447,14 +499,6 @@ fn tableau_or_stack_pos(
} }
} }
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
if matches!(pile, KlondikePile::Stock) {
game.waste_cards()
} else {
game.pile(*pile)
}
}
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
let half = size / 2.0; let half = size / 2.0;
point.x >= center.x - half.x point.x >= center.x - half.x
@@ -563,7 +607,7 @@ mod tests {
#[test] #[test]
fn cursor_over_draggable_returns_false_for_empty_game() { fn cursor_over_draggable_returns_false_for_empty_game() {
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -580,8 +624,8 @@ mod tests {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}}; use solitaire_core::game_state::{DrawMode, GameMode, GameState};
/// Builds an `App` with `MinimalPlugins` and the overlay system /// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers /// registered, plus the resources the system needs. Callers
@@ -605,8 +649,12 @@ mod tests {
/// card. Used to make a specific tableau column accept a chosen /// card. Used to make a specific tableau column accept a chosen
/// drag stack. /// drag stack.
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) { fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
let tableau = GameState::tableau_from_index(idx).expect("tableau pile exists"); let pile = game
game.set_test_tableau_cards(tableau, vec![card]); .piles
.get_mut(&PileType::Tableau(idx))
.expect("tableau pile exists");
pile.cards.clear();
pile.cards.push(card);
} }
/// Inserts a single face-up dragged card into the waste pile and /// Inserts a single face-up dragged card into the waste pile and
@@ -616,14 +664,59 @@ mod tests {
// Place the dragged card on the waste pile (origin). // Place the dragged card on the waste pile (origin).
{ {
let mut game = app.world_mut().resource_mut::<GameStateResource>(); let mut game = app.world_mut().resource_mut::<GameStateResource>();
game.0.set_test_waste_cards(vec![dragged.clone()]); let waste = game
.0
.piles
.get_mut(&PileType::Waste)
.expect("waste pile exists");
waste.cards.clear();
waste.cards.push(dragged.clone());
} }
let mut drag = app.world_mut().resource_mut::<DragState>(); let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![dragged]; drag.cards = vec![dragged.id];
drag.origin_pile = Some(KlondikePile::Stock); drag.origin_pile = Some(PileType::Waste);
drag.committed = true; drag.committed = true;
} }
#[test]
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
// (black, rank 6) — alternating colour, one rank lower → legal.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card {
id: 9001,
suit: Suit::Spades,
rank: Rank::Six,
face_up: true,
},
);
let dragged = Card {
id: 9002,
suit: Suit::Hearts,
rank: Rank::Five,
face_up: true,
};
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let overlays: Vec<PileType> = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.map(|o| o.0.clone())
.collect();
assert!(
overlays.contains(&PileType::Tableau(2)),
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
);
}
#[test] #[test]
fn drop_target_overlay_does_not_spawn_for_invalid_destination() { fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black) // 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
@@ -633,24 +726,86 @@ mod tests {
set_tableau_top( set_tableau_top(
&mut game, &mut game,
2, 2,
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six), Card {
id: 9101,
suit: Suit::Clubs,
rank: Rank::Six,
face_up: true,
},
); );
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five); let dragged = Card {
id: 9102,
suit: Suit::Spades,
rank: Rank::Five,
face_up: true,
};
let mut app = overlay_test_app(game); let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged); begin_drag_with(&mut app, dragged);
app.update(); app.update();
let overlays: Vec<KlondikePile> = app let overlays: Vec<PileType> = app
.world_mut() .world_mut()
.query::<&DropTargetOverlay>() .query::<&DropTargetOverlay>()
.iter(app.world()) .iter(app.world())
.map(|o| o.0) .map(|o| o.0.clone())
.collect(); .collect();
assert!( assert!(
!overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)), !overlays.contains(&PileType::Tableau(2)),
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}" "Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
); );
} }
#[test]
fn drop_target_overlays_despawn_on_drag_end() {
// Set up a scenario that produces at least one valid overlay,
// confirm it spawns, then clear the drag and confirm every
// overlay is despawned.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card {
id: 9201,
suit: Suit::Spades,
rank: Rank::Six,
face_up: true,
},
);
let dragged = Card {
id: 9202,
suit: Suit::Hearts,
rank: Rank::Five,
face_up: true,
};
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let count_during_drag = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.count();
assert!(
count_during_drag >= 1,
"expected ≥1 overlay during drag, got {count_during_drag}"
);
// End the drag — every overlay should despawn next frame.
app.world_mut().resource_mut::<DragState>().clear();
app.update();
let count_after_drag = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.count();
assert_eq!(
count_after_drag, 0,
"all overlays must despawn when the drag ends"
);
}
} }
+4 -16
View File
@@ -13,11 +13,9 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc}; use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to}; use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::ChallengeGoal; use solitaire_sync::ChallengeGoal;
use crate::events::{ use crate::events::{
@@ -27,7 +25,6 @@ use crate::events::{
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
/// Bonus XP awarded for completing today's daily challenge. /// Bonus XP awarded for completing today's daily challenge.
@@ -80,13 +77,8 @@ pub struct DailyChallengeCompletedEvent {
/// Holds the in-flight server challenge fetch so the result can be polled /// Holds the in-flight server challenge fetch so the result can be polled
/// each frame without blocking the main thread. /// each frame without blocking the main thread.
#[derive(Resource, Default)] #[derive(Resource, Default)]
#[cfg(not(target_arch = "wasm32"))]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>); struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
#[derive(Resource, Default)]
#[cfg(target_arch = "wasm32")]
struct DailyChallengeTask;
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has /// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
/// already fired for, so the toast spawns at most once per day. /// already fired for, so the toast spawns at most once per day.
/// ///
@@ -124,21 +116,17 @@ impl Plugin for DailyChallengePlugin {
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight // record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame. // ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate)) .add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation)) .add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning) .add_systems(Update, check_daily_expiry_warning)
.add_systems(Update, check_date_rollover); .add_systems(Update, check_date_rollover);
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
#[cfg(not(target_arch = "wasm32"))]
app.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge);
} }
} }
#[cfg(not(target_arch = "wasm32"))]
/// Startup system: spawns an async task to fetch the server's daily challenge. /// Startup system: spawns an async task to fetch the server's daily challenge.
/// ///
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is /// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
@@ -154,7 +142,6 @@ fn fetch_server_challenge(
task_res.0 = Some(task); task_res.0 = Some(task);
} }
#[cfg(not(target_arch = "wasm32"))]
/// Update system: polls the server-challenge fetch task. /// Update system: polls the server-challenge fetch task.
/// ///
/// On success, replaces the locally-computed seed in `DailyChallengeResource` /// On success, replaces the locally-computed seed in `DailyChallengeResource`
@@ -354,6 +341,7 @@ fn check_date_rollover(
} }
} }
#[cfg(test)] #[cfg(test)]
#[allow(dead_code)] #[allow(dead_code)]
mod tests { mod tests {
@@ -362,7 +350,7 @@ mod tests {
use crate::progress_plugin::ProgressPlugin; use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
#[allow(unused_imports)] #[allow(unused_imports)]
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
+5 -4
View File
@@ -14,7 +14,7 @@
//! because the starting position is effectively random (player-chosen timing //! because the starting position is effectively random (player-chosen timing
//! determines which seed in the 40-entry catalog they start at). //! determines which seed in the 40-entry catalog they start at).
use chrono::Utc; use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, GameMode}; use solitaire_core::game_state::{DifficultyLevel, GameMode};
@@ -104,9 +104,10 @@ fn handle_difficulty_request(
} }
fn seed_from_system_time() -> u64 { fn seed_from_system_time() -> u64 {
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature; SystemTime::now()
// std::time::SystemTime panics on wasm32-unknown-unknown). .duration_since(UNIX_EPOCH)
Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64 .map(|d| d.as_nanos() as u64)
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+13 -13
View File
@@ -1,9 +1,9 @@
//! Cross-system events used by the engine's plugins. //! Cross-system events used by the engine's plugins.
use bevy::prelude::Message; use bevy::prelude::Message;
use solitaire_core::KlondikePile; use solitaire_core::card::Suit;
use solitaire_core::card::{Card, Suit};
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse; use solitaire_sync::SyncResponse;
@@ -11,8 +11,8 @@ use solitaire_sync::SyncResponse;
/// consumed by `GamePlugin`. /// consumed by `GamePlugin`.
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct MoveRequestEvent { pub struct MoveRequestEvent {
pub from: KlondikePile, pub from: PileType,
pub to: KlondikePile, pub to: PileType,
pub count: usize, pub count: usize,
} }
@@ -49,8 +49,8 @@ pub struct StateChangedEvent;
/// `card_invalid.wav` SFX. Not fired for drops in empty space. /// `card_invalid.wav` SFX. Not fired for drops in empty space.
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct MoveRejectedEvent { pub struct MoveRejectedEvent {
pub from: KlondikePile, pub from: PileType,
pub to: KlondikePile, pub to: PileType,
pub count: usize, pub count: usize,
} }
@@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent {
} }
/// Fired when a card's face-up state changes during gameplay. /// Fired when a card's face-up state changes during gameplay.
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub Card); pub struct CardFlippedEvent(pub u32);
/// Fired by the flip animation at its midpoint — the instant the card face /// Fired by the flip animation at its midpoint — the instant the card face
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp). /// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
@@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub Card);
/// Audio systems should listen to this event rather than `CardFlippedEvent` /// Audio systems should listen to this event rather than `CardFlippedEvent`
/// so the flip sound is synchronised with the visual reveal, not the move /// so the flip sound is synchronised with the visual reveal, not the move
/// that triggered the animation. /// that triggered the animation.
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone, Copy)]
pub struct CardFaceRevealedEvent(pub Card); pub struct CardFaceRevealedEvent(pub u32);
/// Achievement unlocked notification carrying the full `AchievementRecord` for /// Achievement unlocked notification carrying the full `AchievementRecord` for
/// the newly unlocked achievement. Consumed by the toast renderer and any /// the newly unlocked achievement. Consumed by the toast renderer and any
@@ -299,8 +299,8 @@ pub struct ScanThemesRequestEvent;
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s). /// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct HintVisualEvent { pub struct HintVisualEvent {
/// The source card to be highlighted. /// The `Card::id` of the source card to be highlighted.
pub source_card: Card, pub source_card_id: u32,
/// The destination pile whose `PileMarker` should be tinted gold. /// The destination pile whose `PileMarker` should be tinted gold.
pub dest_pile: KlondikePile, pub dest_pile: solitaire_core::pile::PileType,
} }
+42 -56
View File
@@ -42,10 +42,7 @@ use std::f32::consts::PI;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw; use solitaire_core::pile::PileType;
use solitaire_core::card::Card;
use solitaire_core::KlondikePile;
use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim; use crate::animation_plugin::CardAnim;
@@ -189,10 +186,6 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 % (jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
} }
// Per-card jitter keys off the shared stable card id so it matches the
// numeric identity used elsewhere (and on the WASM replay side).
use solitaire_core::card::card_to_id;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -211,7 +204,6 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<RequestRedraw>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -251,16 +243,18 @@ fn start_shake_anim(
continue; continue;
} }
let dest_pile = &ev.to; let dest_pile = &ev.to;
// Collect the cards that belong to the destination pile. // Collect the card ids that belong to the destination pile.
let dest_cards = pile_cards(&game.0, dest_pile); let Some(pile) = game.0.piles.get(dest_pile) else {
let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect(); continue;
};
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
if dest_card_set.is_empty() { if dest_card_ids.is_empty() {
continue; continue;
} }
for (entity, card_marker, transform) in card_entities.iter() { for (entity, card_marker, transform) in card_entities.iter() {
if dest_card_set.contains(&card_marker.card) { if dest_card_ids.contains(&card_marker.card_id) {
commands.entity(entity).insert(ShakeAnim { commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0, elapsed: 0.0,
origin_x: transform.translation.x, origin_x: transform.translation.x,
@@ -317,27 +311,27 @@ fn start_settle_anim(
card_entities: Query<(Entity, &CardEntity)>, card_entities: Query<(Entity, &CardEntity)>,
mut commands: Commands, mut commands: Commands,
) { ) {
// Build the list of cards that should bounce this frame from every // Build the list of card ids that should bounce this frame from every
// queued request; multiple events can fire in the same frame (e.g. a move // queued request; multiple events can fire in the same frame (e.g. a move
// followed by a draw via keyboard accelerators). // followed by a draw via keyboard accelerators).
let mut bounce_ids: Vec<Card> = Vec::new(); let mut bounce_ids: Vec<u32> = Vec::new();
for ev in moves.read() { for ev in moves.read() {
let pile = pile_cards(&game.0, &ev.to); if let Some(pile) = game.0.piles.get(&ev.to) {
if !pile.is_empty() { // The moved cards land on top — take the last `count` ids.
// The moved cards land on top — take the last `count` cards. let n = ev.count.min(pile.cards.len());
let n = ev.count.min(pile.len());
if n > 0 { if n > 0 {
let start = pile.len() - n; let start = pile.cards.len() - n;
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone())); bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
} }
} }
} }
if draws.read().next().is_some() if draws.read().next().is_some()
&& let Some((top, _)) = game.0.waste_cards().last() && let Some(pile) = game.0.piles.get(&PileType::Waste)
&& let Some(top) = pile.cards.last()
{ {
bounce_ids.push(top.clone()); bounce_ids.push(top.id);
} }
if bounce_ids.is_empty() { if bounce_ids.is_empty() {
@@ -345,7 +339,7 @@ fn start_settle_anim(
} }
for (entity, card_marker) in card_entities.iter() { for (entity, card_marker) in card_entities.iter() {
if bounce_ids.contains(&card_marker.card) { if bounce_ids.contains(&card_marker.card_id) {
commands.entity(entity).insert(SettleAnim::default()); commands.entity(entity).insert(SettleAnim::default());
} }
} }
@@ -399,11 +393,11 @@ fn start_deal_anim(
return; return;
} }
// Only animate a fresh deal (no moves made yet). // Only animate a fresh deal (no moves made yet).
if game.0.move_count() != 0 { if game.0.move_count != 0 {
return; return;
} }
let Some(layout) = layout else { return }; let Some(layout) = layout else { return };
let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else { let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
return; return;
}; };
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0); let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
@@ -416,7 +410,7 @@ fn start_deal_anim(
// ±10 % jitter, deterministic per card id, so the deal feels organic // ±10 % jitter, deterministic per card id, so the deal feels organic
// without losing reproducibility (a given seed still produces the // without losing reproducibility (a given seed still produces the
// same per-card stagger pattern across runs). // same per-card stagger pattern across runs).
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card))); let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
commands.entity(entity).insert(( commands.entity(entity).insert((
Transform::from_translation(stock_start.with_z(final_pos.z)), Transform::from_translation(stock_start.with_z(final_pos.z)),
CardAnim { CardAnim {
@@ -524,19 +518,21 @@ fn start_foundation_flourish(
if reduce_motion { if reduce_motion {
continue; continue;
} }
let Some(foundation) = foundation_from_slot(ev.slot) else { let pile_type = PileType::Foundation(ev.slot);
continue;
};
let pile_type = KlondikePile::Foundation(foundation);
// Top card of the completed foundation is the King. // Top card of the completed foundation is the King.
let cards = game.0.pile(pile_type); let Some(king_id) = game
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else { .0
.piles
.get(&pile_type)
.and_then(|p| p.cards.last())
.map(|c| c.id)
else {
continue; continue;
}; };
// Tag the King's card entity. // Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() { for (entity, card_marker) in card_entities.iter() {
if card_marker.card == king_card { if card_marker.card_id == king_id {
commands.entity(entity).insert(FoundationFlourish { commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot, foundation_slot: ev.slot,
elapsed: 0.0, elapsed: 0.0,
@@ -636,16 +632,6 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
) )
} }
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<(solitaire_core::card::Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required) // Unit tests (pure functions only — no Bevy world required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -845,8 +831,7 @@ mod tests {
#[test] #[test]
fn shake_anim_skipped_under_reduce_motion() { fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::Tableau; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_data::Settings; use solitaire_data::Settings;
let mut app = App::new(); let mut app = App::new();
@@ -860,25 +845,26 @@ mod tests {
app.update(); app.update();
// Pick a card from Tableau(0) so the event refers to a real pile. // Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1); let dest_pile = PileType::Tableau(0);
let card = app let card_id = app
.world() .world()
.resource::<GameStateResource>() .resource::<GameStateResource>()
.0 .0
.pile(dest_pile) .piles
.last() .get(&dest_pile)
.map(|(c, _)| c.clone()) .and_then(|p| p.cards.last())
.map(|c| c.id)
.expect("Tableau(0) should have at least one card in a fresh game"); .expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that card so the system would // Spawn a minimal CardEntity matching that id so the system would
// find it and insert ShakeAnim if the gate were absent. // find it and insert ShakeAnim if the gate were absent.
app.world_mut() app.world_mut()
.spawn((CardEntity { card }, Transform::default())); .spawn((CardEntity { card_id }, Transform::default()));
app.world_mut() app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>() .resource_mut::<Messages<MoveRejectedEvent>>()
.write(MoveRejectedEvent { .write(MoveRejectedEvent {
from: KlondikePile::Stock, from: PileType::Stock,
to: dest_pile, to: dest_pile,
count: 1, count: 1,
}); });
@@ -900,7 +886,7 @@ mod tests {
#[test] #[test]
fn foundation_flourish_skipped_under_reduce_motion() { fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings; use solitaire_data::Settings;
let mut app = App::new(); let mut app = App::new();
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{DrawMode, game_state::DifficultyLevel}; use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_data::save_settings_to; use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
+39 -97
View File
@@ -8,19 +8,12 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit; use solitaire_core::card::Suit;
use solitaire_core::{DrawMode, game_state::GameMode}; use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None.
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
@@ -315,17 +308,17 @@ pub struct HintButton;
/// Android HUD label for the Hint button — shared with the help screen's /// Android HUD label for the Hint button — shared with the help screen's
/// controls reference so both always agree. /// controls reference so both always agree.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint"; pub(crate) const ANDROID_HINT_LABEL: &str = "!";
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
const ACTION_BAR_LABELS: [&str; 7] = [ const ACTION_BAR_LABELS: [&str; 7] = [
"Menu", "\u{2261}",
"Undo", "\u{2190}",
"Pause", "||",
"Help", "?",
ANDROID_HINT_LABEL, ANDROID_HINT_LABEL,
"Mode", "M",
"New", "+",
]; ];
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
const ACTION_BAR_LABELS: [&str; 7] = [ const ACTION_BAR_LABELS: [&str; 7] = [
@@ -830,8 +823,6 @@ fn spawn_avatar_child(
) { ) {
const SIZE: f32 = 32.0; const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) { if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it.
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
// Image fills the circle container; border_radius clips it to a disc. // Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| { commands.entity(parent).with_children(|b| {
b.spawn(( b.spawn((
@@ -852,15 +843,6 @@ fn spawn_avatar_child(
}) })
.and_then(|c| c.to_uppercase().next()) .and_then(|c| c.to_uppercase().next())
.unwrap_or('?'); .unwrap_or('?');
// Real initial (logged in) keeps the red accent disc; the '?'
// unauthenticated fallback uses a neutral grey so it reads as a
// "tap to log in" affordance rather than an error.
let disc_bg = if initial == '?' {
BG_ELEVATED_HI
} else {
ACCENT_PRIMARY
};
commands.entity(parent).insert(BackgroundColor(disc_bg));
commands.entity(parent).with_children(|b| { commands.entity(parent).with_children(|b| {
b.spawn(( b.spawn((
Text::new(initial.to_string()), Text::new(initial.to_string()),
@@ -1154,12 +1136,12 @@ fn handle_hint_button(
return; return;
} }
let Some(ref g) = game else { return }; let Some(ref g) = game else { return };
if g.0.is_won() { if g.0.is_won {
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string())); info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
return; return;
} }
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) { if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget); hint.spawn(g.0.clone(), cfg.0);
} }
} }
} }
@@ -1662,13 +1644,11 @@ impl Default for HudActionFade {
/// How many pixels from the bottom edge the cursor must be to reveal the bar. /// How many pixels from the bottom edge the cursor must be to reveal the bar.
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the /// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
/// cursor approaches, not only when it crosses into the band itself. /// cursor approaches, not only when it crosses into the band itself.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0; const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full /// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
/// transition — fast enough to feel responsive without flashing on /// transition — fast enough to feel responsive without flashing on
/// brief cursor wanders into the reveal zone. /// brief cursor wanders into the reveal zone.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0; const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// Updates the fade state from cursor position. Sets `target = 1.0` if /// Updates the fade state from cursor position. Sets `target = 1.0` if
@@ -1676,7 +1656,6 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward /// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across /// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates. /// variable framerates.
#[cfg(not(target_os = "android"))]
fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) { fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
let Ok(window) = windows.single() else { let Ok(window) = windows.single() else {
return; return;
@@ -1701,7 +1680,6 @@ fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut
/// `Last` (after `paint_action_buttons`) so a hover-state change in the /// `Last` (after `paint_action_buttons`) so a hover-state change in the
/// same frame doesn't override the fade with an opaque idle / hover /// same frame doesn't override the fade with an opaque idle / hover
/// colour. /// colour.
#[cfg(not(target_os = "android"))]
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn apply_action_fade( fn apply_action_fade(
fade: Res<HudActionFade>, fade: Res<HudActionFade>,
@@ -2106,10 +2084,10 @@ fn update_won_previously(
let Ok(mut text) = q.single_mut() else { let Ok(mut text) = q.single_mut() else {
return; return;
}; };
let won_before = !game.0.is_won() let won_before = !game.0.is_won
&& history.as_ref().is_some_and(|h| { && history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| { h.0.replays.iter().any(|r| {
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode r.seed == game.0.seed && r.draw_mode == game.0.draw_mode && r.mode == game.0.mode
}) })
}); });
let next = if won_before { let next = if won_before {
@@ -2279,11 +2257,11 @@ fn update_hud(
}; };
} }
if let Ok(mut t) = moves_q.single_mut() { if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count()); **t = format!("Moves: {}", g.move_count);
} }
if let Ok(mut t) = mode_q.single_mut() { if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode { **t = match g.mode {
GameMode::Classic => match g.draw_mode() { GameMode::Classic => match g.draw_mode {
DrawMode::DrawOne => String::new(), DrawMode::DrawOne => String::new(),
DrawMode::DrawThree => "Draw 3".to_string(), DrawMode::DrawThree => "Draw 3".to_string(),
}, },
@@ -2296,7 +2274,7 @@ fn update_hud(
// --- Daily challenge constraint (with time-low colour warning) --- // --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.single_mut() { if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won() { if g.is_won {
**t = String::new(); **t = String::new();
} else if let Some(dc) = daily.as_deref() { } else if let Some(dc) = daily.as_deref() {
**t = challenge_hud_text(dc); **t = challenge_hud_text(dc);
@@ -2334,11 +2312,11 @@ fn update_hud(
// --- Draw-cycle indicator (Draw-Three mode only) --- // --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.single_mut() { if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won() || g.draw_mode() != DrawMode::DrawThree { **t = if g.is_won || g.draw_mode != DrawMode::DrawThree {
// Hide when not in Draw-Three or after the game is won. // Hide when not in Draw-Three or after the game is won.
String::new() String::new()
} else { } else {
let stock_len = g.stock_cards().len(); let stock_len = g.piles[&solitaire_core::pile::PileType::Stock].cards.len();
let next_draw = stock_len.min(3); let next_draw = stock_len.min(3);
format!("Cycle: {next_draw}/3") format!("Cycle: {next_draw}/3")
}; };
@@ -2402,14 +2380,15 @@ fn update_selection_hud(
let Ok(mut t) = q.single_mut() else { return }; let Ok(mut t) = q.single_mut() else { return };
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) { let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
None => String::new(), None => String::new(),
Some(KlondikePile::Stock) => "▶ Waste".to_string(), Some(PileType::Waste) => "▶ Waste".to_string(),
Some(KlondikePile::Foundation(slot)) => match game.as_deref() { Some(PileType::Stock) => "▶ Stock".to_string(),
Some(PileType::Foundation(slot)) => match game.as_deref() {
Some(g) => foundation_selection_label(*slot, &g.0), Some(g) => foundation_selection_label(*slot, &g.0),
// No game resource means we can't probe claimed_suit; show the // No game resource means we can't probe claimed_suit; show the
// slot-based placeholder so the HUD still surfaces the selection. // slot-based placeholder so the HUD still surfaces the selection.
None => format!("▶ Foundation {}", foundation_number(*slot)), None => format!("▶ Foundation {}", slot + 1),
}, },
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)), Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
}; };
**t = label; **t = label;
} }
@@ -2419,14 +2398,11 @@ fn update_selection_hud(
/// When the slot has a claimed suit (any card has landed) the announcement is /// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a /// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index. /// "▶ Foundation N" placeholder labelled by the 1-based slot index.
fn foundation_selection_label( fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String {
slot: Foundation,
game: &solitaire_core::game_state::GameState,
) -> String {
let claimed = game let claimed = game
.pile(KlondikePile::Foundation(slot)) .piles
.first() .get(&PileType::Foundation(slot))
.map(|c| c.0.suit()); .and_then(|p| p.claimed_suit());
match claimed { match claimed {
Some(suit) => { Some(suit) => {
let s = match suit { let s = match suit {
@@ -2437,28 +2413,7 @@ fn foundation_selection_label(
}; };
format!("{s} Foundation") format!("{s} Foundation")
} }
None => format!("▶ Foundation {}", foundation_number(slot)), None => format!("▶ Foundation {}", slot + 1),
}
}
const fn foundation_number(foundation: Foundation) -> u8 {
match foundation {
Foundation::Foundation1 => 1,
Foundation::Foundation2 => 2,
Foundation::Foundation3 => 3,
Foundation::Foundation4 => 4,
}
}
const fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
Tableau::Tableau2 => 2,
Tableau::Tableau3 => 3,
Tableau::Tableau4 => 4,
Tableau::Tableau5 => 5,
Tableau::Tableau6 => 6,
Tableau::Tableau7 => 7,
} }
} }
@@ -2582,18 +2537,10 @@ fn restore_hud_on_modal(
/// Returns the action-bar label font size for a given logical window width. /// Returns the action-bar label font size for a given logical window width.
fn action_bar_font_size(window_width: f32) -> f32 { fn action_bar_font_size(window_width: f32) -> f32 {
if USE_TOUCH_UI_LAYOUT { if USE_TOUCH_UI_LAYOUT {
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New") // ~1/40 of the window width gives ~22 px on a 900 logical-px phone.
// must share one row. The widest characters are in FiraMono (a // Clamped so it never goes too tiny on narrow viewports or too large
// monospace whose advance is ~0.62 of the font size). On a 900 // on landscape tablets.
// logical-px phone the row budget after bar padding (2*12) and six (window_width / 40.0).clamp(16.0, 30.0)
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
// labels are advance-bound only on very narrow viewports; the real
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
// at 900 px — comfortably one row with margin to spare — clamped so it
// never drops below the 12 px legibility floor or grows past 18 px on
// landscape tablets where it would crowd the row again.
(window_width / 60.0).clamp(12.0, 18.0)
} else { } else {
TYPE_BODY TYPE_BODY
} }
@@ -2601,14 +2548,9 @@ fn action_bar_font_size(window_width: f32) -> f32 {
fn action_button_metrics() -> (UiRect, Val, Val) { fn action_button_metrics() -> (UiRect, Val, Val) {
if USE_TOUCH_UI_LAYOUT { if USE_TOUCH_UI_LAYOUT {
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
// shortest labels ("New", "Help") shrink to their text rather than
// padding the row out past the 900 logical-px viewport. min_height
// stays at 44 px to preserve the comfortable touch target.
( (
UiRect::axes(Val::Px(3.0), Val::Px(4.0)), UiRect::axes(Val::Px(4.0), Val::Px(4.0)),
Val::Px(44.0), Val::Px(52.0),
Val::Px(44.0), Val::Px(44.0),
) )
} else { } else {
@@ -2726,7 +2668,7 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use chrono::Local; use chrono::Local;
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -2774,7 +2716,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(42); .move_count = 42;
app.update(); app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42"); assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
} }
@@ -2962,7 +2904,7 @@ mod tests {
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
// Mark the game as won — HudChallenge should be empty. // Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true); app.world_mut().resource_mut::<GameStateResource>().0.is_won = true;
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -3012,7 +2954,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(1); .move_count += 1;
app.update(); app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO"); assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
} }
@@ -3024,7 +2966,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(1); .move_count += 1;
app.update(); app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), ""); assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
} }
File diff suppressed because it is too large Load Diff
+52 -107
View File
@@ -7,7 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet}; use bevy::prelude::{Resource, SystemSet};
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::pile::PileType;
/// Schedule labels for layout-related systems so cross-plugin ordering is /// Schedule labels for layout-related systems so cross-plugin ordering is
/// explicit instead of relying on Bevy's automatic resource-conflict ordering /// explicit instead of relying on Bevy's automatic resource-conflict ordering
@@ -138,9 +138,9 @@ pub struct Layout {
/// Centre position of each pile, in 2D world coordinates. /// Centre position of each pile, in 2D world coordinates.
/// ///
/// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up. /// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up.
/// Every `KlondikePile` (Stock, Waste, four Foundations, seven Tableaux) has an /// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
/// entry. The map always contains exactly 13 entries after `compute_layout`. /// entry. The map always contains exactly 13 entries after `compute_layout`.
pub pile_positions: HashMap<KlondikePile, Vec2>, pub pile_positions: HashMap<PileType, Vec2>,
/// Per-step vertical offset fraction for face-up tableau cards, as a /// Per-step vertical offset fraction for face-up tableau cards, as a
/// fraction of `card_size.y`. On height-limited (desktop) windows this /// fraction of `card_size.y`. On height-limited (desktop) windows this
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone) /// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
@@ -241,38 +241,21 @@ pub fn compute_layout(
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0; let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0;
let tableau_y = top_y - card_height - vertical_gap; let tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<KlondikePile, Vec2> = HashMap::with_capacity(13); let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
pile_positions.insert(KlondikePile::Stock, Vec2::new(col_x(1), top_y)); pile_positions.insert(PileType::Stock, Vec2::new(col_x(0), top_y));
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
// Column 2 is skipped — visual separation between waste and foundations. // Column 2 is skipped — visual separation between waste and foundations.
for slot in 0..4_u8 { for slot in 0..4_u8 {
let foundation = match slot {
0 => Foundation::Foundation1,
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
_ => Foundation::Foundation4,
};
pile_positions.insert( pile_positions.insert(
KlondikePile::Foundation(foundation), PileType::Foundation(slot),
Vec2::new(col_x(3 + slot as usize), top_y), Vec2::new(col_x(3 + slot as usize), top_y),
); );
} }
for i in 0..7 { for i in 0..7 {
let tableau = match i { pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
0 => Tableau::Tableau1,
1 => Tableau::Tableau2,
2 => Tableau::Tableau3,
3 => Tableau::Tableau4,
4 => Tableau::Tableau5,
5 => Tableau::Tableau6,
_ => Tableau::Tableau7,
};
pile_positions.insert(
KlondikePile::Tableau(tableau),
Vec2::new(col_x(i), tableau_y),
);
} }
// Adaptive tableau fan fraction. On height-limited (desktop) windows the // Adaptive tableau fan fraction. On height-limited (desktop) windows the
@@ -318,37 +301,23 @@ mod tests {
use super::*; use super::*;
fn assert_all_piles_present(layout: &Layout) { fn assert_all_piles_present(layout: &Layout) {
assert!(layout.pile_positions.contains_key(&KlondikePile::Stock)); assert!(layout.pile_positions.contains_key(&PileType::Stock));
for foundation in [ assert!(layout.pile_positions.contains_key(&PileType::Waste));
Foundation::Foundation1, for slot in 0..4_u8 {
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
assert!( assert!(
layout layout
.pile_positions .pile_positions
.contains_key(&KlondikePile::Foundation(foundation)), .contains_key(&PileType::Foundation(slot)),
"missing foundation slot {foundation:?}", "missing foundation slot {slot}",
); );
} }
for tableau in [ for i in 0..7 {
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
assert!( assert!(
layout layout.pile_positions.contains_key(&PileType::Tableau(i)),
.pile_positions "missing tableau {i}"
.contains_key(&KlondikePile::Tableau(tableau)),
"missing tableau {tableau:?}"
); );
} }
assert_eq!(layout.pile_positions.len(), 12); assert_eq!(layout.pile_positions.len(), 13);
} }
#[test] #[test]
@@ -407,18 +376,9 @@ mod tests {
#[test] #[test]
fn tableau_columns_are_sorted_left_to_right() { fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let tableaus = [ for i in 0..6 {
Tableau::Tableau1, let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
Tableau::Tableau2, let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
for i in 0..tableaus.len() - 1 {
let lhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i])].x;
let rhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i + 1])].x;
assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1); assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1);
} }
} }
@@ -426,8 +386,8 @@ mod tests {
#[test] #[test]
fn top_row_is_above_tableau_row() { fn top_row_is_above_tableau_row() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_y = layout.pile_positions[&KlondikePile::Stock].y; let stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
assert!(stock_y > tableau_y); assert!(stock_y > tableau_y);
} }
@@ -439,7 +399,7 @@ mod tests {
fn top_row_clears_hud_band() { fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0); let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true); let layout = compute_layout(window, 0.0, 0.0, true);
let stock_y = layout.pile_positions[&KlondikePile::Stock].y; let stock_y = layout.pile_positions[&PileType::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0; let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT; let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
assert!( assert!(
@@ -451,35 +411,24 @@ mod tests {
#[test] #[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_x = layout.pile_positions[&KlondikePile::Stock].x; let stock_x = layout.pile_positions[&PileType::Stock].x;
let t1_x = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau2)].x; let waste_x = layout.pile_positions[&PileType::Waste].x;
assert!((stock_x - t1_x).abs() < 1e-5); let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
let t1_x = layout.pile_positions[&PileType::Tableau(1)].x;
assert!((stock_x - t0_x).abs() < 1e-5);
assert!((waste_x - t1_x).abs() < 1e-5);
} }
#[test] #[test]
fn foundations_align_with_tableau_cols_3_to_6() { fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let target_tableaus = [ for slot in 0..4_u8 {
Tableau::Tableau4, let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
Tableau::Tableau5, let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
Tableau::Tableau6,
Tableau::Tableau7,
];
for (idx, foundation) in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
.iter()
.enumerate()
{
let f_x = layout.pile_positions[&KlondikePile::Foundation(*foundation)].x;
let t_x = layout.pile_positions[&KlondikePile::Tableau(target_tableaus[idx])].x;
assert!( assert!(
(f_x - t_x).abs() < 1e-5, (f_x - t_x).abs() < 1e-5,
"foundation slot {idx} should align with tableau {}", "foundation slot {slot} should align with tableau {}",
3 + idx, 3 + slot as usize,
); );
} }
} }
@@ -521,7 +470,7 @@ mod tests {
// Default app resolution (see solitaire_app/src/main.rs). // Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0); let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true); let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card. // Bottom edge of the 13th fanned face-up card.
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0; let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
@@ -540,7 +489,7 @@ mod tests {
// The bug originally reproduced at 1920x1080. Lock in a regression test. // The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0); let window = Vec2::new(1920.0, 1080.0);
let layout = compute_layout(window, 0.0, 0.0, true); let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0; let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
let h_gap = layout.card_size.x / 4.0; let h_gap = layout.card_size.x / 4.0;
@@ -571,7 +520,7 @@ mod tests {
fn expanded_fan_fits_phone_viewport() { fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true); let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0; let h_gap = layout.card_size.x / 4.0;
// Bottom of the 13th (worst-case) fanned face-up card. // Bottom of the 13th (worst-case) fanned face-up card.
@@ -630,8 +579,8 @@ mod tests {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true); let with_inset = compute_layout(window, 32.0, 0.0, true);
let stock_no_inset = without.pile_positions[&KlondikePile::Stock].y; let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&KlondikePile::Stock].y; let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
assert!( assert!(
stock_with_inset < stock_no_inset, stock_with_inset < stock_no_inset,
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}", "safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
@@ -653,10 +602,10 @@ mod tests {
let without = compute_layout(window, 0.0, 0.0, true); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true); let with_inset = compute_layout(window, 32.0, 0.0, true);
for pile in [ for pile in [
KlondikePile::Stock, PileType::Stock,
KlondikePile::Stock, PileType::Waste,
KlondikePile::Tableau(Tableau::Tableau1), PileType::Tableau(0),
KlondikePile::Tableau(Tableau::Tableau7), PileType::Tableau(6),
] { ] {
assert!( assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
@@ -679,7 +628,7 @@ mod tests {
with_inset.tableau_fan_frac, with_inset.tableau_fan_frac,
); );
let card_h = with_inset.card_size.y; let card_h = with_inset.card_size.y;
let tableau_y = with_inset.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y; let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0; let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
let h_gap = with_inset.card_size.x / 4.0; let h_gap = with_inset.card_size.x / 4.0;
let margin = -window.y / 2.0 + 48.0 + h_gap; let margin = -window.y / 2.0 + 48.0 + h_gap;
@@ -712,8 +661,8 @@ mod tests {
// Verify the "wrong" layout actually differs — the bug would push the // Verify the "wrong" layout actually differs — the bug would push the
// top card row upward by exactly safe_top pixels. // top card row upward by exactly safe_top pixels.
let fresh_stock_y = fresh.pile_positions[&KlondikePile::Stock].y; let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
let wrong_stock_y = wrong.pile_positions[&KlondikePile::Stock].y; let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock // In Bevy's +y-is-up system, adding safe_area_top pushes the stock
// downward (y direction). So wrong_stock_y > fresh_stock_y by safe_top. // downward (y direction). So wrong_stock_y > fresh_stock_y by safe_top.
assert!( assert!(
@@ -731,14 +680,14 @@ mod tests {
"card size must be preserved after resume", "card size must be preserved after resume",
); );
assert!( assert!(
(corrected.pile_positions[&KlondikePile::Stock].y - fresh_stock_y).abs() < 1e-3, (corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
"stock y must match fresh launch after resume: \ "stock y must match fresh launch after resume: \
corrected={:.2} fresh={fresh_stock_y:.2}", corrected={:.2} fresh={fresh_stock_y:.2}",
corrected.pile_positions[&KlondikePile::Stock].y, corrected.pile_positions[&PileType::Stock].y,
); );
assert!( assert!(
(corrected.pile_positions[&KlondikePile::Stock].x (corrected.pile_positions[&PileType::Stock].x
- fresh.pile_positions[&KlondikePile::Stock].x) - fresh.pile_positions[&PileType::Stock].x)
.abs() .abs()
< 1e-3, < 1e-3,
"stock x must be unchanged after resume", "stock x must be unchanged after resume",
@@ -746,7 +695,7 @@ mod tests {
// The HUD band top clearance (distance from window top to card top) // The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2. // must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| { let card_top = |layout: &super::Layout| {
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0 layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
}; };
assert!( assert!(
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3, (card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
@@ -763,11 +712,7 @@ mod tests {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0, true); let with_inset = compute_layout(window, 0.0, 48.0, true);
for pile in [ for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
KlondikePile::Stock,
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau7),
] {
assert!( assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_bottom", "{pile:?} x-position must not change with safe_area_bottom",
@@ -191,7 +191,6 @@ fn toggle_leaderboard_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleLeaderboardRequestEvent>, mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
screens: Query<Entity, With<LeaderboardScreen>>, screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
data: Res<LeaderboardResource>, data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>, provider: Option<Res<SyncProviderResource>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
@@ -209,11 +208,6 @@ fn toggle_leaderboard_screen(
return; return;
} }
// Don't stack a second modal scrim over one that is already open.
if !other_modal_scrims.is_empty() {
return;
}
// Spawn the panel immediately with whatever data we have so far. // Spawn the panel immediately with whatever data we have so far.
let remote_available = provider let remote_available = provider
.as_ref() .as_ref()
+1 -13
View File
@@ -1,16 +1,13 @@
//! Bevy integration layer for Ferrous Solitaire. //! Bevy integration layer for Ferrous Solitaire.
pub mod achievement_plugin; pub mod achievement_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod analytics_plugin; pub mod analytics_plugin;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub mod android_clipboard; pub mod android_clipboard;
pub mod animation_plugin; pub mod animation_plugin;
pub mod assets; pub mod assets;
#[cfg(not(target_arch = "wasm32"))]
pub mod audio_plugin; pub mod audio_plugin;
pub mod auto_complete_plugin; pub mod auto_complete_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod avatar_plugin; pub mod avatar_plugin;
pub mod card_animation; pub mod card_animation;
pub mod card_plugin; pub mod card_plugin;
@@ -29,7 +26,6 @@ pub mod home_plugin;
pub mod hud_plugin; pub mod hud_plugin;
pub mod input_plugin; pub mod input_plugin;
pub mod layout; pub mod layout;
#[cfg(not(target_arch = "wasm32"))]
pub mod leaderboard_plugin; pub mod leaderboard_plugin;
pub mod onboarding_plugin; pub mod onboarding_plugin;
pub mod pause_plugin; pub mod pause_plugin;
@@ -47,9 +43,7 @@ pub mod selection_plugin;
pub mod settings_plugin; pub mod settings_plugin;
pub mod splash_plugin; pub mod splash_plugin;
pub mod stats_plugin; pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod sync_plugin; pub mod sync_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod sync_setup_plugin; pub mod sync_setup_plugin;
pub mod table_plugin; pub mod table_plugin;
pub mod theme; pub mod theme;
@@ -63,17 +57,14 @@ pub mod weekly_goals_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
#[cfg(not(target_arch = "wasm32"))]
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource}; pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue}; pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
pub use assets::{ pub use assets::{
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url, AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url,
populate_embedded_dark_theme, register_theme_asset_sources, populate_embedded_dark_theme, register_theme_asset_sources,
}; };
#[cfg(not(target_arch = "wasm32"))]
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use auto_complete_plugin::AutoCompletePlugin; pub use auto_complete_plugin::AutoCompletePlugin;
#[cfg(not(target_arch = "wasm32"))]
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource}; pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use card_animation::{ pub use card_animation::{
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin, AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
@@ -126,7 +117,6 @@ pub use hud_plugin::{
}; };
pub use input_plugin::InputPlugin; pub use input_plugin::InputPlugin;
pub use layout::{Layout, LayoutResource, compute_layout}; pub use layout::{Layout, LayoutResource, compute_layout};
#[cfg(not(target_arch = "wasm32"))]
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource}; pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
@@ -153,6 +143,7 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{ pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState, KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
}; };
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use settings_plugin::{ pub use settings_plugin::{
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource, PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
@@ -164,9 +155,7 @@ pub use stats_plugin::{
ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen,
StatsUpdate, WatchReplayButton, format_replay_caption, StatsUpdate, WatchReplayButton, format_replay_caption,
}; };
#[cfg(not(target_arch = "wasm32"))]
pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use sync_plugin::{SyncPlugin, SyncProviderResource};
#[cfg(not(target_arch = "wasm32"))]
pub use sync_setup_plugin::SyncSetupPlugin; pub use sync_setup_plugin::SyncSetupPlugin;
pub use table_plugin::{ pub use table_plugin::{
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin, BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
@@ -178,7 +167,6 @@ pub use theme::{
pub use time_attack_plugin::{ pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
}; };
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin}; pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{ pub use ui_modal::{
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
+1 -21
View File
@@ -36,7 +36,6 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
}; };
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING}; use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -154,7 +153,7 @@ pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin { impl Plugin for OnboardingPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<OnboardingSlideIndex>() app.init_resource::<OnboardingSlideIndex>()
.add_systems(Update, spawn_if_first_run) .add_systems(PostStartup, spawn_if_first_run)
.add_systems( .add_systems(
Update, Update,
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(), (handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
@@ -171,30 +170,11 @@ fn spawn_if_first_run(
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
mut slide_index: ResMut<OnboardingSlideIndex>, mut slide_index: ResMut<OnboardingSlideIndex>,
splashes: Query<(), With<SplashRoot>>,
existing: Query<(), With<OnboardingScreen>>,
mut spawned: Local<bool>,
) { ) {
if *spawned {
return;
}
// Wait until the launch splash has despawned so the two screens
// never overlap. PostStartup would fire before the first Update
// tick, guaranteeing overlap; checking here costs one frame of
// latency after the splash clears, which is imperceptible.
if !splashes.is_empty() {
return;
}
if !existing.is_empty() {
*spawned = true;
return;
}
let Some(s) = settings else { return }; let Some(s) = settings else { return };
if s.0.first_run_complete { if s.0.first_run_complete {
*spawned = true;
return; return;
} }
*spawned = true;
slide_index.0 = 0; slide_index.0 = 0;
spawn_slide(&mut commands, 0, font_res.as_deref()); spawn_slide(&mut commands, 0, font_res.as_deref());
} }
+5 -5
View File
@@ -21,7 +21,7 @@
//! active opens the overlay as normal. //! active opens the overlay as normal.
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::save_game_state_to; use solitaire_data::save_game_state_to;
use crate::events::{ use crate::events::{
@@ -340,7 +340,7 @@ fn handle_forfeit_request(
if !forfeit_screens.is_empty() { if !forfeit_screens.is_empty() {
return; return;
} }
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won()); let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won);
if !game_in_progress { if !game_in_progress {
toast.write(InfoToastEvent("No game to forfeit".to_string())); toast.write(InfoToastEvent("No game to forfeit".to_string()));
return; return;
@@ -965,7 +965,7 @@ mod tests {
/// Provides a fresh `GameStateResource` (not won) so the modal can /// Provides a fresh `GameStateResource` (not won) so the modal can
/// open. `move_count` doesn't matter — the gate is just `!is_won`. /// open. `move_count` doesn't matter — the gate is just `!is_won`.
fn forfeit_app() -> App { fn forfeit_app() -> App {
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
@@ -1020,12 +1020,12 @@ mod tests {
/// hotkey was received but is currently a no-op. /// hotkey was received but is currently a no-op.
#[test] #[test]
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() { fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
game.set_test_won(true); game.is_won = true;
app.insert_resource(GameStateResource(game)); app.insert_resource(GameStateResource(game));
app.update(); app.update();
+85 -90
View File
@@ -1,10 +1,12 @@
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in //! Async H-key hint solver, modelled on `PendingNewGameSeed` in
//! `game_plugin`. //! `game_plugin`.
//! //!
//! The synchronous version (v0.17.0) called the solver on the main thread //! The synchronous version (v0.17.0) called
//! on every H press. Median latency was ~2 ms but pathological positions //! `solitaire_core::solver::try_solve_from_state` on the main thread on
//! can hit the default solve budget at ~120 ms, which is a noticeable //! every H press. Median latency was ~2 ms but pathological positions
//! input-stall on the same frame the player sees the hint request. //! can hit the `SolverConfig::default()` cap at ~120 ms, which is a
//! noticeable input-stall on the same frame the player sees the hint
//! request.
//! //!
//! This module hosts the resource and polling system that move the //! This module hosts the resource and polling system that move the
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint` //! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
@@ -24,13 +26,13 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_data::solver::try_solve_from_state; use solitaire_core::pile::PileType;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint, hint_piles}; use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint};
use crate::resources::{GameStateResource, HintCycleIndex}; use crate::resources::{GameStateResource, HintCycleIndex};
/// In-flight async work for the H-key hint. /// In-flight async work for the H-key hint.
@@ -58,17 +60,23 @@ impl PendingHintTask {
self.inner = None; self.inner = None;
} }
/// Spawn a new solver task for `state` with the given solve budgets. /// Spawn a new solver task for `state` with `config`. Drops any
/// Drops any previously in-flight task first (cancel-on-replace). /// previously in-flight task first (cancel-on-replace).
pub fn spawn(&mut self, state: GameState, moves_budget: u64, states_budget: u64) { pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
let move_count_at_spawn = state.move_count(); let move_count_at_spawn = state.move_count;
let handle = AsyncComputeTaskPool::get().spawn(async move { let handle = AsyncComputeTaskPool::get().spawn(async move {
// Winnable (`Ok(Some)`) carries the first move on a winning path; let outcome = try_solve_from_state(&state, &config);
// unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back match outcome.result {
// to the live-state heuristic so H always produces feedback. SolverResult::Winnable => outcome
match try_solve_from_state(&state, moves_budget, states_budget) { .first_move
Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move), .map(|mv| HintTaskOutput::SolverMove {
Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic, from: mv.source,
to: mv.dest,
})
.unwrap_or(HintTaskOutput::NeedsHeuristic),
SolverResult::Unwinnable | SolverResult::Inconclusive => {
HintTaskOutput::NeedsHeuristic
}
} }
}); });
self.inner = Some(HintTask { self.inner = Some(HintTask {
@@ -91,10 +99,9 @@ struct HintTask {
/// What the solver task carries back to the main thread. /// What the solver task carries back to the main thread.
enum HintTaskOutput { enum HintTaskOutput {
/// Solver verdict was winnable; here is the first move on the solution /// Solver verdict was `Winnable`; here is the first move on the
/// path. Converted to highlighted `(from, to)` piles by the poll system /// solution path.
/// via [`crate::input_plugin::hint_piles`]. SolverMove { from: PileType, to: PileType },
SolverMove(KlondikeInstruction),
/// Solver was `Unwinnable` or `Inconclusive`. The poll system /// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the /// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists. /// H key always produces feedback while any legal move exists.
@@ -146,22 +153,19 @@ pub fn poll_pending_hint_task(
pending.inner = None; pending.inner = None;
let Some(g) = game else { return }; let Some(g) = game else { return };
if g.0.move_count() != move_count_at_spawn { if g.0.move_count != move_count_at_spawn {
return; return;
} }
// Resolve the solver's first move to highlighted piles; fall back to the let (from, to) = match output {
// live-state heuristic when there's no solver move or it maps to a no-op. HintTaskOutput::SolverMove { from, to } => (from, to),
let solver_pair = match output { HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) {
HintTaskOutput::SolverMove(instruction) => hint_piles(&g.0, instruction), Some(pair) => pair,
HintTaskOutput::NeedsHeuristic => None, None => {
}; info_toast.write(InfoToastEvent("No hints available".to_string()));
let (from, to) = match solver_pair.or_else(|| find_heuristic_hint(&g.0, &mut hint_cycle)) { return;
Some(pair) => pair, }
None => { },
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
}; };
emit_hint_visuals( emit_hint_visuals(
&g.0, &g.0,
@@ -179,9 +183,8 @@ mod tests {
use super::*; use super::*;
use crate::events::HintVisualEvent; use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig; use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
/// Build a minimal Bevy app exercising only the polling system /// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches. /// and the resources/messages it touches.
@@ -211,27 +214,22 @@ mod tests {
/// tableau columns 0..3, stock and waste empty. /// tableau columns 0..3, stock and waste empty.
fn near_finished_state() -> GameState { fn near_finished_state() -> GameState {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
game.set_test_stock_cards(Vec::new()); for slot in 0..4_u8 {
game.set_test_waste_cards(Vec::new()); game.piles
for foundation in [ .get_mut(&PileType::Foundation(slot))
Foundation::Foundation1, .unwrap()
Foundation::Foundation2, .cards
Foundation::Foundation3, .clear();
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
} }
for tableau in [ for i in 0..7_usize {
Tableau::Tableau1, game.piles
Tableau::Tableau2, .get_mut(&PileType::Tableau(i))
Tableau::Tableau3, .unwrap()
Tableau::Tableau4, .cards
Tableau::Tableau5, .clear();
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
} }
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [ let ranks_below_king = [
Rank::Ace, Rank::Ace,
@@ -247,34 +245,31 @@ mod tests {
Rank::Jack, Rank::Jack,
Rank::Queen, Rank::Queen,
]; ];
for (foundation, suit) in [ for (slot, suit) in suits.iter().enumerate() {
Foundation::Foundation1, let pile = game
Foundation::Foundation2, .piles
Foundation::Foundation3, .get_mut(&PileType::Foundation(slot as u8))
Foundation::Foundation4, .unwrap();
] for (i, rank) in ranks_below_king.iter().enumerate() {
.into_iter() pile.cards.push(Card {
.zip(suits.iter()) id: (slot as u32) * 13 + i as u32,
{ suit: *suit,
let mut cards = Vec::new(); rank: *rank,
for rank in ranks_below_king.iter() { face_up: true,
cards.push(Card::new(Deck::Deck1, *suit, *rank)); });
} }
game.set_test_foundation_cards(foundation, cards);
} }
for (tableau, suit) in [ for (col, suit) in suits.iter().enumerate() {
Tableau::Tableau1, game.piles
Tableau::Tableau2, .get_mut(&PileType::Tableau(col))
Tableau::Tableau3, .unwrap()
Tableau::Tableau4, .cards
] .push(Card {
.into_iter() id: 100 + col as u32,
.zip(suits.iter()) suit: *suit,
{ rank: Rank::King,
game.set_test_tableau_cards( face_up: true,
tableau, });
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
} }
game game
} }
@@ -288,10 +283,10 @@ mod tests {
fn winnable_solver_emits_hint_after_async_completes() { fn winnable_solver_emits_hint_after_async_completes() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = app.world().resource::<HintSolverConfig>().0;
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); .spawn(near_finished_state(), cfg);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() { while app.world().resource::<PendingHintTask>().is_pending() {
@@ -314,7 +309,7 @@ mod tests {
"exactly one HintVisualEvent must fire when the solver returns Winnable", "exactly one HintVisualEvent must fire when the solver returns Winnable",
); );
assert!( assert!(
matches!(collected[0].dest_pile, KlondikePile::Foundation(_)), matches!(collected[0].dest_pile, PileType::Foundation(_)),
"solver hint destination must be a foundation slot; got {:?}", "solver hint destination must be a foundation slot; got {:?}",
collected[0].dest_pile, collected[0].dest_pile,
); );
@@ -327,10 +322,10 @@ mod tests {
fn state_change_drops_in_flight_task() { fn state_change_drops_in_flight_task() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = app.world().resource::<HintSolverConfig>().0;
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); .spawn(near_finished_state(), cfg);
assert!( assert!(
app.world().resource::<PendingHintTask>().is_pending(), app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn", "task is in flight after spawn",
@@ -363,12 +358,12 @@ mod tests {
fn second_spawn_drops_first_in_flight_task() { fn second_spawn_drops_first_in_flight_task() {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = app.world().resource::<HintSolverConfig>().0;
// First spawn. // First spawn.
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); .spawn(near_finished_state(), cfg);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending(); let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present); assert!(first_handle_present);
@@ -377,7 +372,7 @@ mod tests {
// in flight. // in flight.
app.world_mut() app.world_mut()
.resource_mut::<PendingHintTask>() .resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); .spawn(near_finished_state(), cfg);
// Resource still pending (the second task), but the first // Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once // is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the // it's been overwritten — what we *can* assert is that the
+8 -16
View File
@@ -23,10 +23,8 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::solver::{ use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
};
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent}; use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
@@ -85,7 +83,7 @@ struct SeedInputDisplay;
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct PendingVerification { struct PendingVerification {
seed: Option<u64>, seed: Option<u64>,
handle: Option<Task<SolveOutcome>>, handle: Option<Task<SolverResult>>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -342,14 +340,8 @@ fn tick_debounce_and_spawn_solver_task(
let draw_mode = settings let draw_mode = settings
.as_ref() .as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode); .map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let task = AsyncComputeTaskPool::get().spawn(async move { let cfg = SolverConfig::default();
try_solve( let task = AsyncComputeTaskPool::get().spawn(async move { try_solve(seed, draw_mode, &cfg) });
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
)
});
pending.seed = Some(seed); pending.seed = Some(seed);
pending.handle = Some(task); pending.handle = Some(task);
@@ -377,15 +369,15 @@ fn poll_solver_task(
return; return;
}; };
match result { match result {
Ok(Some(_)) => { SolverResult::Winnable => {
text.0 = "\u{2713} Provably winnable".to_string(); text.0 = "\u{2713} Provably winnable".to_string();
color.0 = ACCENT_PRIMARY; color.0 = ACCENT_PRIMARY;
} }
Err(_) => { SolverResult::Inconclusive => {
text.0 = "? Likely winnable (search timed out)".to_string(); text.0 = "? Likely winnable (search timed out)".to_string();
color.0 = TEXT_SECONDARY; color.0 = TEXT_SECONDARY;
} }
Ok(None) => { SolverResult::Unwinnable => {
text.0 = "\u{2717} Provably unwinnable".to_string(); text.0 = "\u{2717} Provably unwinnable".to_string();
color.0 = TEXT_DISABLED; color.0 = TEXT_DISABLED;
} }
-4
View File
@@ -12,11 +12,7 @@ use solitaire_core::achievement::{ALL_ACHIEVEMENTS, achievement_by_id};
use solitaire_data::SyncBackend; use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource; use crate::achievement_plugin::AchievementsResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::events::ToggleProfileRequestEvent; use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
+184 -188
View File
@@ -47,12 +47,13 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card; use solitaire_core::card::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent}; use crate::events::MoveRequestEvent;
use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC}; use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC};
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
@@ -107,7 +108,7 @@ pub enum RightClickRadialState {
/// `hovered_index` (or none). /// `hovered_index` (or none).
Active { Active {
/// Pile the right-clicked card came from. /// Pile the right-clicked card came from.
source_pile: KlondikePile, source_pile: PileType,
/// Number of cards that would be moved (always `1` — only the /// Number of cards that would be moved (always `1` — only the
/// top face-up card is ever offered for a quick-drop, since the /// top face-up card is ever offered for a quick-drop, since the
/// radial is built around single-card foundation/tableau /// radial is built around single-card foundation/tableau
@@ -122,7 +123,7 @@ pub enum RightClickRadialState {
/// [`RADIAL_RADIUS_PX`] centred on the press position. A single /// [`RADIAL_RADIUS_PX`] centred on the press position. A single
/// destination is placed directly above the cursor; multiple /// destination is placed directly above the cursor; multiple
/// destinations span an arc. /// destinations span an arc.
legal_destinations: Vec<(KlondikePile, Vec2)>, legal_destinations: Vec<(PileType, Vec2)>,
/// Cursor position (world space) the radial was opened at — /// Cursor position (world space) the radial was opened at —
/// used as the centre of the ring for cursor-hover hit testing. /// used as the centre of the ring for cursor-hover hit testing.
centre: Vec2, centre: Vec2,
@@ -249,20 +250,30 @@ pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option<usize> {
/// that legally accept the card. The source pile is excluded because /// that legally accept the card. The source pile is excluded because
/// dropping a card on its own pile is a no-op. /// dropping a card on its own pile is a no-op.
pub fn legal_destinations_for_card( pub fn legal_destinations_for_card(
_card: &Card, card: &Card,
source_pile: &KlondikePile, source_pile: &PileType,
game: &GameState, game: &GameState,
) -> Vec<KlondikePile> { ) -> Vec<PileType> {
let mut out = Vec::new(); let mut out = Vec::new();
for foundation in foundations() { for slot in 0..4_u8 {
let dest = KlondikePile::Foundation(foundation); let dest = PileType::Foundation(slot);
if game.can_move_cards(source_pile, &dest, 1) { if dest == *source_pile {
continue;
}
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile)
{
out.push(dest); out.push(dest);
} }
} }
for tableau in tableaus() { for i in 0..7_usize {
let dest = KlondikePile::Tableau(tableau); let dest = PileType::Tableau(i);
if game.can_move_cards(source_pile, &dest, 1) { if dest == *source_pile {
continue;
}
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_tableau(card, pile)
{
out.push(dest); out.push(dest);
} }
} }
@@ -281,34 +292,36 @@ pub fn find_top_face_up_card_at(
cursor: Vec2, cursor: Vec2,
game: &GameState, game: &GameState,
layout: &Layout, layout: &Layout,
) -> Option<(KlondikePile, Card)> { ) -> Option<(PileType, Card)> {
let piles = [ let piles = [
KlondikePile::Stock, PileType::Waste,
KlondikePile::Foundation(Foundation::Foundation1), PileType::Foundation(0),
KlondikePile::Foundation(Foundation::Foundation2), PileType::Foundation(1),
KlondikePile::Foundation(Foundation::Foundation3), PileType::Foundation(2),
KlondikePile::Foundation(Foundation::Foundation4), PileType::Foundation(3),
KlondikePile::Tableau(Tableau::Tableau1), PileType::Tableau(0),
KlondikePile::Tableau(Tableau::Tableau2), PileType::Tableau(1),
KlondikePile::Tableau(Tableau::Tableau3), PileType::Tableau(2),
KlondikePile::Tableau(Tableau::Tableau4), PileType::Tableau(3),
KlondikePile::Tableau(Tableau::Tableau5), PileType::Tableau(4),
KlondikePile::Tableau(Tableau::Tableau6), PileType::Tableau(5),
KlondikePile::Tableau(Tableau::Tableau7), PileType::Tableau(6),
]; ];
for pile in piles { for pile in piles {
let pile_cards = pile_cards(game, &pile); let Some(pile_cards) = game.piles.get(&pile) else {
if pile_cards.is_empty() { continue;
};
if pile_cards.cards.is_empty() {
continue; continue;
} }
let is_tableau = matches!(pile, KlondikePile::Tableau(_)); let is_tableau = matches!(pile, PileType::Tableau(_));
for i in (0..pile_cards.len()).rev() { for i in (0..pile_cards.cards.len()).rev() {
let card = &pile_cards[i]; let card = &pile_cards.cards[i];
if !card.1 { if !card.face_up {
continue; continue;
} }
// Only the top card is draggable on non-tableau piles. // Only the top card is draggable on non-tableau piles.
if !is_tableau && i != pile_cards.len() - 1 { if !is_tableau && i != pile_cards.cards.len() - 1 {
continue; continue;
} }
let pos = card_position(game, layout, &pile, i); let pos = card_position(game, layout, &pile, i);
@@ -320,7 +333,7 @@ pub fn find_top_face_up_card_at(
{ {
continue; continue;
} }
return Some((pile, card.0.clone())); return Some((pile, card.clone()));
} }
} }
None None
@@ -329,22 +342,19 @@ pub fn find_top_face_up_card_at(
/// Mirror of `input_plugin::card_position` — kept private to this /// Mirror of `input_plugin::card_position` — kept private to this
/// module so the radial's hit-test geometry tracks renderer geometry /// module so the radial's hit-test geometry tracks renderer geometry
/// without depending on `input_plugin` internals. /// without depending on `input_plugin` internals.
fn card_position( fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
game: &GameState,
layout: &Layout,
pile: &KlondikePile,
stack_index: usize,
) -> Vec2 {
let base = layout.pile_positions[pile]; let base = layout.pile_positions[pile];
if matches!(pile, KlondikePile::Tableau(_)) { if matches!(pile, PileType::Tableau(_)) {
let mut y_offset = 0.0_f32; let mut y_offset = 0.0_f32;
for card in pile_cards(game, pile).iter().take(stack_index) { if let Some(pile_cards) = game.piles.get(pile) {
let step = if card.1 { for card in pile_cards.cards.iter().take(stack_index) {
TABLEAU_FAN_FRAC let step = if card.face_up {
} else { TABLEAU_FAN_FRAC
TABLEAU_FACEDOWN_FAN_FRAC } else {
}; TABLEAU_FACEDOWN_FAN_FRAC
y_offset -= layout.card_size.y * step; };
y_offset -= layout.card_size.y * step;
}
} }
Vec2::new(base.x, base.y + y_offset) Vec2::new(base.x, base.y + y_offset)
} else { } else {
@@ -352,58 +362,17 @@ fn card_position(
} }
} }
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
use solitaire_core::card::card_to_id;
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
}
const fn tableaus() -> [Tableau; 7] {
[
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
}
/// Builds the `(destination, anchor)` list for a fresh radial open. /// Builds the `(destination, anchor)` list for a fresh radial open.
/// fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileType, Vec2)> {
/// `half_extents` is the window half-size in world space — icons are clamped
/// so that their edges stay within the viewport, preventing them from appearing
/// off-screen on small or narrow devices.
fn build_radial_destinations(
centre: Vec2,
dests: Vec<KlondikePile>,
half_extents: Vec2,
) -> Vec<(KlondikePile, Vec2)> {
let count = dests.len(); let count = dests.len();
let margin = RADIAL_ICON_SIZE_PX / 2.0;
dests dests
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(i, d)| { .map(|(i, d)| {
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX); (
let clamped = Vec2::new( d,
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin), radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin), )
);
(d, clamped)
}) })
.collect() .collect()
} }
@@ -438,10 +407,9 @@ fn cursor_world(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// On `MouseButton::Right` `just_pressed`, attempts to open the radial /// On `MouseButton::Right` `just_pressed`, attempts to open the radial
/// menu over the card the cursor is on. When the cursor is on a face-up /// menu over the card the cursor is on. Skips when a left-mouse drag is
/// card but no legal destinations exist, fires `MoveRejectedEvent` so the /// in progress, when the game is paused, or when the clicked card has no
/// shake animation and invalid-move sound play. Skips silently when no /// legal destinations.
/// card is under the cursor, when a drag is in progress, or when paused.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn radial_open_on_right_click( fn radial_open_on_right_click(
buttons: Option<Res<ButtonInput<MouseButton>>>, buttons: Option<Res<ButtonInput<MouseButton>>>,
@@ -453,7 +421,6 @@ fn radial_open_on_right_click(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
mut state: ResMut<RightClickRadialState>, mut state: ResMut<RightClickRadialState>,
mut rejected: MessageWriter<MoveRejectedEvent>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
@@ -482,25 +449,14 @@ fn radial_open_on_right_click(
// cards and the highlight tint shows the same set the radial offers. // cards and the highlight tint shows the same set the radial offers.
let dests = legal_destinations_for_card(&card, &source_pile, &game.0); let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
if dests.is_empty() { if dests.is_empty() {
// No legal destinations — shake the source pile as feedback.
rejected.write(MoveRejectedEvent {
from: source_pile,
to: source_pile,
count: 1,
});
return; return;
} }
let half_extents = windows let legal_destinations = build_radial_destinations(world, dests);
.single()
.ok()
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
.unwrap_or(Vec2::splat(f32::MAX));
let legal_destinations = build_radial_destinations(world, dests, half_extents);
*state = RightClickRadialState::Active { *state = RightClickRadialState::Active {
source_pile, source_pile,
count: 1, count: 1,
cards: vec![card_to_id(&card)], cards: vec![card.id],
legal_destinations, legal_destinations,
centre: world, centre: world,
hovered_index: None, hovered_index: None,
@@ -521,7 +477,6 @@ fn radial_open_on_long_press(
drag: Res<DragState>, drag: Res<DragState>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
touches: Option<Res<Touches>>, touches: Option<Res<Touches>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
@@ -564,16 +519,11 @@ fn radial_open_on_long_press(
if dests.is_empty() { if dests.is_empty() {
return; return;
} }
let half_extents = windows let legal_destinations = build_radial_destinations(world, dests);
.single()
.ok()
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
.unwrap_or(Vec2::splat(f32::MAX));
let legal_destinations = build_radial_destinations(world, dests, half_extents);
*state = RightClickRadialState::Active { *state = RightClickRadialState::Active {
source_pile, source_pile,
count: 1, count: 1,
cards: vec![card_to_id(&card)], cards: vec![card.id],
legal_destinations, legal_destinations,
centre: world, centre: world,
hovered_index: None, hovered_index: None,
@@ -659,8 +609,8 @@ fn radial_handle_release_or_cancel(
&& let Some((dest, _)) = legal_destinations.get(*idx) && let Some((dest, _)) = legal_destinations.get(*idx)
{ {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: *source_pile, from: source_pile.clone(),
to: *dest, to: dest.clone(),
count: *count, count: *count,
}); });
} }
@@ -796,8 +746,8 @@ mod tests {
use super::*; use super::*;
use crate::layout::compute_layout; use crate::layout::compute_layout;
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit}; use solitaire_core::card::{Card as CoreCard, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the /// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
/// resources / messages it depends on. No window, no camera — the /// resources / messages it depends on. No window, no camera — the
@@ -806,7 +756,6 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins); app.add_plugins(MinimalPlugins);
app.add_message::<MoveRequestEvent>(); app.add_message::<MoveRequestEvent>();
app.add_message::<MoveRejectedEvent>();
app.init_resource::<DragState>(); app.init_resource::<DragState>();
app.init_resource::<ButtonInput<MouseButton>>(); app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
@@ -822,32 +771,33 @@ mod tests {
fn ace_only_state() -> GameState { fn ace_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne); let mut g = GameState::new(0, DrawMode::DrawOne);
// Wipe everything. // Wipe everything.
g.set_test_stock_cards(Vec::new()); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.set_test_waste_cards(Vec::new()); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for foundation in [ for slot in 0..4_u8 {
Foundation::Foundation1, g.piles
Foundation::Foundation2, .get_mut(&PileType::Foundation(slot))
Foundation::Foundation3, .unwrap()
Foundation::Foundation4, .cards
] { .clear();
g.set_test_foundation_cards(foundation, Vec::new());
} }
for tableau in [ for i in 0..7_usize {
Tableau::Tableau1, g.piles
Tableau::Tableau2, .get_mut(&PileType::Tableau(i))
Tableau::Tableau3, .unwrap()
Tableau::Tableau4, .cards
Tableau::Tableau5, .clear();
Tableau::Tableau6,
Tableau::Tableau7,
] {
g.set_test_tableau_cards(tableau, Vec::new());
} }
// Ace of Clubs on Tableau(0). // Ace of Clubs on Tableau(0).
g.set_test_tableau_cards( g.piles
Tableau::Tableau1, .get_mut(&PileType::Tableau(0))
vec![CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], .unwrap()
); .cards
.push(CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
g g
} }
@@ -855,31 +805,32 @@ mod tests {
/// must skip it. /// must skip it.
fn face_down_only_state() -> GameState { fn face_down_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne); let mut g = GameState::new(0, DrawMode::DrawOne);
g.set_test_stock_cards(Vec::new()); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.set_test_waste_cards(Vec::new()); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for foundation in [ for slot in 0..4_u8 {
Foundation::Foundation1, g.piles
Foundation::Foundation2, .get_mut(&PileType::Foundation(slot))
Foundation::Foundation3, .unwrap()
Foundation::Foundation4, .cards
] { .clear();
g.set_test_foundation_cards(foundation, Vec::new());
} }
for tableau in [ for i in 0..7_usize {
Tableau::Tableau1, g.piles
Tableau::Tableau2, .get_mut(&PileType::Tableau(i))
Tableau::Tableau3, .unwrap()
Tableau::Tableau4, .cards
Tableau::Tableau5, .clear();
Tableau::Tableau6,
Tableau::Tableau7,
] {
g.set_test_tableau_cards(tableau, Vec::new());
} }
g.set_test_tableau_cards_with_face( g.piles
Tableau::Tableau1, .get_mut(&PileType::Tableau(0))
vec![(CoreCard::new(Deck::Deck1, Suit::Spades, Rank::King), false)], .unwrap()
); .cards
.push(CoreCard {
id: 100,
suit: Suit::Spades,
rank: Rank::King,
face_up: false,
});
g g
} }
@@ -971,28 +922,33 @@ mod tests {
#[test] #[test]
fn legal_destinations_for_ace_includes_only_first_empty_foundation() { fn legal_destinations_for_ace_includes_only_first_empty_foundation() {
let g = ace_only_state(); let g = ace_only_state();
let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace); let card = CoreCard {
let dests = id: 100,
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g); suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
};
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g);
// Ace can be placed on every empty foundation. We only need // Ace can be placed on every empty foundation. We only need
// the count to be ≥ 1 and the source pile to be excluded. // the count to be ≥ 1 and the source pile to be excluded.
assert!( assert!(
!dests.is_empty(), !dests.is_empty(),
"Ace must have at least one legal destination" "Ace must have at least one legal destination"
); );
assert!(!dests.contains(&KlondikePile::Tableau(Tableau::Tableau1))); assert!(!dests.contains(&PileType::Tableau(0)));
} }
#[test] #[test]
fn legal_destinations_excludes_source_pile() { fn legal_destinations_excludes_source_pile() {
let g = ace_only_state(); let g = ace_only_state();
let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace); let card = CoreCard {
let dests = legal_destinations_for_card( id: 100,
&card, suit: Suit::Clubs,
&KlondikePile::Foundation(Foundation::Foundation1), rank: Rank::Ace,
&g, face_up: true,
); };
assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1))); let dests = legal_destinations_for_card(&card, &PileType::Foundation(0), &g);
assert!(!dests.contains(&PileType::Foundation(0)));
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1002,6 +958,46 @@ mod tests {
/// Pressing right-click on a face-up card with at least one legal /// Pressing right-click on a face-up card with at least one legal
/// destination must transition the state to `Active` carrying the /// destination must transition the state to `Active` carrying the
/// expected source / count / legal-destination set. /// expected source / count / legal-destination set.
#[test]
fn right_click_press_on_face_up_card_opens_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
// Initial state — Idle.
assert_eq!(
*app.world().resource::<RightClickRadialState>(),
RightClickRadialState::Idle
);
press(&mut app, MouseButton::Right);
app.update();
let state = app.world().resource::<RightClickRadialState>().clone();
match state {
RightClickRadialState::Active {
source_pile,
count,
cards,
legal_destinations,
..
} => {
assert_eq!(source_pile, PileType::Tableau(0));
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert!(!legal_destinations.is_empty());
assert!(
legal_destinations
.iter()
.any(|(p, _)| matches!(p, PileType::Foundation(_)))
);
}
other => panic!("expected Active, got {other:?}"),
}
}
/// Releasing the right button while the cursor is over a destination /// Releasing the right button while the cursor is over a destination
/// icon must fire a `MoveRequestEvent` and return the state to Idle. /// icon must fire a `MoveRequestEvent` and return the state to Idle.
#[test] #[test]
@@ -1009,7 +1005,7 @@ mod tests {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true); let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right); press(&mut app, MouseButton::Right);
@@ -1019,7 +1015,7 @@ mod tests {
let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() { let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() {
RightClickRadialState::Active { RightClickRadialState::Active {
legal_destinations, .. legal_destinations, ..
} => legal_destinations[0], } => legal_destinations[0].clone(),
_ => panic!("expected Active"), _ => panic!("expected Active"),
}; };
@@ -1036,7 +1032,7 @@ mod tests {
let events = collect_move_events(&mut app); let events = collect_move_events(&mut app);
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected"); assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected");
let evt = &events[0]; let evt = &events[0];
assert_eq!(evt.from, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(evt.from, PileType::Tableau(0));
assert_eq!(evt.to, dest_pile); assert_eq!(evt.to, dest_pile);
assert_eq!(evt.count, 1); assert_eq!(evt.count, 1);
// State must return to Idle. // State must return to Idle.
@@ -1053,7 +1049,7 @@ mod tests {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true); let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right); press(&mut app, MouseButton::Right);
@@ -1084,7 +1080,7 @@ mod tests {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true); let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right); press(&mut app, MouseButton::Right);
@@ -1110,7 +1106,7 @@ mod tests {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true); let layout = compute_layout(layout_window, 0.0, 0.0, true);
let king_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; let king_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos); install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
press(&mut app, MouseButton::Right); press(&mut app, MouseButton::Right);
File diff suppressed because it is too large Load Diff
@@ -1,273 +0,0 @@
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;
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
/// consumed when transitioning out of `Playing`, so the identifier
/// isn't recoverable from state in those branches); spawn-time
/// callers fall back to an empty string.
///
/// Year + chrono ordinal (`{year}-{ordinal:03}`) gives a compact
/// monotonically-increasing identifier shaped like `2026-127` — same
/// shape as the mockup's `GAME #2024-127` motif.
pub(crate) fn format_game_caption(state: &ReplayPlaybackState) -> Option<String> {
match state {
ReplayPlaybackState::Playing { replay, .. } => Some(format!(
"GAME #{}-{:03}",
replay.recorded_at.year(),
replay.recorded_at.ordinal()
)),
ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => None,
}
}
/// Pure helper — formats the centre progress readout for the given state.
/// Exposed at module scope so the spawn path and the per-frame update
/// path produce the exact same string.
pub(crate) fn format_progress(state: &ReplayPlaybackState) -> String {
match state.progress() {
// `MOVE N/M` (uppercase + slash) reads as a Terminal output
// line and matches the floating-chip motif in the mockup at
// `docs/ui-mockups/replay-overlay-mobile.html`.
Some((cursor, total)) => format!("MOVE {cursor}/{total}"),
None if state.is_completed() => "REPLAY COMPLETE".to_string(),
None => String::new(),
}
}
/// Pure helper — formats a [`KlondikePile`] as a short, lowercase,
/// 1-indexed display string for the move-log row. `Foundation(2)`
/// renders as `"foundation 3"` rather than `"foundation 2"` so
/// players see human-friendly numbers; the underlying enum
/// remains 0-indexed.
///
/// Returns `String` rather than `&'static str` because the
/// `Foundation` / `Tableau` variants need formatting; the static
/// variants (`Stock`, `Waste`) still allocate but the cost is
/// trivial against the per-frame update cadence.
pub(crate) fn format_pile(p: &KlondikePile) -> String {
match p {
KlondikePile::Stock => "waste".to_string(),
KlondikePile::Foundation(foundation) => {
format!("foundation {}", foundation_number(*foundation))
}
KlondikePile::Tableau(tableau) => format!("tableau {}", tableau_number(*tableau)),
}
}
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,
Foundation::Foundation2 => 2,
Foundation::Foundation3 => 3,
Foundation::Foundation4 => 4,
}
}
fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
Tableau::Tableau2 => 2,
Tableau::Tableau3 => 3,
Tableau::Tableau4 => 4,
Tableau::Tableau5 => 5,
Tableau::Tableau6 => 6,
Tableau::Tableau7 => 7,
}
}
/// 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
/// 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, .. } => {
format!(
"{} \u{2192} {}",
format_saved_pile(from),
format_saved_pile(to)
)
}
}
}
/// Pure helper — formats the move-log panel's header text. Reads
/// `▌ MOVE LOG · N/M` while playing, where `N` is the count of
/// moves applied so far and `M` is the total in the replay. The
/// cursor-block prefix (`▌`) matches the splash and replay-banner
/// motifs. Empty in `Inactive` (no replay attached); reads
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
match state {
ReplayPlaybackState::Playing { replay, cursor, .. } => {
format!(
"\u{258C} MOVE LOG \u{00B7} {}/{}",
cursor,
replay.moves.len()
)
}
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
ReplayPlaybackState::Inactive => String::new(),
}
}
/// Pure helper — formats the kth-most-recently-applied move's row
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
/// and so on.
///
/// Returns the empty string in any of these cases:
/// - State isn't `Playing` (no replay attached).
/// - `k == 0` (no kth-most-recent for k=0; the active is k=1).
/// - `k > cursor` (not enough history — e.g. cursor=2 has rows
/// for k=1 and k=2 only, k=3 returns empty).
/// - The move list is shorter than expected (defensive guard).
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new();
};
if k == 0 || k > *cursor {
return String::new();
}
let zero_idx = *cursor - k;
let Some(m) = replay.moves.get(zero_idx) else {
return String::new();
};
let display_idx = *cursor - k + 1;
format!("{} \u{2502} {}", display_idx, format_move_body(m))
}
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
/// is the move that will apply next (`replay.moves[cursor]`,
/// displayed as `cursor + 1`); `k = 2` is the move after that,
/// and so on.
///
/// Returns the empty string in any of these cases:
/// - State isn't `Playing` (no replay attached).
/// - `k == 0` (degenerate; the active is k=1 of *recent*, not
/// *next*).
/// - `cursor + k - 1 >= moves.len()` (not enough remaining
/// replay — late in the move list, the trailing next rows
/// stay empty).
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new();
};
if k == 0 {
return String::new();
}
let zero_idx = *cursor + k - 1;
let Some(m) = replay.moves.get(zero_idx) else {
return String::new();
};
let display_idx = *cursor + k;
format!("{} \u{2502} {}", display_idx, format_move_body(m))
}
/// Pure helper — formats the active-row text for the move-log
/// panel. Wraps [`format_kth_recent_row`] with `k=1` and prepends
/// a `▶` focus marker so the active row reads visually distinct
/// from prev rows even before the highlight background lands.
/// Returns empty when there's no row to render (cursor=0 or
/// non-`Playing` state) — never `"▶ "` alone, which would paint
/// a stray prefix.
pub(crate) fn format_active_move_row(state: &ReplayPlaybackState) -> String {
let body = format_kth_recent_row(state, 1);
if body.is_empty() {
return String::new();
}
format!("\u{25B6} {body}") // ▶
}
// ---------------------------------------------------------------------------
// Mini-tableau format helpers and update system
// ---------------------------------------------------------------------------
/// Pure helper — short rank symbol. Single character for all ranks
/// except Ten which uses "T" (keeps every card a consistent 2-char
/// wide render: rank-char + suit-glyph). Players familiar with
/// solitaire shorthand read "T" instantly; the suit glyph immediately
/// follows and disambiguates from an ambiguous "T".
pub(crate) fn format_rank_short(rank: Rank) -> &'static str {
match rank {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "T",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
}
}
/// Pure helper — Unicode suit glyph from FiraMono's covered range
/// (U+2660U+2666). These four code points are confirmed present in
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
match suit {
Suit::Spades => "\u{2660}", // ♠
Suit::Hearts => "\u{2665}", // ♥
Suit::Diamonds => "\u{2666}", // ♦
Suit::Clubs => "\u{2663}", // ♣
}
}
/// 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, bool)>) -> String {
match card {
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
None => "--".to_string(),
}
}
/// Pure helper — one-line summary of the four foundation tops.
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
/// Foundation slots are displayed in their natural 0-3 order
/// (matching the visual left-to-right order on screen).
pub(crate) fn format_foundations_row(game: &GameState) -> String {
let slots = [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
.map(|foundation| {
let cards = game.pile(KlondikePile::Foundation(foundation));
format_card_short(cards.last())
});
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
}
/// Pure helper — one-line stock / waste summary.
/// Renders as `STK:N WST:X♠` where N is the stock card count and
/// X♠ is the top waste card (or `--` when the waste pile is empty).
pub(crate) fn format_stock_waste_row(game: &GameState) -> String {
let stock_cards = game.stock_cards();
let waste_cards = game.waste_cards();
let stock_count = stock_cards.len();
let waste_top = waste_cards.last();
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,249 +0,0 @@
use bevy::prelude::*;
use super::format::{
format_active_move_row, format_foundations_row, format_kth_next_row, format_kth_recent_row,
format_move_log_header, format_progress, format_stock_waste_row,
};
use super::*;
use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use solitaire_core::KlondikePile;
use solitaire_data::ReplayMove;
/// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "▌ replay" for
/// "▌ replay complete" in place without despawning the overlay.
pub(crate) fn update_banner_label(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
) {
if !state.is_changed() {
return;
}
let label = if state.is_completed() {
"\u{258C} replay complete" // ▌
} else if state.is_playing() {
"\u{258C} replay" // ▌
} else {
return;
};
for mut text in &mut q {
**text = label.to_string();
}
}
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
/// Cheap — early-exits if the resource has not changed since the last
/// frame so idle replays don't churn the text mesh.
pub(crate) fn update_progress_text(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
) {
if !state.is_changed() {
return;
}
let label = format_progress(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repositions the floating progress chip above the destination
/// pile of the most-recently-applied move and repaints its text.
///
/// The chip is hidden when:
/// - the cursor is at 0 (no moves applied yet — chip would have
/// nowhere meaningful to land), OR
/// - the most-recently-applied move was a `StockClick` (no
/// destination pile — stock-click feedback already lives at
/// the stock pile and we don't want the chip to jitter back
/// to the stock pile every cycle).
///
/// When visible, the chip's world-space `Transform.translation`
/// is set to the destination pile's centre plus a fixed upward
/// offset (`card_size.y * 0.6`) so the chip floats just above
/// the top edge of the card. World-space placement (rather than
/// UI-space + camera projection) keeps the math trivial and means
/// the chip stays correctly positioned through window resizes
/// without any extra wiring — `LayoutResource` already drives
/// every other piece of pile geometry.
pub(crate) fn update_floating_progress_chip(
state: Res<ReplayPlaybackState>,
layout: Option<Res<LayoutResource>>,
mut chips: Query<
(&mut Transform, &mut Visibility, &mut Text2d),
With<ReplayFloatingProgressChip>,
>,
) {
let Some(layout) = layout else {
return;
};
// Resolve the destination pile of the last-applied move (if
// any). `cursor` is the index of the *next* move to apply, so
// the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
match &replay.moves[cursor - 1] {
ReplayMove::Move { to, .. } => Some(*to),
ReplayMove::StockClick => None,
}
}
_ => None,
};
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())
else {
// Nothing to point at — hide every chip and exit.
for (_, mut visibility, _) in chips.iter_mut() {
*visibility = Visibility::Hidden;
}
return;
};
// Position above the destination pile by ~60 % of a card
// height. Half a card lifts above the centre, the extra 10 %
// is breathing room above the top edge so the chip doesn't
// visually clip the card.
let above = Vec2::new(0.0, layout.0.card_size.y * 0.6);
let target = (world_pos + above).extend(100.0);
let label = format_progress(&state);
for (mut transform, mut visibility, mut text2d) in chips.iter_mut() {
transform.translation = target;
*visibility = Visibility::Inherited;
if **text2d != label {
**text2d = label.clone();
}
}
}
/// Repaints the move-log panel's `▌ MOVE LOG · N/M` header text
/// whenever [`ReplayPlaybackState`] changes. Cheap — early-exits
/// when nothing moved so an idle replay leaves the text mesh
/// untouched.
pub(crate) fn update_move_log_header(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayMoveLogHeader>>,
) {
if !state.is_changed() {
return;
}
let label = format_move_log_header(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repaints the move-log panel's active-row text whenever
/// [`ReplayPlaybackState`] changes. Same change-detection guard
/// as the header updater. Empty string at `cursor == 0` (no move
/// applied yet) and in non-`Playing` states; populated otherwise.
pub(crate) fn update_move_log_active_row(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayMoveLogActiveRow>>,
) {
if !state.is_changed() {
return;
}
let label = format_active_move_row(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repaints every "previous move" row text whenever
/// [`ReplayPlaybackState`] changes. Each row's `offset` is read
/// from the marker; `k = offset + 1` feeds [`format_kth_recent_row`]
/// (active is k=1, prev offset 1 is k=2, prev offset 2 is k=3).
/// Rows with `offset >= cursor` paint as empty — the panel
/// gracefully under-fills early in a replay without spurious
/// "out-of-range" text.
pub(crate) fn update_move_log_prev_rows(
state: Res<ReplayPlaybackState>,
mut q: Query<(&ReplayOverlayMoveLogPrevRow, &mut Text)>,
) {
if !state.is_changed() {
return;
}
for (row, mut text) in &mut q {
let label = format_kth_recent_row(&state, row.offset as usize + 1);
**text = label;
}
}
/// Repaints every "next move" row text whenever
/// [`ReplayPlaybackState`] changes. Symmetric to the prev-row
/// updater but feeds [`format_kth_next_row`]. Rows where
/// `cursor + offset > moves.len()` paint as empty — the panel
/// gracefully under-fills late in a replay (e.g. final moves)
/// without spurious out-of-range text.
pub(crate) fn update_move_log_next_rows(
state: Res<ReplayPlaybackState>,
mut q: Query<(&ReplayOverlayMoveLogNextRow, &mut Text)>,
) {
if !state.is_changed() {
return;
}
for (row, mut text) in &mut q {
let label = format_kth_next_row(&state, row.offset as usize);
**text = label;
}
}
/// Repaints the bottom-edge accent scrub fill to mirror cursor progress.
/// Same change-detection guard as the text updaters — the overlay
/// already early-exits when nothing moved, so an idle replay leaves the
/// scrub bar's `Node` untouched.
pub(crate) fn update_scrub_fill(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Node, With<ReplayOverlayScrubFill>>,
) {
if !state.is_changed() {
return;
}
let pct = scrub_pct(&state);
for mut node in &mut q {
node.width = Val::Percent(pct);
}
}
/// Repaints the foundations row whenever [`GameStateResource`] changes.
/// Split into its own system (rather than combined with the stock/waste
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
/// queries in one system are always ambiguous regardless of marker
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
pub(crate) fn update_mini_tableau_foundations(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_foundations_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
/// guard, separate system to avoid the B0001 query conflict.
pub(crate) fn update_mini_tableau_stock_waste(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_stock_waste_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
+13 -33
View File
@@ -40,7 +40,6 @@
//! flag is threaded through, no every-callsite gate is added. //! flag is threaded through, no every-callsite gate is added.
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_data::{Replay, ReplayMove}; use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
@@ -268,18 +267,9 @@ pub fn step_replay_playback(
} }
match &replay.moves[*cursor] { match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => { 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 { moves_writer.write(MoveRequestEvent {
from, from: from.clone(),
to, to: to.clone(),
count: *count, count: *count,
}); });
} }
@@ -380,20 +370,11 @@ fn tick_replay_playback(
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() { while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] { match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => { ReplayMove::Move { from, to, count } => {
if let (Ok(from), Ok(to)) = moves_writer.write(MoveRequestEvent {
(KlondikePile::try_from(*from), KlondikePile::try_from(*to)) from: from.clone(),
{ to: to.clone(),
moves_writer.write(MoveRequestEvent { count: *count,
from, });
to,
count: *count,
});
} else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
}
} }
ReplayMove::StockClick => { ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent); draws_writer.write(DrawRequestEvent);
@@ -555,9 +536,8 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy; use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{KlondikePile, Tableau}; use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawMode, game_state::GameMode}; use solitaire_core::pile::PileType;
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use std::time::Duration; use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and /// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -606,8 +586,8 @@ mod tests {
vec![ vec![
ReplayMove::StockClick, ReplayMove::StockClick,
ReplayMove::Move { ReplayMove::Move {
from: SavedKlondikePile::Stock, from: PileType::Waste,
to: SavedKlondikePile::Tableau(SavedTableau(3)), to: PileType::Tableau(3),
count: 1, count: 1,
}, },
ReplayMove::StockClick, ReplayMove::StockClick,
@@ -759,8 +739,8 @@ mod tests {
"expected 1 MoveRequestEvent (the single Move variant)", "expected 1 MoveRequestEvent (the single Move variant)",
); );
let m = &captured_moves.0[0]; let m = &captured_moves.0[0];
assert!(matches!(m.from, KlondikePile::Stock)); assert!(matches!(m.from, PileType::Waste));
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4))); assert!(matches!(m.to, PileType::Tableau(3)));
assert_eq!(m.count, 1); assert_eq!(m.count, 1);
} }
+4 -13
View File
@@ -1,14 +1,12 @@
//! Bevy resources owned by the engine crate. //! Bevy resources owned by the engine crate.
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc; use std::sync::Arc;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::card::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game. /// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
@@ -28,10 +26,10 @@ pub struct GameStateResource(pub GameState);
/// This prevents accidental drags on quick taps, especially on touch screens. /// This prevents accidental drags on quick taps, especially on touch screens.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct DragState { pub struct DragState {
/// Cards being dragged (bottom-to-top stacking order). /// IDs of the cards being dragged (bottom-to-top stacking order).
pub cards: Vec<Card>, pub cards: Vec<u32>,
/// Pile the drag originated from. /// Pile the drag originated from.
pub origin_pile: Option<KlondikePile>, pub origin_pile: Option<PileType>,
/// World-space offset from the cursor/touch to the bottom card's centre. /// World-space offset from the cursor/touch to the bottom card's centre.
pub cursor_offset: Vec2, pub cursor_offset: Vec2,
/// Z coordinate used for the dragged cards. /// Z coordinate used for the dragged cards.
@@ -130,16 +128,9 @@ pub struct GameInputConsumedResource(pub bool);
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply /// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply
/// into every network task — safe for concurrent `block_on` calls from multiple /// into every network task — safe for concurrent `block_on` calls from multiple
/// worker threads. /// worker threads.
///
/// Gated to non-wasm because `tokio::runtime::Builder::new_multi_thread()` uses
/// `mio` for OS-level I/O polling which does not compile for wasm32. The
/// plugins that depend on this resource (AudioPlugin, SyncPlugin,
/// AnalyticsPlugin) are also gated out on wasm32 in `CoreGamePlugin`.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Clone)] #[derive(Resource, Clone)]
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>); pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
#[cfg(not(target_arch = "wasm32"))]
impl TokioRuntimeResource { impl TokioRuntimeResource {
/// Attempts to build the shared multi-threaded Tokio runtime. /// Attempts to build the shared multi-threaded Tokio runtime.
/// ///
+12 -27
View File
@@ -147,13 +147,8 @@ fn apply_safe_area_bottom_anchors(
} }
} }
/// Pads both edges of every [`ModalScrim`] by the logical system-bar insets so /// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
/// modal cards are centred within the usable area (between the status bar at /// modal cards don't extend into the Android gesture-navigation zone.
/// the top and the gesture-navigation bar at the bottom).
///
/// `padding.top` = status-bar inset; `padding.bottom` = gesture-bar inset.
/// With `align_items: Center` / `justify_content: Center` on the scrim the
/// `ModalCard` lands at the visual midpoint of the visible content area.
/// ///
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets /// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
/// arriving a few frames after app start) AND when a new `ModalScrim` is /// arriving a few frames after app start) AND when a new `ModalScrim` is
@@ -170,18 +165,8 @@ fn apply_safe_area_to_modal_scrims(
} }
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let window_height = windows.iter().next().map_or(800.0, |w| w.height()); let window_height = windows.iter().next().map_or(800.0, |w| w.height());
// Clamp each inset to 25% of screen height so an unexpectedly large OS
// value can't push the modal card off the visible area entirely.
let top_logical = (insets.top / scale).min(window_height * 0.25);
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25); let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
for mut node in &mut scrims { for mut node in &mut scrims {
// Set both edges so the scrim's content box equals the usable area
// between the status bar and the gesture/navigation bar. With
// `align_items: Center` / `justify_content: Center` on the scrim,
// the modal card is centred within that usable region rather than
// the full viewport, correcting the slight upward shift seen when
// only the bottom inset was applied.
node.padding.top = Val::Px(top_logical);
node.padding.bottom = Val::Px(bottom_logical); node.padding.bottom = Val::Px(bottom_logical);
} }
} }
@@ -268,24 +253,24 @@ mod android {
} }
} }
/// Resets the inset poller on `AppLifecycle::WillResume` so that /// Resets the inset poller and clears cached insets on
/// `refresh_insets` re-queries JNI in the frames immediately after the app /// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
/// returns to the foreground. /// frames immediately after the app returns to the foreground.
/// ///
/// The cached `SafeAreaInsets` are intentionally **not** zeroed here. /// Clearing `SafeAreaInsets` to the default (all-zero) fires
/// Zeroing them would cause two layout recomputes on every resume: /// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
/// once with zero insets (wrong position) and again when JNI resolves the /// `WindowResized`. `on_window_resized` then recomputes the layout;
/// real values — visible as a flash. By preserving the last-known values /// once `refresh_insets` resolves the real values a second synthetic
/// the layout remains stable; if JNI returns a different value (e.g. after /// `WindowResized` fires and the layout converges to the correct position.
/// a rotation) the single update that fires when `SafeAreaInsets` actually
/// changes is enough.
pub(super) fn rearm_on_resumed( pub(super) fn rearm_on_resumed(
mut lifecycle: MessageReader<AppLifecycle>, mut lifecycle: MessageReader<AppLifecycle>,
mut poll: ResMut<SafeAreaPollTries>, mut poll: ResMut<SafeAreaPollTries>,
mut insets: ResMut<SafeAreaInsets>,
) { ) {
for event in lifecycle.read() { for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) { if matches!(event, AppLifecycle::WillResume) {
poll.0 = 0; poll.0 = 0;
*insets = SafeAreaInsets::default();
} }
} }
} }
+357 -235
View File
@@ -37,11 +37,11 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::CardEntityIndex; use crate::card_plugin::CardEntity;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack}; use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
@@ -60,7 +60,7 @@ use crate::ui_theme::{ACCENT_PRIMARY, STATE_SUCCESS, STATE_WARNING};
#[derive(Resource, Debug, Default)] #[derive(Resource, Debug, Default)]
pub struct SelectionState { pub struct SelectionState {
/// The pile whose top face-up card is currently selected, or `None`. /// The pile whose top face-up card is currently selected, or `None`.
pub selected_pile: Option<KlondikePile>, pub selected_pile: Option<PileType>,
} }
/// Sentinel value used in [`crate::resources::DragState::active_touch_id`] /// Sentinel value used in [`crate::resources::DragState::active_touch_id`]
@@ -87,18 +87,18 @@ pub enum KeyboardDragState {
/// `legal_destinations` and `Enter` fires the move. /// `legal_destinations` and `Enter` fires the move.
Lifted { Lifted {
/// Pile the cards were lifted from. /// Pile the cards were lifted from.
source_pile: KlondikePile, source_pile: PileType,
/// Number of cards lifted (1 for waste / foundation, full face-up /// Number of cards lifted (1 for waste / foundation, full face-up
/// run length for a tableau column). /// run length for a tableau column).
count: usize, count: usize,
/// Cards being lifted, in the same bottom-to-top order /// Card ids being lifted, in the same bottom-to-top order
/// `DragState.cards` expects. /// `DragState.cards` expects.
cards: Vec<Card>, cards: Vec<u32>,
/// Pre-computed list of piles the lifted stack can legally be /// Pre-computed list of piles the lifted stack can legally be
/// placed on. Always at least one entry while in this variant — /// placed on. Always at least one entry while in this variant —
/// if no legal destinations exist the state machine refuses to /// if no legal destinations exist the state machine refuses to
/// enter `Lifted` in the first place. /// enter `Lifted` in the first place.
legal_destinations: Vec<KlondikePile>, legal_destinations: Vec<PileType>,
/// Cursor into `legal_destinations`. Always `< legal_destinations.len()`. /// Cursor into `legal_destinations`. Always `< legal_destinations.len()`.
destination_index: usize, destination_index: usize,
}, },
@@ -110,7 +110,7 @@ impl KeyboardDragState {
/// ///
/// [`Lifted`]: KeyboardDragState::Lifted /// [`Lifted`]: KeyboardDragState::Lifted
/// [`Idle`]: KeyboardDragState::Idle /// [`Idle`]: KeyboardDragState::Idle
pub fn focused_destination(&self) -> Option<&KlondikePile> { pub fn focused_destination(&self) -> Option<&PileType> {
match self { match self {
Self::Idle => None, Self::Idle => None,
Self::Lifted { Self::Lifted {
@@ -147,12 +147,8 @@ pub struct SelectionPlugin;
impl Plugin for SelectionPlugin { impl Plugin for SelectionPlugin {
fn build(&self, app: &mut App) { 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>() app.init_resource::<SelectionState>()
.init_resource::<KeyboardDragState>() .init_resource::<KeyboardDragState>()
.init_resource::<CardEntityIndex>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -177,26 +173,13 @@ impl Plugin for SelectionPlugin {
/// The ordered list of piles that are considered for keyboard cycling. /// The ordered list of piles that are considered for keyboard cycling.
/// ///
/// Order: Waste → Foundation slots 03 → Tableau 06. /// Order: Waste → Foundation slots 03 → Tableau 06.
fn cycled_piles() -> Vec<KlondikePile> { fn cycled_piles() -> Vec<PileType> {
let mut piles = vec![KlondikePile::Stock]; let mut piles = vec![PileType::Waste];
for foundation in [ for slot in 0..4_u8 {
Foundation::Foundation1, piles.push(PileType::Foundation(slot));
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
piles.push(KlondikePile::Foundation(foundation));
} }
for tableau in [ for i in 0..7_usize {
Tableau::Tableau1, piles.push(PileType::Tableau(i));
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
piles.push(KlondikePile::Tableau(tableau));
} }
piles piles
} }
@@ -206,10 +189,7 @@ fn cycled_piles() -> Vec<KlondikePile> {
/// ///
/// If `current` is `None` the first available pile is returned. /// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned. /// If `available` is empty, `None` is returned.
pub fn cycle_next_pile( pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Option<PileType> {
available: &[KlondikePile],
current: Option<&KlondikePile>,
) -> Option<KlondikePile> {
if available.is_empty() { if available.is_empty() {
return None; return None;
} }
@@ -230,7 +210,7 @@ pub fn cycle_next_pile(
for offset in 0..n { for offset in 0..n {
let candidate = &order[(start + offset) % n]; let candidate = &order[(start + offset) % n];
if available.contains(candidate) { if available.contains(candidate) {
return Some(*candidate); return Some(candidate.clone());
} }
} }
None None
@@ -242,18 +222,14 @@ pub fn cycle_next_pile(
/// ///
/// Both `current` and `next` must be `Some`; if either is `None` this returns /// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`. /// `false`.
fn did_wrap( fn did_wrap(available: &[PileType], current: Option<&PileType>, next: Option<&PileType>) -> bool {
available: &[KlondikePile],
current: Option<&KlondikePile>,
next: Option<&KlondikePile>,
) -> bool {
let (Some(cur), Some(nxt)) = (current, next) else { let (Some(cur), Some(nxt)) = (current, next) else {
return false; return false;
}; };
let order = cycled_piles(); let order = cycled_piles();
// Position of each pile within the *available* subset, ordered by the // Position of each pile within the *available* subset, ordered by the
// global cycle order. // global cycle order.
let pos_in_available = |target: &KlondikePile| -> Option<usize> { let pos_in_available = |target: &PileType| -> Option<usize> {
order order
.iter() .iter()
.filter(|p| available.contains(p)) .filter(|p| available.contains(p))
@@ -350,7 +326,7 @@ fn handle_selection_keys(
if keys.just_pressed(KeyCode::Enter) { if keys.just_pressed(KeyCode::Enter) {
if let Some(dest) = legal_destinations.get(*destination_index).cloned() { if let Some(dest) = legal_destinations.get(*destination_index).cloned() {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: *source_pile, from: source_pile.clone(),
to: dest, to: dest,
count: *count, count: *count,
}); });
@@ -381,23 +357,29 @@ fn handle_selection_keys(
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Build the list of piles that currently have a face-up draggable top card. // Build the list of piles that currently have a face-up draggable top card.
let available: Vec<KlondikePile> = { let available: Vec<PileType> = {
let all = [ let all = [
KlondikePile::Stock, PileType::Waste,
KlondikePile::Foundation(Foundation::Foundation1), PileType::Foundation(0),
KlondikePile::Foundation(Foundation::Foundation2), PileType::Foundation(1),
KlondikePile::Foundation(Foundation::Foundation3), PileType::Foundation(2),
KlondikePile::Foundation(Foundation::Foundation4), PileType::Foundation(3),
KlondikePile::Tableau(Tableau::Tableau1), PileType::Tableau(0),
KlondikePile::Tableau(Tableau::Tableau2), PileType::Tableau(1),
KlondikePile::Tableau(Tableau::Tableau3), PileType::Tableau(2),
KlondikePile::Tableau(Tableau::Tableau4), PileType::Tableau(3),
KlondikePile::Tableau(Tableau::Tableau5), PileType::Tableau(4),
KlondikePile::Tableau(Tableau::Tableau6), PileType::Tableau(5),
KlondikePile::Tableau(Tableau::Tableau7), PileType::Tableau(6),
]; ];
all.into_iter() all.into_iter()
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.1)) .filter(|p| {
game.0
.piles
.get(p)
.and_then(|pile| pile.cards.last())
.is_some_and(|c| c.face_up)
})
.collect() .collect()
}; };
@@ -425,16 +407,18 @@ fn handle_selection_keys(
// tableau stack target. Preserved so the muscle memory built around // tableau stack target. Preserved so the muscle memory built around
// `Tab` → `Space` keeps working; `Enter` is now the lift trigger. // `Tab` → `Space` keeps working; `Enter` is now the lift trigger.
if keys.just_pressed(KeyCode::Space) if keys.just_pressed(KeyCode::Space)
&& let Some(ref pile) = selection.selected_pile && let Some(ref pile) = selection.selected_pile.clone()
&& let Some(card) = game
.0
.piles
.get(pile)
.and_then(|p| p.cards.last())
.filter(|c| c.face_up)
{ {
let selected_cards = pile_cards(&game.0, pile);
let Some((card, _)) = selected_cards.last().filter(|c| c.1) else {
return;
};
// Priority 1: foundation move (single card). // Priority 1: foundation move (single card).
if let Some(dest) = try_foundation_dest(card, &game.0) { if let Some(dest) = try_foundation_dest(card, &game.0) {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: *pile, from: pile.clone(),
to: dest, to: dest,
count: 1, count: 1,
}); });
@@ -442,16 +426,17 @@ fn handle_selection_keys(
return; return;
} }
// Priority 2: tableau stack move. // Priority 2: tableau stack move.
let run_len = face_up_run_len(&selected_cards); let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()));
let bottom_card = selected_cards let bottom_card = game.0.piles.get(pile).and_then(|p| {
.get(selected_cards.len().saturating_sub(run_len)) let start = p.cards.len().saturating_sub(run_len);
.map(|(c, _)| c.clone()); p.cards.get(start)
});
if let Some(bottom) = bottom_card if let Some(bottom) = bottom_card
&& let Some((dest, count)) = && let Some((dest, count)) =
best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len) best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
{ {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: *pile, from: pile.clone(),
to: dest, to: dest,
count, count,
}); });
@@ -461,7 +446,7 @@ fn handle_selection_keys(
// Fallback for non-tableau sources. // Fallback for non-tableau sources.
if let Some(dest) = best_destination(card, &game.0) { if let Some(dest) = best_destination(card, &game.0) {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: *pile, from: pile.clone(),
to: dest, to: dest,
count: 1, count: 1,
}); });
@@ -472,24 +457,25 @@ fn handle_selection_keys(
// Enter — lift the focused pile into destination-pick mode. // Enter — lift the focused pile into destination-pick mode.
if keys.just_pressed(KeyCode::Enter) if keys.just_pressed(KeyCode::Enter)
&& let Some(ref source) = selection.selected_pile && let Some(ref source) = selection.selected_pile.clone()
{ {
let source_cards = pile_cards(&game.0, source); let Some(pile_cards) = game.0.piles.get(source) else {
if source_cards.is_empty() {
return; return;
} };
// Determine the lift range: tableau lifts the full face-up run, all // Determine the lift range: tableau lifts the full face-up run, all
// other sources lift only the top card. // other sources lift only the top card.
let run_len = face_up_run_len(&source_cards); let run_len = face_up_run_len(pile_cards.cards.as_slice());
let count = if matches!(source, KlondikePile::Tableau(_)) { let count = if matches!(source, PileType::Tableau(_)) {
run_len.max(1) run_len.max(1)
} else { } else {
1 1
}; };
let start = source_cards.len().saturating_sub(count); if pile_cards.cards.is_empty() {
let lifted_cards: Vec<Card> = return;
source_cards[start..].iter().map(|(c, _)| c.clone()).collect(); }
let Some((bottom, _)) = source_cards.get(start) else { let start = pile_cards.cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> = pile_cards.cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = pile_cards.cards.get(start) else {
return; return;
}; };
let legal = legal_destinations_for(bottom, source, &game.0, count); let legal = legal_destinations_for(bottom, source, &game.0, count);
@@ -501,7 +487,7 @@ fn handle_selection_keys(
// Populate `DragState` with the keyboard sentinel so the existing // Populate `DragState` with the keyboard sentinel so the existing
// mouse-drag systems treat this as "not their drag". // mouse-drag systems treat this as "not their drag".
drag.cards = lifted_cards.clone(); drag.cards = lifted_cards.clone();
drag.origin_pile = Some(*source); drag.origin_pile = Some(source.clone());
drag.cursor_offset = Vec2::ZERO; drag.cursor_offset = Vec2::ZERO;
drag.origin_z = 1.0; drag.origin_z = 1.0;
drag.press_pos = Vec2::ZERO; drag.press_pos = Vec2::ZERO;
@@ -509,7 +495,7 @@ fn handle_selection_keys(
drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID); drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);
*kbd_drag = KeyboardDragState::Lifted { *kbd_drag = KeyboardDragState::Lifted {
source_pile: *source, source_pile: source.clone(),
count, count,
cards: lifted_cards, cards: lifted_cards,
legal_destinations: legal, legal_destinations: legal,
@@ -534,36 +520,33 @@ fn handle_selection_keys(
/// destination after a lift. Players who want a different column simply /// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice. /// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for( pub(crate) fn legal_destinations_for(
_bottom: &solitaire_core::card::Card, bottom: &solitaire_core::card::Card,
source: &KlondikePile, source: &PileType,
game: &GameState, game: &GameState,
stack_count: usize, stack_count: usize,
) -> Vec<KlondikePile> { ) -> Vec<PileType> {
let mut out = Vec::new(); let mut out = Vec::new();
if stack_count == 1 { if stack_count == 1 {
for foundation in [ for slot in 0..4_u8 {
Foundation::Foundation1, let dest = PileType::Foundation(slot);
Foundation::Foundation2, if &dest == source {
Foundation::Foundation3, continue;
Foundation::Foundation4, }
] { if let Some(pile) = game.piles.get(&dest)
let dest = KlondikePile::Foundation(foundation); && can_place_on_foundation(bottom, pile)
if game.can_move_cards(source, &dest, 1) { {
out.push(dest); out.push(dest);
} }
} }
} }
for tableau in [ for i in 0..7_usize {
Tableau::Tableau1, let dest = PileType::Tableau(i);
Tableau::Tableau2, if &dest == source {
Tableau::Tableau3, continue;
Tableau::Tableau4, }
Tableau::Tableau5, if let Some(pile) = game.piles.get(&dest)
Tableau::Tableau6, && can_place_on_tableau(bottom, pile)
Tableau::Tableau7, {
] {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source, &dest, stack_count) {
out.push(dest); out.push(dest);
} }
} }
@@ -579,10 +562,10 @@ pub(crate) fn legal_destinations_for(
/// Walks backwards from the last element and stops at the first face-down card /// Walks backwards from the last element and stops at the first face-down card
/// (or when the slice is exhausted). Returns at least `1` when the top card is /// (or when the slice is exhausted). Returns at least `1` when the top card is
/// face-up; returns `0` for an empty slice or when the top card is face-down. /// face-up; returns `0` for an empty slice or when the top card is face-down.
fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize { fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
let mut count = 0; let mut count = 0;
for (_, face_up) in cards.iter().rev() { for card in cards.iter().rev() {
if *face_up { if card.face_up {
count += 1; count += 1;
} else { } else {
break; break;
@@ -600,16 +583,13 @@ fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
fn try_foundation_dest( fn try_foundation_dest(
card: &solitaire_core::card::Card, card: &solitaire_core::card::Card,
game: &solitaire_core::game_state::GameState, game: &solitaire_core::game_state::GameState,
) -> Option<KlondikePile> { ) -> Option<PileType> {
let source = game.pile_containing_card(card.clone())?; use solitaire_core::rules::can_place_on_foundation;
for foundation in [ for slot in 0..4_u8 {
Foundation::Foundation1, let dest = PileType::Foundation(slot);
Foundation::Foundation2, if let Some(pile) = game.piles.get(&dest)
Foundation::Foundation3, && can_place_on_foundation(card, pile)
Foundation::Foundation4, {
] {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(&source, &dest, 1) {
return Some(dest); return Some(dest);
} }
} }
@@ -661,7 +641,7 @@ fn update_selection_highlight(
kbd_drag: Res<KeyboardDragState>, kbd_drag: Res<KeyboardDragState>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
card_index: Res<CardEntityIndex>, card_entities: Query<(Entity, &CardEntity)>,
highlights: Query<Entity, With<SelectionHighlight>>, highlights: Query<Entity, With<SelectionHighlight>>,
) { ) {
// Always despawn any existing highlight first. // Always despawn any existing highlight first.
@@ -689,9 +669,9 @@ fn update_selection_highlight(
// Resolve the source pile from KeyboardDragState (when lifted) or // Resolve the source pile from KeyboardDragState (when lifted) or
// SelectionState (otherwise). Lifted takes precedence so the gold // SelectionState (otherwise). Lifted takes precedence so the gold
// outline follows the actual lifted cards. // outline follows the actual lifted cards.
let source_pile: Option<KlondikePile> = match &*kbd_drag { let source_pile: Option<PileType> = match &*kbd_drag {
KeyboardDragState::Lifted { source_pile, .. } => Some(*source_pile), KeyboardDragState::Lifted { source_pile, .. } => Some(source_pile.clone()),
KeyboardDragState::Idle => selection.selected_pile, KeyboardDragState::Idle => selection.selected_pile.clone(),
}; };
if let Some(ref pile) = source_pile if let Some(ref pile) = source_pile
@@ -699,8 +679,8 @@ fn update_selection_highlight(
{ {
spawn_highlight_on_card( spawn_highlight_on_card(
&mut commands, &mut commands,
&card_index, &card_entities,
&card, card.id,
card_size, card_size,
source_color, source_color,
); );
@@ -716,8 +696,8 @@ fn update_selection_highlight(
if let Some(card) = top_face_up_card(dest, &game.0) { if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card( spawn_highlight_on_card(
&mut commands, &mut commands,
&card_index, &card_entities,
&card, card.id,
card_size, card_size,
dest_color, dest_color,
); );
@@ -727,42 +707,41 @@ fn update_selection_highlight(
/// Returns the top face-up card on `pile`, or `None` if the pile is /// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down. /// empty or its top card is face-down.
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> { fn top_face_up_card<'a>(
pile_cards(game, pile) pile: &PileType,
.last() game: &'a GameState,
.filter(|(_, up)| *up) ) -> Option<&'a solitaire_core::card::Card> {
.map(|(c, _)| c.clone()) game.piles
} .get(pile)
.and_then(|p| p.cards.last())
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> { .filter(|c| c.face_up)
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
} }
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying /// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
/// the matching `CardEntity::card`. No-op if no entity matches. /// the matching `CardEntity::card_id`. No-op if no entity matches.
fn spawn_highlight_on_card( fn spawn_highlight_on_card(
commands: &mut Commands, commands: &mut Commands,
card_index: &CardEntityIndex, card_entities: &Query<(Entity, &CardEntity)>,
card: &Card, card_id: u32,
card_size: Vec2, card_size: Vec2,
color: Color, color: Color,
) { ) {
if let Some(entity) = card_index.get(card) { for (entity, card_entity) in card_entities {
commands.entity(entity).with_children(|b| { if card_entity.card_id == card_id {
b.spawn(( commands.entity(entity).with_children(|b| {
SelectionHighlight, b.spawn((
Sprite { SelectionHighlight,
color, Sprite {
custom_size: Some(card_size + Vec2::splat(4.0)), color,
..default() custom_size: Some(card_size + Vec2::splat(4.0)),
}, ..default()
Transform::from_xyz(0.0, 0.0, -0.01), },
Visibility::default(), Transform::from_xyz(0.0, 0.0, -0.01),
)); Visibility::default(),
}); ));
});
break;
}
} }
} }
@@ -774,15 +753,15 @@ fn spawn_highlight_on_card(
mod tests { mod tests {
use super::*; use super::*;
fn piles_from(names: &[&str]) -> Vec<KlondikePile> { fn piles_from(names: &[&str]) -> Vec<PileType> {
names names
.iter() .iter()
.map(|&n| match n { .map(|&n| match n {
"Waste" => KlondikePile::Stock, "Waste" => PileType::Waste,
"T0" => KlondikePile::Tableau(Tableau::Tableau1), "T0" => PileType::Tableau(0),
"T1" => KlondikePile::Tableau(Tableau::Tableau2), "T1" => PileType::Tableau(1),
"T2" => KlondikePile::Tableau(Tableau::Tableau3), "T2" => PileType::Tableau(2),
_ => KlondikePile::Stock, _ => PileType::Waste,
}) })
.collect() .collect()
} }
@@ -796,23 +775,23 @@ mod tests {
// With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste. // With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste.
let available = piles_from(&["Waste", "T0", "T1"]); let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, None); let result = cycle_next_pile(&available, None);
assert_eq!(result, Some(KlondikePile::Stock)); assert_eq!(result, Some(PileType::Waste));
} }
#[test] #[test]
fn cycle_next_pile_from_waste() { fn cycle_next_pile_from_waste() {
// Starting from Waste → Tableau(0). // Starting from Waste → Tableau(0).
let available = piles_from(&["Waste", "T0", "T1"]); let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock)); let result = cycle_next_pile(&available, Some(&PileType::Waste));
assert_eq!(result, Some(KlondikePile::Tableau(Tableau::Tableau1))); assert_eq!(result, Some(PileType::Tableau(0)));
} }
#[test] #[test]
fn cycle_next_pile_wraps() { fn cycle_next_pile_wraps() {
// Starting from Tableau(1) → Waste (wraps back to start). // Starting from Tableau(1) → Waste (wraps back to start).
let available = piles_from(&["Waste", "T0", "T1"]); let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, Some(&KlondikePile::Tableau(Tableau::Tableau2))); let result = cycle_next_pile(&available, Some(&PileType::Tableau(1)));
assert_eq!(result, Some(KlondikePile::Stock)); assert_eq!(result, Some(PileType::Waste));
} }
#[test] #[test]
@@ -837,7 +816,7 @@ mod tests {
// Press 1: no current selection → first pile, no wrap. // Press 1: no current selection → first pile, no wrap.
let sel1 = cycle_next_pile(&available, None); let sel1 = cycle_next_pile(&available, None);
assert_eq!(sel1, Some(KlondikePile::Stock)); assert_eq!(sel1, Some(PileType::Waste));
assert!( assert!(
!did_wrap(&available, None, sel1.as_ref()), !did_wrap(&available, None, sel1.as_ref()),
"first Tab should not wrap" "first Tab should not wrap"
@@ -845,7 +824,7 @@ mod tests {
// Press 2: Waste → Tableau(0), no wrap. // Press 2: Waste → Tableau(0), no wrap.
let sel2 = cycle_next_pile(&available, sel1.as_ref()); let sel2 = cycle_next_pile(&available, sel1.as_ref());
assert_eq!(sel2, Some(KlondikePile::Tableau(Tableau::Tableau1))); assert_eq!(sel2, Some(PileType::Tableau(0)));
assert!( assert!(
!did_wrap(&available, sel1.as_ref(), sel2.as_ref()), !did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
"second Tab should not wrap" "second Tab should not wrap"
@@ -853,7 +832,7 @@ mod tests {
// Press 3: Tableau(0) → Tableau(1), still no wrap. // Press 3: Tableau(0) → Tableau(1), still no wrap.
let sel3 = cycle_next_pile(&available, sel2.as_ref()); let sel3 = cycle_next_pile(&available, sel2.as_ref());
assert_eq!(sel3, Some(KlondikePile::Tableau(Tableau::Tableau2))); assert_eq!(sel3, Some(PileType::Tableau(1)));
assert!( assert!(
!did_wrap(&available, sel2.as_ref(), sel3.as_ref()), !did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
"third Tab (T0→T1) should not wrap" "third Tab (T0→T1) should not wrap"
@@ -861,7 +840,7 @@ mod tests {
// Press 4: Tableau(1) → Waste, this IS the wrap. // Press 4: Tableau(1) → Waste, this IS the wrap.
let sel4 = cycle_next_pile(&available, sel3.as_ref()); let sel4 = cycle_next_pile(&available, sel3.as_ref());
assert_eq!(sel4, Some(KlondikePile::Stock)); assert_eq!(sel4, Some(PileType::Waste));
assert!( assert!(
did_wrap(&available, sel3.as_ref(), sel4.as_ref()), did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
"fourth Tab should wrap back to Waste" "fourth Tab should wrap back to Waste"
@@ -870,9 +849,9 @@ mod tests {
#[test] #[test]
fn cycle_next_pile_single_element_wraps_to_itself() { fn cycle_next_pile_single_element_wraps_to_itself() {
let available = vec![KlondikePile::Stock]; let available = vec![PileType::Waste];
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock)); let result = cycle_next_pile(&available, Some(&PileType::Waste));
assert_eq!(result, Some(KlondikePile::Stock)); assert_eq!(result, Some(PileType::Waste));
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -886,23 +865,58 @@ mod tests {
#[test] #[test]
fn face_up_run_len_all_face_up() { fn face_up_run_len_all_face_up() {
use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![ let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), Card {
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true), id: 0,
(Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true), 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,
},
]; ];
assert_eq!(face_up_run_len(&cards), 3); assert_eq!(face_up_run_len(&cards), 3);
} }
#[test] #[test]
fn face_up_run_len_mixed_stops_at_face_down() { fn face_up_run_len_mixed_stops_at_face_down() {
use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![ let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false), Card {
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), id: 0,
(Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true), suit: Suit::Clubs,
(Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ten), true), 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,
},
]; ];
// Only the top two cards are face-up. // Only the top two cards are face-up.
assert_eq!(face_up_run_len(&cards), 2); assert_eq!(face_up_run_len(&cards), 2);
@@ -910,18 +924,33 @@ mod tests {
#[test] #[test]
fn face_up_run_len_top_card_face_down_is_zero() { fn face_up_run_len_top_card_face_down_is_zero() {
use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![ let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), Card {
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
]; ];
assert_eq!(face_up_run_len(&cards), 0); assert_eq!(face_up_run_len(&cards), 0);
} }
#[test] #[test]
fn face_up_run_len_single_face_up_card() { fn face_up_run_len_single_face_up_card() {
use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)]; let cards = vec![Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
}];
assert_eq!(face_up_run_len(&cards), 1); assert_eq!(face_up_run_len(&cards), 1);
} }
@@ -934,8 +963,8 @@ mod tests {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no /// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` / /// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
@@ -970,32 +999,46 @@ mod tests {
fn deterministic_state() -> GameState { fn deterministic_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne); let mut g = GameState::new(0, DrawMode::DrawOne);
// Clear stock, waste, all tableaus. // Clear stock, waste, all tableaus.
g.set_test_stock_cards(Vec::new()); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.set_test_waste_cards(Vec::new()); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for tableau in [ for i in 0..7 {
Tableau::Tableau1, g.piles
Tableau::Tableau2, .get_mut(&PileType::Tableau(i))
Tableau::Tableau3, .unwrap()
Tableau::Tableau4, .cards
Tableau::Tableau5, .clear();
Tableau::Tableau6,
Tableau::Tableau7,
] {
g.set_test_tableau_cards(tableau, Vec::new());
} }
// Place test cards. // Place test cards.
g.set_test_tableau_cards( g.piles
Tableau::Tableau1, .get_mut(&PileType::Tableau(0))
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)], .unwrap()
); .cards
g.set_test_tableau_cards( .push(Card {
Tableau::Tableau2, id: 100,
vec![Card::new(Deck::Deck1, Suit::Hearts, Rank::Six)], suit: Suit::Clubs,
); rank: Rank::Five,
g.set_test_tableau_cards( face_up: true,
Tableau::Tableau3, });
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::Six)], g.piles
); .get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
});
g.piles
.get_mut(&PileType::Tableau(2))
.unwrap()
.cards
.push(Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
});
g g
} }
@@ -1050,10 +1093,11 @@ mod tests {
let selected = app let selected = app
.world() .world()
.resource::<SelectionState>() .resource::<SelectionState>()
.selected_pile; .selected_pile
.clone();
// The cycle order starts at Waste, but Waste is empty so the next // The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected. // available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1))); assert_eq!(selected, Some(PileType::Tableau(0)));
assert_eq!( assert_eq!(
*app.world().resource::<KeyboardDragState>(), *app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle KeyboardDragState::Idle
@@ -1073,7 +1117,7 @@ mod tests {
// Manually focus Tableau(0) so we don't depend on Tab. // Manually focus Tableau(0) so we don't depend on Tab.
app.world_mut() app.world_mut()
.resource_mut::<SelectionState>() .resource_mut::<SelectionState>()
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1)); .selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter); press_key(&mut app, KeyCode::Enter);
app.update(); app.update();
@@ -1088,9 +1132,9 @@ mod tests {
legal_destinations, legal_destinations,
destination_index, destination_index,
} => { } => {
assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(source_pile, PileType::Tableau(0));
assert_eq!(count, 1); assert_eq!(count, 1);
assert_eq!(cards, vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]); assert_eq!(cards, vec![100]);
assert!( assert!(
!legal_destinations.is_empty(), !legal_destinations.is_empty(),
"lifted stack must have at least one legal destination" "lifted stack must have at least one legal destination"
@@ -1102,20 +1146,96 @@ mod tests {
// DragState must mirror the lifted cards and carry the keyboard sentinel. // DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>(); let drag = app.world().resource::<DragState>();
assert_eq!( assert_eq!(drag.cards, vec![100]);
drag.cards, assert_eq!(drag.origin_pile, Some(PileType::Tableau(0)));
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]
);
assert_eq!(
drag.origin_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1))
);
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID)); assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
} }
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations /// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
/// only (foundations and tableaus that pass `can_place_on_*`), and /// only (foundations and tableaus that pass `can_place_on_*`), and
/// wrap at the end of the list. /// wrap at the end of the list.
#[test]
fn arrow_in_lifted_cycles_legal_destinations_only() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
// Capture the destination list. For the deterministic state the 5♣
// (black) can land on 6♥ (T1) or 6♦ (T2) — both red, rank one
// higher. Verify that the destinations are exactly those tableaus
// (in cycle order T1 then T2).
let initial_dests: Vec<PileType> = match app.world().resource::<KeyboardDragState>() {
KeyboardDragState::Lifted {
legal_destinations, ..
} => legal_destinations.clone(),
_ => panic!("expected Lifted"),
};
assert_eq!(
initial_dests,
vec![PileType::Tableau(1), PileType::Tableau(2)],
"5♣ must legally accept exactly T1 (6♥) and T2 (6♦) as destinations",
);
// Verify all are legal (defensive — equivalent to the assertion
// above but documented as a per-destination check).
for dest in &initial_dests {
let bottom_card = Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
};
let pile = app
.world()
.resource::<GameStateResource>()
.0
.piles
.get(dest)
.unwrap()
.clone();
assert!(
can_place_on_tableau(&bottom_card, &pile),
"destination {dest:?} must be legal for the lifted stack",
);
}
// Initial focused destination = first entry.
assert_eq!(
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(1)),
);
// ArrowRight → next.
clear_input(&mut app);
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(2)),
);
// ArrowRight again → wraps to first.
clear_input(&mut app);
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(1)),
"destination index must wrap back to 0 after exhausting the list",
);
}
/// Test 4 — Enter while `Lifted` with a destination focused fires /// Test 4 — Enter while `Lifted` with a destination focused fires
/// exactly one `MoveRequestEvent` and resets the state machine to /// exactly one `MoveRequestEvent` and resets the state machine to
/// `Idle` with `DragState` cleared. /// `Idle` with `DragState` cleared.
@@ -1126,7 +1246,7 @@ mod tests {
app.update(); app.update();
app.world_mut() app.world_mut()
.resource_mut::<SelectionState>() .resource_mut::<SelectionState>()
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1)); .selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter); press_key(&mut app, KeyCode::Enter);
app.update(); app.update();
@@ -1146,7 +1266,7 @@ mod tests {
let events = collect_move_events(&mut app); let events = collect_move_events(&mut app);
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire"); assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire");
assert_eq!(events[0].from, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(events[0].from, PileType::Tableau(0));
assert_eq!(events[0].to, expected_dest); assert_eq!(events[0].to, expected_dest);
assert_eq!(events[0].count, 1); assert_eq!(events[0].count, 1);
@@ -1171,7 +1291,7 @@ mod tests {
app.update(); app.update();
app.world_mut() app.world_mut()
.resource_mut::<SelectionState>() .resource_mut::<SelectionState>()
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1)); .selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter); press_key(&mut app, KeyCode::Enter);
app.update(); app.update();
assert!(app.world().resource::<KeyboardDragState>().is_lifted()); assert!(app.world().resource::<KeyboardDragState>().is_lifted());
@@ -1188,7 +1308,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
app.world().resource::<SelectionState>().selected_pile, app.world().resource::<SelectionState>().selected_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1)), Some(PileType::Tableau(0)),
"Esc on lifted must keep SelectionState intact (source-pick mode)", "Esc on lifted must keep SelectionState intact (source-pick mode)",
); );
assert!( assert!(
@@ -1210,8 +1330,8 @@ mod tests {
// keyboard sentinel. // keyboard sentinel.
{ {
let mut drag = app.world_mut().resource_mut::<DragState>(); let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]; drag.cards = vec![100];
drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1)); drag.origin_pile = Some(PileType::Tableau(0));
drag.committed = true; drag.committed = true;
drag.active_touch_id = None; drag.active_touch_id = None;
} }
@@ -1219,13 +1339,15 @@ mod tests {
let before = app let before = app
.world() .world()
.resource::<SelectionState>() .resource::<SelectionState>()
.selected_pile; .selected_pile
.clone();
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let after = app let after = app
.world() .world()
.resource::<SelectionState>() .resource::<SelectionState>()
.selected_pile; .selected_pile
.clone();
assert_eq!( assert_eq!(
before, after, before, after,
@@ -1242,7 +1364,7 @@ mod tests {
app.update(); app.update();
app.world_mut() app.world_mut()
.resource_mut::<SelectionState>() .resource_mut::<SelectionState>()
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1)); .selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter); press_key(&mut app, KeyCode::Enter);
app.update(); app.update();
@@ -1251,7 +1373,7 @@ mod tests {
app.update(); app.update();
assert_eq!( assert_eq!(
app.world().resource::<SelectionState>().selected_pile, app.world().resource::<SelectionState>().selected_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1)), Some(PileType::Tableau(0)),
"first Esc only cancels the lift", "first Esc only cancels the lift",
); );
+7 -15
View File
@@ -15,7 +15,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform}; use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::{WindowMoved, WindowResized}; use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::{ use solitaire_data::{
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP, AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme, TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
@@ -24,7 +24,6 @@ use solitaire_data::{
use solitaire_data::settings::SyncBackend; use solitaire_data::settings::SyncBackend;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_theme_dir; use crate::assets::user_theme_dir;
use crate::events::{ use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
@@ -33,9 +32,9 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
#[cfg(not(target_arch = "wasm32"))] use crate::theme::{
use crate::theme::{ImportError, import_theme, refresh_registry}; ImportError, ThemeThumbnailCache, ThemeThumbnailPair, import_theme, refresh_registry,
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair}; };
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
@@ -241,7 +240,7 @@ enum SettingsButton {
ToggleTouchInputMode, ToggleTouchInputMode,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through /// random Classic-mode deals are filtered through
/// [`solitaire_data::solver::try_solve`] until one is provably /// [`solitaire_core::solver::try_solve`] until one is provably
/// winnable (or the retry cap is hit). Off by default. /// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly, ToggleWinnableDealsOnly,
/// Toggle the inverse of [`Settings::disable_smart_default_size`]. /// Toggle the inverse of [`Settings::disable_smart_default_size`].
@@ -252,10 +251,10 @@ enum SettingsButton {
/// player's last window size always wins. /// player's last window size always wins.
ToggleSmartDefaultSize, ToggleSmartDefaultSize,
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a /// Toggle [`Settings::analytics_enabled`]. Only rendered when a
/// Matomo URL is configured. /// sync server is configured — there is no server to send to in
/// local-only mode.
ToggleAnalytics, ToggleAnalytics,
/// Scan `user_theme_dir()` for new `.zip` files and import each one. /// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))]
ScanThemes, ScanThemes,
SyncNow, SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local). /// Open the sync-server Connect modal (shown when backend = Local).
@@ -318,7 +317,6 @@ impl SettingsButton {
SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80, SettingsButton::SelectBackground(_) => 80,
SettingsButton::SelectTheme(_) => 85, SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86, SettingsButton::ScanThemes => 86,
// Sync section // Sync section
SettingsButton::SyncNow => 90, SettingsButton::SyncNow => 90,
@@ -406,7 +404,6 @@ impl Plugin for SettingsPlugin {
sync_settings_panel_visibility, sync_settings_panel_visibility,
handle_settings_buttons, handle_settings_buttons,
handle_sync_buttons, handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))]
handle_scan_themes, handle_scan_themes,
update_sync_status_text, update_sync_status_text,
update_card_back_text, update_card_back_text,
@@ -1257,7 +1254,6 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
} }
} }
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => { SettingsButton::ScanThemes => {
// Handled by `handle_scan_themes`. // Handled by `handle_scan_themes`.
} }
@@ -1861,7 +1857,6 @@ fn spawn_settings_panel(
font_res, font_res,
); );
} }
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res); import_themes_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) --- // --- Privacy (only shown when a Matomo URL is configured) ---
@@ -2646,7 +2641,6 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
/// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme /// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
/// already installed) are silently skipped; all other errors produce a warning /// already installed) are silently skipped; all other errors produce a warning
/// toast. A final toast tells the player to reopen Settings to see new themes. /// toast. A final toast tells the player to reopen Settings to see new themes.
#[cfg(not(target_arch = "wasm32"))]
fn handle_scan_themes( fn handle_scan_themes(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>, interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
@@ -2725,7 +2719,6 @@ fn handle_scan_themes(
} }
} }
#[cfg(not(target_arch = "wasm32"))]
/// A small pill-shaped settings button, matching the style used in `sync_row`. /// A small pill-shaped settings button, matching the style used in `sync_row`.
fn pill_button( fn pill_button(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
@@ -2766,7 +2759,6 @@ fn pill_button(
/// then presses the button. [`handle_scan_themes`] picks them up, validates, /// then presses the button. [`handle_scan_themes`] picks them up, validates,
/// and installs them. Reopen Settings to see newly imported themes in the /// and installs them. Reopen Settings to see newly imported themes in the
/// card-theme picker. /// card-theme picker.
#[cfg(not(target_arch = "wasm32"))]
fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) { fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
let caption_font = TextFont { let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
+8 -8
View File
@@ -534,7 +534,7 @@ fn update_stats_on_win(
let prev_streak = stats.0.win_streak_current; let prev_streak = stats.0.win_streak_current;
stats stats
.0 .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 // Per-mode best score / fastest win — additive on top of the
// lifetime totals tracked by `update_on_win`. TimeAttack is a // lifetime totals tracked by `update_on_win`. TimeAttack is a
// no-op inside the helper because it has its own session-level // 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>, mut toast: MessageWriter<InfoToastEvent>,
) { ) {
for _ in events.read() { 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; let streak = stats.0.win_streak_current;
stats.0.record_abandoned(); stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game"); persist(&path, &stats.0, "abandoned game");
@@ -614,7 +614,7 @@ fn handle_forfeit(
mut auto_complete: Option<ResMut<AutoCompleteState>>, mut auto_complete: Option<ResMut<AutoCompleteState>>,
) { ) {
for _ in events.read() { 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; let streak = stats.0.win_streak_current;
stats.0.record_abandoned(); stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit"); persist(&path, &stats.0, "forfeit");
@@ -1327,7 +1327,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree); .draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -1373,7 +1373,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.set_test_move_count(3); .move_count = 3;
app.world_mut().write_message(NewGameRequestEvent { app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999), seed: Some(999),
@@ -1699,7 +1699,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.set_test_move_count(1); .move_count = 1;
app.world_mut().write_message(ForfeitEvent); app.world_mut().write_message(ForfeitEvent);
app.update(); app.update();
@@ -1725,7 +1725,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.set_test_move_count(1); .move_count = 1;
app.world_mut().write_message(ForfeitEvent); app.world_mut().write_message(ForfeitEvent);
app.update(); app.update();
@@ -1952,7 +1952,7 @@ mod tests {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
let mut r = solitaire_data::Replay::new( let mut r = solitaire_data::Replay::new(
1, 1,
solitaire_core::DrawMode::DrawOne, solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic, solitaire_core::game_state::GameMode::Classic,
time_seconds, time_seconds,
0, 0,
+24 -21
View File
@@ -3,8 +3,8 @@
//! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`] //! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`]
//! that fetches the remote payload from the active [`SyncProvider`]. Once the //! that fetches the remote payload from the active [`SyncProvider`]. Once the
//! task resolves, the merged result is written to disk and the in-world //! task resolves, the merged result is written to disk and the in-world
//! resources are updated. On app exit, a best-effort async push sends the //! resources are updated. On app exit, a blocking push sends the current local
//! current local state to the backend without blocking the Bevy main thread. //! state to the backend.
//! //!
//! The plugin is completely backend-agnostic: the caller (usually //! The plugin is completely backend-agnostic: the caller (usually
//! `solitaire_app`) constructs the right [`SyncProvider`] implementation and //! `solitaire_app`) constructs the right [`SyncProvider`] implementation and
@@ -79,8 +79,8 @@ struct PendingReplayUpload(Option<Task<Result<String, SyncError>>>);
/// - **Update** — polls the task each frame; on completion merges the remote /// - **Update** — polls the task each frame; on completion merges the remote
/// payload with local data, persists the result, and updates in-world /// payload with local data, persists the result, and updates in-world
/// resources. /// resources.
/// - **Last** — on [`AppExit`], starts a best-effort async push of the current /// - **Last** — on [`AppExit`], performs a blocking push of the current local
/// local state to the active backend without blocking shutdown. /// state to the active backend.
/// ///
/// Construct via [`SyncPlugin::new`], passing any type that implements /// Construct via [`SyncPlugin::new`], passing any type that implements
/// [`SyncProvider`]. /// [`SyncProvider`].
@@ -272,12 +272,11 @@ fn poll_pull_result(
} }
} }
/// Last-schedule system: starts a best-effort push of the current local state /// Last-schedule system: pushes the current local state on [`AppExit`].
/// on [`AppExit`] without blocking the Bevy main thread.
/// ///
/// The detached task may be cut short by process teardown, so local atomic /// A blocking push is acceptable here — ARCHITECTURE.md §4 explicitly notes
/// persistence remains the durable source of truth even if the final remote /// that blocking on exit is permitted because the game loop is already
/// push does not complete. /// shutting down.
fn push_on_exit( fn push_on_exit(
mut exit_events: MessageReader<AppExit>, mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
@@ -292,16 +291,20 @@ fn push_on_exit(
exit_events.clear(); exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0); let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone(); let result = rt.0.block_on(provider.0.push(&payload));
let rt = rt.0.clone(); match result {
AsyncComputeTaskPool::get() Ok(_) => {}
.spawn(async move { // `UnsupportedPlatform` is the expected response of
match rt.block_on(provider.push(&payload)) { // `LocalOnlyProvider`; treat it the same as the pull path does —
Ok(_) | Err(SyncError::UnsupportedPlatform) => {} // no backend configured is not a failure.
Err(e) => warn!("sync push on exit failed: {e}"), Err(SyncError::UnsupportedPlatform) => {}
} Err(e) => {
}) // Log real push failures on exit so they appear in crash/log
.detach(); // reports. We cannot surface them to the UI at this point (game
// loop is done).
warn!("sync push on exit failed: {e}");
}
}
} }
/// Update-schedule system: on each `GameWonEvent` push the just-completed /// Update-schedule system: on each `GameWonEvent` push the just-completed
@@ -331,7 +334,7 @@ fn push_replay_on_win(
} }
let replay = Replay::new( let replay = Replay::new(
game.0.seed, game.0.seed,
game.0.draw_mode(), game.0.draw_mode,
game.0.mode, game.0.mode,
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
@@ -604,7 +607,7 @@ mod tests {
/// would silently drop the link. /// would silently drop the link.
#[test] #[test]
fn upload_result_writes_share_url_into_replay_and_persists() { fn upload_result_writes_share_url_into_replay_and_persists() {
use solitaire_core::{DrawMode, game_state::GameMode}; use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{ use solitaire_data::{
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to, Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
}; };
+42 -116
View File
@@ -6,8 +6,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit; use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility; use crate::hud_plugin::HudVisibility;
@@ -22,28 +22,15 @@ use crate::ui_theme::TEXT_PRIMARY;
use solitaire_data::Theme; use solitaire_data::Theme;
/// Default tint applied to every empty-pile marker sprite. Pure white /// Default tint applied to every empty-pile marker sprite. Pure white
/// at 15% alpha — soft enough that the marker reads as a "hint of a /// at 8% alpha — soft enough that the marker reads as a "hint of a
/// slot" rather than a panel, but discernible even against a very dark /// slot" rather than a panel, but visible against every felt
/// felt background under bright ambient light (the old 8% alpha vanished /// background.
/// on a #151515 felt during on-device Android testing).
/// ///
/// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`, /// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`,
/// which used to duplicate the literal alongside a "kept in sync" doc /// which used to duplicate the literal alongside a "kept in sync" doc
/// comment. Pulling both call sites through this const makes drift a /// comment. Pulling both call sites through this const makes drift a
/// compile error instead of a stale comment. /// compile error instead of a stale comment.
pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.15); pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Tint applied to the thin outline rectangle sitting behind every
/// empty-pile marker. A slightly brighter white at 28% alpha gives the
/// slot a defined edge — the standard solitaire "empty pile" affordance —
/// without competing with real cards. Rendered as a marginally larger
/// child rectangle one z-step behind the fill, so the fill overlaps it
/// and only a hairline frame remains visible.
const PILE_MARKER_OUTLINE_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.28);
/// Width in logical pixels of the visible outline frame around an empty
/// pile marker (the outline rect is this much larger on each side).
const PILE_MARKER_OUTLINE_WIDTH: f32 = 2.0;
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds. /// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
/// ///
@@ -67,7 +54,7 @@ pub struct TableBackground;
/// Marker component attached to each of the 13 empty-pile placeholders. /// Marker component attached to each of the 13 empty-pile placeholders.
#[derive(Component, Debug, Clone)] #[derive(Component, Debug, Clone)]
pub struct PileMarker(pub KlondikePile); pub struct PileMarker(pub PileType);
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold /// Attached to a `PileMarker` entity when it has been temporarily tinted gold
/// as a hint destination. Stores the remaining countdown and the original sprite /// as a hint destination. Stores the remaining countdown and the original sprite
@@ -278,13 +265,14 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let marker_size = layout.card_size; let marker_size = layout.card_size;
let font_size = layout.card_size.x * 0.28; let font_size = layout.card_size.x * 0.28;
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12); let mut piles: Vec<PileType> = Vec::with_capacity(13);
piles.push(KlondikePile::Stock); piles.push(PileType::Stock);
for foundation in foundations() { piles.push(PileType::Waste);
piles.push(KlondikePile::Foundation(foundation)); for slot in 0..4_u8 {
piles.push(PileType::Foundation(slot));
} }
for tableau in tableaus() { for i in 0..7 {
piles.push(KlondikePile::Tableau(tableau)); piles.push(PileType::Tableau(i));
} }
for pile in piles { for pile in piles {
@@ -296,30 +284,14 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
..default() ..default()
}, },
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER), Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
PileMarker(pile), PileMarker(pile.clone()),
)); ));
// Outline frame: a marginally larger rectangle sitting one z-step
// behind the fill. The fill overlaps its centre, leaving only a
// hairline border visible — a defined slot edge without an extra
// asset or 9-slice. Untagged so the `PileMarker` count is unchanged.
let outline_size = marker_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
entity.with_children(|b| {
b.spawn((
Sprite {
color: PILE_MARKER_OUTLINE_COLOUR,
custom_size: Some(outline_size),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.05),
));
});
// Tableau markers show "K" (only a King may start an empty column). // Tableau markers show "K" (only a King may start an empty column).
// Foundation markers show "A" (only an Ace may claim an empty slot). // Foundation markers show "A" (only an Ace may claim an empty slot).
// Neither label carries a suit because any suit may start any slot. // Neither label carries a suit because any suit may start any slot.
match &pile { match &pile {
KlondikePile::Tableau(_) => { PileType::Tableau(_) => {
entity.with_children(|b| { entity.with_children(|b| {
b.spawn(( b.spawn((
Text2d::new("K"), Text2d::new("K"),
@@ -332,7 +304,7 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
)); ));
}); });
} }
KlondikePile::Foundation(_) => { PileType::Foundation(_) => {
entity.with_children(|b| { entity.with_children(|b| {
b.spawn(( b.spawn((
Text2d::new("A"), Text2d::new("A"),
@@ -508,7 +480,11 @@ fn sync_pile_marker_visibility(
return; return;
} }
for (pile_marker, mut visibility) in markers.iter_mut() { for (pile_marker, mut visibility) in markers.iter_mut() {
let is_empty = pile_cards(&game.0, &pile_marker.0).is_empty(); let is_empty = game
.0
.piles
.get(&pile_marker.0)
.is_none_or(|pile| pile.cards.is_empty());
*visibility = if is_empty { *visibility = if is_empty {
Visibility::Inherited Visibility::Inherited
} else { } else {
@@ -517,44 +493,6 @@ fn sync_pile_marker_visibility(
} }
} }
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<(solitaire_core::card::Card, bool)> {
match pile {
KlondikePile::Stock => {
let stock = game.stock_cards();
if stock.is_empty() {
game.waste_cards()
} else {
stock
}
}
_ => game.pile(*pile),
}
}
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
}
const fn tableaus() -> [Tableau; 7] {
[
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -572,14 +510,14 @@ mod tests {
} }
#[test] #[test]
fn table_plugin_spawns_twelve_pile_markers() { fn table_plugin_spawns_thirteen_pile_markers() {
let mut app = headless_app(); let mut app = headless_app();
let count = app let count = app
.world_mut() .world_mut()
.query::<&PileMarker>() .query::<&PileMarker>()
.iter(app.world()) .iter(app.world())
.count(); .count();
assert_eq!(count, 12); assert_eq!(count, 13);
} }
#[test] #[test]
@@ -602,23 +540,23 @@ mod tests {
#[test] #[test]
fn every_pile_marker_has_unique_type() { fn every_pile_marker_has_unique_type() {
let mut app = headless_app(); let mut app = headless_app();
let mut types: Vec<KlondikePile> = app let mut types: Vec<PileType> = app
.world_mut() .world_mut()
.query::<&PileMarker>() .query::<&PileMarker>()
.iter(app.world()) .iter(app.world())
.map(|m| m.0) .map(|m| m.0.clone())
.collect(); .collect();
types.sort_by_key(|p| format!("{p:?}")); types.sort_by_key(|p| format!("{p:?}"));
types.dedup(); types.dedup();
assert_eq!(types.len(), 12); assert_eq!(types.len(), 13);
} }
#[test] #[test]
fn pile_markers_hide_when_pile_is_occupied() { fn pile_markers_hide_when_pile_is_occupied() {
// After a fresh deal: the 7 tableau piles + the stock pile are // After a fresh deal: the 7 tableau piles + the stock pile are
// occupied; the 4 foundation piles are empty. The visibility-by- // all occupied; the 4 foundation piles + the waste pile are
// occupancy system must hide the first 8 markers and keep the // empty. The visibility-by-occupancy system must hide the
// last 4 visible. This implements // first 8 markers and keep the last 5 visible. This implements
// the "remain visible only where a pile is empty" invariant // the "remain visible only where a pile is empty" invariant
// in the module-level doc comment that was previously // in the module-level doc comment that was previously
// declared but not enforced — pile markers used to always // declared but not enforced — pile markers used to always
@@ -632,13 +570,13 @@ mod tests {
app.update(); app.update();
let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>(); let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>();
let mut hidden_piles: Vec<KlondikePile> = Vec::new(); let mut hidden_piles: Vec<PileType> = Vec::new();
let mut visible_piles: Vec<KlondikePile> = Vec::new(); let mut visible_piles: Vec<PileType> = Vec::new();
for (marker, visibility) in q.iter(app.world()) { for (marker, visibility) in q.iter(app.world()) {
if matches!(visibility, Visibility::Hidden) { if matches!(visibility, Visibility::Hidden) {
hidden_piles.push(marker.0); hidden_piles.push(marker.0.clone());
} else { } else {
visible_piles.push(marker.0); visible_piles.push(marker.0.clone());
} }
} }
@@ -648,31 +586,19 @@ mod tests {
8, 8,
"stock + 7 tableau piles should hide their markers post-deal", "stock + 7 tableau piles should hide their markers post-deal",
); );
assert!(hidden_piles.contains(&KlondikePile::Stock)); assert!(hidden_piles.contains(&PileType::Stock));
for tableau in [ for i in 0..7 {
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
assert!( assert!(
hidden_piles.contains(&KlondikePile::Tableau(tableau)), hidden_piles.contains(&PileType::Tableau(i)),
"{tableau:?} marker should be hidden — it has cards", "tableau {i} marker should be hidden — it has cards",
); );
} }
// 4 empty piles: foundations only. // 5 empty piles: waste + 4 foundations.
assert_eq!(visible_piles.len(), 4); assert_eq!(visible_piles.len(), 5);
for foundation in [ assert!(visible_piles.contains(&PileType::Waste));
Foundation::Foundation1, for i in 0..4_u8 {
Foundation::Foundation2, assert!(visible_piles.contains(&PileType::Foundation(i)));
Foundation::Foundation3,
Foundation::Foundation4,
] {
assert!(visible_piles.contains(&KlondikePile::Foundation(foundation)));
} }
} }
+5 -7
View File
@@ -12,7 +12,6 @@
//! handles directly on card entities, so a theme switch propagates on //! handles directly on card entities, so a theme switch propagates on
//! the next frame without re-spawning anything. //! the next frame without re-spawning anything.
#[cfg(not(target_arch = "wasm32"))]
pub mod importer; pub mod importer;
pub mod loader; pub mod loader;
pub mod manifest; pub mod manifest;
@@ -29,7 +28,6 @@ use thiserror::Error;
use solitaire_core::card::{Rank, Suit}; use solitaire_core::card::{Rank, Suit};
#[cfg(not(target_arch = "wasm32"))]
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into}; pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
pub use loader::{CardThemeLoader, CardThemeLoaderError}; pub use loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest; pub use manifest::ThemeManifest;
@@ -43,11 +41,11 @@ pub use registry::{
/// Hashable lookup key into [`CardTheme::faces`]. /// Hashable lookup key into [`CardTheme::faces`].
/// ///
/// Distinct from `card_game::Card`, which also encodes a deck id: `CardKey` /// Distinct from `solitaire_core::Card`: the core type carries an `id`
/// is just the (suit, rank) pair that uniquely identifies which artwork to /// and a `face_up` flag that vary per deal, neither of which is
/// draw. Serialised theme manifests address faces by /// relevant to image lookup. `CardKey` is just the (suit, rank) pair
/// [`CardKey::manifest_name`] strings, not by serialising `CardKey` itself. /// that uniquely identifies which artwork to draw.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CardKey { pub struct CardKey {
pub suit: Suit, pub suit: Suit,
pub rank: Rank, pub rank: Rank,
+4 -13
View File
@@ -22,15 +22,11 @@
use std::path::Path; use std::path::Path;
use bevy::log::warn; use bevy::log::warn;
#[cfg(not(target_arch = "wasm32"))] use bevy::prelude::{App, Plugin, Resource, Startup};
use bevy::prelude::Startup;
use bevy::prelude::{App, Plugin, Resource};
use serde::Deserialize; use serde::Deserialize;
use super::ThemeMeta; use super::ThemeMeta;
use crate::assets::DARK_THEME_MANIFEST_URL; use crate::assets::{DARK_THEME_MANIFEST_URL, user_theme_dir};
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_theme_dir;
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs /// One entry in the [`ThemeRegistry`] — the data the picker UI needs
/// to render a row and load the theme on selection. /// to render a row and load the theme on selection.
@@ -89,18 +85,13 @@ pub struct ThemeRegistryPlugin;
impl Plugin for ThemeRegistryPlugin { impl Plugin for ThemeRegistryPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<ThemeRegistry>(); app.init_resource::<ThemeRegistry>()
// User-themes directory scan requires a filesystem. On wasm32 there .add_systems(Startup, build_registry_on_startup);
// is no filesystem so the scan is skipped; the bundled default theme
// (from the EmbeddedAssetRegistry) is all that's available.
#[cfg(not(target_arch = "wasm32"))]
app.add_systems(Startup, build_registry_on_startup);
} }
} }
/// Reads `user_theme_dir()` and replaces the registry's contents with /// Reads `user_theme_dir()` and replaces the registry's contents with
/// the bundled default plus every valid user theme. /// the bundled default plus every valid user theme.
#[cfg(not(target_arch = "wasm32"))]
fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegistry>) { fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegistry>) {
*registry = build_registry(&user_theme_dir()); *registry = build_registry(&user_theme_dir());
} }
+6 -7
View File
@@ -22,8 +22,7 @@
//! was closed, the file is treated as missing. //! was closed, the file is treated as missing.
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::Utc;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
@@ -182,7 +181,7 @@ fn advance_time_attack(
// No shared screen-state enum currently covers every overlay. Pause the // No shared screen-state enum currently covers every overlay. Pause the
// countdown whenever gameplay is blocked by a modal, the pause flag, or a // countdown whenever gameplay is blocked by a modal, the pause flag, or a
// just-won board state. // 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; return;
} }
session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0); session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0);
@@ -223,9 +222,9 @@ fn auto_deal_on_time_attack_win(
/// the system time predates the epoch (impossible under any sane clock, /// the system time predates the epoch (impossible under any sane clock,
/// but the fallback keeps the function infallible). /// but the fallback keeps the function infallible).
fn current_unix_secs() -> u64 { fn current_unix_secs() -> u64 {
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature; SystemTime::now()
// std::time::SystemTime panics on wasm32-unknown-unknown). .duration_since(UNIX_EPOCH)
Utc::now().timestamp().max(0) as u64 .map_or(0, |d| d.as_secs())
} }
/// Periodically persists the live `TimeAttackResource` to /// Periodically persists the live `TimeAttackResource` to
@@ -299,7 +298,7 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin; use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::{DrawMode, game_state::GameState}; use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
+34 -64
View File
@@ -28,8 +28,7 @@
use bevy::ecs::message::MessageReader; use bevy::ecs::message::MessageReader;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::KlondikePile; use solitaire_core::pile::PileType;
use solitaire_core::card::Card;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
@@ -50,8 +49,8 @@ use crate::ui_theme::ACCENT_PRIMARY;
/// card ids that will be moved (1 for a single card, multiple for a face-up run). /// card ids that will be moved (1 for a single card, multiple for a face-up run).
#[derive(Resource, Debug, Default)] #[derive(Resource, Debug, Default)]
pub struct TouchSelectionState { pub struct TouchSelectionState {
/// Currently selected source pile and the cards to move (bottom-to-top). /// Currently selected source pile and the card ids to move (bottom-to-top).
pub selected: Option<(KlondikePile, Vec<Card>)>, pub selected: Option<(PileType, Vec<u32>)>,
} }
impl TouchSelectionState { impl TouchSelectionState {
@@ -61,12 +60,12 @@ impl TouchSelectionState {
} }
/// Takes the current selection, leaving `selected` as `None`. /// Takes the current selection, leaving `selected` as `None`.
pub fn take(&mut self) -> Option<(KlondikePile, Vec<Card>)> { pub fn take(&mut self) -> Option<(PileType, Vec<u32>)> {
self.selected.take() self.selected.take()
} }
/// Sets the current selection. /// Sets the current selection.
pub fn set(&mut self, pile: KlondikePile, cards: Vec<Card>) { pub fn set(&mut self, pile: PileType, cards: Vec<u32>) {
self.selected = Some((pile, cards)); self.selected = Some((pile, cards));
} }
@@ -78,9 +77,8 @@ impl TouchSelectionState {
/// Marker component placed on the highlight sprite child of a selected source card. /// Marker component placed on the highlight sprite child of a selected source card.
/// ///
/// Despawned and respawned by [`update_touch_selection_highlight`] whenever /// Despawned and respawned each frame by [`update_touch_selection_highlight`] so
/// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it /// stale highlights never linger after a game-state change.
/// is a no-op every frame that the selection is stable.
#[derive(Component)] #[derive(Component)]
pub struct TouchSelectionHighlight; pub struct TouchSelectionHighlight;
@@ -93,15 +91,16 @@ pub struct TouchSelectionPlugin;
impl Plugin for TouchSelectionPlugin { impl Plugin for TouchSelectionPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<TouchSelectionState>().add_systems( app.init_resource::<TouchSelectionState>()
Update, .add_systems(
( Update,
clear_touch_selection_on_state_change, (
update_touch_selection_highlight, clear_touch_selection_on_state_change,
) update_touch_selection_highlight,
.chain() )
.after(GameMutation), .chain()
); .after(GameMutation),
);
} }
} }
@@ -122,9 +121,9 @@ pub(crate) fn clear_touch_selection_on_state_change(
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card. /// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
/// ///
/// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout /// All existing `TouchSelectionHighlight` entities are despawned each frame and
/// actually changes — not every frame. Existing highlights are despawned first, /// a new one is spawned on the top card of the selected pile (if any). This
/// then a fresh highlight is spawned on every card in the selected stack. /// matches the pattern used by `selection_plugin::update_selection_highlight`.
pub(crate) fn update_touch_selection_highlight( pub(crate) fn update_touch_selection_highlight(
mut commands: Commands, mut commands: Commands,
selection: Res<TouchSelectionState>, selection: Res<TouchSelectionState>,
@@ -132,18 +131,12 @@ pub(crate) fn update_touch_selection_highlight(
highlights: Query<Entity, With<TouchSelectionHighlight>>, highlights: Query<Entity, With<TouchSelectionHighlight>>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
) { ) {
// Skip when neither the selection nor the layout changed this frame.
let layout_changed = layout.as_ref().map(|l| l.is_changed()).unwrap_or(false);
if !selection.is_changed() && !layout_changed {
return;
}
// Despawn stale highlights first. // Despawn stale highlights first.
for entity in &highlights { for entity in &highlights {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
let Some((_, ref cards)) = selection.selected else { let Some((_, ref card_ids)) = selection.selected else {
return; return;
}; };
let Some(layout) = layout else { let Some(layout) = layout else {
@@ -155,8 +148,8 @@ pub(crate) fn update_touch_selection_highlight(
// but highlighting the whole run gives the player clear confirmation // but highlighting the whole run gives the player clear confirmation
// of how many cards are involved in the move. // of how many cards are involved in the move.
let card_size = layout.0.card_size; let card_size = layout.0.card_size;
for card in cards { for &card_id in card_ids {
spawn_touch_highlight(&mut commands, &card_entities, card, card_size); spawn_touch_highlight(&mut commands, &card_entities, card_id, card_size);
} }
} }
@@ -164,11 +157,11 @@ pub(crate) fn update_touch_selection_highlight(
fn spawn_touch_highlight( fn spawn_touch_highlight(
commands: &mut Commands, commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>, card_entities: &Query<(Entity, &CardEntity)>,
card: &Card, card_id: u32,
card_size: Vec2, card_size: Vec2,
) { ) {
for (entity, card_entity) in card_entities { for (entity, card_entity) in card_entities {
if card_entity.card == *card { if card_entity.card_id == card_id {
commands.entity(entity).with_children(|b| { commands.entity(entity).with_children(|b| {
b.spawn(( b.spawn((
TouchSelectionHighlight, TouchSelectionHighlight,
@@ -193,18 +186,6 @@ fn spawn_touch_highlight(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use solitaire_core::Tableau;
use solitaire_core::card::{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] #[test]
fn selection_state_default_is_idle() { fn selection_state_default_is_idle() {
@@ -216,24 +197,20 @@ mod tests {
#[test] #[test]
fn set_and_take_roundtrip() { fn set_and_take_roundtrip() {
let mut state = TouchSelectionState::default(); let mut state = TouchSelectionState::default();
let cards = test_cards().to_vec(); state.set(PileType::Tableau(0), vec![1, 2, 3]);
state.set(KlondikePile::Tableau(Tableau::Tableau1), cards.clone());
assert!(state.has_selection()); assert!(state.has_selection());
let taken = state.take(); let taken = state.take();
assert!(taken.is_some()); assert!(taken.is_some());
let (pile, taken_cards) = taken.unwrap(); let (pile, cards) = taken.unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(pile, PileType::Tableau(0));
assert_eq!(taken_cards, cards); assert_eq!(cards, vec![1, 2, 3]);
assert!(!state.has_selection()); assert!(!state.has_selection());
} }
#[test] #[test]
fn clear_removes_selection() { fn clear_removes_selection() {
let mut state = TouchSelectionState::default(); let mut state = TouchSelectionState::default();
state.set( state.set(PileType::Waste, vec![42]);
KlondikePile::Stock,
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
);
state.clear(); state.clear();
assert!(!state.has_selection()); assert!(!state.has_selection());
} }
@@ -248,17 +225,10 @@ mod tests {
#[test] #[test]
fn set_overwrites_previous_selection() { fn set_overwrites_previous_selection() {
let mut state = TouchSelectionState::default(); let mut state = TouchSelectionState::default();
state.set( state.set(PileType::Tableau(0), vec![1]);
KlondikePile::Tableau(Tableau::Tableau1), state.set(PileType::Tableau(3), vec![7, 8]);
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(); let (pile, cards) = state.take().unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4)); assert_eq!(pile, PileType::Tableau(3));
assert_eq!(cards, second); assert_eq!(cards, vec![7, 8]);
} }
} }
+6 -7
View File
@@ -216,14 +216,13 @@ where
// modal at `Z_PAUSE` (220) in some scenes. // modal at `Z_PAUSE` (220) in some scenes.
GlobalZIndex(z_panel), GlobalZIndex(z_panel),
ZIndex(z_panel), ZIndex(z_panel),
// B0004: ModalCard carries Transform (for the scale animation) // B0004: ModalCard carries Transform (for the scale animation).
// and visibility-related UI components. Bevy validates that // Bevy's GlobalTransform hook fires B0004 when a child has
// GlobalTransform / InheritedVisibility parents carry the same // GlobalTransform but the parent does not. Adding Identity
// hierarchy components, so the scrim root explicitly carries the // Transform here gives the scrim GlobalTransform so the check
// matching identity components. UI layout still uses UiTransform; // passes. UI layout still uses UiTransform; this has no layout
// this has no layout effect. // effect.
Transform::default(), Transform::default(),
Visibility::default(),
)) ))
.with_children(|root| { .with_children(|root| {
root.spawn(( root.spawn((

Some files were not shown because too many files have changed in this diff Show More