Compare commits

...

52 Commits

Author SHA1 Message Date
funman300 c66baceb10 Merge pull request 'docs(android): update NDK reference to 30.0.14904198' (#108) from docs/android-ndk-version into master
Android Release / build-apk (push) Successful in 5m39s
2026-06-25 17:37:03 +00:00
funman300 329f224ffd docs(android): update NDK reference to 30.0.14904198
The setup doc pinned NDK 26.3.11579264, but newer NDKs build fine
(verified locally on 30.0.14904198 / build-tools 37.0.0: cross-compile,
android-target clippy, and a full signed arm64-v8a APK). Note that the
exact versions are not load-bearing and build_android_apk.sh
auto-discovers the newest installed NDK/build-tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:36:48 -07:00
Gitea CI 2fc190ee42 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m22s
Web E2E / web-e2e (push) Successful in 4m39s
2026-06-25 17:20:48 +00:00
funman300 060efaee7b Merge pull request 'docs(changelog): note Draw-Three waste fan hit-test fix' (#107) from docs/changelog-waste-fan into master 2026-06-25 17:13:51 +00:00
funman300 ef599ffa17 docs(changelog): note Draw-Three waste fan hit-test fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:13:39 -07:00
funman300 22334e0dd5 Merge pull request 'fix(engine): share Draw-Three waste fan step between renderer and hit-test' (#106) from fix/draw-three-waste-fan-hittest into master
Build and Deploy / build-and-push (push) Successful in 1m51s
Web WASM Rebuild / rebuild (push) Successful in 7m36s
2026-06-25 17:11:26 +00:00
funman300 942b9c2161 fix(engine): share Draw-Three waste fan step between renderer and hit-test
The waste fan x-offset was computed two ways: the renderer used
tableau_col_step * 0.224 while the hit-test hard-coded card_size.x * 0.28.
These coincide on desktop (col_step = 1.25*cw) but drift on Android, where
tighter column spacing (H_GAP_DIVISOR=32, col_step ~= 1.03*cw) makes the
renderer fan at ~0.231*cw. The top fanned waste card's sprite then sits
~14px left of its click target, so dragging the visible top card grabs
the card beneath it.

Extract waste_fan_step() and tableau_col_step() as the single source for
both the renderer (card_plugin::card_positions) and the hit-test
(input_plugin::card_position) so they can no longer diverge. Add a
regression test that simulates Android-tight spacing and asserts the hit
target tracks the renderer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:10:57 -07:00
funman300 fde863a4e4 Merge pull request 'test(engine): regression tests for waste-card draggability' (#105) from test/waste-draggable-regression into master
Build and Deploy / build-and-push (push) Successful in 2m6s
Web WASM Rebuild / rebuild (push) Successful in 6m42s
2026-06-25 16:58:15 +00:00
funman300 0cf5fc4293 test(engine): regression tests for waste-card draggability
Adds two find_draggable_at tests covering the reported stock/waste
drag bug: clicking the visible top of a multi-card waste must pick the
top index (not the buffer card beneath), and a lone waste card must
still be draggable. Both pass against current logic, pinning the
correct behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:56 -07:00
Gitea CI 79ddfbc034 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 4m1s
2026-06-24 17:40:04 +00:00
funman300 7919365775 Merge pull request 'fix(engine): clicking the waste card no longer draws from stock' (#104) from fix/waste-click-draws into master
Build and Deploy / build-and-push (push) Successful in 1m37s
Web WASM Rebuild / rebuild (push) Successful in 7m7s
2026-06-24 17:31:31 +00:00
funman300 f88b6f61d0 fix(engine): clicking the waste card no longer draws from stock
handle_stock_click (and handle_touch_stock_tap) hit-tested both the face-down
deck AND the waste slot, so a click/tap on the drawn waste card fired
DrawRequestEvent — drawing the next card instead of playing the waste card, and
swallowing the first click of a double-click so auto-move never triggered.

Only the deck draws now. The waste card is left free to play: double-click /
double-tap to auto-move, or drag it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:31:21 -07:00
funman300 189e0afd24 Merge pull request 'fix(e2e): cycle gate resets games in place (no 240 page reloads)' (#103) from fix/cycle-gate-newgame into master
Build and Deploy / build-and-push (push) Successful in 5m22s
Web E2E / web-e2e (push) Successful in 4m36s
2026-06-24 17:07:46 +00:00
funman300 a32d666751 fix(e2e): reset cycle-gate games in place instead of 240 page reloads
The cycle regression gate did a fresh page.goto() for each of 240 games in one
browser context. Around game ~100 the accumulated resources made
page.waitForFunction time out (30s), failing the gate on ~88% of runs — a
long-standing flaky-CI issue, not a product regression (the 18 e2e tests always
pass).

Load the page once and reset each game via a new __FERROUS_DEBUG__.newGame(seed,
drawThree) bridge method (added to game.js — it was already in play.html).
cycle_metrics.js now navigates once, then loops newGame() + runAutoplay with no
per-game reload, so the run stays fast and stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:07:31 -07:00
funman300 a5902ac0af Merge pull request 'fix(server): scope no-cache to HTML pages (fix web-e2e cycle gate)' (#102) from fix/cache-scope-html-only into master
Build and Deploy / build-and-push (push) Successful in 6m24s
Web E2E / web-e2e (push) Failing after 5m28s
2026-06-24 16:46:36 +00:00
funman300 c3b83f30d1 fix(server): scope Cache-Control no-cache to HTML pages, not static assets
The blanket `no-cache` from the earlier fix (#99) regressed the web-e2e cycle
regression gate: it reloads /play-classic 240 times, and with no-cache the
browser re-validated and recompiled the wasm on every load, blowing past the
30s bridge-ready timeout (green at #98, red from #99 onward).

Scope no-cache to just the `include_str!` HTML routes (which change on every
deploy and have no validators — the actual staleness source). The `/web` +
`/assets` ServeDir keep their default Last-Modified caching, so repeated page
loads reuse the downloaded/compiled wasm and the cycle gate is fast again. HTML
freshness — the fix Rhys needed — is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:46:23 -07:00
Gitea CI a46505fe45 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m59s
Web E2E / web-e2e (push) Failing after 4m7s
2026-06-24 16:37:55 +00:00
funman300 1602f1952d Merge pull request 'fix(web): use adapter limits so /play renders at native res (no 2048 cap)' (#101) from test/web-adapter-limits into master
Build and Deploy / build-and-push (push) Successful in 5m38s
Web E2E / web-e2e (push) Failing after 5m44s
Web WASM Rebuild / rebuild (push) Successful in 6m33s
2026-06-24 16:20:07 +00:00
funman300 81893788c1 fix(web): use adapter limits (Functionality) so /play renders at native res
The 2048 surface limit was never wgpu's or the GPU's — it's
downlevel_webgl2_defaults().max_texture_dimension_2d (2048), which
WgpuSettingsPriority::WebGL2 forces. Switch to Functionality: on the WebGL2
(Gl) backend Bevy then adopts the adapter's real limits, which are still
WebGL2-constrained for features/buffers (shaders stay GLES-compatible) but
report the GPU's true max texture dimension (e.g. 16384). The device is
requested with exactly what the adapter offers, so creation can't fail, the
surface is no longer capped, and large viewports (4K) render with no letterbox
and no hardcoded cap.

Removes the resize_constraints cap and the play.html max-width/height caps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:18:07 -07:00
Gitea CI c232444ef0 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m41s
Web E2E / web-e2e (push) Failing after 4m58s
2026-06-24 02:16:19 +00:00
funman300 56e05caaa9 Merge pull request 'fix(web): cap canvas via Window resize_constraints (real surface fix)' (#100) from fix/web-canvas-resize-constraints into master
Build and Deploy / build-and-push (push) Successful in 5m11s
Web E2E / web-e2e (push) Failing after 5m25s
Web WASM Rebuild / rebuild (push) Successful in 6m12s
2026-06-24 01:59:36 +00:00
funman300 090b5e789e fix(web): cap canvas via Window resize_constraints (the actual fix)
The previous attempts (#97/#98) capped a wrapper element's max-width and
relied on the canvas's width:100% resolving against it — but winit observes
and sizes the *canvas element itself* (via ResizeObserver on its content box),
so the wrapper cap never reached the surface and /play still panicked at
2560x1440 on a 4K@150% viewport.

Use the canonical mechanism instead: set the primary Window's
`resize_constraints { max_width: 2048, max_height: 2048 }`. On web, Bevy maps
this to winit `set_max_inner_size` → the canvas's own `max-width`/`max-height`
style, so the wgpu surface can never exceed wgpu's downlevel_webgl2
max_texture_dimension_2d (2048). play.html mirrors the same `max-*` directly on
#bevy-canvas (belt-and-suspenders) and centres the letterbox with margin:auto;
the obsolete wrapper + clamp script are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:59:26 -07:00
funman300 1e0619897a Merge pull request 'fix(server): Cache-Control no-cache for web assets (stop stale builds)' (#99) from fix/web-no-cache-headers into master
Build and Deploy / build-and-push (push) Successful in 5m58s
Web E2E / web-e2e (push) Failing after 5m4s
2026-06-24 01:45:09 +00:00
funman300 1b5dfa3e27 fix(server): send Cache-Control: no-cache for web assets
Returning players kept getting stale builds: after the /play canvas fix
deployed, the origin served the new play.html (verified — 8/8 cache-busted
requests) but browsers still rendered the old one even after a hard reload.

Cause: the server sets no Cache-Control on the web router. The HTML pages are
include_str!'d into the binary and the wasm-bindgen output (canvas.js,
canvas_bg.wasm, solitaire_wasm.*) uses fixed filenames that change in place on
every deploy, so browsers heuristically cache them indefinitely.

Add `Cache-Control: no-cache` in the security_headers middleware (which wraps
the web router). Browsers now revalidate before using a cached copy; ServeDir
supplies Last-Modified/ETag so unchanged assets still return a cheap 304, while
changed ones (a new deploy) are re-fetched. Stops the stale-build problem for
everyone going forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:44:59 -07:00
funman300 4850e9417e Merge pull request 'fix(web): cap /play canvas at the real 2048 wgpu limit (corrects #97)' (#98) from fix/web-canvas-cap-2048-constant into master
Build and Deploy / build-and-push (push) Successful in 5m53s
Web E2E / web-e2e (push) Successful in 5m39s
2026-06-24 01:14:49 +00:00
funman300 8a2a22ff1b fix(web): cap /play canvas at the real 2048 wgpu limit, not gl.MAX_TEXTURE_SIZE
Correction to the previous canvas clamp (#97), which would NOT have fixed the
crash. It capped the wrapper to the device's gl.MAX_TEXTURE_SIZE, but that's
the hardware limit — on a 4K/integrated GPU it reports 8192+, so the wrapper
was never actually capped and the 2560-wide surface still exceeded the limit.

The real ceiling is wgpu's, not the hardware's: on wasm Bevy creates the device
with Limits::downlevel_webgl2_defaults() (forced by WgpuSettingsPriority::WebGL2
in solitaire_web/src/lib.rs; see bevy_render-0.18.1 settings.rs), whose
max_texture_dimension_2d is a fixed 2048 regardless of GPU. So the cap must be
the constant 2048.

(For the record: the reporter's display is 3840x2160 at 150% scale → a 2560x1440
logical viewport, which is the 2560 in the panic — nothing hardcodes 1440p.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:14:20 -07:00
funman300 b3b282ec2d Merge pull request 'fix(web): clamp /play canvas to GPU MAX_TEXTURE_SIZE (wgpu 2048 panic)' (#97) from fix/web-canvas-max-texture-size into master
Build and Deploy / build-and-push (push) Successful in 5m29s
Web E2E / web-e2e (push) Failing after 6m18s
2026-06-24 00:45:29 +00:00
funman300 7b5d69e164 fix(web): clamp /play canvas to GPU MAX_TEXTURE_SIZE to stop wgpu panic
Rhys hit a fatal wgpu validation panic loading /play on a 1440p display:

    Surface::configure ... Requested was (2560, 1440), maximum extent for
    either dimension is 2048

The earlier scale_factor_override(1.0) fix only neutralised HiDPI (CSS×DPR);
it didn't help when the *logical* viewport itself exceeds 2048. `fit_canvas_to_
parent` sizes the wgpu surface to the canvas's parent, so a 2560-wide viewport
configures a 2560-wide surface — past WebGL2's 2048 per-dimension limit on
laptop/integrated GPUs, and the panic kills the WASM thread on the first frame.

Wrap the canvas in #bevy-wrap and, before init, clamp that element's max-
width/height to the device's actual gl.MAX_TEXTURE_SIZE (falling back to the
2048 WebGL2 floor). fit_canvas_to_parent then never produces a surface larger
than the GPU allows. Only devices at the 2048 floor letterbox (centered); GPUs
that report 4096/8192 still fill the viewport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:44:46 -07:00
funman300 923a67dc7b Merge pull request 'fix(web): classic timer ran at 2x (idempotent startTimer)' (#96) from fix/classic-timer-double-count into master
Build and Deploy / build-and-push (push) Successful in 6m8s
Web E2E / web-e2e (push) Successful in 7m5s
2026-06-24 00:18:53 +00:00
funman300 d4ad184324 fix(web): make classic timer idempotent to stop 2x double-counting
The /play-classic timer ran at double speed. startTimer() always created a new
setInterval and overwrote timerInterval without clearing the old one, so any
extra call leaked a second interval that also incremented elapsedSecs. The
visibilitychange handler calls startTimer() on "visible", and a load-time
visibilitychange (while startGame's timer is already running) stacks a second
interval — after which stopTimer() only clears one, so the leak persists and
the clock counts ~2x forever.

Guard startTimer() to no-op when an interval is already running. This fixes the
two e2e timer tests that surfaced it once the suite actually ran (they were
asserting 0:03 / 0:04 but seeing 0:06 / 0:13), and the user-visible 2x timer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:18:10 -07:00
Gitea CI 0b7a24a75b chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m10s
Web E2E / web-e2e (push) Failing after 5m16s
2026-06-23 17:20:10 +00:00
funman300 fc45b6d261 Merge pull request 'fix(web): CI-owned wasm artifacts + e2e harness fixes' (#95) from fix/web-ci-reproducible-and-e2e into master
Build and Deploy / build-and-push (push) Successful in 6m15s
Web E2E / web-e2e (push) Failing after 4m40s
Web WASM Rebuild / rebuild (push) Successful in 8m54s
2026-06-23 17:01:04 +00:00
funman300 2328643223 ci(web): replace wasm freshness gate with CI-side rebuild-and-commit
The rebuild-and-diff freshness gate (#93) could never pass: the Bevy wasm
artifacts aren't byte-reproducible across machines. Confirmed conclusively —
identical rustc 1.95.0 / LLVM 22.1.2, identical flags, Cargo.lock and remapped
source paths still yield host-dependent output (CI's build was 248 KB smaller
than a local one), while same-machine rebuilds are bit-identical. Path
remapping (kept in build_wasm.sh) is necessary but not sufficient.

Make CI the single source of truth instead: `web-wasm-rebuild` rebuilds pkg/ on
every master change to a wasm-feeding crate and commits it back. The deployed
artifacts can't silently rot and contributors no longer hand-run build_wasm.sh.
The pkg/ commit isn't in this workflow's trigger paths (no loop) but is in
docker-build's, so the refreshed wasm deploys.

Removes the false-failing web-wasm-freshness workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:59:51 -07:00
funman300 5b2b234c54 fix(web-e2e): expose serialize() on classic bridge; use real clock API
Web WASM Freshness / freshness (pull_request) Failing after 7m8s
Two latent test bugs surfaced once the e2e suite actually ran (it had been
red on the webServer startup timeout, so these never executed): 14 passed,
4 failed.

- game.js `__FERROUS_DEBUG__` was missing `serialize()` — play.html's bridge
  has it but the /play-classic bridge (which the resume/move-history tests
  use) drifted. Added it (the wasm SolitaireGame already exposes serialize()).
- game_behaviors.spec.js called `page.clock.tick()`, which is the sinon name;
  Playwright's Clock API method is `page.clock.runFor()`. Replaced all 6 calls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:46:12 -07:00
funman300 9c473d6a51 fix(web): make wasm builds reproducible so the freshness gate passes
The web-wasm-freshness gate (#93) false-failed because the Bevy wasm build
baked in machine-specific absolute source paths — the cargo registry
(/home/<user>/.cargo/registry/...), the rustup std sources (~/.rustup/...),
and this checkout — so CI's rebuild (under /workspace, /root) never matched the
committed bytes even with identical pinned tool versions.

build_wasm.sh now exports CARGO_ENCODED_RUSTFLAGS with three --remap-path-prefix
rules (cargo home -> /cargo, rustup home -> /rustup, repo -> /build) so the
embedded paths are identical on any machine. It re-states the
getrandom_backend cfg because a *_RUSTFLAGS env var replaces (not merges with)
.cargo/config.toml's target rustflags.

Regenerated all four artifacts with the remap applied — verified zero
machine-specific paths remain (only canonical /cargo + /rustup prefixes), which
is the precondition for the gate's byte-for-byte rebuild-and-diff to match
across machines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:46:12 -07:00
funman300 b3a8575bbd Merge pull request 'ci(web-e2e): prebuild server so Playwright webServer stops timing out' (#94) from ci/web-e2e-prebuild-server into master
Build and Deploy / build-and-push (push) Successful in 1m30s
Web E2E / web-e2e (push) Failing after 5m18s
2026-06-22 19:11:22 +00:00
funman300 5091d1b397 ci(web-e2e): prebuild server so Playwright webServer stops timing out
The web-e2e job has been failing on every run with:

    Error: Timed out waiting 120000ms from config.webServer.

Playwright's `webServer` runs `cargo run -p solitaire_server --quiet` and
waits 120s for /health. The workflow cached only npm — no Rust cache — so each
run cold-compiled the entire server graph (axum/sqlx/reqwest/aws-lc-sys) inside
that 120s window and never came up. The browser tests never executed; the
failure was infra, not a web regression.

Fix the harness so the tests actually run:
- add `Swatinem/rust-cache@v2` to warm the cargo cache across runs
- add a `Prebuild server` step (`SQLX_OFFLINE=true cargo build -p
  solitaire_server`) before Playwright, so `webServer`'s `cargo run` reuses the
  compiled binary and starts in seconds (.sqlx offline cache is committed)
- raise `webServer.timeout` 120s -> 300s as a safety margin for a cold cargo
  cache (e.g. first run after a deps bump)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:09:47 -07:00
funman300 da165f1622 Merge pull request 'ci(web): add precise wasm-freshness gate; drop flawed drift heuristic' (#93) from ci/web-wasm-freshness-gate into master
Build and Deploy / build-and-push (push) Successful in 1m15s
Web WASM Freshness / freshness (push) Failing after 7m6s
2026-06-22 19:03:49 +00:00
funman300 43076a48d6 ci(web): add precise wasm-freshness gate; drop flawed drift heuristic
Web WASM Freshness / freshness (pull_request) Failing after 7m14s
The web build shipped a ~3-week-stale pkg/ because the old "Check wasm pkg
drift" step in docker-build.yml only hard-failed on direct solitaire_web/
edits and treated solitaire_engine/_core changes as a non-blocking notice —
so the entire card_game migration (engine/core/data churn, v4->v5 save
schema) slipped through.

Replace it with a dedicated `web-wasm-freshness` workflow that rebuilds the
artifacts and diffs them against what's committed. A fresh build on a pinned
toolchain (rust 1.95.0 / wasm-bindgen 0.2.120 / wasm-pack 0.14.0 / binaryen
130) is byte-for-byte reproducible — verified locally — so this is precise:
it fails on *any* real drift (including gameplay-logic changes that don't
touch the JS API surface) and has no false positives on wasm-irrelevant edits.
Runs on pull_request as well as master push, so staleness is caught before
merge rather than only blocking the post-merge deploy.

Remove the superseded heuristic from docker-build.yml; master stays fresh via
the PR gate, so the deploy image is always built from current artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:03:00 -07:00
funman300 5af69d7551 Merge pull request 'fix(web): regenerate stale WASM artifacts against current master' (#92) from fix/regenerate-web-wasm into master
Build and Deploy / build-and-push (push) Successful in 5m55s
Web E2E / web-e2e (push) Failing after 3m19s
2026-06-22 18:56:31 +00:00
funman300 6c9259beff fix(web): regenerate stale WASM artifacts against current master
The committed `solitaire_server/web/pkg/` artifacts (canvas.* and
solitaire_wasm.*) were last built 2026-06-02 (8b262af) and predate the entire
card_game/klondike migration (#82–#90) plus the v4→v5 save-schema change. The
deployed WASM therefore no longer matched the current source, the JS API glue,
or the HTML (play.html changed in 2cf7282) — which is why the web build was
broken even though the wasm crates compile cleanly on `wasm32-unknown-unknown`.

Rebuilt all four artifacts via build_wasm.sh (wasm-pack 0.14.0,
wasm-bindgen CLI 0.2.120 matching the crate). The regenerated solitaire_wasm.js
and canvas.js glue confirm the API drift (the replay move currency became
KlondikeInstruction in #89).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:53:56 -07:00
funman300 dde65a7e30 Merge pull request 'refactor(core): card_game redundancy cleanup + derive scoring from upstream stats' (#88) from refactor/strip-card_game-redundancies into master
Build and Deploy / build-and-push (push) Failing after 1m0s
Web E2E / web-e2e (push) Failing after 3m19s
2026-06-22 18:44:37 +00:00
funman300 e3b8a403ef style(engine): clear latent android-only clippy warnings in hud_plugin
These six warnings only surfaced on an `aarch64-linux-android` clippy build;
the host workspace gate never compiles the `cfg(target_os = "android")` HUD
tap-toggle code, so they had accumulated unseen.

- unqualify `Vec2` / `TouchInput` (reachable via the bevy prelude) in the
  android tap tracker, plugin wiring, and `toggle_hud_on_tap` signature
- make the `PausedResource` import unconditional and unqualify its use in
  `handle_hint_button` (host-compiled, so the import had been android-gated
  purely to satisfy `toggle_hud_on_tap`)
- allow `clippy::too_many_arguments` on the `toggle_hud_on_tap` Bevy system
- collapse a nested `if let` / `if` into a let-chain

Verified clean on both host `clippy -p solitaire_engine --all-targets
-- -D warnings` and `aarch64-linux-android` clippy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:39:21 -07:00
funman300 9299176b2d refactor(android): forbid unsafe workspace-wide, quarantine JNI in app (#91)
Addresses #91: the #90 posture (`deny(unsafe_code)` + three scattered
`#![allow(unsafe_code)]` across solitaire_data and solitaire_engine) punched
unsafe holes into otherwise-pure logic crates. Replace it by *reducing* the
unsafe rather than relocating it, then forbidding it everywhere it can be
forbidden.

Changes:
- solitaire_data: new safe `android_jni` bridge owning the cached `JavaVM`
  and activity `GlobalRef`; exposes `with_env` / `with_activity_env` so
  keystore/clipboard/safe-area never touch a raw handle.
- keystore: drop the `JavaVM::from_raw` init path (now in the app) and
  replace the three `unsafe { JByteArray::from_raw(x.into_raw()) }` casts
  with the safe `JByteArray::from(JObject)` conversion jni 0.21 provides.
- engine clipboard + safe_area: route through the bridge; remove their
  `#![allow(unsafe_code)]` and all `from_raw` calls.
- solitaire_app: becomes the single owner of FFI unsafe. `android_main`
  reconstructs the raw `JavaVM` / activity once (it must, as the cdylib that
  exports `#[unsafe(no_mangle)]`) and registers the safe wrappers. It opts to
  its own `deny`-level lints with two scoped `#[allow(unsafe_code)]`.
- workspace: `unsafe_code` is now `forbid`. Every crate except the app entry
  point is fully unsafe-free.

Net: 7 unsafe sites across three crates collapse to 3 at the OS boundary in
one crate. Verified with host `clippy --workspace -- -D warnings` and an
`aarch64-linux-android` clippy build of solitaire_app (transitively engine +
data); also fixed two latent android-only `collapsible_if` warnings surfaced
in the keystore by the cross-target check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:32:26 -07:00
funman300 6a9352cde1 docs: correct font-embed claims in font_plugin and CLAUDE.md
font_plugin's module doc claimed a parse failure aborts the program, but
the code warns and continues with glyph-less UI; fix the doc to match.
CLAUDE.md §4.2 listed only audio and the card theme as embedded while
saying "do not embed user fonts"; document that the bundled FiraMono face
is legitimately embedded via include_bytes! as the canonical UI font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:04:11 -07:00
funman300 8995a8ae9c refactor(core): move compute_time_bonus into scoring module
The win-time bonus is Ferrous house-rule scoring policy, not a bridge to
the upstream klondike crate, so it does not belong in klondike_adapter.
Relocate it to a dedicated solitaire_core::scoring module and update the
sole caller (win_summary_plugin) and the adapter/settings doc references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:04:11 -07:00
funman300 0d5c9cdb1d refactor(core): delegate check_win to Session::is_win
check_win() manually projected through session.state().state().is_win(),
reaching the inner Klondike's is_win. Session::is_win() (card_game 0.4.1)
wraps that exact same projection, so collapse the three-hop reach into a
single-hop delegation. check_auto_complete() keeps its projection because
is_win_trivial() is a Klondike-only method with no Session wrapper.

Behavior is bit-for-bit identical; the test-support override in
is_won()/is_auto_completable() (which call check_win) is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:22:35 -07:00
funman300 ceb9c950a1 chore: add pedantic workspace lints (#90)
Add [workspace.lints.rust] and wire each member crate up with
[lints] workspace = true:

  unsafe_code = "deny"        (forbid would break the Android JNI build)
  single_use_lifetimes = "warn"
  trivial_casts = "warn"
  unused_lifetimes = "warn"
  unused_qualifications = "warn"
  variant_size_differences = "warn"
  unexpected_cfgs = "warn"

unsafe_code is "deny" rather than the issue's "forbid" so the three
Android JNI FFI modules (android_keystore, android_clipboard, safe_area)
can opt back in with a scoped #![allow(unsafe_code)] — forbid cannot be
locally overridden. Pure crates carry no unsafe and stay clean.

Clean up the warnings the new lints surface:
- 150ish unused_qualifications removed via `cargo fix` (purely syntactic
  redundant-path-prefix removals).
- table_plugin: the TABLE_COLOUR import was #[cfg(test)]-gated while the
  camera clear-colour used the fully-qualified path; unqualifying it left
  a non-test build with no import. Made the import unconditional instead.
- assets/sources: the `as &[u8]` casts in embed_*_svg! coerce each
  fixed-size &[u8; N] to a uniform slice so the tuples fit the
  &[(&str, &[u8])] arrays — load-bearing, so scoped #[allow(trivial_casts)].

Workspace clippy -D warnings and the full test suite pass. Android build
not compiled here (needs the NDK; built separately per CLAUDE.md §15) —
the deny + scoped-allow keeps the JNI unsafe blocks legal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:05:28 -07:00
funman300 9bbb57134f refactor: persist replay/save moves as KlondikeInstruction, not pile coords (#89)
Pile-position types (Tableau, Foundation, KlondikePile, KlondikePileStack)
are runtime-only and have no serde upstream. Per Rhys's guidance, the
persistence layer now stores the moves (KlondikeInstruction) rather than
board coordinates, decoding back to runtime pile positions on demand.

Core / data:
- game_state: instruction_history() -> Vec<KlondikeInstruction>; add
  instruction_to_piles() and apply_instruction(); drop AnyInstruction.
- klondike_adapter: delete the entire Saved* serde mirror section
  (SavedTableau/Foundation/SkipCards/KlondikePile/TableauStack/
  KlondikePileStack/DstFoundation/DstTableau/SavedInstruction).
- replay: drop the bespoke ReplayMove serde mirror; Replay.moves is now
  Vec<KlondikeInstruction>; REPLAY_SCHEMA_VERSION 2 -> 3.
- storage: game_state save format v3 rejected (v4/v5 only).

Engine / wasm consumers:
- record via KlondikeInstruction (stock click = RotateStock).
- playback decodes each instruction to (from, to, count) against the
  live state via instruction_to_piles, then fires the canonical event;
  undecodable instructions are skipped with a warning, never panic.
- remove all use solitaire_data::ReplayMove and Saved* imports.

Workspace check, clippy -D warnings, and the full test suite all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:43:47 -07:00
funman300 e0a858d4e8 refactor: remove card.rs / card_to_id; use card_game::Card directly (#83)
card_to_id was a frankenstein 0..=51 id shim. Replace it with card_game::Card:
- feedback_anim deal jitter now seeds off a hash of the Card itself
- radial_menu RightClickRadialState.cards: Vec<u32> -> Vec<Card>
- wasm CardSnapshot.id: u32 -> Card (serialises transparently as a plain JS
  number, the same opaque key the renderer already used; new test asserts the
  JSON id field is a number)
- wasm DebugInvariantReport deck-completeness check reworked from a [bool;52]
  index into a HashSet<Card> + Card::new reference deck; the out-of-range check
  is dropped since a Card is always valid

Delete card.rs entirely: the Card/Deck/Rank/Suit re-exports move to the crate
root and the 69 `solitaire_core::card::` import paths flatten to `solitaire_core::`.

The JS card.id is purely an opaque identity key (Map key / dataset.cardId, no
arithmetic, card faces render from rank+suit), so the value change is safe.
cargo test --workspace and clippy --workspace --all-targets -- -D warnings green.

Closes #83

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:33:47 -07:00
funman300 5c992cbdca refactor: replace local DrawMode with upstream klondike::DrawStockConfig (#82)
DrawMode was a 1:1 mirror of klondike::DrawStockConfig (DrawOne/DrawThree).
Delete it and use the upstream type everywhere; re-export DrawStockConfig from
solitaire_core. config_for assigns draw_stock directly and draw_mode() returns
session.config().inner.draw_stock.

Serde is unchanged — DrawStockConfig serialises to the same "DrawOne"/"DrawThree"
named variants, so persisted game_state.json / replay JSON stay byte-compatible
(no migration). Field/method/variable names containing draw_mode are unchanged.

35 files, mechanical type swap across all crates. Implemented via a multi-agent
workflow (core → per-crate consumers → verify). cargo test --workspace and
clippy --workspace --all-targets -- -D warnings green.

Closes #82

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:01:11 -07:00
funman300 d045781119 chore: gitignore .claude-flow scratch dirs
A claude-flow tool run left solitaire_engine/src/.claude-flow/. Ignore
.claude-flow/ anywhere in the tree, matching the existing agent-tooling
artifact rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:12:18 -07:00
100 changed files with 1513 additions and 1662 deletions
+6 -41
View File
@@ -36,47 +36,12 @@ jobs:
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: Check wasm pkg drift
run: |
set -euo pipefail
BASE_SHA="${{ github.event.before }}"
HEAD_SHA="${{ github.sha }}"
if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
RANGE="$BASE_SHA..$HEAD_SHA"
else
RANGE="HEAD~1..HEAD"
fi
CHANGED="$(git diff --name-only "$RANGE")"
echo "Changed files:"
echo "$CHANGED"
if echo "$CHANGED" | grep -Eq '^(solitaire_wasm/|solitaire_core/|Cargo\.toml|Cargo\.lock)$|^(solitaire_wasm/|solitaire_core/)'; then
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/solitaire_wasm\.js$|^solitaire_server/web/pkg/solitaire_wasm_bg\.wasm$'; then
echo "error: wasm/core/Cargo changed but committed web pkg artifacts are missing."
echo "Run: wasm-pack build --target web --out-dir solitaire_server/web/pkg --no-typescript solitaire_wasm"
exit 1
fi
fi
# Hard check: solitaire_web/ is the direct Bevy WASM source — any
# change there MUST rebuild canvas_bg.wasm or the binary goes stale.
if echo "$CHANGED" | grep -Eq '^solitaire_web/'; then
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
echo "error: solitaire_web/ changed but canvas_bg.wasm not updated."
echo "Run: ./build_wasm.sh (requires wasm-bindgen-cli + wasm32-unknown-unknown target)"
exit 1
fi
fi
# Advisory notice: solitaire_engine/ and solitaire_core/ changes often
# require a Bevy WASM rebuild but are not enforced (formatting-only
# commits should not be blocked).
if echo "$CHANGED" | grep -Eq '^(solitaire_engine/|solitaire_core/)' && \
! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
echo "notice: solitaire_engine/core changed without a canvas_bg.wasm rebuild."
echo " If the change affects gameplay run ./build_wasm.sh before pushing."
fi
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
# and commits it back (CI is the single source of truth — the artifacts
# aren't byte-reproducible on contributor machines). That pkg/ commit then
# triggers this workflow, so the deployed image always ships fresh wasm.
# No drift check is needed here.
- name: Log in to Gitea registry
uses: docker/login-action@v3
+13
View File
@@ -25,6 +25,19 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
# Prebuild the server so Playwright's `webServer` (which runs
# `cargo run -p solitaire_server`) starts from a compiled binary instead
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
# its 120s startup window — the timeout that was failing every run.
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB).
- name: Prebuild server
env:
SQLX_OFFLINE: 'true'
run: cargo build -p solitaire_server --quiet
- name: Set up Node.js
uses: actions/setup-node@v4
with:
+89
View File
@@ -0,0 +1,89 @@
name: Web WASM Rebuild
# CI is the single source of truth for solitaire_server/web/pkg/.
#
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
# flags, the same Cargo.lock and remapped source paths, the output still differs
# by host environment. So rather than police freshness with a rebuild-and-diff
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
# itself on every master change to a wasm-feeding crate and commits them back.
#
# Result: the deployed pkg/ can't silently rot, and contributors never need to
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
# workflow's trigger paths (so it does not re-trigger here) but does match
# docker-build's, so the refreshed wasm deploys.
on:
push:
branches: [master]
paths:
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-wasm-rebuild.yml'
workflow_dispatch:
concurrency:
group: web-wasm-rebuild
cancel-in-progress: false
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_TOKEN }}
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
targets: wasm32-unknown-unknown
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
- name: Install binaryen 130 (wasm-opt, pinned)
run: |
set -euo pipefail
curl -sSL \
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
| tar xz
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
- name: Rebuild WASM artifacts
run: ./build_wasm.sh
- name: Commit refreshed artifacts if changed
run: |
set -euo pipefail
if git diff --quiet -- solitaire_server/web/pkg/; then
echo "pkg/ already up to date — nothing to commit."
exit 0
fi
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
git add solitaire_server/web/pkg/
git commit -m "chore(web): regenerate wasm artifacts"
# master is unprotected; retry once if the tip moved under us.
git push origin HEAD:master || {
git fetch origin master
git rebase origin/master
git push origin HEAD:master
}
+2
View File
@@ -35,6 +35,8 @@ deploy/*-auth-secret.yaml
/.agents/
/.codex/
/AGENTS.md
# claude-flow scratch dirs, anywhere in the tree (e.g. solitaire_engine/src/)
.claude-flow/
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
+5
View File
@@ -47,6 +47,11 @@ project follows [Semantic Versioning](https://semver.org/).
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
clicks, delayed first-run onboarding until splash teardown, and kept dragged
stacks above all piles.
- **Draw-Three waste fan hit testing on Android.** The renderer and the click
hit-test now share a single `waste_fan_step` / `tableau_col_step` source. They
previously diverged under Android's tighter column spacing, shifting the top
fanned waste card's hit target onto the card beneath it, so dragging the visible
card played the wrong one.
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
+7 -2
View File
@@ -208,9 +208,14 @@ Embed via `include_bytes!()` only when ALL of the following are true:
Currently embedded:
* **Audio** — all `.wav` files in `audio_plugin.rs`
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
* **Bundled UI font** — `assets/fonts/main.ttf` (FiraMono) via `include_bytes!`
in `font_plugin.rs` and `assets/svg_loader.rs`; it is the canonical UI face
and must always be present, so it is embedded rather than `AssetServer`-loaded
Do NOT embed card face PNGs, background images, or user fonts —
these are loaded via `AssetServer` so art can be swapped without recompile.
Do NOT embed card face PNGs or background images — these are loaded via
`AssetServer` so art can be swapped without recompile. User-supplied fonts
(if ever added) likewise go through `AssetServer`; only the bundled FiraMono
face above is embedded.
---
Generated
+1
View File
@@ -7306,6 +7306,7 @@ name = "solitaire_app"
version = "0.1.0"
dependencies = [
"bevy",
"jni 0.21.1",
"keyring",
"solitaire_data",
"solitaire_engine",
+16
View File
@@ -18,6 +18,22 @@ version = "0.1.0"
license = "MIT"
rust-version = "1.95"
# Pedantic correctness lints applied across every member crate via
# `[lints] workspace = true`.
[workspace.lints.rust]
# Workspace-wide ban on `unsafe`. The sole exception is `solitaire_app`,
# which sets its own `deny`-level lints (see its Cargo.toml) because the
# Android cdylib entry point must reconstruct raw JNI handles. Every other
# crate reaches Android JNI through the safe `solitaire_data::android_jni`
# bridge and stays fully unsafe-free.
unsafe_code = "forbid"
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
variant_size_differences = "warn"
unexpected_cfgs = "warn"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+16
View File
@@ -22,6 +22,22 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
# Reproducible builds. The wasm artifacts otherwise bake in machine-specific
# absolute source paths (the cargo registry, the rustup std sources, and this
# checkout), so a rebuild on a different machine produces different bytes and
# the CI freshness gate (rebuild-and-diff) false-positives. Remap all three
# prefixes to fixed names so the output is byte-identical anywhere.
#
# We must use CARGO_ENCODED_RUSTFLAGS (not RUSTFLAGS) and re-state the
# getrandom backend cfg here: a `*_RUSTFLAGS` env var *replaces* — does not
# merge with — the `[target.wasm32-unknown-unknown] rustflags` in
# .cargo/config.toml, so dropping that cfg would break the wasm getrandom build.
# Keep this `--cfg` in sync with .cargo/config.toml.
CARGO_HOME_DIR="${CARGO_HOME:-$HOME/.cargo}"
RUSTUP_HOME_DIR="${RUSTUP_HOME:-$HOME/.rustup}"
US=$'\x1f' # unit separator: CARGO_ENCODED_RUSTFLAGS arg delimiter
export CARGO_ENCODED_RUSTFLAGS="--cfg${US}getrandom_backend=\"wasm_js\"${US}--remap-path-prefix=${CARGO_HOME_DIR}=/cargo${US}--remap-path-prefix=${RUSTUP_HOME_DIR}=/rustup${US}--remap-path-prefix=${REPO_ROOT}=/build"
if ! command -v wasm-pack &> /dev/null; then
echo "error: wasm-pack not found." >&2
echo " Install with: cargo install wasm-pack" >&2
+7 -2
View File
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
echo ''
echo '# Android dev'
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.14904198"'
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
} >> ~/.bashrc
@@ -49,10 +49,15 @@ sdkmanager \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0" \
"ndk;26.3.11579264" \
"ndk;30.0.14904198" \
"emulator" \
"system-images;android-34;google_apis;x86_64"
# The exact NDK/build-tools versions above are not load-bearing — newer ones
# work (verified on NDK 30.0.14904198 / build-tools 37.0.0). `scripts/build_android_apk.sh`
# auto-discovers the newest installed NDK and build-tools, so set ANDROID_NDK_HOME
# (step 3) to whatever version you actually install here.
# 6. AVD for testing (one-time).
echo no | avdmanager create avd \
-n bevy_test \
+22
View File
@@ -22,6 +22,13 @@ bevy = { workspace = true }
solitaire_engine = { workspace = true }
solitaire_data = { workspace = true }
# Android-only: the entry point reconstructs the raw `JavaVM` / activity
# handles and registers the safe `solitaire_data::android_jni` bridge. This
# is the one crate in the workspace that performs `unsafe` FFI, so it is also
# the only one that depends on `jni` directly at the app layer.
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
# Desktop-only deps. `keyring`'s default-store init only matters on
# platforms with a real keychain backend (Linux Secret Service,
# macOS Keychain, Windows Credential Store), and its transitive
@@ -99,3 +106,18 @@ icon = "@mipmap/ic_launcher"
# in portrait orientation. Remove (or add a landscape layout) before
# enabling auto-rotate.
orientation = "portrait"
# `solitaire_app` is the one crate that cannot inherit the workspace
# `forbid(unsafe_code)`: as the Android cdylib it must export the
# `#[unsafe(no_mangle)]` entry point and reconstruct the raw JNI handles
# there (a `no_mangle` symbol cannot live in a dependency rlib). It mirrors
# the workspace lints but at `deny`, so the two `#[allow(unsafe_code)]`
# scopes in the Android entry point are the only unsafe in the whole tree.
[lints.rust]
unsafe_code = "deny"
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
variant_size_differences = "warn"
unexpected_cfgs = "warn"
+46 -5
View File
@@ -144,7 +144,7 @@ fn build_app_with_settings(
// Android windows always fill the screen; max_width/max_height
// default to 0.0, which panics Bevy's clamp when min > max.
#[cfg(not(target_os = "android"))]
resize_constraints: bevy::window::WindowResizeConstraints {
resize_constraints: WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
@@ -166,7 +166,7 @@ fn build_app_with_settings(
// default makes it walk *out* of the APK's assets root and
// all loads fail silently — which is what produced the
// solid-red card-back fallback in the v0.22.3 screenshot.
.set(bevy::asset::AssetPlugin {
.set(AssetPlugin {
#[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(),
..default()
@@ -363,16 +363,57 @@ fn set_window_icon(
/// works on a function named `main`; our shared entry point is `run`, so
/// we emit the equivalent expansion manually.
#[cfg(target_os = "android")]
#[allow(unsafe_code)]
#[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let vm_ptr = android_app.vm_as_ptr().cast();
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) {
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
if let Err(e) = init_android_jni(&android_app) {
eprintln!("warn: could not initialise Android JNI bridge ({e})");
}
let _ = bevy::android::ANDROID_APP.set(android_app);
run();
}
/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
/// Android runtime and registers safe wrappers with `solitaire_data`.
///
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
/// reconstruction. Every other crate consumes the safe
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
/// function and the `#[unsafe(no_mangle)]` entry point above.
#[cfg(target_os = "android")]
#[allow(unsafe_code)]
fn init_android_jni(
android_app: &bevy::android::android_activity::AndroidApp,
) -> Result<(), String> {
use jni::JavaVM;
use jni::objects::JObject;
let vm_ptr = android_app.vm_as_ptr();
if vm_ptr.is_null() {
return Err("JavaVM pointer is null".into());
}
// SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
// Android runtime; it is valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;
let env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
// valid for the lifetime of the process. Promote it to a global reference
// so the safe bridge can hand it to any thread.
let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
let activity_ref = env
.new_global_ref(&activity)
.map_err(|e| format!("activity global ref: {e}"))?;
solitaire_data::android_jni::set_jvm(vm);
solitaire_data::android_jni::set_activity(activity_ref);
Ok(())
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
+3
View File
@@ -30,3 +30,6 @@ path = "src/bin/gen_seeds.rs"
[[bin]]
name = "gen_difficulty_seeds"
path = "src/bin/gen_difficulty_seeds.rs"
[lints]
workspace = true
@@ -19,7 +19,7 @@
//! --per-tier Seeds to emit per tier (default 40)
//! --help Print this message
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::GameState;
// Budget boundaries defining each tier. A seed belongs to the lowest tier
@@ -74,7 +74,7 @@ fn main() {
std::process::exit(1);
}
let draw_mode = DrawMode::DrawOne;
let draw_mode = DrawStockConfig::DrawOne;
let num_tiers = BUDGETS.len();
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
let mut tried: u64 = 0;
+2 -2
View File
@@ -17,7 +17,7 @@
//! --count Number of Winnable seeds to emit (default 75)
//! --help Print this message
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::GameState;
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
@@ -68,7 +68,7 @@ fn main() {
std::process::exit(1);
}
let draw_mode = DrawMode::DrawOne;
let draw_mode = DrawStockConfig::DrawOne;
let mut found: Vec<u64> = Vec::with_capacity(count);
let mut tried: u64 = 0;
let mut seed = start;
+3
View File
@@ -16,3 +16,6 @@ serde = { workspace = true }
thiserror = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
[lints]
workspace = true
-23
View File
@@ -1,23 +0,0 @@
pub use card_game::{Card, Deck, Rank, Suit};
/// Maps a [`Card`] to a stable `0..=51` numeric identity, independent of the
/// upstream `card_game::Card` bit-packing.
///
/// Encoding: `suit_index * 13 + (rank as u32 - 1)`, where `suit_index` is
/// Clubs=0, Diamonds=1, Hearts=2, Spades=3 and `rank` is 1 (Ace) ..= 13 (King).
/// The deck id is intentionally ignored so the id depends only on the visible
/// face.
///
/// This is the single source of truth shared by `CardEntity` numeric tracking,
/// deterministic per-card animation jitter, and the WASM replay layer — those
/// must agree byte-for-byte so replay snapshots are identical across the
/// desktop and browser builds.
pub fn card_to_id(card: &Card) -> u32 {
let suit_index: u32 = match card.suit() {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
suit_index * 13 + (card.rank() as u32 - 1)
}
+43 -76
View File
@@ -1,7 +1,6 @@
use crate::error::MoveError;
use crate::klondike_adapter::{
DrawMode, KlondikeAdapter, SavedInstruction,
foundation_from_slot as adapter_foundation_from_slot,
KlondikeAdapter, foundation_from_slot as adapter_foundation_from_slot,
skip_cards_from_count as adapter_skip_cards_from_count,
tableau_from_index as adapter_tableau_from_index,
};
@@ -19,11 +18,10 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// History:
/// - v1: `Foundation(Suit)` keys.
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
/// - v3: session-backed save files using local `SavedInstruction` mirror types
/// with u8 indices for enum variants.
/// - v3 (rejected): session-backed save files using local mirror types with u8
/// indices for enum variants. No longer loadable — v3 files are discarded.
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
/// variants (e.g. `"Foundation1"` instead of `0`). v3 files are auto-migrated
/// on load via `AnyInstruction` transparent deserialization.
/// variants (e.g. `"Foundation1"` instead of `0`).
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
/// persisted. They are derived from the upstream `card_game`/`klondike` session
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
@@ -101,7 +99,7 @@ pub enum GameMode {
/// `KlondikeInstruction` serde, which produces named enum variants.
#[derive(Debug, Clone, Serialize)]
struct PersistedGameState {
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
pub mode: GameMode,
pub elapsed_seconds: u64,
pub seed: u64,
@@ -110,31 +108,16 @@ struct PersistedGameState {
pub saved_moves: Vec<KlondikeInstruction>,
}
/// Transparent migration wrapper for deserialisation.
/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
///
/// Tries `KlondikeInstruction` (schema v4, named variants) first; if that
/// fails (because the value uses u8 indices), falls back to `SavedInstruction`
/// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via
/// the existing `TryFrom` impl.
///
/// `SavedInstruction` remains `pub` in `klondike_adapter` because
/// `solitaire_data::ReplayMove` and the WASM replay layer depend on it.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum AnyInstruction {
V4(KlondikeInstruction),
V3(SavedInstruction),
}
/// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats.
///
/// `score`, `undo_count`, and `recycle_count` are intentionally absent: all
/// three are rebuilt by replaying the instruction history through the upstream
/// session stats. Older save files (v3/v4) still carry those keys; serde ignores
/// them.
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
/// intentionally absent: all three are rebuilt by replaying the instruction
/// history through the upstream session stats. Older v4 save files still carry
/// those keys; serde ignores them.
#[derive(Debug, Clone, Deserialize)]
struct PersistedGameStateIn {
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
#[serde(default)]
pub mode: GameMode,
pub elapsed_seconds: u64,
@@ -143,7 +126,7 @@ struct PersistedGameStateIn {
pub take_from_foundation: bool,
#[serde(default = "schema_v1")]
pub schema_version: u32,
pub saved_moves: Vec<AnyInstruction>,
pub saved_moves: Vec<KlondikeInstruction>,
}
#[cfg(feature = "test-support")]
@@ -249,10 +232,10 @@ impl<'de> Deserialize<'de> for GameState {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
// Accept v3 (legacy u8-index format, auto-migrated), v4 (upstream
// named-variant serde), and v5 (current, derived stats). Reject the rest.
// Accept v4 (upstream named-variant serde) and v5 (current, derived
// stats). v3 (legacy u8-index format) and all others are rejected.
match persisted.schema_version {
3..=5 => {}
4 | 5 => {}
v => {
return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}"
@@ -276,17 +259,7 @@ impl<'de> Deserialize<'de> for GameState {
// to 0 across save/load because undone moves are not part of the saved
// forward history.
let replay_config = Self::replay_config(persisted.draw_mode);
for any in persisted.saved_moves {
// AnyInstruction::V4 arrives directly from upstream serde (schema v4+).
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
// converted here via the existing TryFrom impl.
let instruction = match any {
AnyInstruction::V4(i) => i,
AnyInstruction::V3(s) => {
KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)?
}
};
for instruction in persisted.saved_moves {
if !game
.session
.state()
@@ -306,12 +279,12 @@ impl<'de> Deserialize<'de> for GameState {
impl GameState {
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
}
/// Creates a new game with an explicit `GameMode`.
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
pub fn new_with_mode(seed: u64, draw_mode: DrawStockConfig, mode: GameMode) -> Self {
Self {
mode,
elapsed_seconds: 0,
@@ -325,11 +298,8 @@ impl GameState {
/// Whether the player draws one or three cards from the stock per turn.
/// Derived from the underlying session config (set once at deal time).
pub fn draw_mode(&self) -> DrawMode {
match self.session.config().inner.draw_stock {
DrawStockConfig::DrawOne => DrawMode::DrawOne,
DrawStockConfig::DrawThree => DrawMode::DrawThree,
}
pub fn draw_mode(&self) -> DrawStockConfig {
self.session.config().inner.draw_stock
}
/// Current game score, derived from the upstream session stats.
@@ -405,11 +375,11 @@ impl GameState {
!self.check_win() && self.check_auto_complete()
}
fn new_session(seed: u64, draw_mode: DrawMode) -> Session<Klondike> {
fn new_session(seed: u64, draw_mode: DrawStockConfig) -> Session<Klondike> {
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
}
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
fn session_config(draw_mode: DrawStockConfig) -> SessionConfig<KlondikeConfig> {
SessionConfig {
inner: Self::replay_config(draw_mode),
// The 15 WXP undo penalty is now applied by the upstream score
@@ -419,7 +389,7 @@ impl GameState {
}
}
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
fn replay_config(draw_mode: DrawStockConfig) -> KlondikeConfig {
// Always allow foundation returns during replay, regardless of the
// player's current `take_from_foundation` setting. A move recorded
// when the rule was enabled must replay correctly even if the player
@@ -443,20 +413,17 @@ impl GameState {
}
/// Returns the deterministic instruction history for the current deal as
/// legacy mirror types.
/// upstream [`KlondikeInstruction`] values.
///
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
/// sequence is sufficient to replay the game state exactly.
///
/// Returns [`SavedInstruction`] (u8-index mirror types) for backward
/// compatibility with the WASM replay layer and `solitaire_data::ReplayMove`
/// format. New code that does not need serde should prefer
/// `session().history()` directly.
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
/// sequence is sufficient to replay the game state exactly. Consumers
/// record these directly (they serialise via `KlondikeInstruction`'s
/// compact serde) and play them back via [`GameState::apply_instruction`].
pub fn instruction_history(&self) -> Vec<KlondikeInstruction> {
self.session
.history()
.iter()
.map(|snapshot| SavedInstruction::from(*snapshot.instruction()))
.map(|snapshot| *snapshot.instruction())
.collect()
}
@@ -587,7 +554,7 @@ impl GameState {
/// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need
/// a specific mode use this instead of mutating a field.
#[cfg(feature = "test-support")]
pub fn set_test_draw_mode(&mut self, draw_mode: DrawMode) {
pub fn set_test_draw_mode(&mut self, draw_mode: DrawStockConfig) {
self.session = Self::new_session(self.seed, draw_mode);
}
@@ -1002,7 +969,7 @@ impl GameState {
/// Returns `true` when all four foundation slots each contain a complete A→K sequence.
pub fn check_win(&self) -> bool {
self.session.state().state().is_win()
self.session.is_win()
}
/// Returns `true` when the game can be completed without further player input
@@ -1148,7 +1115,7 @@ impl GameState {
/// "Winnable deals only" retry loop.
pub fn solve_fresh_deal(
seed: u64,
draw_mode: DrawMode,
draw_mode: DrawStockConfig,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
@@ -1177,7 +1144,7 @@ mod tests {
const MAX_STEPS: usize = 160;
for seed in 1..=MAX_SEED {
let mut game = GameState::new(seed, DrawMode::DrawOne);
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
game.take_from_foundation = true;
for _ in 0..MAX_STEPS {
@@ -1226,7 +1193,7 @@ mod tests {
/// iteration limit (shouldn't happen in practice).
fn game_at_first_recycle() -> Option<GameState> {
for seed in 1..=256_u64 {
let mut game = GameState::new(seed, DrawMode::DrawOne);
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
for _ in 0..200 {
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
// This draw will recycle.
@@ -1259,7 +1226,7 @@ mod tests {
fn undo_applies_minus_15_penalty_via_upstream_score() {
// A foundation move scores +10 upstream; undoing it nets the move score
// back to 0 and adds the 15 undo penalty, which `score()` floors at 0.
let mut game = GameState::new(1, DrawMode::DrawOne);
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Find and play any scoring move, then undo it.
let scoring_move = game
.possible_instructions()
@@ -1319,13 +1286,13 @@ mod tests {
fn solve_fresh_deal_is_deterministic() {
let a = GameState::solve_fresh_deal(
7,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
);
let b = GameState::solve_fresh_deal(
7,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
);
@@ -1335,7 +1302,7 @@ mod tests {
#[test]
fn winnable_verdict_carries_a_first_move() {
// Contract: a first move is present iff the verdict is winnable.
let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 5_000);
let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
@@ -1343,7 +1310,7 @@ mod tests {
#[test]
fn solve_first_move_uses_live_game_state() {
let mut game = GameState::new(42, DrawMode::DrawOne);
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
game.draw().expect("draw must succeed");
let outcome = game.solve_first_move(5_000, 5_000);
@@ -1354,7 +1321,7 @@ mod tests {
#[test]
fn zero_state_budget_is_inconclusive() {
let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 0);
let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
@@ -1362,9 +1329,9 @@ mod tests {
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 = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
let medium =
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
+10 -291
View File
@@ -3,44 +3,32 @@
//! [`KlondikeAdapter`] is a pure helper namespace for:
//! - building [`KlondikeConfig`] from Ferrous settings
//! - translating between local and upstream types
//! - applying Ferrous-specific scoring policy on top of upstream defaults
//!
//! Ferrous-specific scoring policy (the win-time bonus) lives in
//! [`crate::scoring`], not here.
//!
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
//! upstream `card_game` / `klondike` types live here so that the product modules
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
TableauStack,
DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
SkipCards, Tableau,
};
use serde::{Deserialize, Serialize};
/// 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.
/// Bridges `solitaire_core` game config to the upstream `klondike` crate.
///
/// This type is intentionally zero-sized: it does not carry mutable runtime
/// state, and exists only as a namespace for configuration, conversion, and
/// scoring helpers.
/// state, and exists only as a namespace for configuration and conversion
/// helpers.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KlondikeAdapter;
impl KlondikeAdapter {
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig {
KlondikeConfig {
draw_stock: match draw_mode {
DrawMode::DrawOne => DrawStockConfig::DrawOne,
DrawMode::DrawThree => DrawStockConfig::DrawThree,
},
draw_stock: draw_mode,
move_from_foundation: if take_from_foundation {
MoveFromFoundationConfig::Allowed
} else {
@@ -95,272 +83,3 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
_ => 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
}
+3 -4
View File
@@ -1,8 +1,8 @@
pub mod achievement;
pub mod card;
pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod scoring;
// Re-export the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct
@@ -12,9 +12,8 @@ pub mod klondike_adapter;
// 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, SolveError};
pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
pub use klondike_adapter::DrawMode;
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module.
+3 -121
View File
@@ -1,14 +1,8 @@
use card_game::{Card, Game};
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
use klondike::{DrawStockConfig, Foundation, KlondikePile, 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
@@ -52,8 +46,8 @@ fn all_cards(game: &GameState) -> Vec<Card> {
cards
}
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
}
/// Apply a sequence of random actions to a game, silently ignoring errors.
@@ -262,116 +256,4 @@ proptest! {
}
}
// -------------------------------------------------------------------------
// SavedInstruction ↔ KlondikeInstruction round-trip
// -------------------------------------------------------------------------
/// Every valid `SavedInstruction` survives a round-trip through
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
///
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
/// and all legal sub-field ranges:
/// - `SavedTableau`: 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)));
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Ferrous-specific scoring policy layered on top of upstream `klondike`.
//!
//! Upstream [`klondike::KlondikeStats::score`] owns the per-move point values
//! (move-to-foundation, flip-up bonus, recycle penalty, etc.). The functions
//! here are the Ferrous Solitaire house rules that upstream has no opinion on —
//! currently just the win-time bonus shown in the win modal.
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
if elapsed_seconds == 0 {
return 0;
}
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
}
+3
View File
@@ -47,3 +47,6 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
[lints]
workspace = true
+65
View File
@@ -0,0 +1,65 @@
//! Safe JNI bridge for Android platform integration.
//!
//! Reconstructing the raw `JavaVM` / `NativeActivity` handles handed over by
//! the Android runtime requires `unsafe` FFI. That single reconstruction lives
//! in `solitaire_app`'s entry point ([`solitaire_app::android_main`]); this
//! module only ever stores the resulting *safe* [`JavaVM`] and activity
//! [`GlobalRef`] and hands callers an attached [`JNIEnv`].
//!
//! As a result every consumer of Android JNI — the keystore, clipboard, and
//! safe-area subsystems — goes through the safe functions here and stays
//! `forbid(unsafe_code)`. The only crate carrying `unsafe` is `solitaire_app`.
//!
//! Only compiled and linked on `target_os = "android"`.
use jni::objects::{GlobalRef, JObject};
use jni::{JNIEnv, JavaVM};
use std::sync::OnceLock;
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
static ANDROID_ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
/// Store the process-wide [`JavaVM`]. Called once from Android startup
/// (`solitaire_app::android_main`); subsequent calls are ignored.
pub fn set_jvm(vm: JavaVM) {
let _ = ANDROID_JVM.set(vm);
}
/// Store a global reference to the `NativeActivity`. Called once from Android
/// startup; subsequent calls are ignored.
pub fn set_activity(activity: GlobalRef) {
let _ = ANDROID_ACTIVITY.set(activity);
}
/// Run `f` with a [`JNIEnv`] attached to the current thread.
///
/// Returns an error string if the bridge has not been initialised yet or the
/// thread cannot be attached. The closure's JNI errors are surfaced through the
/// same `String` channel so callers have a single error type to map.
pub fn with_env<F, R>(f: F) -> Result<R, String>
where
F: for<'local> FnOnce(&mut JNIEnv<'local>) -> jni::errors::Result<R>,
{
let vm = ANDROID_JVM
.get()
.ok_or_else(|| "Android JavaVM not initialised".to_string())?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
f(&mut env).map_err(|e| format!("JNI: {e}"))
}
/// Run `f` with an attached [`JNIEnv`] and the `NativeActivity` object.
///
/// Like [`with_env`] but also resolves the cached activity global reference, so
/// callers that need to invoke instance methods on the activity (clipboard,
/// window insets) never touch a raw handle.
pub fn with_activity_env<F, R>(f: F) -> Result<R, String>
where
F: for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> jni::errors::Result<R>,
{
let activity = ANDROID_ACTIVITY
.get()
.ok_or_else(|| "Android activity not initialised".to_string())?;
with_env(|env| f(env, activity.as_obj()))
}
+35 -67
View File
@@ -14,19 +14,16 @@
///
/// Only compiled and linked on `target_os = "android"`.
use jni::{
JNIEnv, JavaVM,
JNIEnv,
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ffi::c_void;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
#[derive(Serialize, Deserialize)]
struct TokenBlob {
@@ -39,43 +36,15 @@ struct TokenBlob {
// JVM helper
// ---------------------------------------------------------------------------
/// Initialise Android Keystore access with the process-wide `JavaVM*`.
///
/// This is called by `solitaire_app` from Android startup code. Keeping the
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or
/// engine layer just to reach platform startup state.
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
if vm_ptr.is_null() {
return Err(TokenError::KeychainUnavailable(
"JavaVM pointer is null".into(),
));
}
if ANDROID_JVM.get().is_some() {
return Ok(());
}
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
// the process lifetime.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
let _ = ANDROID_JVM.set(vm);
Ok(())
}
/// Run `f` with an attached `JNIEnv`, delegating thread attach and the
/// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
/// initialised once from Android startup, so the keystore never touches a raw
/// pointer and this module stays `forbid(unsafe_code)`.
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
where
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
{
let vm = ANDROID_JVM
.get()
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
}
// ---------------------------------------------------------------------------
@@ -230,9 +199,10 @@ fn encrypt_gcm(
.v()?;
// IV is generated by Android's provider; read it back after init.
// `getIV()` returns `[B`; the safe `From<JObject>` reinterprets the
// returned object reference as a typed byte array.
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
// SAFETY: the method signature guarantees a byte array return.
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
let iv_arr = JByteArray::from(iv_jobj);
let iv = env.convert_byte_array(&iv_arr)?;
let pt_arr = env.byte_array_from_slice(plaintext)?;
@@ -240,8 +210,7 @@ fn encrypt_gcm(
let ct_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
.l()?;
// SAFETY: doFinal([B) returns [B.
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
let ct_arr = JByteArray::from(ct_jobj);
let ciphertext = env.convert_byte_array(&ct_arr)?;
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
@@ -292,8 +261,7 @@ fn decrypt_gcm(
let pt_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
.l()?;
// SAFETY: doFinal([B) returns [B.
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
let pt_arr = JByteArray::from(pt_jobj);
env.convert_byte_array(&pt_arr)
}
@@ -380,29 +348,29 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
}
// --- 2. Legacy path migration ---
if let Some(ref lpath) = legacy_path {
if lpath.exists() {
let data = read_file_bytes_from(lpath).map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
other => other,
if let Some(ref lpath) = legacy_path
&& lpath.exists()
{
let data = read_file_bytes_from(lpath).map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
other => other,
})?;
if data.len() >= 12 {
let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?;
decrypt_gcm(env, &key, &data)
})?;
if data.len() >= 12 {
let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?;
decrypt_gcm(env, &key, &data)
})?;
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
let mut map = HashMap::new();
map.insert(blob.username.clone(), blob);
// Write to the new location, then remove the legacy file.
if write_map_inner(&map).is_ok() {
let _ = std::fs::remove_file(lpath);
}
return Ok(map);
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
let mut map = HashMap::new();
map.insert(blob.username.clone(), blob);
// Write to the new location, then remove the legacy file.
if write_map_inner(&map).is_ok() {
let _ = std::fs::remove_file(lpath);
}
return Ok(map);
}
// Legacy file corrupt or unrecognised — treat as empty.
}
// Legacy file corrupt or unrecognised — treat as empty.
}
// --- 3. No file found ---
@@ -491,11 +459,11 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
if map.is_empty() {
// No more users — remove the file and the Keystore key.
if let Some(path) = token_file_path() {
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
}
if let Some(path) = token_file_path()
&& path.exists()
{
std::fs::remove_file(&path)
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
}
// Remove the Keystore key so a future re-login generates a fresh key.
+3 -2
View File
@@ -19,8 +19,9 @@
//! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android this module delegates to an Android Keystore
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm`
//! from Android startup before token operations can succeed.
//! JNI backend. `solitaire_app` must initialise the safe
//! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
//! startup before token operations can succeed.
//!
//! # Note: no unit tests — requires live OS keychain.
+6 -5
View File
@@ -58,7 +58,7 @@ pub trait SyncProvider: Send + Sync {
/// so backends without a server (e.g. `LocalOnlyProvider`) are
/// silently no-op'd by the engine's push-on-win system, matching
/// the same pattern `pull` / `push` follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
}
@@ -94,7 +94,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await
}
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
(**self).push_replay(replay).await
}
}
@@ -144,9 +144,10 @@ pub use settings::{
};
#[cfg(target_os = "android")]
mod android_keystore;
pub mod android_jni;
#[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm;
mod android_keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens;
@@ -163,7 +164,7 @@ pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
ReplayHistory, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
};
// `latest_replay_path` is still consumed by the engine's one-shot legacy
+57 -66
View File
@@ -12,13 +12,22 @@
//! carries any other version so older replays are silently dropped instead
//! of crashing the loader.
//!
//! The recording is intentionally minimal — only [`ReplayMove`] entries
//! that successfully advanced the game. `Undo` is **not** recorded: a
//! replay represents the canonical path the player ultimately took to win,
//! so backed-out missteps simply do not appear in the move list. The
//! starting deal is not stored either — the [`seed`](Replay::seed) +
//! The recording is intentionally minimal — only the
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
//! successfully advanced the game. `Undo` is **not** recorded: a replay
//! represents the canonical path the player ultimately took to win, so
//! backed-out missteps simply do not appear in the move list. The starting
//! deal is not stored either — the [`seed`](Replay::seed) +
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
//! for `GameState::new_with_mode` to rebuild the identical layout.
//!
//! Each recorded move is the player's atomic *input*, not its outcome.
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
//! the engine resolves draw-vs-recycle deterministically from the current
//! stock state during playback, so the same input always produces the same
//! effect on the same starting deal. Runtime-only pile-position types are
//! never serialised — the instruction itself serialises via its compact
//! upstream serde representation.
use std::fs;
use std::io;
@@ -26,8 +35,7 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 {
/// seeing a broken one.
///
/// History:
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
/// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input.
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
/// variant. The engine resolves draw-vs-recycle deterministically from
/// the current stock state, so the input alone is sufficient and the
/// replay model now stores atomic player inputs end-to-end.
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
/// are now stored directly as upstream
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
/// and are discarded by the loader.
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
/// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
@@ -81,32 +92,6 @@ fn schema_v0() -> u32 {
0
}
/// One atomic player input recorded during a winning game, in the order
/// it was applied to the live `GameState`.
///
/// `Undo` is intentionally absent — see the module-level docs.
///
/// The variants represent *inputs*, not outcomes. `StockClick` covers
/// every player click on the stock pile; the engine then resolves
/// draw-vs-recycle deterministically from the current state during both
/// recording and playback, so the same input always produces the same
/// effect on the same starting deal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call.
Move {
/// Source pile.
from: SavedKlondikePile,
/// Destination pile.
to: SavedKlondikePile,
/// Number of cards moved.
count: usize,
},
/// A click on the stock pile. Resolves to a draw when stock is
/// non-empty and to a waste→stock recycle when stock is empty.
StockClick,
}
/// A complete recording of a single winning game.
///
/// Replays are reconstructed by rebuilding a fresh
@@ -124,7 +109,7 @@ pub struct Replay {
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
pub seed: u64,
/// Draw mode the recorded game was played in.
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
/// Game mode the recorded game was played in.
pub mode: GameMode,
/// Total wall-clock seconds the win took. Used for the Stats UI
@@ -134,9 +119,11 @@ pub struct Replay {
pub final_score: i32,
/// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate,
/// Ordered move list. Each entry is what the player did, replayable
/// against a fresh `GameState` constructed from the seed.
pub moves: Vec<ReplayMove>,
/// Ordered move list. Each entry is the atomic
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
/// issued, replayable against a fresh `GameState` constructed from the
/// seed via `GameState::apply_instruction`.
pub moves: Vec<KlondikeInstruction>,
/// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only
@@ -180,12 +167,12 @@ impl Replay {
/// latter directly when the upload task resolves.
pub fn new(
seed: u64,
draw_mode: DrawMode,
draw_mode: DrawStockConfig,
mode: GameMode,
time_seconds: u64,
final_score: i32,
recorded_at: NaiveDate,
moves: Vec<ReplayMove>,
moves: Vec<KlondikeInstruction>,
) -> Self {
Self {
schema_version: REPLAY_SCHEMA_VERSION,
@@ -442,7 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
#[allow(deprecated)]
mod tests {
use super::*;
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
use klondike::{
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
};
use std::env;
fn tmp_path(name: &str) -> PathBuf {
@@ -453,24 +442,22 @@ mod tests {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
12345,
DrawMode::DrawThree,
DrawStockConfig::DrawThree,
GameMode::Classic,
134,
5_120,
date,
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1,
},
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Tableau(SavedTableau(3)),
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
count: 1,
},
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: Tableau::Tableau4,
}),
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(Tableau::Tableau4),
foundation: Foundation::Foundation1,
}),
],
)
}
@@ -596,12 +583,12 @@ mod tests {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
id as u64,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
60,
id,
date,
vec![ReplayMove::StockClick],
vec![KlondikeInstruction::RotateStock],
)
}
@@ -837,9 +824,11 @@ mod tests {
let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path);
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
let v2_no_field = r#"{
"schema_version": 2,
// Hand-rolled minimal current-schema replay JSON with no
// win_move_index field — the additive field must still default to None.
let no_field = format!(
r#"{{
"schema_version": {schema},
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
@@ -847,8 +836,10 @@ mod tests {
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}"#;
fs::write(&path, v2_no_field).expect("write fixture");
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
fs::write(&path, no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None);
+5 -5
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::{DrawMode, game_state::DifficultyLevel};
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -101,7 +101,7 @@ pub struct WindowGeometry {
pub struct Settings {
/// Draw mode selected for new games.
#[serde(default = "default_draw_mode")]
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
#[serde(default = "default_sfx_volume")]
pub sfx_volume: f32,
@@ -288,8 +288,8 @@ pub struct Settings {
pub touch_input_mode: TouchInputMode,
}
fn default_draw_mode() -> DrawMode {
DrawMode::DrawOne
fn default_draw_mode() -> DrawStockConfig {
DrawStockConfig::DrawOne
}
fn default_sfx_volume() -> f32 {
@@ -392,7 +392,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
impl Default for Settings {
fn default() -> Self {
Self {
draw_mode: DrawMode::DrawOne,
draw_mode: DrawStockConfig::DrawOne,
sfx_volume: default_sfx_volume(),
music_volume: default_music_volume(),
animation_speed: AnimSpeed::Normal,
+26 -26
View File
@@ -2,10 +2,10 @@
//!
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
//! This module adds the [`StatsExt`] extension trait, which supplies the
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
//! `update_on_win` method that depends on [`DrawStockConfig`] from `solitaire_core`.
use chrono::Utc;
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
pub use solitaire_sync::StatsSnapshot;
@@ -18,9 +18,9 @@ pub trait StatsExt {
///
/// Tracks lifetime totals only — per-mode best scores and times are
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
/// long-standing call sites that only know about [`DrawMode`] keep
/// long-standing call sites that only know about [`DrawStockConfig`] keep
/// compiling.
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig);
/// Updates the per-mode best score and fastest-win-time fields for the
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
@@ -37,7 +37,7 @@ pub trait StatsExt {
}
impl StatsExt for StatsSnapshot {
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig) {
let prev_wins = self.games_won;
self.games_played += 1;
self.games_won += 1;
@@ -64,8 +64,8 @@ impl StatsExt for StatsSnapshot {
};
match draw_mode {
DrawMode::DrawOne => self.draw_one_wins += 1,
DrawMode::DrawThree => self.draw_three_wins += 1,
DrawStockConfig::DrawOne => self.draw_one_wins += 1,
DrawStockConfig::DrawThree => self.draw_three_wins += 1,
}
self.last_modified = Utc::now();
@@ -135,7 +135,7 @@ mod tests {
#[test]
fn first_win_sets_all_fields() {
let mut s = StatsSnapshot::default();
s.update_on_win(1500, 120, &DrawMode::DrawOne);
s.update_on_win(1500, 120, &DrawStockConfig::DrawOne);
assert_eq!(s.games_played, 1);
assert_eq!(s.games_won, 1);
assert_eq!(s.win_streak_current, 1);
@@ -152,7 +152,7 @@ mod tests {
fn streak_tracks_across_wins() {
let mut s = StatsSnapshot::default();
for _ in 0..3 {
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
}
assert_eq!(s.win_streak_current, 3);
assert_eq!(s.win_streak_best, 3);
@@ -161,8 +161,8 @@ mod tests {
#[test]
fn record_abandoned_resets_streak_and_increments_played() {
let mut s = StatsSnapshot::default();
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.win_streak_current, 2);
s.record_abandoned();
assert_eq!(s.games_played, 3);
@@ -174,35 +174,35 @@ mod tests {
#[test]
fn fastest_win_takes_minimum() {
let mut s = StatsSnapshot::default();
s.update_on_win(100, 300, &DrawMode::DrawOne);
s.update_on_win(100, 120, &DrawMode::DrawOne);
s.update_on_win(100, 500, &DrawMode::DrawOne);
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
s.update_on_win(100, 120, &DrawStockConfig::DrawOne);
s.update_on_win(100, 500, &DrawStockConfig::DrawOne);
assert_eq!(s.fastest_win_seconds, 120);
}
#[test]
fn avg_time_is_correct_rolling_average() {
let mut s = StatsSnapshot::default();
s.update_on_win(100, 100, &DrawMode::DrawOne);
s.update_on_win(100, 200, &DrawMode::DrawOne);
s.update_on_win(100, 300, &DrawMode::DrawOne);
s.update_on_win(100, 100, &DrawStockConfig::DrawOne);
s.update_on_win(100, 200, &DrawStockConfig::DrawOne);
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
assert_eq!(s.avg_time_seconds, 200);
}
#[test]
fn best_score_updates_only_on_higher_score() {
let mut s = StatsSnapshot::default();
s.update_on_win(500, 60, &DrawMode::DrawOne);
s.update_on_win(300, 60, &DrawMode::DrawOne);
s.update_on_win(500, 60, &DrawStockConfig::DrawOne);
s.update_on_win(300, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.best_single_score, 500);
s.update_on_win(800, 60, &DrawMode::DrawOne);
s.update_on_win(800, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.best_single_score, 800);
}
#[test]
fn negative_score_treated_as_zero() {
let mut s = StatsSnapshot::default();
s.update_on_win(-50, 60, &DrawMode::DrawOne);
s.update_on_win(-50, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.best_single_score, 0);
assert_eq!(s.lifetime_score, 0);
}
@@ -210,8 +210,8 @@ mod tests {
#[test]
fn draw_three_wins_tracked_separately() {
let mut s = StatsSnapshot::default();
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawMode::DrawThree);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawThree);
assert_eq!(s.draw_one_wins, 1);
assert_eq!(s.draw_three_wins, 1);
}
@@ -221,7 +221,7 @@ mod tests {
let mut s = StatsSnapshot::default();
// Build a streak of 5.
for _ in 0..5 {
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
}
assert_eq!(s.win_streak_best, 5);
// Lose (abandon), resetting current.
@@ -229,7 +229,7 @@ mod tests {
assert_eq!(s.win_streak_current, 0);
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
// Win once — current becomes 1, best must remain 5.
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.win_streak_current, 1);
assert_eq!(
s.win_streak_best, 5,
@@ -243,7 +243,7 @@ mod tests {
lifetime_score: u64::MAX - 100,
..Default::default()
};
s.update_on_win(200, 60, &DrawMode::DrawOne);
s.update_on_win(200, 60, &DrawStockConfig::DrawOne);
assert_eq!(
s.lifetime_score,
u64::MAX,
+18 -27
View File
@@ -279,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
mod tests {
use super::*;
use crate::stats::{StatsExt, StatsSnapshot};
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
@@ -292,7 +292,7 @@ mod tests {
let _ = fs::remove_file(&path);
let mut stats = StatsSnapshot::default();
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne);
save_stats_to(&path, &stats).expect("save");
let loaded = load_stats_from(&path);
@@ -381,7 +381,7 @@ mod tests {
let path = gs_path("round_trip");
let _ = fs::remove_file(&path);
let gs = GameState::new(12345, DrawMode::DrawOne);
let gs = GameState::new(12345, DrawStockConfig::DrawOne);
save_game_state_to(&path, &gs).expect("save");
let loaded = load_game_state_from(&path).expect("load");
@@ -410,7 +410,7 @@ mod tests {
let path = gs_path("won_skip");
let _ = fs::remove_file(&path);
let mut gs = GameState::new(99, DrawMode::DrawOne);
let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
gs.set_test_won(true);
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!(
@@ -423,7 +423,7 @@ mod tests {
fn delete_game_state_removes_file() {
use solitaire_core::game_state::GameState;
let path = gs_path("delete");
let gs = GameState::new(1, DrawMode::DrawOne);
let gs = GameState::new(1, DrawStockConfig::DrawOne);
save_game_state_to(&path, &gs).expect("save");
assert!(path.exists());
delete_game_state_at(&path).expect("delete");
@@ -441,7 +441,7 @@ mod tests {
fn save_game_state_is_atomic() {
use solitaire_core::game_state::GameState;
let path = gs_path("atomic");
let gs = GameState::new(55, DrawMode::DrawThree);
let gs = GameState::new(55, DrawStockConfig::DrawThree);
save_game_state_to(&path, &gs).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
@@ -512,7 +512,7 @@ mod tests {
let path = gs_path("v4_mid_game");
let _ = fs::remove_file(&path);
let mut gs = GameState::new(42, DrawMode::DrawOne);
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
// Draw several times to populate the instruction history with
// RotateStock entries and expose waste cards for further moves.
@@ -583,24 +583,16 @@ mod tests {
);
}
/// A schema v3 save (instruction history using u8 indices) must load
/// successfully and be transparently migrated to schema v4.
///
/// This verifies the `AnyInstruction` untagged deserialization migration
/// path. v3 files with `RotateStock` (unit variant, format-identical in
/// v3 and v4) load correctly and report `schema_version == 4` after load.
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
/// A schema v3 save (instruction history using the old u8-index mirror
/// types) is no longer loadable. The legacy migration path was dropped,
/// so any file claiming `schema_version: 3` must be rejected and the
/// player started on a fresh game.
#[test]
fn game_state_v3_migrates_to_v4() {
use solitaire_core::game_state::GameState;
let path = gs_path("v3_migrate");
fn game_state_v3_is_rejected() {
let path = gs_path("v3_reject");
let _ = fs::remove_file(&path);
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
// so this exercises the schema version acceptance code path.
let v3_json = r#"{
"draw_mode": "DrawOne",
"mode": "Classic",
@@ -615,13 +607,12 @@ mod tests {
}"#;
fs::write(&path, v3_json).expect("write v3 fixture");
let loaded = load_game_state_from(&path)
.expect("schema v3 must be accepted and migrated to v4");
assert!(
load_game_state_from(&path).is_none(),
"schema v3 must be rejected (no migration path)",
);
// The loaded game should match a fresh game that had one draw applied.
let mut expected = GameState::new(42, DrawMode::DrawOne);
expected.draw().expect("draw must succeed on a fresh game");
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
let _ = fs::remove_file(&path);
}
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
+5 -5
View File
@@ -4,7 +4,7 @@
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
use chrono::{Datelike, NaiveDate};
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
/// XP awarded each time a weekly goal is just completed.
pub const WEEKLY_GOAL_XP: u64 = 75;
@@ -36,7 +36,7 @@ pub struct WeeklyGoalDef {
pub struct WeeklyGoalContext {
pub time_seconds: u64,
pub used_undo: bool,
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
}
impl WeeklyGoalDef {
@@ -47,7 +47,7 @@ impl WeeklyGoalDef {
WeeklyGoalKind::WinGame => true,
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawStockConfig::DrawThree,
}
}
}
@@ -106,7 +106,7 @@ mod tests {
WeeklyGoalContext {
time_seconds: time,
used_undo: undo,
draw_mode: DrawMode::DrawOne,
draw_mode: DrawStockConfig::DrawOne,
}
}
@@ -114,7 +114,7 @@ mod tests {
WeeklyGoalContext {
time_seconds: time,
used_undo: false,
draw_mode: DrawMode::DrawThree,
draw_mode: DrawStockConfig::DrawThree,
}
}
+3
View File
@@ -52,3 +52,6 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
async-trait = { workspace = true }
tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] }
[lints]
workspace = true
+11 -11
View File
@@ -116,7 +116,7 @@ impl Plugin for AchievementPlugin {
// achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
.add_message::<TouchInput>()
// Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee).
@@ -671,7 +671,7 @@ mod tests {
.add_plugins(AchievementPlugin::headless());
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered.
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
@@ -819,7 +819,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
.set_test_draw_mode(DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
.set_test_draw_mode(DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -912,7 +912,7 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
@@ -946,7 +946,7 @@ mod tests {
// Default GameMode is Classic; assert and rely on it.
assert_eq!(
app.world().resource::<GameStateResource>().0.mode,
solitaire_core::game_state::GameMode::Classic
GameMode::Classic
);
app.world_mut().write_message(GameWonEvent {
@@ -1250,7 +1250,7 @@ mod tests {
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
.add_plugins(AchievementPlugin::headless());
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
@@ -1393,8 +1393,8 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate;
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_data::{Replay, ReplayMove};
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
use solitaire_data::Replay;
/// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
@@ -1409,12 +1409,12 @@ mod tests {
fn dummy_replay() -> Replay {
Replay::new(
1,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
vec![KlondikeInstruction::RotateStock],
)
}
+7 -26
View File
@@ -1,37 +1,19 @@
/// Android clipboard bridge via JNI.
///
/// Writes text to the system clipboard by calling into `ClipboardManager`
/// through the JNI. Only compiled and linked on `target_os = "android"`.
/// through the safe [`solitaire_data::android_jni`] bridge. Only compiled and
/// linked on `target_os = "android"`.
#[cfg(target_os = "android")]
pub fn set_text(text: &str) -> Result<(), String> {
use bevy::android::ANDROID_APP;
use jni::{
JavaVM,
objects::{JObject, JValueOwned},
};
use jni::objects::JValueOwned;
use solitaire_data::android_jni;
let app = ANDROID_APP
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
// valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<()> {
android_jni::with_activity_env(|env, activity| {
// ClipboardManager cm = activity.getSystemService("clipboard")
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
let cm = env
.call_method(
&activity,
activity,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[svc_name.borrow()],
@@ -60,6 +42,5 @@ pub fn set_text(text: &str) -> Result<(), String> {
&[clip_val.borrow()],
)?
.v()
})()
.map_err(|e| format!("clipboard JNI: {e}"))
})
}
+1 -1
View File
@@ -354,7 +354,7 @@ fn handle_win_cascade(
end: target.truncate(),
elapsed: 0.0,
duration,
curve: crate::card_animation::MotionCurve::Expressive,
curve: MotionCurve::Expressive,
delay: i as f32 * step,
start_z: start.z,
end_z: target.z,
+1 -1
View File
@@ -22,7 +22,7 @@
//! red/black colour split.
use bevy::math::UVec2;
use solitaire_core::card::{Rank, Suit};
use solitaire_core::{Rank, Suit};
/// Target rasterisation size in pixels (2:3 aspect, half the default
/// `SvgLoaderSettings` resolution).
+6
View File
@@ -115,6 +115,10 @@ macro_rules! embed_classic_svg {
}
/// Every Dark-theme SVG file bundled into the binary.
// The `as &[u8]` in `embed_dark_svg!` coerces each fixed-size
// `&[u8; N]` (N varies per file) to a uniform `&[u8]` so the tuples fit
// this array type. The cast is load-bearing, not trivial.
#[allow(trivial_casts)]
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
embed_dark_svg!("back.svg"),
embed_dark_svg!("clubs_ace.svg"),
@@ -172,6 +176,8 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
];
/// Every Classic-theme SVG file bundled into the binary.
// See `DARK_THEME_SVGS`: the `as &[u8]` cast is load-bearing.
#[allow(trivial_casts)]
const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
embed_classic_svg!("back.svg"),
embed_classic_svg!("clubs_ace.svg"),
+2 -2
View File
@@ -192,7 +192,7 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
use usvg::FontResolver;
usvg::FontResolver {
FontResolver {
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
select_fallback: FontResolver::default_fallback_selector(),
}
@@ -282,7 +282,7 @@ mod tests {
/// tightens.
#[test]
fn settings_satisfies_loader_bounds() {
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
fn assert_loader_settings<T: Default + Serialize + serde::de::DeserializeOwned>() {}
assert_loader_settings::<SvgLoaderSettings>();
}
}
+6 -6
View File
@@ -168,8 +168,8 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -177,13 +177,13 @@ mod tests {
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(AutoCompletePlugin);
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
let mut g = GameState::new(1, DrawMode::DrawOne);
let mut g = GameState::new(1, DrawStockConfig::DrawOne);
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
for foundation in [
@@ -207,7 +207,7 @@ mod tests {
}
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
g.set_test_auto_completable(true);
let expected = (
@@ -227,7 +227,7 @@ mod tests {
#[test]
fn detect_activates_when_auto_completable() {
let mut app = headless_app();
let mut g = GameState::new(42, DrawMode::DrawOne);
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
g.set_test_auto_completable(true);
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent);
+1 -1
View File
@@ -36,7 +36,7 @@ pub struct AvatarFetchEvent {
pub url: String,
}
impl bevy::prelude::Message for AvatarFetchEvent {}
impl Message for AvatarFetchEvent {}
/// In-flight avatar download task. Returns the raw image bytes on success,
/// or `None` on any network / decode error.
@@ -33,7 +33,7 @@ use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::card::Card;
use solitaire_core::Card;
use super::animation::CardAnimation;
use super::tuning::AnimationTuning;
@@ -73,7 +73,7 @@ pub struct HoverState {
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move {
from: crate::events::MoveRequestEvent,
from: MoveRequestEvent,
},
Draw,
Undo,
+87 -73
View File
@@ -17,8 +17,8 @@ use bevy::prelude::*;
use bevy::sprite::Anchor;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
@@ -63,6 +63,36 @@ pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
// foundation piles bleeding through when a 2 sits on an Ace.
pub const STACK_FAN_FRAC: f32 = 0.025;
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
///
/// Derived from the actual tableau column spacing (`Tableau2.x Tableau1.x`)
/// rather than a fixed fraction of card width, so the fan scales with the
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
/// land on the card beneath it.
pub fn waste_fan_step(layout: &Layout) -> f32 {
tableau_col_step(layout) * 0.224
}
/// Horizontal distance between adjacent tableau columns (`Tableau2.x
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
pub fn tableau_col_step(layout: &Layout) -> f32 {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
}
/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;
@@ -483,7 +513,7 @@ impl Plugin for CardPlugin {
update_stock_empty_indicator.after(GameMutation),
update_stock_count_badge
.after(GameMutation)
.run_if(resource_changed::<crate::GameStateResource>),
.run_if(resource_changed::<GameStateResource>),
collect_resize_events.after(LayoutSystem::UpdateOnResize),
snap_cards_on_window_resize.after(collect_resize_events),
),
@@ -789,8 +819,8 @@ fn sync_cards(
// and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<Card> = {
let visible = match game.draw_mode() {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
let waste_cards = game.waste_cards();
(waste_cards.len() > visible)
@@ -908,34 +938,18 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
(KlondikePile::Tableau(Tableau::Tableau7), false),
];
// Compute the Draw-Three waste fan step proportional to the column spacing
// (waste_x stock_x = card_width + h_gap) rather than a fixed fraction of
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
// the top fanned card's centre within the waste column's own horizontal
// footprint instead of spilling into the adjacent gap.
let tableau_col_step = {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
};
let waste_fan_step = tableau_col_step * 0.224;
// Draw-Three waste fan step, proportional to the column spacing so it scales
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
// click target on the card beneath it).
let waste_fan_step = waste_fan_step(layout);
for (pile_type, is_stock_area) in piles {
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
continue;
};
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
base.x -= tableau_col_step;
base.x -= tableau_col_step(layout);
}
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
@@ -958,8 +972,8 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
// shows up to 3 fanned in X (matching the standard Klondike presentation).
let render_start = if is_waste {
let visible = match game.draw_mode() {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
// Render one extra card so that the card sliding off the waste
// during a draw animation is still present in the world at z=0
@@ -972,7 +986,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
let mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len();
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode(), DrawMode::DrawThree) {
let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) {
// When len > visible, slot 0 is a hidden buffer card kept at
// x=0 to prevent a flash during the draw tween. When len ≤
// visible (small pile), every card is visible and should fan
@@ -2431,7 +2445,7 @@ fn update_tableau_fan_frac(
.into_iter()
.map(|tableau| {
game.0
.pile(solitaire_core::KlondikePile::Tableau(tableau))
.pile(KlondikePile::Tableau(tableau))
.into_iter()
.filter(|(_, face_up)| *face_up)
.count()
@@ -2472,7 +2486,7 @@ mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::card::Deck;
use solitaire_core::Deck;
/// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card {
@@ -2566,7 +2580,7 @@ mod tests {
#[test]
fn card_positions_includes_all_52_cards_at_game_start() {
// At game start waste is empty, so all 52 cards are across stock + tableau.
let g = GameState::new(42, solitaire_core::DrawMode::DrawOne);
let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52);
@@ -2574,13 +2588,13 @@ mod tests {
#[test]
fn waste_draw_one_only_renders_top_card() {
use solitaire_core::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawOne);
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
// Draw 3 cards so the waste pile has 3 cards.
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<Card> =
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3);
@@ -2612,8 +2626,8 @@ mod tests {
#[test]
fn waste_draw_three_renders_up_to_three_fanned_cards() {
use solitaire_core::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
// 5 draw() calls in Draw-Three mode accumulates multiple waste cards.
for _ in 0..5 {
let _ = g.draw();
@@ -2624,7 +2638,7 @@ mod tests {
"need at least 3 waste cards for this test"
);
let waste_ids: std::collections::HashSet<Card> =
let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -2666,8 +2680,8 @@ mod tests {
// Regression: slot.saturating_sub(1) always hid slot-0 even when the
// pile was too small to have a buffer card, collapsing 2 visible cards
// onto x=0 instead of fanning them.
use solitaire_core::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
// Draw exactly once — in Draw-Three mode with a full stock this gives
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
let _ = g.draw();
@@ -2677,7 +2691,7 @@ mod tests {
let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<Card> =
let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -2709,13 +2723,13 @@ mod tests {
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
#[test]
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
use solitaire_core::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawOne);
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<Card> =
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -2740,7 +2754,7 @@ mod tests {
#[test]
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::DrawMode::DrawOne);
let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -3083,7 +3097,7 @@ mod tests {
#[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::DrawMode::DrawOne);
let g = GameState::new(42, DrawStockConfig::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -3153,7 +3167,7 @@ mod tests {
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height.
let window = bevy::ecs::entity::Entity::from_raw_u32(0)
let window = Entity::from_raw_u32(0)
.expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized {
window,
@@ -3171,9 +3185,9 @@ mod tests {
// entity IDs must remain alive.
let mut app = app();
let labels_before: std::collections::HashSet<bevy::prelude::Entity> = app
let labels_before: HashSet<Entity> = app
.world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert!(
@@ -3184,9 +3198,9 @@ mod tests {
fire_window_resize(&mut app, 1024.0, 768.0);
advance_past_resize_throttle(&mut app);
let labels_after: std::collections::HashSet<bevy::prelude::Entity> = app
let labels_after: HashSet<Entity> = app
.world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
@@ -3336,9 +3350,9 @@ mod tests {
// Each shadow's parent must be a CardEntity, so the child relation
// is wired correctly.
let cards: HashSet<bevy::prelude::Entity> = app
let cards: HashSet<Entity> = app
.world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardEntity>>()
.query_filtered::<Entity, With<CardEntity>>()
.iter(app.world())
.collect();
let mut q = app
@@ -3423,7 +3437,7 @@ mod tests {
let card_entity = {
let mut q = app
.world_mut()
.query::<(bevy::prelude::Entity, &CardEntity)>();
.query::<(Entity, &CardEntity)>();
q.iter(app.world())
.find(|(_, c)| c.card == *card)
.map(|(e, _)| e)
@@ -3533,7 +3547,7 @@ mod tests {
#[test]
fn stock_card_count_helper_reads_zero_for_empty_stock() {
let g = GameState::new(42, solitaire_core::DrawMode::DrawOne);
let g = GameState::new(42, DrawStockConfig::DrawOne);
let mut g_empty_stock = g.clone();
g_empty_stock.set_test_stock_cards(Vec::new());
assert_eq!(stock_card_count(&g_empty_stock), 0);
@@ -3553,9 +3567,9 @@ mod tests {
// Allocate five different strong handles by passing each a
// distinct dummy `Image`. We never render these; we only
// compare ids.
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
let backs: [Handle<bevy::image::Image>; 5] =
std::array::from_fn(|_| images.add(bevy::image::Image::default()));
let mut images = Assets::<Image>::default();
let backs: [Handle<Image>; 5] =
std::array::from_fn(|_| images.add(Image::default()));
CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs,
@@ -3569,8 +3583,8 @@ mod tests {
// card must render with the theme's back regardless of which
// legacy back the player picked in Settings.
let mut set = image_set_with_distinct_back_handles();
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
let mut images = Assets::<Image>::default();
let theme_back: Handle<Image> = images.add(Image::default());
set.theme_back = Some(theme_back.clone());
let face_down = make_card(Suit::Spades, Rank::Ace);
@@ -3634,8 +3648,8 @@ mod tests {
use std::collections::HashMap;
let mut set = image_set_with_distinct_back_handles();
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
let mut images = Assets::<Image>::default();
let theme_back: Handle<Image> = images.add(Image::default());
let theme = CardTheme {
meta: ThemeMeta {
@@ -3645,7 +3659,7 @@ mod tests {
version: "0".into(),
card_aspect: (2, 3),
},
faces: HashMap::<CardKey, Handle<bevy::image::Image>>::new(),
faces: HashMap::<CardKey, Handle<Image>>::new(),
back: theme_back.clone(),
};
@@ -3805,15 +3819,15 @@ mod tests {
#[test]
fn waste_pile_cards_have_strictly_increasing_z() {
use solitaire_core::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
for _ in 0..5 {
let _ = g.draw();
}
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<Card> =
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
@@ -3849,8 +3863,8 @@ mod tests {
/// offsets or flips the fan direction is caught immediately.
#[test]
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
use solitaire_core::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
for _ in 0..5 {
let _ = g.draw();
}
@@ -3863,7 +3877,7 @@ mod tests {
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: std::collections::HashSet<Card> =
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
@@ -3885,15 +3899,15 @@ mod tests {
#[test]
fn waste_pile_draw_one_cards_have_distinct_z() {
use solitaire_core::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawOne);
use solitaire_core::DrawStockConfig;
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
for _ in 0..3 {
let _ = g.draw();
}
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<Card> =
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
+3 -3
View File
@@ -117,7 +117,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -135,7 +135,7 @@ mod tests {
fn challenge_win_advances_index() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -224,7 +224,7 @@ mod tests {
.0
.challenge_index = 2;
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
app.world_mut().write_message(GameWonEvent {
score: 500,
+9 -9
View File
@@ -34,9 +34,9 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Card;
use solitaire_core::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
Update,
(
update_cursor_icon,
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays,
),
);
@@ -437,7 +437,7 @@ fn tableau_or_stack_pos(
base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32;
@@ -563,9 +563,9 @@ mod tests {
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use crate::layout::compute_layout;
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(
@@ -580,8 +580,8 @@ mod tests {
// -----------------------------------------------------------------------
use crate::layout::compute_layout;
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
/// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers
@@ -629,7 +629,7 @@ mod tests {
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
// — same colour family, illegal. Tableau(2) must NOT be
// highlighted.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
@@ -362,7 +362,7 @@ mod tests {
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
#[allow(unused_imports)]
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -391,7 +391,7 @@ mod tests {
// Replace the GameState with one whose seed matches the daily seed.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed, DrawMode::DrawOne);
GameState::new(daily_seed, DrawStockConfig::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -419,7 +419,7 @@ mod tests {
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
// Use a deliberately different seed.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
GameState::new(daily_seed.wrapping_add(7777), DrawStockConfig::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -442,7 +442,7 @@ mod tests {
let mut app = headless_app();
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed, DrawMode::DrawOne);
GameState::new(daily_seed, DrawStockConfig::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
+1 -1
View File
@@ -2,7 +2,7 @@
use bevy::prelude::Message;
use solitaire_core::KlondikePile;
use solitaire_core::card::{Card, Suit};
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode;
use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
+15 -15
View File
@@ -43,7 +43,7 @@ use std::hash::{Hash, Hasher};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_core::card::Card;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed;
@@ -189,10 +189,6 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
}
// 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
// ---------------------------------------------------------------------------
@@ -413,10 +409,14 @@ fn start_deal_anim(
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
let final_pos = transform.translation;
// ±10 % jitter, deterministic per card id, so the deal feels organic
// without losing reproducibility (a given seed still produces the
// same per-card stagger pattern across runs).
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card)));
// ±10 % jitter, deterministic per card, so the deal feels organic
// without losing reproducibility (a given deal produces the same
// per-card stagger pattern across runs). The seed is a hash of the
// card's own identity — no separate numeric id needed.
let mut card_hasher = DefaultHasher::new();
card_marker.card.hash(&mut card_hasher);
let per_card_stagger =
stagger_secs * (1.0 + deal_stagger_jitter(card_hasher.finish() as u32));
commands.entity(entity).insert((
Transform::from_translation(stock_start.with_z(final_pos.z)),
CardAnim {
@@ -639,7 +639,7 @@ 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)> {
) -> Vec<(Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
@@ -846,13 +846,13 @@ mod tests {
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::Tableau;
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
@@ -900,13 +900,13 @@ mod tests {
#[test]
fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
@@ -917,7 +917,7 @@ mod tests {
.resource_mut::<Messages<FoundationCompletedEvent>>()
.write(FoundationCompletedEvent {
slot: 0,
suit: solitaire_core::card::Suit::Spades,
suit: solitaire_core::Suit::Spades,
});
app.update();
+3 -2
View File
@@ -2,8 +2,9 @@
//!
//! Bundling rather than runtime-loading guarantees the canonical UI face is
//! always available regardless of install or platform. The bytes are
//! validated at startup; a parse failure aborts the program with a clear
//! error because it means the binary is corrupt.
//! validated at startup; a parse failure logs a warning and continues with
//! glyph-less UI rather than aborting, since crashing on a corrupt embed is
//! worse than degraded text.
use bevy::prelude::*;
+68 -67
View File
@@ -14,12 +14,12 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile;
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use solitaire_data::{
Replay, ReplayMove, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at,
Replay, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at,
game_state_file_path, load_game_state_from, migrate_legacy_latest_replay, replay_history_path,
save_game_state_to,
};
@@ -105,18 +105,21 @@ pub struct RestoreContinueButton;
#[derive(Component, Debug)]
pub struct RestoreNewGameButton;
/// In-memory accumulator for [`ReplayMove`] entries during the current
/// game. Cleared on every new-game start; frozen into a [`Replay`] and
/// flushed to disk by [`record_replay_on_win`] when the player wins.
/// In-memory accumulator for [`KlondikeInstruction`] entries during the
/// current game. Cleared on every new-game start; frozen into a [`Replay`]
/// and flushed to disk by [`record_replay_on_win`] when the player wins.
///
/// Recording captures only successful state-mutating events the player
/// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is
/// intentionally not recorded — see [`solitaire_data::replay`] for the
/// design rationale.
/// design rationale. Each entry is the atomic player input as a
/// [`KlondikeInstruction`] (a stock click is
/// [`KlondikeInstruction::RotateStock`]); pile-position types are
/// runtime-only and never persisted.
#[derive(Resource, Debug, Default, Clone)]
pub struct RecordingReplay {
/// Ordered list of moves applied so far this game.
pub moves: Vec<ReplayMove>,
/// Ordered list of instructions applied so far this game.
pub moves: Vec<KlondikeInstruction>,
}
impl RecordingReplay {
@@ -157,12 +160,12 @@ impl Plugin for GamePlugin {
.is_some_and(|g| g.move_count() > 0 && !g.is_won());
let (initial_state, pending_restore) = if prompt_worthy {
(
GameState::new(seed_from_system_time(), DrawMode::DrawOne),
GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne),
saved,
)
} else {
(
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne)),
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
None,
)
};
@@ -198,7 +201,7 @@ impl Plugin for GamePlugin {
.add_message::<StateChangedEvent>()
.add_message::<crate::events::MoveRejectedEvent>()
.add_message::<GameWonEvent>()
.add_message::<crate::events::CardFlippedEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>()
@@ -388,7 +391,7 @@ fn poll_pending_new_game_seed(
/// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawStockConfig) -> u64 {
let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP {
match GameState::solve_fresh_deal(
@@ -527,7 +530,7 @@ fn handle_new_game(
// hides that information and reads naturally as "dealt from the
// deck." Skipped when LayoutResource isn't present (headless tests).
if let Some(layout) = layout.as_ref()
&& let Some(stock) = layout.0.pile_positions.get(&solitaire_core::KlondikePile::Stock)
&& let Some(stock) = layout.0.pile_positions.get(&KlondikePile::Stock)
{
for mut tx in &mut card_transforms {
tx.translation.x = stock.x;
@@ -824,14 +827,14 @@ fn handle_draw(
// so we can fire flip events after they land face-up in the waste.
// Only relevant when stock is non-empty; a recycle moves waste back to
// stock face-down, so no flip events are needed in that case.
let drawn_cards: Vec<solitaire_core::card::Card> = {
let drawn_cards: Vec<solitaire_core::Card> = {
let stock = game.0.stock_cards();
if stock.is_empty() {
Vec::new()
} else {
let draw_count = match game.0.draw_mode() {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
let n = stock.len();
let take = n.min(draw_count);
@@ -851,7 +854,7 @@ fn handle_draw(
// the click happens — re-executing on the same starting
// deal produces the same effect, so the input alone is
// sufficient to recover the move on playback.
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(KlondikeInstruction::RotateStock);
changed.write(StateChangedEvent);
}
Err(e) => warn!("draw rejected: {e}"),
@@ -865,7 +868,7 @@ fn handle_move(
mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>,
mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
mut flipped: MessageWriter<CardFlippedEvent>,
mut foundation_done: MessageWriter<FoundationCompletedEvent>,
mut recording: ResMut<RecordingReplay>,
path: Option<Res<GameStatePath>>,
@@ -889,18 +892,24 @@ fn handle_move(
// Record the move in the in-flight replay buffer. Done
// first so the entry is captured even if a subsequent
// event-write or pile-lookup happens to bail out below.
recording.moves.push(ReplayMove::Move {
from: ev.from.into(),
to: ev.to.into(),
count: ev.count,
});
// `move_cards` resolved the pile coordinates to a
// `KlondikeInstruction` and pushed it onto the session
// history; recover that exact instruction from the tail
// (no clone — the instruction is `Copy`). Pile-position
// types are runtime-only, so we persist the instruction
// rather than the (from, to, count) triple.
if let Some(instruction) =
game.0.session().history().last().map(|s| *s.instruction())
{
recording.moves.push(instruction);
}
// Fire flip event if the candidate card is now face-up.
if let Some(fcard) = flip_candidate
&& pile_cards(&game.0, &ev.from)
.last()
.is_some_and(|c| c.0 == fcard && c.1)
{
flipped.write(crate::events::CardFlippedEvent(fcard));
flipped.write(CardFlippedEvent(fcard));
}
// If this move landed on a foundation pile and that pile is
// now complete (Ace → King, 13 cards), fire the per-suit
@@ -1013,7 +1022,7 @@ pub fn record_replay_on_win(
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::card::Card, bool)> {
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
@@ -1301,7 +1310,6 @@ fn save_game_state_on_exit(
mod tests {
use super::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
/// Build a minimal headless `App` with just `GamePlugin` installed.
/// Disables persistence and overrides the seed so tests are deterministic
@@ -1324,7 +1332,7 @@ mod tests {
app.insert_resource(PendingRestoredGame(None));
// Override the system-time seed with a known value.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(seed, DrawMode::DrawOne);
GameState::new(seed, DrawStockConfig::DrawOne);
app
}
@@ -1391,7 +1399,7 @@ mod tests {
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<solitaire_core::card::Card> = app
let before: Vec<solitaire_core::Card> = app
.world()
.resource::<GameStateResource>()
.0
@@ -1407,7 +1415,7 @@ mod tests {
});
app.update();
let after: Vec<solitaire_core::card::Card> = app
let after: Vec<solitaire_core::Card> = app
.world()
.resource::<GameStateResource>()
.0
@@ -1522,7 +1530,7 @@ mod tests {
// Persistence tests
// -----------------------------------------------------------------------
fn tmp_gs_path(name: &str) -> std::path::PathBuf {
fn tmp_gs_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("engine_test_gs_{name}.json"))
}
@@ -1540,7 +1548,7 @@ mod tests {
app.insert_resource(GameStatePath(Some(path.clone())));
// Override the seed so we can verify it was written.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(7654, DrawMode::DrawOne);
GameState::new(7654, DrawStockConfig::DrawOne);
app.world_mut().write_message(AppExit::Success);
app.update();
@@ -1559,7 +1567,7 @@ mod tests {
let path = tmp_gs_path("new_game_delete");
// Pre-create a saved file.
save_game_state_to(&path, &GameState::new(1, DrawMode::DrawOne)).unwrap();
save_game_state_to(&path, &GameState::new(1, DrawStockConfig::DrawOne)).unwrap();
assert!(path.exists());
let mut app = test_app(1);
@@ -1649,7 +1657,7 @@ mod tests {
#[test]
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let mut app = test_app(1);
// Build a tableau with two face-up cards.
{
@@ -1676,7 +1684,7 @@ mod tests {
let events = app
.world()
.resource::<Messages<crate::events::CardFlippedEvent>>();
.resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert!(
@@ -1693,7 +1701,7 @@ mod tests {
fn has_legal_moves_returns_true_for_fresh_game() {
// A fresh deal always has a non-empty stock (24 cards), so drawing
// is always a legal move regardless of the initial face-up tableau cards.
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
assert!(
has_legal_moves(&game),
"fresh deal must contain at least one legal move"
@@ -1706,8 +1714,8 @@ mod tests {
// Klondike (unlimited recycles), even if the drawn card cannot be
// immediately placed. The game is only stuck when both stock AND waste
// are exhausted and no visible card can be moved.
use solitaire_core::card::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
@@ -1742,8 +1750,8 @@ mod tests {
#[test]
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
use solitaire_core::card::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Empty stock and waste so draw is NOT available.
game.set_test_stock_cards(Vec::new());
@@ -1786,8 +1794,8 @@ mod tests {
// If the only legal move involves a face-up card that is NOT the top
// card of its column the previous code would return false (softlock)
// even though the player can still move that run.
use solitaire_core::card::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
game.set_test_stock_cards(Vec::new());
game.set_test_waste_cards(Vec::new());
@@ -1976,7 +1984,7 @@ mod tests {
/// to have been a King.
#[test]
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let mut app = test_app(1);
// Reset the world: clear stock + waste so a draw isn't possible,
@@ -2102,7 +2110,7 @@ mod tests {
1,
"only the draw is recorded; the undo does not erase it nor add a new entry",
);
assert!(matches!(recording.moves[0], ReplayMove::StockClick));
assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
}
/// Starting a new game wipes the recording so the next deal begins
@@ -2154,16 +2162,16 @@ mod tests {
let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone())));
// Push two recorded moves manually so we can verify they survive
// the freeze/save round-trip without having to drive a real win.
// Push two recorded instructions manually so we can verify they
// survive the freeze/save round-trip without having to drive a
// real win. Both are `RotateStock` — the only instruction
// constructible without the runtime-only `klondike` pile-stack
// types (which the engine intentionally does not depend on); the
// round-trip shape is identical for any instruction variant.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(2)),
count: 1,
});
recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(KlondikeInstruction::RotateStock);
}
// Fire the win event the engine emits when the last foundation
@@ -2185,7 +2193,7 @@ mod tests {
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
assert_eq!(
loaded.draw_mode,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
"draw_mode must be captured"
);
assert_eq!(
@@ -2197,15 +2205,8 @@ mod tests {
"time_seconds must come from the win event"
);
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
assert!(matches!(loaded.moves[0], ReplayMove::StockClick));
match &loaded.moves[1] {
ReplayMove::Move { from, to, count } => {
assert_eq!(*from, SavedKlondikePile::Stock);
assert_eq!(*to, SavedKlondikePile::Tableau(SavedTableau(2)));
assert_eq!(*count, 1);
}
other => panic!("second entry must be a Move, got {other:?}"),
}
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
@@ -2229,7 +2230,7 @@ mod tests {
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(KlondikeInstruction::RotateStock);
}
app.world_mut().write_message(GameWonEvent {
score: 100,
@@ -2241,8 +2242,8 @@ mod tests {
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(KlondikeInstruction::RotateStock);
}
app.world_mut().write_message(GameWonEvent {
score: 200,
@@ -2326,7 +2327,7 @@ mod tests {
"with solver toggle off, the requested seed must be honoured exactly"
);
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
let expected = GameState::new(999, DrawMode::DrawOne);
let expected = GameState::new(999, DrawStockConfig::DrawOne);
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
@@ -2403,7 +2404,7 @@ mod tests {
//
// Seed 394 was previously Unwinnable under the old DFS; now it resolves
// as Inconclusive, so the helper must accept it immediately.
let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
let chosen = choose_winnable_seed(394, DrawStockConfig::DrawOne);
assert_eq!(
chosen, 394,
"seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is"
+1 -1
View File
@@ -51,7 +51,7 @@ impl Plugin for HelpPlugin {
// plugin under `DefaultPlugins`; register them explicitly so
// scroll systems run cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
.add_message::<TouchInput>()
.add_systems(
Update,
(
+7 -7
View File
@@ -16,7 +16,7 @@
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_core::{DrawMode, game_state::DifficultyLevel};
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
@@ -432,7 +432,7 @@ fn build_home_context<'a>(
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
@@ -620,9 +620,9 @@ fn handle_home_draw_mode_buttons(
return;
};
let target = if want_one {
DrawMode::DrawOne
DrawStockConfig::DrawOne
} else {
DrawMode::DrawThree
DrawStockConfig::DrawThree
};
if settings.0.draw_mode == target {
return; // already in this mode — avoid a redundant respawn.
@@ -857,7 +857,7 @@ struct HomeContext<'a> {
challenge_best: u32,
daily_streak: u32,
daily_today: Option<DailyToday>,
draw_mode: DrawMode,
draw_mode: DrawStockConfig,
font_res: Option<&'a FontResource>,
/// Whether the difficulty section header is currently expanded.
difficulty_expanded: bool,
@@ -1038,7 +1038,7 @@ fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>)
..default()
};
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne);
parent
.spawn(Node {
@@ -1878,7 +1878,7 @@ mod tests {
let states: Vec<(HomeMode, bool)> = app
.world_mut()
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
.query::<(&HomeModeCard, Has<Disabled>)>()
.iter(app.world())
.map(|(c, d)| (c.0, d))
.collect();
+26 -25
View File
@@ -9,8 +9,8 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit;
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use crate::auto_complete_plugin::AutoCompleteState;
#[cfg(not(target_arch = "wasm32"))]
@@ -36,7 +36,6 @@ use crate::game_plugin::GameMutation;
use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT;
use crate::layout::LayoutSystem;
#[cfg(target_os = "android")]
use crate::pause_plugin::PausedResource;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
use crate::progress_plugin::ProgressResource;
@@ -174,7 +173,7 @@ pub enum HudVisibility {
#[cfg(target_os = "android")]
#[derive(Resource, Debug, Default)]
struct HudTapTracker {
start_pos: Option<bevy::math::Vec2>,
start_pos: Option<Vec2>,
/// Set `true` when the finger-down hit an action button so the
/// finger-up never toggles bar visibility.
started_on_button: bool,
@@ -529,7 +528,7 @@ impl Plugin for HudPlugin {
#[cfg(target_os = "android")]
{
app.init_resource::<HudTapTracker>()
.add_message::<bevy::input::touch::TouchInput>()
.add_message::<TouchInput>()
.add_systems(
Update,
toggle_hud_on_tap
@@ -1140,7 +1139,7 @@ fn handle_help_button(
fn handle_hint_button(
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
paused: Option<Res<crate::PausedResource>>,
paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>,
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
@@ -2284,8 +2283,8 @@ fn update_hud(
if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode {
GameMode::Classic => match g.draw_mode() {
DrawMode::DrawOne => String::new(),
DrawMode::DrawThree => "Draw 3".to_string(),
DrawStockConfig::DrawOne => String::new(),
DrawStockConfig::DrawThree => "Draw 3".to_string(),
},
GameMode::Zen => "ZEN".to_string(),
GameMode::Challenge => "CHALLENGE".to_string(),
@@ -2334,7 +2333,7 @@ fn update_hud(
// --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won() || g.draw_mode() != DrawMode::DrawThree {
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
// Hide when not in Draw-Three or after the game is won.
String::new()
} else {
@@ -2658,8 +2657,9 @@ fn resize_action_bar_labels(
}
#[cfg(target_os = "android")]
#[allow(clippy::too_many_arguments)]
fn toggle_hud_on_tap(
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
mut touch_events: MessageReader<TouchInput>,
drag: Res<DragState>,
scrims: Query<(), With<ModalScrim>>,
paused: Option<Res<PausedResource>>,
@@ -2697,13 +2697,14 @@ fn toggle_hud_on_tap(
// regardless of whether we toggle.
let on_button = tracker.started_on_button || game_consumed.0;
game_consumed.0 = false;
if let Some(start) = tracker.start_pos.take() {
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX {
*hud_vis = match *hud_vis {
HudVisibility::Visible => HudVisibility::Hidden,
HudVisibility::Hidden => HudVisibility::Visible,
};
}
if let Some(start) = tracker.start_pos.take()
&& !on_button
&& (event.position - start).length() < HUD_TAP_SLOP_PX
{
*hud_vis = match *hud_vis {
HudVisibility::Visible => HudVisibility::Hidden,
HudVisibility::Hidden => HudVisibility::Visible,
};
}
tracker.started_on_button = false;
}
@@ -2726,7 +2727,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use chrono::Local;
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -2747,7 +2748,7 @@ mod tests {
fn update_hud_runs_after_game_mutation_without_panic() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawMode::DrawOne);
GameState::new(42, DrawStockConfig::DrawOne);
app.update();
}
@@ -2784,7 +2785,7 @@ mod tests {
use solitaire_core::game_state::GameMode;
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawMode::DrawThree, GameMode::Classic);
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
app.update();
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
}
@@ -2794,7 +2795,7 @@ mod tests {
use solitaire_core::game_state::GameMode;
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
app.update();
// Zen mode spec: "No score display" → text must be empty.
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
@@ -3037,7 +3038,7 @@ mod tests {
let mut app = headless_app();
// Draw-One, no recycles yet — text must be empty.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawMode::DrawOne);
GameState::new(42, DrawStockConfig::DrawOne);
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
}
@@ -3047,7 +3048,7 @@ mod tests {
let mut app = headless_app();
// Draw-Three, no recycles yet — text must also be empty.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawMode::DrawThree);
GameState::new(42, DrawStockConfig::DrawThree);
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
}
@@ -3055,7 +3056,7 @@ mod tests {
#[test]
fn recycles_hud_shows_count_draw_three() {
let mut app = headless_app();
let mut gs = GameState::new(42, DrawMode::DrawThree);
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
gs.force_test_recycles(3);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
@@ -3066,7 +3067,7 @@ mod tests {
fn recycles_hud_shows_count_draw_one() {
let mut app = headless_app();
// Draw-One with recycle_count > 0 must now show the counter too.
let mut gs = GameState::new(42, DrawMode::DrawOne);
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
gs.force_test_recycles(2);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
+143 -56
View File
@@ -10,7 +10,8 @@
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
//!
//! Mouse:
//! - Left-click on the stock pile (face-down deck) or waste slot → `DrawRequestEvent`
//! - Left-click on the stock pile (face-down deck) → `DrawRequestEvent`
//! (the waste card is left free to play: double-click to auto-move, or drag)
//! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between
//! the origin pile and whatever pile the cursor is over at release.
//! On rejection, the drag cards snap back to their origin via a
@@ -27,14 +28,14 @@ use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::card::{Card, Suit};
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC,
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, waste_fan_step,
};
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
@@ -54,7 +55,7 @@ use crate::settings_plugin::SettingsResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::touch_selection_plugin::TouchSelectionState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
/// System-set labels used to anchor external systems relative to the touch
/// drag pipeline without duplicating the internal chain ordering.
@@ -536,8 +537,9 @@ fn handle_stock_click(
// `pile_positions[Stock]` is the waste column (col_x(1)). card_plugin renders the
// face-down deck one column to the left via `base.x -= tableau_col_step`, placing it
// at Tableau1's x (col_x(0)). Hit-test both the deck AND the waste slot: in standard
// Klondike UX clicking either card draws from the deck.
// at Tableau1's x (col_x(0)). Only the deck draws — clicking the waste card must
// leave it free to be played (double-click to auto-move, or drag); hit-testing the
// waste slot here would intercept that click and draw the next card instead.
let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
return;
};
@@ -549,9 +551,7 @@ fn handle_stock_click(
return;
};
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
if point_in_rect(world, deck_pos, layout.0.card_size)
|| point_in_rect(world, waste_pos, layout.0.card_size)
{
if point_in_rect(world, deck_pos, layout.0.card_size) {
draw.write(DrawRequestEvent);
}
}
@@ -596,9 +596,9 @@ fn handle_touch_stock_tap(
continue;
};
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
if point_in_rect(world, deck_pos, layout.0.card_size)
|| point_in_rect(world, waste_pos, layout.0.card_size)
{
// Only the face-down deck draws; tapping the waste card leaves it free to
// play (double-tap to auto-move, or drag).
if point_in_rect(world, deck_pos, layout.0.card_size) {
draw.write(DrawRequestEvent);
game_consumed.0 = true;
break; // one draw per tap frame
@@ -1173,14 +1173,17 @@ fn card_position(
y_offset -= layout.card_size.y * step;
}
Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
// In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing must use the same offsets
// so clicking the visually rightmost (top) card actually registers.
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers — a
// fixed `card_size.x * 0.28` matched the renderer on desktop but drifted
// on Android (tighter column spacing), shifting the top card's hit target
// onto the card beneath it.
let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3);
let slot = stack_index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
Vec2::new(base.x + slot * waste_fan_step(layout), base.y)
} else {
base
}
@@ -1829,8 +1832,8 @@ const _VEC3_REFERENCED: Option<Vec3> = None;
mod tests {
use super::*;
use crate::layout::compute_layout;
use solitaire_core::{Foundation, Tableau};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn clear_test_piles(game: &mut GameState) {
game.set_test_stock_cards(Vec::new());
@@ -1898,7 +1901,7 @@ mod tests {
#[test]
fn find_draggable_picks_top_of_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// In tableau 6, the visually topmost card is the last (face-up) one.
@@ -1910,9 +1913,93 @@ mod tests {
assert_eq!(result.2.len(), 1);
}
#[test]
fn find_draggable_picks_waste_top_with_multiple_cards() {
// Reproduces the reported "drags the wrong waste card" bug: with several
// cards in the waste, clicking the visible top must pick the actual top
// (last index), not the buffer card underneath it.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
}
#[test]
fn find_draggable_picks_lone_waste_card() {
// "can't play the first card in the stock" — a waste of one card must
// still be draggable.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
game.set_test_waste_cards(vec![card.clone()]);
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
assert_eq!(result.0, KlondikePile::Stock);
assert_eq!(result.1, 0);
assert_eq!(result.2, vec![card]);
}
#[test]
fn draw_three_waste_hit_test_matches_render_fan_step() {
// Regression: the Draw-Three waste hit-test must use the same fan step as
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
// 1.25*cw); under tighter Android-style spacing the two drift and the top
// fanned card's click target lands on the card beneath it — so dragging
// the visible top card plays the wrong one.
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Force tight (Android-like) column spacing: ~1.03 * card_width.
let cw = layout.card_size.x;
let base = layout.pile_positions[&KlondikePile::Stock];
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
layout.pile_positions.insert(
KlondikePile::Tableau(Tableau::Tableau2),
Vec2::new(t1.x + cw * 1.03, t1.y),
);
clear_test_piles(&mut game);
let waste = vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
];
game.set_test_waste_cards(waste.clone());
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
let top_index = waste.len() - 1;
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let expected = base.x + 2.0 * waste_fan_step(&layout);
assert!(
(pos.x - expected).abs() < 1e-3,
"hit-test must use the shared waste fan step"
);
// The old fixed constant would have drifted from the renderer here.
let old = base.x + 2.0 * cw * 0.28;
assert!(
(pos.x - old).abs() > 1.0,
"shared step must differ from the old fixed step under tight spacing"
);
}
#[test]
fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
@@ -1934,7 +2021,7 @@ mod tests {
// at 0.12 — so for any column with face-down cards above the
// face-up bottom card, clicking the visible card face missed the
// hit-test box and only the bottom strip of the card responded.
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
@@ -1952,9 +2039,9 @@ mod tests {
#[test]
fn find_draggable_returns_run_when_picking_mid_stack() {
// Manually construct a tableau with three face-up cards all stacked.
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
@@ -1979,9 +2066,9 @@ mod tests {
#[test]
fn find_draggable_skips_non_top_waste_card() {
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
@@ -1998,7 +2085,7 @@ mod tests {
#[test]
fn find_drop_target_hits_empty_tableau_pile_marker() {
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Move all cards out of tableau 0 so its marker is the only drop area.
let mut game = game;
@@ -2015,7 +2102,7 @@ mod tests {
#[test]
fn find_drop_target_returns_none_for_origin() {
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
let target = find_drop_target(
@@ -2029,7 +2116,7 @@ mod tests {
#[test]
fn pile_drop_rect_extends_for_tableau_with_cards() {
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
@@ -2044,10 +2131,10 @@ mod tests {
#[test]
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameMode};
let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
// Three waste cards; top (four_clubs) is rightmost in the fan.
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
@@ -2072,7 +2159,7 @@ mod tests {
#[test]
fn find_draggable_returns_none_for_click_on_empty_pile() {
let mut game = GameState::new(42, DrawMode::DrawOne);
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Clear tableau 0 so it's an empty slot.
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
@@ -2086,7 +2173,7 @@ mod tests {
#[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for pile in [
KlondikePile::Stock,
@@ -2103,9 +2190,9 @@ mod tests {
#[test]
fn best_destination_returns_none_when_no_legal_move() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Clear everything except one card that has nowhere to go.
clear_test_piles(&mut game);
@@ -2121,9 +2208,9 @@ mod tests {
#[test]
fn best_tableau_destination_for_stack_skips_source_pile() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
clear_test_piles(&mut game);
@@ -2147,9 +2234,9 @@ mod tests {
#[test]
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
clear_test_piles(&mut game);
@@ -2176,9 +2263,9 @@ mod tests {
#[test]
fn find_hint_finds_ace_to_foundation() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Place Ace of Clubs on top of tableau 0.
clear_test_piles(&mut game);
@@ -2220,9 +2307,9 @@ mod tests {
/// are no other moves and the stock is non-empty.
#[test]
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Remove all foundation, tableau, and waste cards so no pile-to-pile
// move exists. Leave one card in the stock.
@@ -2273,8 +2360,8 @@ mod tests {
/// gets a CardAnimation" — same coverage, new component.
#[test]
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
// Simulate a stack drag of two cards.
let dragged_cards: Vec<Card> = vec![
Card::new(D::Deck1, Suit::Hearts, Rank::King),
@@ -2428,10 +2515,10 @@ mod tests {
app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource(
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
app.insert_resource(LayoutResource(
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
));
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame.
+1 -1
View File
@@ -745,7 +745,7 @@ mod tests {
);
// The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| {
let card_top = |layout: &Layout| {
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
};
assert!(
+2 -2
View File
@@ -862,7 +862,7 @@ fn handle_display_name_confirm(
.leaderboard_display_name
.clone()
.unwrap_or_else(|| {
if let solitaire_data::settings::SyncBackend::SolitaireServer {
if let SyncBackend::SolitaireServer {
ref username,
..
} = settings.0.sync_backend
@@ -1091,7 +1091,7 @@ mod tests {
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
.add_plugins(SyncPlugin::new(NoOpProvider))
.add_plugins(LeaderboardPlugin);
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
+21 -21
View File
@@ -21,7 +21,7 @@
//! active opens the overlay as normal.
use bevy::prelude::*;
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
use solitaire_data::save_game_state_to;
use crate::events::{
@@ -86,10 +86,10 @@ struct ForfeitConfirmButton;
/// Returns the human-readable label for a draw mode.
///
/// Used on the pause overlay draw-mode toggle button.
pub fn draw_mode_label(mode: DrawMode) -> &'static str {
pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
match mode {
DrawMode::DrawOne => "Draw 1",
DrawMode::DrawThree => "Draw 3",
DrawStockConfig::DrawOne => "Draw 1",
DrawStockConfig::DrawThree => "Draw 3",
}
}
@@ -273,9 +273,9 @@ fn handle_pause_draw_buttons(
}
let Some(mut settings) = settings else { return };
let new_mode = if pressed_one {
DrawMode::DrawOne
DrawStockConfig::DrawOne
} else {
DrawMode::DrawThree
DrawStockConfig::DrawThree
};
if settings.0.draw_mode == new_mode {
return;
@@ -477,7 +477,7 @@ fn spawn_pause_screen(
commands: &mut Commands,
level: Option<u32>,
streak: Option<u32>,
draw_mode: Option<DrawMode>,
draw_mode: Option<DrawStockConfig>,
font_res: Option<&FontResource>,
) {
spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| {
@@ -516,7 +516,7 @@ fn spawn_pause_screen(
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
fn spawn_draw_mode_row(
parent: &mut ChildSpawnerCommands,
mode: DrawMode,
mode: DrawStockConfig,
font_res: Option<&FontResource>,
) {
let label_font = TextFont {
@@ -530,8 +530,8 @@ fn spawn_draw_mode_row(
..default()
};
let (one_variant, three_variant) = match mode {
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
DrawStockConfig::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawStockConfig::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
};
parent
.spawn(Node {
@@ -800,20 +800,20 @@ mod tests {
#[test]
fn draw_mode_label_draw_one() {
assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1");
assert_eq!(draw_mode_label(DrawStockConfig::DrawOne), "Draw 1");
}
#[test]
fn draw_mode_label_draw_three() {
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
assert_eq!(draw_mode_label(DrawStockConfig::DrawThree), "Draw 3");
}
/// Both variants are covered so the match is exhaustive — this test would
/// fail to compile if a new DrawMode variant were added without updating
/// fail to compile if a new DrawStockConfig variant were added without updating
/// `draw_mode_label`.
#[test]
fn draw_mode_label_covers_all_variants() {
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
for mode in [DrawStockConfig::DrawOne, DrawStockConfig::DrawThree] {
let label = draw_mode_label(mode);
assert!(
!label.is_empty(),
@@ -842,7 +842,7 @@ mod tests {
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.draw_mode = DrawMode::DrawOne;
.draw_mode = DrawStockConfig::DrawOne;
// Set paused so handle_pause_draw_toggle acts.
app.world_mut().resource_mut::<PausedResource>().0 = true;
@@ -856,7 +856,7 @@ mod tests {
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
assert_eq!(
*mode,
DrawMode::DrawThree,
DrawStockConfig::DrawThree,
"pressing Draw 3 must set mode to DrawThree"
);
@@ -869,7 +869,7 @@ mod tests {
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
assert_eq!(
*mode2,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
"pressing Draw 1 must set mode to DrawOne"
);
@@ -965,11 +965,11 @@ mod tests {
/// Provides a fresh `GameStateResource` (not won) so the modal can
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
fn forfeit_app() -> App {
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.update();
app
}
@@ -1020,11 +1020,11 @@ mod tests {
/// hotkey was received but is currently a no-op.
#[test]
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
let mut game = GameState::new(1, DrawMode::DrawOne);
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
game.set_test_won(true);
app.insert_resource(GameStateResource(game));
app.update();
+3 -3
View File
@@ -179,8 +179,8 @@ mod tests {
use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches.
@@ -209,7 +209,7 @@ mod tests {
/// foundations hold A..Q for each suit, four Kings sit on
/// tableau columns 0..3, stock and waste empty.
fn near_finished_state() -> GameState {
let mut game = GameState::new(1, DrawMode::DrawOne);
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
game.set_test_stock_cards(Vec::new());
game.set_test_waste_cards(Vec::new());
for foundation in [
+2 -2
View File
@@ -23,7 +23,7 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::GameState;
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
@@ -340,7 +340,7 @@ fn tick_debounce_and_spawn_solver_task(
let draw_mode = settings
.as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
.map_or(DrawStockConfig::DrawOne, |s| s.0.draw_mode);
let task = AsyncComputeTaskPool::get().spawn(async move {
GameState::solve_fresh_deal(
seed,
+9 -10
View File
@@ -48,7 +48,7 @@ use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
@@ -113,9 +113,9 @@ pub enum RightClickRadialState {
/// radial is built around single-card foundation/tableau
/// shortcuts and that matches the right-click highlight set).
count: usize,
/// Card ids that would be moved (bottom-to-top order). Length
/// Cards that would be moved (bottom-to-top order). Length
/// always equals `count`. Currently always one element.
cards: Vec<u32>,
cards: Vec<Card>,
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
///
/// Anchors are evenly spaced around a ring of radius
@@ -359,7 +359,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
}
}
use solitaire_core::card::card_to_id;
const fn foundations() -> [Foundation; 4] {
[
@@ -500,7 +499,7 @@ fn radial_open_on_right_click(
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card_to_id(&card)],
cards: vec![card.clone()],
legal_destinations,
centre: world,
hovered_index: None,
@@ -573,7 +572,7 @@ fn radial_open_on_long_press(
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card_to_id(&card)],
cards: vec![card.clone()],
legal_destinations,
centre: world,
hovered_index: None,
@@ -796,8 +795,8 @@ mod tests {
use super::*;
use crate::layout::compute_layout;
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{Card as CoreCard, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
/// resources / messages it depends on. No window, no camera — the
@@ -820,7 +819,7 @@ mod tests {
/// destination — Foundation(0) — under the standard rules
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
fn ace_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
// Wipe everything.
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
@@ -854,7 +853,7 @@ mod tests {
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
/// must skip it.
fn face_down_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
for foundation in [
+29 -21
View File
@@ -1,10 +1,8 @@
use super::ReplayPlaybackState;
use chrono::Datelike;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_data::ReplayMove;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
@@ -60,12 +58,6 @@ pub(crate) fn format_pile(p: &KlondikePile) -> String {
}
}
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
KlondikePile::try_from(*p)
.map(|pile| format_pile(&pile))
.unwrap_or_else(|_| "unknown pile".to_string())
}
fn foundation_number(foundation: Foundation) -> u8 {
match foundation {
Foundation::Foundation1 => 1,
@@ -87,20 +79,36 @@ fn tableau_number(tableau: Tableau) -> u8 {
}
}
/// Pure helper — formats a [`ReplayMove`] as the body of a
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
/// reads as `"{from} → {to}"` using [`format_pile`] for both
/// endpoints. The `count` field is omitted from the row body —
/// at row scale it adds visual noise without meaningful
/// Pure helper — formats a [`KlondikeInstruction`] as the body of a
/// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*`
/// instruction reads as `"{from} → {to}"` using [`format_pile`] for
/// each nameable endpoint. The card count is omitted from the row
/// body — at row scale it adds visual noise without meaningful
/// information for the typical 1-card moves.
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
match m {
ReplayMove::StockClick => "stock cycle".to_string(),
ReplayMove::Move { from, to, .. } => {
///
/// The destination pile is always recoverable directly from the
/// instruction. The source pile is shown when it is statically
/// nameable (a `DstFoundation` carries a [`KlondikePile`] source);
/// a `DstTableau`'s source is the runtime-only `KlondikePileStack`
/// type — not re-exported across the `solitaire_core` boundary and so
/// not pattern-matchable here — so its row renders `"→ {to}"` without
/// a leading source label. Faithful full-coordinate decoding lives in
/// [`GameState::instruction_to_piles`] on the playback path; the
/// move-log is a display-only digest.
pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
match instruction {
KlondikeInstruction::RotateStock => "stock cycle".to_string(),
KlondikeInstruction::DstFoundation(dst) => {
format!(
"{} \u{2192} {}",
format_saved_pile(from),
format_saved_pile(to)
format_pile(&dst.src),
format_pile(&KlondikePile::Foundation(dst.foundation))
)
}
KlondikeInstruction::DstTableau(dst) => {
format!(
"\u{2192} {}",
format_pile(&KlondikePile::Tableau(dst.tableau))
)
}
}
+21 -3
View File
@@ -8,6 +8,7 @@ use crate::replay_playback::{
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
stop_replay_playback, toggle_pause_replay_playback,
};
use crate::resources::GameStateResource;
/// Per-arrow-key time-since-last-fire accumulators that drive the
/// continuous-scrub repeat behaviour for held arrow keys. Each
@@ -1033,6 +1034,7 @@ pub(crate) fn handle_pause_button(
/// guard lives inside `step_replay_playback`.
pub(crate) fn handle_step_button(
mut state: ResMut<ReplayPlaybackState>,
game: Option<Res<GameStateResource>>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
@@ -1040,7 +1042,12 @@ pub(crate) fn handle_step_button(
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
}
/// Repaints the Pause / Resume button's label whenever
@@ -1112,6 +1119,7 @@ pub(crate) fn handle_pause_keyboard(
pub(crate) fn handle_arrow_keyboard(
keys: Option<Res<ButtonInput<KeyCode>>>,
time: Res<Time>,
game: Option<Res<GameStateResource>>,
mut hold: ResMut<ReplayScrubKeyHold>,
mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
@@ -1136,12 +1144,22 @@ pub(crate) fn handle_arrow_keyboard(
// Right (forward step) — initial press fires immediately;
// held repeats fire when the accumulator crosses the interval.
if keys.just_pressed(KeyCode::ArrowRight) {
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
hold.right_held_secs = 0.0;
} else if keys.pressed(KeyCode::ArrowRight) {
hold.right_held_secs += dt;
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
hold.right_held_secs = 0.0;
}
} else {
+22 -24
View File
@@ -1,24 +1,25 @@
use super::*;
use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use solitaire_data::{Replay, ReplayMove};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{Rank, Suit};
use solitaire_data::Replay;
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
/// `RotateStock` entries. Tests only ever read `replay.moves.len()`
/// (denominator of the progress indicator), so the move kind is
/// irrelevant beyond producing the right count.
fn synthetic_replay(move_count: usize) -> Replay {
Replay::new(
42,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
120,
1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
(0..move_count)
.map(|_| KlondikeInstruction::RotateStock)
.collect(),
)
}
@@ -1123,20 +1124,17 @@ fn format_pile_uses_one_indexed_lowercase_names() {
);
}
/// Move-body formatter renders `StockClick` as a label and
/// `Move` as a `from → to` arrow. The `count` field is
/// deliberately omitted — at row scale it adds noise.
/// Move-body formatter renders `RotateStock` as a label. The
/// `Dst*` variants render as a `→ to` arrow, but their pile-stack
/// source types are runtime-only and not constructible from this
/// crate, so only the stock-cycle label is asserted here; the
/// arrow path is exercised end-to-end through the move-log
/// integration tests.
#[test]
fn format_move_body_handles_both_variants() {
assert_eq!(format_move_body(&ReplayMove::StockClick), "stock cycle");
fn format_move_body_handles_stock_cycle() {
assert_eq!(
format_move_body(&ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(4)),
count: 1,
}),
"waste \u{2192} tableau 5",
"Move variant must render as `{{from}} → {{to}}` with 1-indexed pile numbers",
format_move_body(&KlondikeInstruction::RotateStock),
"stock cycle"
);
}
@@ -2314,8 +2312,8 @@ fn format_suit_glyph_all_suits() {
fn format_foundations_row_empty_board() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
DrawStockConfig::DrawOne,
GameMode::Classic,
);
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
}
@@ -2326,8 +2324,8 @@ fn format_foundations_row_empty_board() {
fn format_stock_waste_row_initial_state() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
DrawStockConfig::DrawOne,
GameMode::Classic,
);
let text = format_stock_waste_row(&game);
assert!(
+10 -6
View File
@@ -8,8 +8,7 @@ use super::*;
use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use solitaire_core::KlondikePile;
use solitaire_data::ReplayMove;
use solitaire_core::{KlondikeInstruction, KlondikePile};
/// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "▌ replay" for
@@ -85,9 +84,15 @@ pub(crate) fn update_floating_progress_chip(
// the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
// The destination pile is recoverable directly from the
// instruction — no live state needed. `RotateStock` has no
// destination (the chip hides over the stock pile).
match &replay.moves[cursor - 1] {
ReplayMove::Move { to, .. } => Some(*to),
ReplayMove::StockClick => None,
KlondikeInstruction::DstFoundation(dst) => {
Some(KlondikePile::Foundation(dst.foundation))
}
KlondikeInstruction::DstTableau(dst) => Some(KlondikePile::Tableau(dst.tableau)),
KlondikeInstruction::RotateStock => None,
}
}
_ => None,
@@ -95,8 +100,7 @@ pub(crate) fn update_floating_progress_chip(
let Some(world_pos) = dest_pile
.as_ref()
.and_then(|p| KlondikePile::try_from(*p).ok())
.and_then(|p| layout.0.pile_positions.get(&p).copied())
.and_then(|p| layout.0.pile_positions.get(p).copied())
else {
// Nothing to point at — hide every chip and exit.
for (_, mut visibility, _) in chips.iter_mut() {
+75 -73
View File
@@ -40,8 +40,8 @@
//! flag is threaded through, no every-callsite gate is added.
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_data::{Replay, ReplayMove};
use solitaire_core::KlondikeInstruction;
use solitaire_data::Replay;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::game_plugin::{GameMutation, RecordingReplay};
@@ -94,7 +94,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
/// replay's recorded deal.
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
/// for each [`ReplayMove`].
/// for each [`KlondikeInstruction`].
/// 4. When `cursor == replay.moves.len()`, the state transitions to
/// [`Completed`](Self::Completed). It lingers for
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
@@ -251,6 +251,7 @@ pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) ->
/// normal advance loop takes.
pub fn step_replay_playback(
state: &mut ResMut<ReplayPlaybackState>,
game: Option<&GameStateResource>,
moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool {
@@ -266,31 +267,49 @@ pub fn step_replay_playback(
if *cursor >= replay.moves.len() {
return false;
}
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
*cursor += 1;
return false;
};
moves_writer.write(MoveRequestEvent {
from,
to,
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
let instruction = replay.moves[*cursor];
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
*cursor += 1;
true
}
/// Translates one recorded [`KlondikeInstruction`] into the canonical
/// engine event that drives the live animation pipeline.
///
/// `RotateStock` fires a [`DrawRequestEvent`]; a `Dst*` instruction is
/// decoded back to its runtime `(from, to, count)` pile coordinates via
/// [`GameState::instruction_to_piles`] against the *current* live state
/// (decoded before the event mutates it, so the source pile's face-up
/// run length is the one in effect when the move applies) and fires a
/// [`MoveRequestEvent`]. A decode that returns `None` (e.g. a malformed
/// instruction loaded from disk) is skipped with a warning rather than
/// panicking — the cursor still advances so playback never stalls.
///
/// `game` is `None` only in headless fixtures that install no
/// [`GameStateResource`]; in that case only `RotateStock` (which needs
/// no live state) is dispatched and `Dst*` instructions are skipped.
fn dispatch_instruction(
instruction: KlondikeInstruction,
cursor: usize,
game: Option<&GameStateResource>,
moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) {
match instruction {
KlondikeInstruction::RotateStock => {
draws_writer.write(DrawRequestEvent);
}
_ => match game.and_then(|g| g.0.instruction_to_piles(instruction)) {
Some((from, to, count)) => {
moves_writer.write(MoveRequestEvent { from, to, count });
}
None => {
warn!("skipping replay move that did not decode to piles at cursor {cursor}");
}
},
}
}
/// Steps the replay **backwards** by exactly one move while paused.
///
/// Strategy: the live game's undo system is the source of truth for
@@ -355,6 +374,7 @@ pub fn step_backwards_replay_playback(
fn tick_replay_playback(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
game: Option<Res<GameStateResource>>,
mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
@@ -378,27 +398,14 @@ fn tick_replay_playback(
if !*paused {
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
if let (Ok(from), Ok(to)) =
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
{
moves_writer.write(MoveRequestEvent {
from,
to,
count: *count,
});
} else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
}
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
let instruction = replay.moves[*cursor];
dispatch_instruction(
instruction,
*cursor,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
*cursor += 1;
*secs_to_next += interval;
}
@@ -555,9 +562,8 @@ mod tests {
use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use solitaire_core::KlondikeInstruction;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -592,25 +598,24 @@ mod tests {
}
}
/// A 3-move replay covering both `Move` and `StockClick` variants.
/// Seed 12345 is arbitrary — the test asserts on event counts and
/// move shapes, not on board positions.
/// A 3-move replay of `RotateStock` inputs. Pile-position types are
/// runtime-only and intentionally not constructible from the engine
/// crate, so a `Dst*` fixture can't be hand-built here; `RotateStock`
/// exercises the dispatch path (it fires a `DrawRequestEvent` without
/// needing a live state to decode piles). Seed 12345 is arbitrary —
/// the test asserts on event counts, not board positions.
fn sample_replay_three_moves() -> Replay {
Replay::new(
12345,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
60,
500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1,
},
ReplayMove::StockClick,
KlondikeInstruction::RotateStock,
KlondikeInstruction::RotateStock,
KlondikeInstruction::RotateStock,
],
)
}
@@ -748,20 +753,17 @@ mod tests {
let captured_moves = app.world().resource::<CapturedMoves>();
let captured_draws = app.world().resource::<CapturedDraws>();
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
// Sample replay: three `RotateStock` inputs — each dispatches a
// `DrawRequestEvent` and never a `MoveRequestEvent`.
assert_eq!(
captured_draws.0, 2,
"expected 2 DrawRequestEvent (two StockClicks)",
captured_draws.0, 3,
"expected 3 DrawRequestEvent (one per RotateStock)",
);
assert_eq!(
captured_moves.0.len(),
1,
"expected 1 MoveRequestEvent (the single Move variant)",
0,
"RotateStock inputs must not produce MoveRequestEvent",
);
let m = &captured_moves.0[0];
assert!(matches!(m.from, KlondikePile::Stock));
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
assert_eq!(m.count, 1);
}
/// Driving past one interval on a single-move replay must
@@ -771,12 +773,12 @@ mod tests {
let mut app = headless_app();
let one_move = Replay::new(
42,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
vec![KlondikeInstruction::RotateStock],
);
start_playback(&mut app, one_move);
app.update();
@@ -822,7 +824,7 @@ mod tests {
// Replay — their in-flight recording must not get clobbered.
{
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
rec.moves.push(ReplayMove::StockClick);
rec.moves.push(KlondikeInstruction::RotateStock);
}
start_playback(&mut app, sample_replay_three_moves());
app.update();
@@ -880,12 +882,12 @@ mod tests {
fn ten_draws_replay() -> Replay {
Replay::new(
7,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick; 10],
vec![KlondikeInstruction::RotateStock; 10],
)
}
+1 -1
View File
@@ -7,7 +7,7 @@ use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::card::Card;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+4 -23
View File
@@ -291,30 +291,12 @@ mod android {
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP;
use jni::{JavaVM, objects::JObject};
use solitaire_data::android_jni;
let app = ANDROID_APP
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
// runtime; valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
// pointer — valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<SafeAreaInsets> {
android_jni::with_activity_env(|env, activity| {
// Window window = activity.getWindow();
let window = env
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?;
// View decor = window.getDecorView();
@@ -366,8 +348,7 @@ mod android {
left,
right,
})
})()
.map_err(|e| format!("safe-area JNI: {e}"))
})
}
}
+13 -13
View File
@@ -38,7 +38,7 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use crate::card_plugin::CardEntityIndex;
@@ -163,7 +163,7 @@ impl Plugin for SelectionPlugin {
update_selection_highlight.after(GameMutation).run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>),
.or(resource_changed::<GameStateResource>),
),
),
);
@@ -534,7 +534,7 @@ fn handle_selection_keys(
/// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for(
_bottom: &solitaire_core::card::Card,
_bottom: &Card,
source: &KlondikePile,
game: &GameState,
stack_count: usize,
@@ -579,7 +579,7 @@ pub(crate) fn legal_destinations_for(
/// Walks backwards from the last element and stops at the first face-down card
/// (or when the slice is exhausted). Returns at least `1` when the top card is
/// face-up; returns `0` for an empty slice or when the top card is face-down.
fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
let mut count = 0;
for (_, face_up) in cards.iter().rev() {
if *face_up {
@@ -598,8 +598,8 @@ fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
/// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest(
card: &solitaire_core::card::Card,
game: &solitaire_core::game_state::GameState,
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?;
for foundation in [
@@ -886,7 +886,7 @@ mod tests {
#[test]
fn face_up_run_len_all_face_up() {
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
@@ -897,7 +897,7 @@ mod tests {
#[test]
fn face_up_run_len_mixed_stops_at_face_down() {
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
@@ -910,7 +910,7 @@ mod tests {
#[test]
fn face_up_run_len_top_card_face_down_is_zero() {
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
@@ -920,7 +920,7 @@ mod tests {
#[test]
fn face_up_run_len_single_face_up_card() {
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
assert_eq!(face_up_run_len(&cards), 1);
}
@@ -934,8 +934,8 @@ mod tests {
// -----------------------------------------------------------------------
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
@@ -968,7 +968,7 @@ mod tests {
/// Ace first). It cannot go to an empty tableau (only Kings).
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
fn deterministic_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
// Clear stock, waste, all tableaus.
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
+14 -14
View File
@@ -15,7 +15,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
use solitaire_data::{
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
@@ -379,8 +379,8 @@ impl Plugin for SettingsPlugin {
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
.add_message::<MouseWheel>()
.add_message::<TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher
@@ -1086,8 +1086,8 @@ fn handle_settings_buttons(
}
SettingsButton::ToggleDrawMode => {
settings.0.draw_mode = match settings.0.draw_mode {
DrawMode::DrawOne => DrawMode::DrawThree,
DrawMode::DrawThree => DrawMode::DrawOne,
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
};
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
@@ -1310,10 +1310,10 @@ fn handle_sync_buttons(
}
}
fn draw_mode_label(mode: &DrawMode) -> String {
fn draw_mode_label(mode: &DrawStockConfig) -> String {
match mode {
DrawMode::DrawOne => "Draw 1".into(),
DrawMode::DrawThree => "Draw 3".into(),
DrawStockConfig::DrawOne => "Draw 1".into(),
DrawStockConfig::DrawThree => "Draw 3".into(),
}
}
@@ -2662,7 +2662,7 @@ fn handle_scan_themes(
let themes_dir = user_theme_dir();
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) {
let zips: Vec<PathBuf> = match std::fs::read_dir(&themes_dir) {
Ok(entries) => entries
.flatten()
.map(|e| e.path())
@@ -3019,7 +3019,7 @@ mod tests {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -3.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
window: Entity::PLACEHOLDER,
});
app.update();
// ScrollPosition must remain at 0.0 — panel was closed.
@@ -3052,7 +3052,7 @@ mod tests {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -2.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
window: Entity::PLACEHOLDER,
});
app.update();
let offset = app
@@ -3364,7 +3364,7 @@ mod tests {
fn fire_resize(app: &mut App, width: f32, height: f32) {
app.world_mut().write_message(WindowResized {
window: bevy::ecs::entity::Entity::PLACEHOLDER,
window: Entity::PLACEHOLDER,
width,
height,
});
@@ -3372,7 +3372,7 @@ mod tests {
fn fire_move(app: &mut App, x: i32, y: i32) {
app.world_mut().write_message(WindowMoved {
window: bevy::ecs::entity::Entity::PLACEHOLDER,
window: Entity::PLACEHOLDER,
position: IVec2::new(x, y),
});
}
@@ -3494,7 +3494,7 @@ mod tests {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 5.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
window: Entity::PLACEHOLDER,
});
app.update();
let offset = app
+2 -2
View File
@@ -1010,8 +1010,8 @@ mod tests {
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(bevy::asset::AssetPlugin::default())
.init_asset::<bevy::image::Image>()
.add_plugins(AssetPlugin::default())
.init_asset::<Image>()
.add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
+11 -11
View File
@@ -203,7 +203,7 @@ impl Plugin for StatsPlugin {
// `DefaultPlugins`; register it explicitly so the stats-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
.add_message::<TouchInput>()
// record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external
@@ -464,7 +464,7 @@ fn repaint_replay_selector_detail(
/// Pure helper: render the detail line for the selected replay. Returns
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
/// when a share URL is present. Empty when the history slice is empty.
pub fn replay_selector_detail(replays: &[solitaire_data::Replay], index: usize) -> String {
pub fn replay_selector_detail(replays: &[Replay], index: usize) -> String {
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
return String::new();
};
@@ -1325,9 +1325,9 @@ mod tests {
fn draw_three_win_increments_draw_three_wins_only() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -1371,7 +1371,7 @@ mod tests {
let mut app = headless_app();
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(3);
@@ -1501,7 +1501,7 @@ mod tests {
fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
@@ -1697,7 +1697,7 @@ mod tests {
stats.0.win_streak_current = 3;
}
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(1);
@@ -1723,7 +1723,7 @@ mod tests {
stats.0.win_streak_current = 1;
}
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(1);
@@ -1948,11 +1948,11 @@ mod tests {
///
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
/// `time_seconds` and `share_url` are the only varying fields across tests.
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay {
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> Replay {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
let mut r = solitaire_data::Replay::new(
let mut r = Replay::new(
1,
solitaire_core::DrawMode::DrawOne,
solitaire_core::DrawStockConfig::DrawOne,
solitaire_core::game_state::GameMode::Classic,
time_seconds,
0,
+6 -6
View File
@@ -496,7 +496,7 @@ mod tests {
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
.add_plugins(SyncPlugin::new(provider));
// MinimalPlugins does not register keyboard input.
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
@@ -604,7 +604,7 @@ mod tests {
/// would silently drop the link.
#[test]
fn upload_result_writes_share_url_into_replay_and_persists() {
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_data::{
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
};
@@ -617,7 +617,7 @@ mod tests {
// share_url — the upload-poll path must populate it.
let initial = Replay::new(
42,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
60,
500,
@@ -629,8 +629,8 @@ mod tests {
replays: vec![initial],
};
save_replay_history_to(&path, &history).expect("seed history on disk");
app.insert_resource(crate::stats_plugin::ReplayHistoryResource(history));
app.insert_resource(crate::stats_plugin::LatestReplayPath(Some(path.clone())));
app.insert_resource(ReplayHistoryResource(history));
app.insert_resource(LatestReplayPath(Some(path.clone())));
// Pre-resolved task carrying the URL the production path would
// get back from the server.
@@ -659,7 +659,7 @@ mod tests {
// In-memory contract: replays[0].share_url is now Some(url).
let live = app
.world()
.resource::<crate::stats_plugin::ReplayHistoryResource>();
.resource::<ReplayHistoryResource>();
assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()),
+6 -8
View File
@@ -7,13 +7,11 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit;
use solitaire_core::Suit;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
#[cfg(test)]
use crate::layout::TABLE_COLOUR;
use crate::layout::{Layout, LayoutResource, LayoutSystem, compute_layout};
use crate::layout::{Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, compute_layout};
use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -177,9 +175,9 @@ fn setup_table(
Camera2d,
Camera {
clear_color: ClearColorConfig::Custom(Color::srgb(
crate::layout::TABLE_COLOUR[0],
crate::layout::TABLE_COLOUR[1],
crate::layout::TABLE_COLOUR[2],
TABLE_COLOUR[0],
TABLE_COLOUR[1],
TABLE_COLOUR[2],
)),
..default()
},
@@ -520,7 +518,7 @@ fn sync_pile_marker_visibility(
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<(solitaire_core::card::Card, bool)> {
) -> Vec<(solitaire_core::Card, bool)> {
match pile {
KlondikePile::Stock => {
let stock = game.stock_cards();
+6 -6
View File
@@ -236,7 +236,7 @@ pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId,
/// Sums every entry's declared uncompressed size and rejects archives
/// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central
/// directory only — does not actually decompress anything.
fn enforce_archive_size_limit<R: io::Read + io::Seek>(
fn enforce_archive_size_limit<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Result<(), ImportError> {
let mut total: u64 = 0;
@@ -257,7 +257,7 @@ fn enforce_archive_size_limit<R: io::Read + io::Seek>(
/// (after normalisation) escapes its root. Catches `..`, absolute
/// paths, drive prefixes on Windows, and the awkward case where
/// `enclosed_name` returns `None` because the entry is suspicious.
fn enforce_zip_slip_safe<R: io::Read + io::Seek>(
fn enforce_zip_slip_safe<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Result<(), ImportError> {
for i in 0..archive.len() {
@@ -291,7 +291,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
/// `theme.ron` entry at its root.
/// - [`ImportError::ManifestParse`] when the bytes don't form valid
/// RON for `ThemeManifest`.
fn read_manifest<R: io::Read + io::Seek>(
fn read_manifest<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Result<ThemeManifest, ImportError> {
// We can't use `?` directly across `by_name` because a missing
@@ -318,7 +318,7 @@ fn read_manifest<R: io::Read + io::Seek>(
///
/// Returns [`ImportError::MissingFile`] when the archive has no entry
/// matching the path.
fn read_archive_entry<R: io::Read + io::Seek>(
fn read_archive_entry<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
path: &Path,
) -> Result<Vec<u8>, ImportError> {
@@ -351,7 +351,7 @@ fn archive_key(path: &Path) -> String {
/// parent directories as needed. The destination path is rebuilt from
/// the safe components we already vetted in
/// [`enforce_zip_slip_safe`], not from the raw entry name.
fn write_archive_entry<R: io::Read + io::Seek>(
fn write_archive_entry<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
name: &str,
staging: &Path,
@@ -384,7 +384,7 @@ fn write_archive_entry<R: io::Read + io::Seek>(
/// Variant of [`write_archive_entry`] keyed by `Path` for the
/// manifest-declared face/back paths.
fn write_archive_entry_pathbuf<R: io::Read + io::Seek>(
fn write_archive_entry_pathbuf<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
path: &Path,
staging: &Path,
+1 -1
View File
@@ -27,7 +27,7 @@ use bevy::reflect::TypePath;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use solitaire_core::card::{Rank, Suit};
use solitaire_core::{Rank, Suit};
#[cfg(not(target_arch = "wasm32"))]
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
+2 -2
View File
@@ -12,7 +12,7 @@ use bevy::asset::AssetEvent;
use bevy::ecs::message::MessageReader;
use bevy::math::UVec2;
use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit};
use solitaire_core::{Rank, Suit};
use crate::assets::{
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
@@ -484,7 +484,7 @@ mod tests {
let mut image_set = empty_card_image_set();
// Snapshot the legacy back ids so we can prove they don't
// change when a theme is applied.
let legacy_ids_before: [bevy::asset::AssetId<bevy::image::Image>; 5] =
let legacy_ids_before: [AssetId<Image>; 5] =
std::array::from_fn(|i| image_set.backs[i].id());
let theme = empty_theme();
assert!(image_set.theme_back.is_none(), "theme_back starts empty");
+4 -4
View File
@@ -299,7 +299,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -430,7 +430,7 @@ mod tests {
};
// The current game must be in TimeAttack mode for auto-deal to fire.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -454,7 +454,7 @@ mod tests {
let mut app = headless_app();
// Default session is inactive. Game is TimeAttack mode — still no count.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -521,7 +521,7 @@ mod tests {
// the session timer or the running win count.
// -----------------------------------------------------------------------
fn tmp_ta_path(name: &str) -> std::path::PathBuf {
fn tmp_ta_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("engine_test_ta_{name}.json"))
}
@@ -29,7 +29,7 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::card::Card;
use solitaire_core::Card;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;
@@ -194,7 +194,7 @@ fn spawn_touch_highlight(
mod tests {
use super::*;
use solitaire_core::Tableau;
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
/// numeric ids. Identity is now the `Card` value.
+1 -1
View File
@@ -1324,7 +1324,7 @@ mod tests {
let row = world.spawn((FocusRow, Node::default())).id();
world.entity_mut(scrim).add_child(row);
let make_swatch = |w: &mut World, marker: fn(&mut bevy::ecs::world::EntityWorldMut)| {
let make_swatch = |w: &mut World, marker: fn(&mut EntityWorldMut)| {
let mut e = w.spawn((
Button,
Node::default(),
+3 -3
View File
@@ -982,9 +982,9 @@ mod tests {
outline_width: 0.0,
outline_offset: 0.0,
unrounded_size: card_size,
border: bevy::sprite::BorderRect::default(),
border_radius: bevy::ui::ResolvedBorderRadius::default(),
padding: bevy::sprite::BorderRect::default(),
border: BorderRect::default(),
border_radius: ResolvedBorderRadius::default(),
padding: BorderRect::default(),
inverse_scale_factor: 1.0,
};
// `is_empty` guard inside Bevy treats zero-size
+7 -7
View File
@@ -249,12 +249,12 @@ pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0);
pub struct HighContrastBorder {
/// Border colour to use when high-contrast mode is *off* — the
/// site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color,
pub default_color: Color,
}
impl HighContrastBorder {
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
pub const fn with_default(default_color: Color) -> Self {
Self { default_color }
}
}
@@ -282,18 +282,18 @@ impl HighContrastBorder {
pub struct HighContrastBackground {
/// Background colour to use when high-contrast mode is *off* —
/// the site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color,
pub default_color: Color,
/// Background colour to use when high-contrast mode is *on*.
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
///
/// [`with_default`]: HighContrastBackground::with_default
pub hc_color: bevy::prelude::Color,
pub hc_color: Color,
}
impl HighContrastBackground {
/// Convenience constructor — HC colour defaults to
/// [`BORDER_SUBTLE_HC`].
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
pub const fn with_default(default_color: Color) -> Self {
Self {
default_color,
hc_color: BORDER_SUBTLE_HC,
@@ -305,8 +305,8 @@ impl HighContrastBackground {
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray.
pub const fn with_hc(
default_color: bevy::prelude::Color,
hc_color: bevy::prelude::Color,
default_color: Color,
hc_color: Color,
) -> Self {
Self {
default_color,
+7 -7
View File
@@ -12,7 +12,7 @@
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use solitaire_core::klondike_adapter::compute_time_bonus;
use solitaire_core::scoring::compute_time_bonus;
use solitaire_data::AnimSpeed;
use crate::achievement_plugin::display_name_for;
@@ -551,7 +551,7 @@ fn spawn_win_summary_after_delay(
// speed the duration is zero anyway, suppressing the shake.
let speed = settings
.as_ref()
.map_or(solitaire_data::AnimSpeed::Normal, |s| s.0.animation_speed);
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
shake.remaining = scaled;
shake.total = scaled;
@@ -1205,7 +1205,7 @@ mod tests {
.insert_resource(StatsResource(StatsSnapshot::default()))
.insert_resource(GameStateResource(GameState::new(
0,
solitaire_core::DrawMode::DrawOne,
solitaire_core::DrawStockConfig::DrawOne,
)))
.insert_resource(ProgressResource(PlayerProgress::default()));
app.update();
@@ -1534,9 +1534,9 @@ mod tests {
.challenge_index = 4;
// Switch game mode to Challenge.
{
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
}
app.world_mut().write_message(GameWonEvent {
@@ -1580,13 +1580,13 @@ mod tests {
/// mode-multiplier rows.
#[test]
fn cache_win_data_captures_undo_count_and_mode() {
use solitaire_core::DrawMode;
use solitaire_core::DrawStockConfig;
let mut app = make_app();
// Set up a Zen-mode game with 2 undos used.
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
game.0 = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Zen);
game.0.force_test_undos(2);
}
+3
View File
@@ -32,3 +32,6 @@ dotenvy = { workspace = true }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
[lints]
workspace = true
+5 -1
View File
@@ -35,7 +35,11 @@ module.exports = defineConfig({
`cargo run -p solitaire_server --quiet`,
cwd: repoRoot,
url: `http://127.0.0.1:${serverPort}/health`,
timeout: 120_000,
// CI prebuilds the server (see web-e2e.yml) so `cargo run` reuses the
// compiled binary and starts in seconds. The generous timeout is a
// safety margin for a cold cargo cache (e.g. first run after a deps
// bump) where `cargo run` may still recompile.
timeout: 300_000,
reuseExistingServer: !process.env.CI,
},
});
+27 -22
View File
@@ -142,10 +142,32 @@ async function main() {
const page = await context.newPage();
const results = [];
// Load the page once, then reset each game in place via the bridge's
// newGame(). A fresh page.goto() per game (hundreds of navigations in a
// single browser context) accumulates resources and eventually makes
// waitForFunction time out around game ~100. One load stays fast.
await page.goto(`${baseUrl}/${route}`, { waitUntil: "domcontentloaded" });
if (route === "play-classic") {
const resumeVisible = await page
.locator("#resume-overlay:not(.hidden)")
.isVisible()
.catch(() => false);
if (resumeVisible) {
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
await page.reload({ waitUntil: "domcontentloaded" });
}
}
await page.waitForFunction(
() =>
typeof window.__FERROUS_DEBUG__ === "object" &&
typeof window.__FERROUS_DEBUG__.newGame === "function",
null,
{ timeout: 30_000 }
);
for (let i = 0; i < games; i++) {
const seed = i;
const draw3 = i % 2 === 1;
const suffix = draw3 ? "&draw3=" : "";
const pageErrors = [];
const consoleErrors = [];
@@ -158,27 +180,10 @@ async function main() {
}
});
await page.goto(`${baseUrl}/${route}?seed=${seed}${suffix}`, {
waitUntil: "domcontentloaded",
});
if (route === "play-classic") {
const resumeVisible = await page
.locator("#resume-overlay:not(.hidden)")
.isVisible()
.catch(() => false);
if (resumeVisible) {
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
await page.reload({ waitUntil: "domcontentloaded" });
}
}
await page.waitForFunction(
() =>
typeof window.__FERROUS_DEBUG__ === "object" &&
window.__FERROUS_DEBUG__.seed() !== null,
null,
{ timeout: 30_000 }
// Reset to a fresh seeded game without navigating.
await page.evaluate(
({ seed, draw3 }) => window.__FERROUS_DEBUG__.newGame(seed, draw3),
{ seed, draw3 }
);
const run = await page.evaluate(({ stepCap, policyName, maxVisits }) => {
@@ -136,7 +136,7 @@ test("new game button resets move history and score", async ({ page }) => {
test("timer stops accumulating while tab is hidden", async ({ page }) => {
// Install the fake clock before navigation so the game's setInterval is
// controlled by page.clock.tick() and won't fire on real wall-clock time.
// controlled by page.clock.runFor() and won't fire on real wall-clock time.
await page.clock.install();
await page.goto("/play-classic?seed=42");
@@ -144,7 +144,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
await waitForBridge(page);
// Advance 3 fake seconds to get a non-zero timer reading.
await page.clock.tick(3_000);
await page.clock.runFor(3_000);
const timerAfter3s = await page.locator("#hud-timer").textContent();
expect(timerAfter3s).toBe("0:03");
@@ -152,7 +152,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
await setTabHidden(page, true);
// Advance 10 fake seconds while hidden.
await page.clock.tick(10_000);
await page.clock.runFor(10_000);
const timerWhileHidden = await page.locator("#hud-timer").textContent();
expect(timerWhileHidden).toBe("0:03"); // must not have advanced
@@ -160,7 +160,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
await setTabHidden(page, false);
// Advance 2 more fake seconds.
await page.clock.tick(2_000);
await page.clock.runFor(2_000);
const timerAfterResume = await page.locator("#hud-timer").textContent();
expect(timerAfterResume).toBe("0:05"); // only 3 + 2 visible seconds counted
});
@@ -182,11 +182,11 @@ test("timer does not restart while tab is visible during an auto-complete or won
// the snap state correctly gates the restart.
//
// Advance 2 s, then hide+show — timer should continue normally.
await page.clock.tick(2_000);
await page.clock.runFor(2_000);
await setTabHidden(page, true);
await page.clock.tick(5_000);
await page.clock.runFor(5_000);
await setTabHidden(page, false);
await page.clock.tick(2_000);
await page.clock.runFor(2_000);
const timerText = await page.locator("#hud-timer").textContent();
// 2 visible + 0 hidden + 2 visible = 4 total
+23 -2
View File
@@ -54,7 +54,7 @@ struct UserIdKeyExtractor {
impl KeyExtractor for UserIdKeyExtractor {
type Key = String;
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> {
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
if let Some(user_id) = self.try_extract_user_id(req.headers()) {
return Ok(user_id);
}
@@ -203,7 +203,12 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
// and the wasm-bindgen-generated `web/pkg/`). The HTML page is the
// same regardless of `:id` — it reads the path from `location` in JS
// and fetches the replay JSON from `/api/replays/:id`.
let web = Router::new()
// HTML pages are `include_str!`'d into the binary and change on every
// deploy, so they get `Cache-Control: no-cache` (always revalidate). The
// `/web` + `/assets` static files keep ServeDir's default Last-Modified
// caching — applying no-cache to *those* too made the e2e cycle gate's 240
// page reloads recompile the wasm each time and time out.
let html_pages = Router::new()
.route(
"/",
get(|| async { Html(include_str!("../web/home.html")) }),
@@ -233,6 +238,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
"/replays",
get(|| async { Html(include_str!("../web/replays.html")) }),
)
.layer(axum_middleware::from_fn(no_cache_headers));
let web = Router::new()
.merge(html_pages)
.nest_service("/web", ServeDir::new("solitaire_server/web"))
.nest_service("/assets", ServeDir::new("assets"))
.layer(axum_middleware::from_fn(security_headers));
@@ -270,6 +279,18 @@ async fn security_headers(req: Request<axum::body::Body>, next: axum_middleware:
res
}
/// Adds `Cache-Control: no-cache` so the browser always revalidates before
/// using a cached copy. Scoped to the `include_str!` HTML pages (which change
/// on every deploy and have no validators) — not the ServeDir static assets,
/// which keep normal Last-Modified caching so repeated page loads can reuse the
/// already-downloaded/compiled wasm.
async fn no_cache_headers(req: Request<axum::body::Body>, next: axum_middleware::Next) -> Response {
let mut res = next.run(req).await;
res.headers_mut()
.insert("Cache-Control", HeaderValue::from_static("no-cache"));
res
}
/// `GET /health` — simple liveness probe, no auth required.
async fn health() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({
+3 -3
View File
@@ -565,7 +565,7 @@ async fn register_login_push_pull_full_roundtrip() {
},
achievements: vec![],
progress: PlayerProgress::default(),
last_modified: chrono::Utc::now(),
last_modified: Utc::now(),
};
let push_resp = post_authed(
@@ -1299,7 +1299,7 @@ async fn expired_access_token_returns_401() {
exp: usize,
kind: String,
}
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_token = encode(
&Header::default(),
&ExpiredClaims {
@@ -1375,7 +1375,7 @@ async fn refresh_with_expired_refresh_token_returns_401() {
exp: usize,
kind: String,
}
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_token = encode(
&Header::default(),
&ExpiredRefreshClaims {
+16
View File
@@ -270,6 +270,11 @@ function startGame(seed) {
// ── Timer ────────────────────────────────────────────────────────────────────
function startTimer() {
// Idempotent: never stack a second interval. The visibilitychange handler
// and startGame can both call this, and a stray call (e.g. a load-time
// visibilitychange while a timer is already running) would otherwise leak
// the old interval and make elapsedSecs increment twice per second.
if (timerInterval) return;
timerInterval = setInterval(() => {
elapsedSecs++;
updateTimerDisplay();
@@ -986,6 +991,17 @@ window.__FERROUS_DEBUG__ = {
snapshot() {
return game ? game.debug_snapshot() : null;
},
serialize() {
return game ? game.serialize() : null;
},
// Reset to a fresh seeded game in place (no page reload). Lets the cycle
// regression harness reuse one page across hundreds of games instead of
// navigating per game.
newGame(seed, drawThreeMode) {
drawThree = !!drawThreeMode;
startGame(seed ?? randomSeed());
return game ? game.state() : null;
},
applyLegalMove(index) {
if (!game) return { ok: false, error: "game_not_ready" };
const result = game.debug_apply_legal_move(index);
+48 -48
View File
@@ -1649,63 +1649,63 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114621, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf0188236128725a8);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114855, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9767, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9847, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9775, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9843, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9769, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9839, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9);
return ret;
},
__wbindgen_cast_000000000000000d: function(arg0) {
@@ -1769,55 +1769,55 @@ function __wbg_get_imports() {
};
}
function wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hf0188236128725a8(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf0188236128725a8(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1(arg0, arg1, arg2, arg3);
function wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8(arg0, arg1, arg2, arg3);
}
function wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
function wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
}
Binary file not shown.
+4 -4
View File
@@ -244,11 +244,11 @@ export class SolitaireGame {
return this;
}
/**
* Returns replay moves encoded in the `solitaire_data::Replay` wire format.
* Returns replay moves encoded in the `solitaire_data::Replay` wire format
* a list of upstream [`KlondikeInstruction`]s.
*
* This derives move counts from the deterministic instruction history and
* validates that the resulting move stream replays cleanly from the current
* game's seed/draw mode.
* This is the deterministic instruction history; together with `seed()`
* and the draw mode it replays cleanly via `apply_instruction`.
* @returns {any}
*/
replay_moves() {
Binary file not shown.
+5 -2
View File
@@ -6,8 +6,11 @@
<title>Ferrous Solitaire</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #000; overflow: hidden; }
#bevy-canvas { display: block; width: 100vw; height: 100vh; }
html, body { height: 100%; background: #000; overflow: hidden; }
/* No size cap: the wgpu device now takes its max_texture_dimension from
the adapter (see solitaire_web/src/lib.rs), so the surface can match
the full viewport. fit_canvas_to_parent sizes the canvas to 100%. */
#bevy-canvas { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
+3
View File
@@ -9,3 +9,6 @@ serde = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
[lints]
workspace = true
+7 -7
View File
@@ -755,14 +755,14 @@ mod tests {
fn progress_total_xp_takes_max() {
let mut local = default_payload();
local.progress.total_xp = 1500;
local.progress.level = crate::progress::level_for_xp(1500);
local.progress.level = level_for_xp(1500);
let mut remote = default_payload();
remote.progress.total_xp = 2500;
remote.progress.level = crate::progress::level_for_xp(2500);
remote.progress.level = level_for_xp(2500);
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.progress.total_xp, 2500);
assert_eq!(merged.progress.level, crate::progress::level_for_xp(2500));
assert_eq!(merged.progress.level, level_for_xp(2500));
}
#[test]
@@ -815,7 +815,7 @@ mod tests {
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.progress.total_xp, 5500);
assert_eq!(merged.progress.level, crate::progress::level_for_xp(5500));
assert_eq!(merged.progress.level, level_for_xp(5500));
}
// -----------------------------------------------------------------------
@@ -1051,11 +1051,11 @@ mod tests {
// drop the oldest 50 so the cap is preserved.
let start = nd(2024, 1, 1);
let local_dates: Vec<NaiveDate> = (0..DAILY_CHALLENGE_HISTORY_CAP as i64)
.map(|i| start + chrono::Duration::days(i))
.map(|i| start + Duration::days(i))
.collect();
let remote_dates: Vec<NaiveDate> = (DAILY_CHALLENGE_HISTORY_CAP as i64
..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50)
.map(|i| start + chrono::Duration::days(i))
.map(|i| start + Duration::days(i))
.collect();
let mut local = default_payload();
@@ -1073,7 +1073,7 @@ mod tests {
// is therefore start + 50 days.
assert_eq!(
merged.progress.daily_challenge_history.first().copied(),
Some(start + chrono::Duration::days(50))
Some(start + Duration::days(50))
);
// Most recent retained is the last remote date.
assert_eq!(
+3
View File
@@ -28,3 +28,6 @@ web-sys = { version = "0.3", features = ["console"] }
[features]
default = ["console_error_panic_hook"]
[lints]
workspace = true
+109 -192
View File
@@ -3,8 +3,8 @@
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
//! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each
//! step applies one [`ReplayMove`] to the underlying `GameState` and
//! returns the resulting pile snapshot as JSON for the JS layer to
//! step applies one [`KlondikeInstruction`] to the underlying `GameState`
//! and returns the resulting pile snapshot as JSON for the JS layer to
//! render.
//!
//! The state machine is the same Rust [`solitaire_core::GameState`]
@@ -19,41 +19,31 @@
//! is the contract.
use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use serde::{Deserialize, Serialize};
use solitaire_core::card::Suit;
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError;
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
use solitaire_core::klondike_adapter::{
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
};
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use wasm_bindgen::prelude::*;
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
/// player inputs, post-StockClick refinement). Only the JSON shape
/// matters for cross-crate compatibility.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
Move {
from: SavedKlondikePile,
to: SavedKlondikePile,
count: usize,
},
StockClick,
}
/// Mirrors `solitaire_data::Replay` v2.
/// Mirrors `solitaire_data::Replay` v3.
///
/// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
/// move-currency `solitaire_core` persists. A stock click is
/// `KlondikeInstruction::RotateStock`; a card move is a
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are
/// runtime-only and intentionally not part of the wire format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replay {
#[serde(default)]
pub schema_version: u32,
pub seed: u64,
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
pub mode: GameMode,
pub time_seconds: u64,
pub final_score: i32,
pub recorded_at: NaiveDate,
pub moves: Vec<ReplayMove>,
pub moves: Vec<KlondikeInstruction>,
}
/// JS-friendly snapshot of a `GameState` at a particular replay step.
@@ -77,9 +67,11 @@ pub struct StateSnapshot {
/// means the card back is drawn; in that case `suit` and `rank` are
/// still set (so the renderer doesn't need separate "unknown" data),
/// just hidden visually.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CardSnapshot {
pub id: u32,
/// Stable per-card identity for the JS renderer (an opaque key). Serialises
/// as the upstream `Card`'s transparent integer value.
pub id: Card,
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
pub suit: &'static str,
/// 1-13, where 1 is Ace and 13 is King.
@@ -87,14 +79,10 @@ pub struct CardSnapshot {
pub face_up: bool,
}
// Stable 0..=51 card identity, shared with the desktop engine via
// solitaire_core so replay snapshots are identical across platforms.
use solitaire_core::card::card_to_id;
impl From<&(solitaire_core::card::Card, bool)> for CardSnapshot {
fn from((card, face_up): &(solitaire_core::card::Card, bool)) -> Self {
impl From<&(Card, bool)> for CardSnapshot {
fn from((card, face_up): &(Card, bool)) -> Self {
Self {
id: card_to_id(card),
id: card.clone(),
suit: match card.suit() {
Suit::Clubs => "clubs",
Suit::Diamonds => "diamonds",
@@ -112,7 +100,7 @@ impl From<&(solitaire_core::card::Card, bool)> for CardSnapshot {
#[wasm_bindgen]
pub struct ReplayPlayer {
game: GameState,
moves: Vec<ReplayMove>,
moves: Vec<KlondikeInstruction>,
step_idx: usize,
}
@@ -147,17 +135,8 @@ impl ReplayPlayer {
if self.step_idx >= self.moves.len() {
return Ok(None);
}
let mv = self.moves[self.step_idx].clone();
match mv {
ReplayMove::Move { from, to, count } => self.game.move_cards(
from.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
to.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
count,
)?,
ReplayMove::StockClick => self.game.draw()?,
}
let instruction = self.moves[self.step_idx];
self.game.apply_instruction(instruction)?;
self.step_idx += 1;
Ok(Some(self.snapshot()))
}
@@ -318,9 +297,10 @@ pub enum DebugMove {
pub struct DebugInvariantReport {
pub state_ok: bool,
pub total_cards_seen: usize,
pub duplicate_card_ids: Vec<u32>,
pub missing_card_ids: Vec<u32>,
pub out_of_range_card_ids: Vec<u32>,
/// Cards that appeared more than once across all piles.
pub duplicate_cards: Vec<Card>,
/// Cards from the full single-deck set that are absent from the board.
pub missing_cards: Vec<Card>,
pub stock_has_face_up_cards: bool,
pub waste_has_face_down_cards: bool,
pub foundation_has_face_down_cards: bool,
@@ -332,11 +312,11 @@ pub struct DebugInvariantReport {
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DebugSnapshot {
pub seed: u64,
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
pub mode: GameMode,
pub state: GameSnapshot,
pub legal_moves: Vec<DebugMove>,
pub move_history: Vec<SavedInstruction>,
pub move_history: Vec<KlondikeInstruction>,
pub invariants: DebugInvariantReport,
pub state_json: String,
}
@@ -389,24 +369,15 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
];
let mut seen = [false; 52];
let mut duplicate_card_ids = Vec::new();
let mut out_of_range_card_ids = Vec::new();
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
let mut duplicate_cards = Vec::new();
let mut total_cards_seen = 0_usize;
let mut feed = |cards: &[(solitaire_core::card::Card, bool)]| {
let mut feed = |cards: &[(Card, bool)]| {
for (card, _) in cards {
total_cards_seen += 1;
let id = card_to_id(card);
if id >= 52 {
out_of_range_card_ids.push(id);
continue;
}
let idx = id as usize;
if seen[idx] {
duplicate_card_ids.push(id);
} else {
seen[idx] = true;
if !seen.insert(card.clone()) {
duplicate_cards.push(card.clone());
}
}
};
@@ -420,9 +391,22 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
feed(pile);
}
let missing_card_ids = (0_u32..52_u32)
.filter(|id| !seen[*id as usize])
.collect::<Vec<_>>();
// Reference set: the full 52-card single deck, using whichever deck id the
// dealt cards carry. Any of those 52 not on the board is missing.
let deck = seen
.iter()
.next()
.map(|c| c.deck())
.unwrap_or_else(|| Deck::new(0).expect("deck id 0 is valid"));
let mut missing_cards = Vec::new();
for suit in Suit::SUITS {
for rank in Rank::RANKS {
let card = Card::new(deck, suit, rank);
if !seen.contains(&card) {
missing_cards.push(card);
}
}
}
let stock_has_face_up_cards = stock.iter().any(|(_, face_up)| *face_up);
let waste_has_face_down_cards = waste.iter().any(|(_, face_up)| !*face_up);
@@ -444,9 +428,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let state_ok = duplicate_card_ids.is_empty()
&& missing_card_ids.is_empty()
&& out_of_range_card_ids.is_empty()
let state_ok = duplicate_cards.is_empty()
&& missing_cards.is_empty()
&& !stock_has_face_up_cards
&& !waste_has_face_down_cards
&& !foundation_has_face_down_cards
@@ -455,9 +438,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
DebugInvariantReport {
state_ok,
total_cards_seen,
duplicate_card_ids,
missing_card_ids,
out_of_range_card_ids,
duplicate_cards,
missing_cards,
stock_has_face_up_cards,
waste_has_face_down_cards,
foundation_has_face_down_cards,
@@ -568,90 +550,15 @@ impl SolitaireGame {
legal_moves_for_game(&self.game)
}
fn move_history_native(&self) -> Vec<SavedInstruction> {
fn move_history_native(&self) -> Vec<KlondikeInstruction> {
self.game.instruction_history()
}
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> {
let mut replay_game =
GameState::new_with_mode(self.game.seed, self.game.draw_mode(), self.game.mode);
let mut replay_moves = Vec::new();
for instruction in self.game.instruction_history() {
let replay_move = match instruction {
SavedInstruction::RotateStock => ReplayMove::StockClick,
SavedInstruction::DstFoundation(dst) => ReplayMove::Move {
from: dst.src,
to: SavedKlondikePile::Foundation(dst.foundation),
count: 1,
},
SavedInstruction::DstTableau(dst) => {
let (from, count) = match dst.src {
SavedKlondikePileStack::Stock => (SavedKlondikePile::Stock, 1),
SavedKlondikePileStack::Foundation(foundation) => {
(SavedKlondikePile::Foundation(foundation), 1)
}
SavedKlondikePileStack::Tableau(tableau_stack) => {
let tableau =
tableau_from_index(tableau_stack.tableau.0 as usize).ok_or_else(
|| {
format!(
"invalid tableau index in move history: {}",
tableau_stack.tableau.0
)
},
)?;
let face_up_count = replay_game
.pile(KlondikePile::Tableau(tableau))
.iter()
.rev()
.take_while(|(_, face_up)| *face_up)
.count();
let skip = tableau_stack.skip_cards.0 as usize;
let count = face_up_count.checked_sub(skip).ok_or_else(|| {
format!(
"invalid tableau skip in move history: face_up={face_up_count}, skip={skip}"
)
})?;
if count == 0 {
return Err(
"invalid tableau move in move history: zero-card move".into()
);
}
(SavedKlondikePile::Tableau(tableau_stack.tableau), count)
}
};
ReplayMove::Move {
from,
to: SavedKlondikePile::Tableau(dst.tableau),
count,
}
}
};
match &replay_move {
ReplayMove::StockClick => replay_game
.draw()
.map_err(|e| format!("failed to apply stock click while exporting replay: {e}"))?,
ReplayMove::Move { from, to, count } => {
let src: KlondikePile = (*from)
.try_into()
.map_err(|e| format!("invalid replay source pile: {e}"))?;
let dst: KlondikePile = (*to)
.try_into()
.map_err(|e| format!("invalid replay destination pile: {e}"))?;
replay_game.move_cards(src, dst, *count).map_err(|e| {
format!(
"failed to apply move while exporting replay ({from:?} -> {to:?}, count={count}): {e}"
)
})?;
}
}
replay_moves.push(replay_move);
}
Ok(replay_moves)
fn replay_moves_native(&self) -> Vec<KlondikeInstruction> {
// The session's forward instruction history *is* the replayable
// move list: each entry replays cleanly via `apply_instruction`
// against a fresh game with the same seed/draw mode/mode.
self.game.instruction_history()
}
fn debug_snapshot_native(&self) -> DebugSnapshot {
@@ -726,9 +633,9 @@ impl SolitaireGame {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
let dm = if draw_three {
DrawMode::DrawThree
DrawStockConfig::DrawThree
} else {
DrawMode::DrawOne
DrawStockConfig::DrawOne
};
SolitaireGame {
game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic),
@@ -828,15 +735,13 @@ impl SolitaireGame {
}
}
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format.
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format
/// — a list of upstream [`KlondikeInstruction`]s.
///
/// This derives move counts from the deterministic instruction history and
/// validates that the resulting move stream replays cleanly from the current
/// game's seed/draw mode.
/// This is the deterministic instruction history; together with `seed()`
/// and the draw mode it replays cleanly via `apply_instruction`.
pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
let moves = self
.replay_moves_native()
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let moves = self.replay_moves_native();
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
}
@@ -893,6 +798,25 @@ mod tests {
use std::collections::HashSet;
use std::fmt::Write;
/// The JS card renderer reads `card.id` as an opaque identity key. After the
/// `card_to_id` removal, `CardSnapshot.id` is a `card_game::Card`, which is
/// `#[serde(transparent)]` over `NonZeroU8` — so it must still serialise as a
/// plain JSON number (the same `Serialize` impl `serde_wasm_bindgen` uses).
#[test]
fn card_snapshot_id_serialises_as_a_plain_number() {
let card = Card::new(Deck::new(0).unwrap(), Suit::Hearts, Rank::RANKS[0]);
let snap = CardSnapshot::from(&(card, true));
let json = serde_json::to_value(&snap).expect("serialise CardSnapshot");
assert!(
json["id"].is_number(),
"card.id must serialise as a JSON number for the JS opaque key, got {:?}",
json["id"]
);
assert_eq!(json["suit"], "hearts");
assert_eq!(json["rank"], 1);
assert_eq!(json["face_up"], true);
}
fn pick_move_index(moves: &[DebugMove]) -> Option<usize> {
if moves.is_empty() {
return None;
@@ -933,7 +857,7 @@ mod tests {
for card in cards {
let _ = write!(
key,
"{}:{}:{},",
"{:?}:{}:{},",
card.id,
card.rank,
if card.face_up { 1 } else { 0 }
@@ -952,7 +876,7 @@ mod tests {
key
}
fn run_autonomous(seed: u64, draw_mode: DrawMode, max_steps: usize) -> DebugSnapshot {
fn run_autonomous(seed: u64, draw_mode: DrawStockConfig, max_steps: usize) -> DebugSnapshot {
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
};
@@ -983,7 +907,7 @@ mod tests {
#[test]
fn debug_snapshot_exposes_replayable_seed_and_history() {
let seed = 42_u64;
let final_snapshot = run_autonomous(seed, DrawMode::DrawOne, 1500);
let final_snapshot = run_autonomous(seed, DrawStockConfig::DrawOne, 1500);
assert_eq!(final_snapshot.seed, seed);
assert!(
!final_snapshot.state_json.is_empty(),
@@ -1000,7 +924,7 @@ mod tests {
#[test]
fn replay_moves_export_is_json_compatible_and_replayable() {
let seed = 7_u64;
let draw_mode = DrawMode::DrawThree;
let draw_mode = DrawStockConfig::DrawThree;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
};
@@ -1016,10 +940,7 @@ mod tests {
}
}
let exported_moves = match game.replay_moves_native() {
Ok(moves) => moves,
Err(err) => panic!("replay export failed: {err}"),
};
let exported_moves = game.replay_moves_native();
assert!(
!exported_moves.is_empty(),
"progressed game must export a non-empty replay move list"
@@ -1029,24 +950,20 @@ mod tests {
Ok(value) => value,
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
};
let array = match moves_json.as_array() {
Some(values) => values,
None => panic!("exported replay moves must serialise as a JSON array"),
};
assert!(
array.iter().all(|entry| {
entry.as_str() == Some("StockClick") || entry.get("Move").is_some()
}),
"replay move JSON must match ReplayMove wire shape"
moves_json.is_array(),
"exported replay moves must serialise as a JSON array"
);
let parsed_back: Vec<ReplayMove> = match serde_json::from_value(moves_json) {
let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
Ok(parsed) => parsed,
Err(err) => panic!("failed to parse replay move JSON as ReplayMove list: {err}"),
Err(err) => {
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
}
};
assert_eq!(
parsed_back, exported_moves,
"replay move JSON must round-trip through ReplayMove"
"replay move JSON must round-trip through KlondikeInstruction"
);
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
@@ -1054,7 +971,7 @@ mod tests {
None => panic!("invalid recorded_at date in test"),
};
let replay = Replay {
schema_version: 2,
schema_version: 3,
seed,
draw_mode,
mode: GameMode::Classic,
@@ -1098,9 +1015,9 @@ mod tests {
fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 {
let draw_mode = if seed % 2 == 0 {
DrawMode::DrawOne
DrawStockConfig::DrawOne
} else {
DrawMode::DrawThree
DrawStockConfig::DrawThree
};
let snapshot = run_autonomous(seed, draw_mode, 2000);
assert_invariants(&snapshot, seed);
@@ -1112,9 +1029,9 @@ mod tests {
fn debug_api_autonomous_thousands_seed_soak() {
for seed in 10_000_u64..12_000_u64 {
let draw_mode = if seed % 2 == 0 {
DrawMode::DrawOne
DrawStockConfig::DrawOne
} else {
DrawMode::DrawThree
DrawStockConfig::DrawThree
};
let snapshot = run_autonomous(seed, draw_mode, 3000);
assert_invariants(&snapshot, seed);
@@ -1125,7 +1042,7 @@ mod tests {
fn serialize_from_saved_round_trip() {
let seed = 55_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
};
// Advance a few moves so there is non-trivial state to round-trip.
for _ in 0..20 {
@@ -1160,7 +1077,7 @@ mod tests {
fn undo_reverts_to_prior_state() {
let seed = 99_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
};
let before_key = board_key(&game.debug_snapshot_native().state);
@@ -1198,7 +1115,7 @@ mod tests {
fn draw_one_advances_waste_by_one() {
let seed = 1_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
};
let stock_before = game.game.stock_cards().len();
@@ -1224,7 +1141,7 @@ mod tests {
fn draw_three_advances_waste_by_three() {
let seed = 1_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawThree, GameMode::Classic),
game: GameState::new_with_mode(seed, DrawStockConfig::DrawThree, GameMode::Classic),
};
let stock_before = game.game.stock_cards().len();
@@ -1254,7 +1171,7 @@ mod tests {
fn debug_apply_move_json_stock_click_advances_waste() {
let seed = 3_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
};
let waste_before = game.game.waste_cards().len();
+3
View File
@@ -21,3 +21,6 @@ console_error_panic_hook = "0.1"
# renderer to WebGL2 compatibility limits, which is wrong for native builds.
[target.'cfg(target_arch = "wasm32")'.dependencies]
bevy = { workspace = true, features = ["webgl2"] }
[lints]
workspace = true
+18 -12
View File
@@ -34,12 +34,13 @@ pub fn start() {
fit_canvas_to_parent: true,
// Prevent the browser stealing keyboard events and scroll.
prevent_default_event_handling: true,
// Force scale_factor = 1.0 so the wgpu surface is sized in
// CSS/logical pixels rather than physical pixels. Without this,
// HiDPI displays (devicePixelRatio ≥ 2) produce a framebuffer
// whose physical width can exceed WebGL2's 2048-pixel per-
// dimension limit, causing a wgpu validation panic on the first
// resize event and killing the WASM thread.
// Render at CSS/logical pixels (scale_factor 1.0) rather
// than physical (CSS × devicePixelRatio). This keeps the
// surface smaller on HiDPI displays — lighter GPU load and
// stable sizing — at the cost of some crispness. The wgpu
// texture-dimension limit is now taken from the adapter (see
// the RenderPlugin below), so this is purely a quality/perf
// choice, no longer a crash-avoidance hack.
resolution: WindowResolution::default()
.with_scale_factor_override(1.0),
..default()
@@ -53,14 +54,19 @@ pub fn start() {
meta_check: AssetMetaCheck::Never,
..default()
})
// WebGL2 priority constrains naga (the shader translator) to emit
// GLES 300es-compatible GLSL. Without this, Chromium's ANGLE driver
// rejects certain shader constructs (storage buffers, tight component
// limits) causing a fatal wgpu "Shader translation error". Firefox is
// more lenient; this setting makes both browsers work identically.
// `Functionality` makes wgpu adopt the *adapter's* real limits
// instead of the conservative `downlevel_webgl2_defaults()` that
// `WebGL2` priority forces. On the WebGL2 (Gl) backend the adapter
// already reports WebGL2-constrained features/limits — no storage
// buffers, etc., so shaders stay GLES-compatible on both Firefox and
// Chromium — but it reports the GPU's *true* `max_texture_dimension`
// (e.g. 16384) rather than 2048. The device is requested with exactly
// what the adapter offers, so creation can't fail, and the surface is
// no longer capped at 2048: large viewports (4K, etc.) render natively
// with no letterbox and no hardcoded cap.
.set(RenderPlugin {
render_creation: RenderCreation::Automatic(WgpuSettings {
priority: WgpuSettingsPriority::WebGL2,
priority: WgpuSettingsPriority::Functionality,
..default()
}),
..default()