Compare commits

...

91 Commits

Author SHA1 Message Date
funman300 783f01628e Merge pull request 'fix(engine): raise dynamic tableau fan cap to fill tall viewports' (#115) from fix/cover-screen-fan-cap into master
Build and Deploy / build-and-push (push) Successful in 2m8s
Web WASM Rebuild / rebuild (push) Successful in 7m24s
Android Release / build-apk (push) Successful in 5m17s
2026-06-26 21:15:53 +00:00
funman300 94a6feb5db fix(engine): raise dynamic tableau fan cap to fill tall viewports
MAX_DYNAMIC_FAN_FRAC 0.6 -> 0.9 so the dynamic tableau fill spreads
further on very tall / narrow viewports (e.g. a foldable cover screen),
which were left ~40% empty at the cap. Fills the unfolded near-square
screen to ~100% and lets the cover screen fill further (and the rest fills
as columns deepen during play). Normal phones are unaffected — their fill
fraction is already below the cap. apply_dynamic_tableau_fan still floors
at TABLEAU_FAN_FRAC and deeper columns drive the fraction down, so nothing
overflows and hit-testing stays in sync.

Vertical centring of the residual was investigated but dropped: a 21:9
phone is aspect-identical to the cover screen, so centring can't be
targeted to foldables without also disconnecting the board from the HUD on
tall phones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:56:31 -07:00
Gitea CI 3daaf47689 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 4m47s
2026-06-26 20:20:59 +00:00
funman300 c00656ec8f Merge pull request 'docs(changelog): note foldable tableau fill and card-move jank fixes' (#114) from docs/changelog-v0.40.2 into master
Android Release / build-apk (push) Successful in 5m3s
2026-06-26 20:12:47 +00:00
funman300 aea2167eee docs(changelog): note foldable tableau fill and card-move jank fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:12:45 -07:00
funman300 0ed91ea24c Merge pull request 'fix(engine): fill tableau fan to viewport on all aspect ratios' (#113) from fix/foldable-tableau-fan-fill into master
Build and Deploy / build-and-push (push) Successful in 2m3s
Web WASM Rebuild / rebuild (push) Successful in 7m27s
2026-06-26 20:11:33 +00:00
funman300 18af49c0f3 fix(engine): fill tableau fan to viewport on all aspect ratios
On a near-square viewport (e.g. an unfolded Galaxy Fold) a fresh deal left
the bottom ~40% of the screen empty: the dynamic fan (update_tableau_fan_frac)
measured only face-up column depth and returned early at a fresh deal (face-up
depth 1), so it never spread the tableau, and the deep face-down stacks were
ignored. The cold-start deal also never fired StateChangedEvent, and on Android
the safe-area-inset resize (frames 1-3) reset the fan to compute_layout's
sparse worst-case value, so even the post-move fill was wiped.

Move the fill into layout::apply_dynamic_tableau_fan, driven by each column's
TOTAL weighted depth (face-down cards count, scaled by the face-down/face-up
step ratio) so the deepest column fills the available height. Run it in three
places so every path stays filled: PostStartup (cold-start deal), on
StateChangedEvent (moves), and inside on_window_resized after compute_layout
(safe-area resize + fold/unfold). MAX_DYNAMIC_FAN_FRAC caps the spread so a
near-empty column keeps readable overlap; TABLEAU_FAN_FRAC floors it. Deeper
columns drive the fraction down so everything still fits — no overflow.
card_position/card_positions read the same fractions, so hit-testing stays
in sync.

Adds regression tests: cold-start deal fills the fan, and a resize re-fills it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:11:00 -07:00
Gitea CI e208245036 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m48s
Web E2E / web-e2e (push) Successful in 4m47s
2026-06-26 19:11:53 +00:00
funman300 0247efcb07 Merge pull request 'perf(engine): rebuild card children only on appearance change' (#112) from fix/card-move-anim-jank into master
Build and Deploy / build-and-push (push) Successful in 2m1s
Web WASM Rebuild / rebuild (push) Successful in 6m40s
2026-06-26 19:03:19 +00:00
funman300 1d5266a811 perf(engine): rebuild card children only on appearance change
Every move fired StateChangedEvent -> sync_cards, which rebuilt the full
visual for all 52 cards: despawning and respawning every child entity
(drop-shadow, border frame, and on Android a Text2d corner label needing
a glyph re-layout) even for the ~50 cards that did not move. That ~250
entity despawn/spawns plus 52 text re-layouts in a single frame spiked
the StateChangedEvent frame and stuttered the slide animation on
high-resolution devices (reported on a Galaxy Fold 7).

Add a CardChildrenKey component capturing the only inputs the child
entities depend on (face_up, card_size, color_blind, high_contrast).
update_card_entity now rebuilds children only when that key changes (a
flip, resize/fold, or accessibility toggle); a position-only move just
updates the Transform. The Sprite is still refreshed every sync (a cheap
handle swap), so theme/card-back image changes need no child rebuild.

Adds a regression test asserting an appearance-neutral StateChangedEvent
no longer despawns/respawns card label children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:02:42 -07:00
funman300 26283b5478 Merge pull request 'fix(android): sign release APK v2+v3 only (drop invalid v1 JAR signature)' (#111) from fix/apk-signing-v1-obtainium into master
Android Release / build-apk (push) Successful in 5m15s
2026-06-25 18:16:57 +00:00
funman300 3627e9f9cf fix(android): sign release APK v2+v3 only (drop invalid v1 JAR signature)
The apksigner step relied on auto scheme selection, which produced an APK
carrying invalid v1 (JAR) signature files: META-INF/*.SF and *.RSA were
present but failed v1 verification (apksigner reports `v1 scheme: false`
while v2/v3 verify). Android installs such an APK fine via v2/v3, but
Obtainium parses the legacy v1 certificate at install time, gets an empty
cert list, and crashes with:

  RangeError (length): Invalid value: valid value range is empty: 0

This is why the app adds fine in Obtainium (Gitea API only) but fails on
install (APK parse). minSdk is 26, so v1/JAR signing is unnecessary —
sign explicit v2+v3 only (matching modern Android tooling for minSdk >= 24)
and pass --min-sdk-version 26. Adds a post-sign guard that fails the build
if any META-INF v1 signature files remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:16:38 -07:00
funman300 b81a79c51c Merge pull request 'docs(handoff): mark physical-device smoke test as the only v0.40.0 item left' (#110) from docs/handoff-only-smoke-test into master 2026-06-25 17:48:31 +00:00
funman300 968721eeb4 docs(handoff): mark physical-device smoke test as the only v0.40.0 item left
Elevates the physical-device smoke test as the single remaining task for the
v0.40.0 release and clarifies the Matomo validation is an independent task,
not a release blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:48:16 -07:00
funman300 780e82ca4b Merge pull request 'docs(handoff): record v0.40.0 release' (#109) from docs/handoff-v0.40.0 into master 2026-06-25 17:46:06 +00:00
funman300 207747db4b docs(handoff): record v0.40.0 release
Updates SESSION_HANDOFF.md with the v0.40.0 Android release (PRs #105/#106/#108),
the pre-release validation performed, and the still-open physical-device smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:45:49 -07:00
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
funman300 f0871c03e8 chore: gitignore local helper scripts
Ignore the local token-saving Go helpers under scripts/ (peek, cargoclip,
testfail, diffclip, cratemap, sessionpack, etc.) via scripts/*.go. These are
inspection-only dev tools, not committed. Tracked scripts/*.sh and *.md are
unaffected. Replaces a broad, machine-local .git/info/exclude rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:10:18 -07:00
funman300 e841a7ab4f refactor: delete solitaire_data::solver wrapper; solve via card_game directly
Remove the standalone solver wrapper module. Its thin shaping — build a
solve-budgeted Session, run card_game::Session::solve(), extract the first
useful move — moves onto the domain type in solitaire_core as
GameState::solve_first_move() / GameState::solve_fresh_deal(), with the budget
consts and the SolveOutcome alias re-exported from solitaire_core.

Solving is deterministic, IO-free game logic, so core (which already owns
GameState and exposes session().solve()) is its correct home; solitaire_data is
the persistence/sync layer and never should have owned it.

Consumers now call the core API directly:
- engine: pending_hint (solve_first_move), game_plugin + play_by_seed_plugin
  (solve_fresh_deal), input_plugin (budget consts)
- assetgen: gen_seeds + gen_difficulty_seeds (solve_fresh_deal)

The solver tests move to solitaire_core. cargo test --workspace and
clippy --workspace --all-targets -- -D warnings both green.

Resolves the "delete the solver" directive — card_game provides the solver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:04:47 -07:00
funman300 424c8b2d50 perf(engine): route remaining drag card→entity lookups through CardEntityIndex
Replace O(n) `Query::iter().find()` card scans with O(1) `CardEntityIndex`
lookups in the mouse and touch drag pipelines (`follow_drag`, `end_drag`,
`touch_follow_drag`, `touch_end_drag`) and `update_drag_shadow` — 7 sites
across 5 systems. Each ran per dragged card per frame during a drag.

`InputPlugin` now defensively `init_resource::<CardEntityIndex>()` (idempotent;
`CardPlugin` still owns and rebuilds it) so the plugin is self-sufficient in
tests. The lone remaining card-keyed `.find` is a `#[cfg(test)]` world-query
helper, which is the correct pattern there.

Completes the CardEntityIndex migration started in ef1efdc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:05:24 -07:00
funman300 372b6423d8 refactor(core): derive score/undo/recycle from upstream session stats
Replace the bespoke WXP scoring engine with the upstream
card_game/klondike session stats, eliminating duplicated state that
could drift from the single source of truth.

score()/undo_count()/recycle_count() now read session.stats(); the -15
undo penalty is configured as SessionConfig::undo_penalty and applied by
the upstream score formula. Save schema bumped v4 -> v5 (the three
counters are no longer persisted -- they are rebuilt by replaying the
forward instruction history on load).

- Remove GameState fields score, undo_count, recycle_count (#87)
- Remove score_history / is_recycle_history undo journal (#86)
- Remove KlondikeAdapter::apply_undo_score and the score_for_* helpers,
  plus pre_instruction_score_delta / will_flip_tableau_source (#84)

These three issues are a single atomic change: each removed field/helper
is consumed by the same draw/apply_instruction/undo/serde/PartialEq
paths, so they cannot compile or pass tests in isolation.

Behaviour changes (intentional): the escalating recycle penalty and
per-step score floor are gone (upstream linear scoring, floored once at
0); recycle_count is now cumulative; undo_count resets across save/load.

Refs #84, #86, #87

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:36:31 -07:00
funman300 9e3c6b06b0 chore: gitignore local agent-tooling artifacts
Keep Codex / claude-flow scaffolding (.agents/, .codex/, AGENTS.md) out
of the repo — these are locally generated and not project sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:17:04 -07:00
funman300 f0832f3dfa refactor: remove leftover redundancies after card_game migration
Post-migration audit found the card_game/klondike migration essentially
complete; these are the four small redundancies that remained:

- core: delete dead GameState::compute_time_bonus (zero callers; engine
  uses the klondike_adapter free fn directly)
- data: drop dead public re-exports load_latest_replay_from /
  save_latest_replay_to (no callers outside replay.rs); keep
  latest_replay_path (engine legacy migration still uses it)
- data+engine: lift win-XP scoring into a shared XpBreakdown so the
  win-summary modal breakdown and xp_for_win share one source of truth
  instead of duplicating the speed/no-undo constants
- engine: replace feedback_anim_plugin's private foundation_from_slot
  copy with the canonical klondike_adapter::foundation_from_slot

cargo test --workspace + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:12:03 -07:00
funman300 ef1efdc3b5 refactor(core): make KlondikeInstruction the move currency
Build and Deploy / build-and-push (push) Failing after 1m1s
Web E2E / web-e2e (push) Failing after 3m26s
Remove the (from, to, count) tuple as an internal move-passing wrapper.
Game logic now stays in KlondikeInstruction space end to end:

- Add GameState::apply_instruction, the native apply path. move_cards
  becomes a thin pile-coordinate adapter that converts to an instruction
  and delegates, so move bookkeeping (validation, score/recycle history,
  undo snapshot) lives in one place instead of being duplicated.
- next_auto_complete_move matches DstFoundation directly instead of
  projecting every candidate to pile coordinates.
- proptests and the storage round-trip test apply instructions directly
  rather than round-tripping instruction -> tuple -> move_cards.

The single instruction -> pile decode is renamed instruction_to_highlight
-> instruction_to_piles and kept in core: decoding a tableau run length
needs upstream pile-stack types core does not re-export, so relocating it
would duplicate the logic across engine and wasm. The two rendering edges
(engine hint highlight, wasm debug move list) call this one decoder; the
engine's hint_piles is a thin delegation to it.

Also includes the CardEntityIndex render-side index and a SelectionPlugin
init_resource fix so update_selection_highlight no longer panics in test
harnesses that omit CardPlugin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:58:28 -07:00
funman300 dc4cf45ea0 build(deps): switch klondike/card_game to Quaternions registry
Replace the git-rev pin (fb01881f, commit 2d0359c) with the published
Quaternions registry releases klondike 0.4.0 / card_game 0.4.1. The
mainline-rev switch broke clean resolution because it dropped the
`registry = "Quaternions"` selector; pinning the registry versions
restores a reproducible lockfile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:58:13 -07:00
funman300 0d3f037672 refactor: consolidate card_to_id into solitaire_core
Build and Deploy / build-and-push (push) Failing after 1m21s
Web E2E / web-e2e (push) Failing after 3m27s
Three byte-identical copies of the stable 0..=51 card-id helper
(suit_index*13 + rank-1) lived in feedback_anim_plugin, radial_menu, and
solitaire_wasm. The WASM copy's own comment notes it MUST match the engine
for cross-platform replay parity — exactly the kind of invariant a single
source of truth should enforce.

Add `solitaire_core::card::card_to_id(&Card) -> u32` and have all three
call sites import it. No behaviour change (same formula).

cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:14:20 -07:00
funman300 cac77a54a6 refactor: slim solver to card_game-native types
Build and Deploy / build-and-push (push) Failing after 1m34s
Web E2E / web-e2e (push) Failing after 4m22s
Per Rhys: card_game's solver is the real engine, so drop the redundant
adapter types in solitaire_data::solver rather than maintain a parallel
verdict/config/move vocabulary.

- Delete SolverResult, SolverConfig, SolverMove, and snapshot_to_solver_move.
  The verdict now reads straight off card_game's return:
    Ok(Some(instr)) = winnable (first move on the path)
    Ok(None)        = provably unwinnable
    Err(_)          = inconclusive (budget exceeded)
- SolveOutcome is now Result<Option<KlondikeInstruction>, SolveError>.
- try_solve / try_solve_from_state take plain (moves_budget, states_budget)
  u64s; add DEFAULT_SOLVE_{MOVES,STATES}_BUDGET consts.
- snapshot_to_solver_move duplicated core's GameState::instruction_to_move,
  so make that pub and have the hint convert the first-move instruction to
  highlighted (from, to) piles through it. Re-export KlondikeInstruction
  from solitaire_core.
- HintSolverConfig now holds { moves_budget, states_budget } instead of
  wrapping the deleted SolverConfig.
- Update consumers: pending_hint, play_by_seed (verdict badge), game_plugin
  (choose_winnable_seed), input_plugin, hud_plugin, and the gen_seeds /
  gen_difficulty_seeds asset tools.

solver.rs drops 274 -> 140 lines. cargo test --workspace and
cargo clippy --workspace --all-targets -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:05:47 -07:00
funman300 2d0359c2ee build(deps): switch card_game/klondike to mainline fb01881f
Build and Deploy / build-and-push (push) Failing after 1m6s
Web E2E / web-e2e (push) Failing after 3m7s
Move both crates off the damaged "hacked" rev 99b49e62 onto mainline
master (card_game 0.4.0->0.4.1, klondike 0.3.0->0.4.0) to pick up the new
serialize implementation.

Mainline drops the serde derives from Deck/Suit/Rank (only Card is serde
now, as a compact transparent NonZeroU8) and gives KlondikeInstruction a
hand-written serde impl. Adapt the repo:
- Rank::value() was removed; the enum discriminant is the 1..=13 value, so
  use `rank as u32/u8` in the three card_to_id helpers (wasm, radial_menu,
  feedback_anim).
- Drop the vestigial Serialize/Deserialize derive on theme::CardKey; theme
  manifests address faces by manifest_name strings, never by serialising
  CardKey, and Suit/Rank no longer implement serde.

GameState's own instruction-mirror serde (schema v3/v4) is insulated from
the klondike serde change, so the on-disk save format is unchanged.

cargo test --workspace and cargo clippy --workspace -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:34:56 -07:00
funman300 056459619b refactor(core): derive draw_mode/is_won/move_count/is_auto_completable from session
Remove the draw_mode, move_count, is_won, and is_auto_completable fields
from GameState; they are now &self methods deriving from the underlying
card_game session (draw_mode from session config, move_count from history
length, is_won/is_auto_completable from check_win/check_auto_complete).

Tests previously fabricated these via direct field writes, which is no
longer possible. Add gated test-support overrides on TestPileState
(won/auto_completable/move_count) plus setters set_test_won,
set_test_auto_completable, set_test_move_count, and set_test_draw_mode
(re-deals the seed). All compiled out in production builds.

Fix the field->method ripple across solitaire_data, solitaire_wasm, and
solitaire_engine. Add a test-support dev-dependency to solitaire_data for
the won-game storage test.

cargo test --workspace and cargo clippy --workspace -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:24:03 -07:00
funman300 1438fd6265 refactor(core): complete card_game::Card migration across engine + wasm
Build and Deploy / build-and-push (push) Failing after 1m2s
Web E2E / web-e2e (push) Failing after 3m19s
Finish the half-applied Card refactor. solitaire_core::card::Card is now an
alias for the opaque card_game::Card: suit()/rank() are methods, there is no
id or face_up field, and it is Clone+Eq+Hash but not Copy. Pile accessors
return Vec<(Card, bool)> where the bool is face-up.

Card identity is now the Card value itself (via Eq/Hash), not a numeric u32:
- CardEntity stores `card: Card` (was `card_id: u32`); lookups compare cards.
- Drag/selection collections and the touch/keyboard selection setters use
  Vec<Card>; CardFlippedEvent/CardFaceRevealedEvent/HintVisualEvent carry Card.
- replay_overlay and feedback/settle/deal animations updated accordingly.

solitaire_wasm: CardSnapshot derives its JSON id from suit+rank (matching the
desktop engine), and consumes the (Card, bool) pile tuples.

test-support: TestPileState tableau overrides now carry a per-card face-up flag
so tests can place face-down tableau cards. set_test_tableau_cards keeps its
Vec<Card> signature (defaulting to face-up); new set_test_tableau_cards_with_face
takes Vec<(Card, bool)>.

cargo test --workspace passes (engine lib 897 ok, 0 failed); cargo clippy
--workspace --all-targets -- -D warnings is clean. Save/serde format unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:45:34 -07:00
funman300 920f2c8597 refactor(core): move solver to solitaire_data, DrawMode to klondike_adapter, remove pile/solver/schema_version
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root)
- Delete solitaire_core::pile — no external users
- Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode
- Remove schema_version field from GameState (redundant — deserializer stamps it from the constant)
- Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:38:04 -07:00
funman300 37a21b9b42 docs: record android avd smoke 2026-06-08 19:24:42 -07:00
funman300 712ed6be80 docs: clarify android support status 2026-06-08 19:14:48 -07:00
funman300 324003562b test: cover mobile card label glyphs
Build and Deploy / build-and-push (push) Successful in 1m4s
2026-06-08 19:13:40 -07:00
funman300 a69a774edf docs: refresh handoff after runbooks 2026-06-08 19:12:15 -07:00
funman300 df4887fb36 docs: update android smoke test runbook 2026-06-08 19:11:02 -07:00
funman300 159774f811 docs: add analytics validation runbook
Build and Deploy / build-and-push (push) Successful in 1m6s
2026-06-08 19:09:22 -07:00
funman300 b3c4d08dfc docs: avoid stale handoff head hash 2026-06-08 19:06:07 -07:00
funman300 f313cfd8b7 docs: update session handoff state 2026-06-08 19:05:20 -07:00
funman300 7fe6ac6c1c docs: catch up handoff and changelog
Build and Deploy / build-and-push (push) Successful in 5m23s
2026-06-08 19:03:40 -07:00
111 changed files with 3959 additions and 3782 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
}
+11
View File
@@ -30,3 +30,14 @@ solitaire_server/e2e/test-results/
deploy/matomo-secret.yaml
deploy/*-secret.yaml
deploy/*-auth-secret.yaml
# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo
/.agents/
/.codex/
/AGENTS.md
# claude-flow scratch dirs, anywhere in the tree (e.g. solitaire_engine/src/)
.claude-flow/
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
scripts/*.go
+293
View File
@@ -6,6 +6,299 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- **Analytics validation runbook.** Documented native Matomo live validation,
expected event payloads, and the current web/WASM analytics split.
- **Android smoke-test runbook.** Updated the Android doc with the current
platform status, support matrix, and a physical-device
launch/touch/safe-area checklist.
- **Browser Bevy canvas route and automation support.** Added the `solitaire_web`
Bevy WASM build, wired `/play` to the Bevy canvas, added a
`window.__FERROUS_DEBUG__` bridge, and introduced Playwright coverage for the
web routes and interactive canvas behavior.
- **Card-game / klondike integration.** Began replacing in-house card and pile
internals with upstream `card_game` / `klondike` types, including adapter
work, GameMode-aware scoring, upstream instruction serde, `KlondikePile`
migration, and documentation for the in-place rewrite phases.
- **Android keystore integration.** Added Android Keystore JNI wiring via
`OnceLock` and improved Android token handling around the app directory.
### Changed
- **Core type ownership.** Routed all klondike/card imports through
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
types.
- **Web/WASM build reliability.** Rebuilt WASM packages, cleaned up wasm32 build
warnings, added a Binaryen `wasm-opt` pass, pinned upstream git dependencies,
and added a CI guard for canvas WASM drift.
- **Difficulty seed catalog.** Regenerated the difficulty seed list for the
latest verified catalog.
### Fixed
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
each column's total depth (face-down cards included) and refills on the
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
resize and fold/unfold), so a near-square viewport such as an unfolded Galaxy
Fold no longer leaves the bottom of the screen empty. The fan spread cap was
raised so very tall / narrow viewports (e.g. a foldable cover screen) fill
further.
- **Card-move animation jank.** A move now rebuilds a card's child visuals only
when its appearance changes (flip / resize / accessibility) instead of
despawning and respawning every card's children each `StateChangedEvent`,
removing the per-move spike that stuttered the slide animation on
high-resolution devices.
- **Android and modal safe-area layout.** Modal cards now center within the
usable area between status and gesture bars, additional modal-spawn guards were
added, and Android build scripts now auto-discover SDK/NDK paths and strip
native libraries.
- **Core scoring and undo correctness.** Fixed recycle-count drift, undo score
compounding, foundation-to-tableau instruction coverage, and several
illegal-move paths discovered during the card-game migration.
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
clicks, delayed first-run onboarding until splash teardown, and kept dragged
stacks above all piles.
- **Draw-Three waste fan hit testing on Android.** The renderer and the click
hit-test now share a single `waste_fan_step` / `tableau_col_step` source. They
previously diverged under Android's tighter column spacing, shifting the top
fanned waste card's hit target onto the card beneath it, so dragging the visible
card played the wrong one.
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
file paths to async I/O where needed, and validated `JWT_SECRET` at startup.
- **CI and deployment workflow.** Fixed deploy-branch handling, Docker registry
secret usage, and related release automation issues.
### Tests
- Ran an Android AVD `Pixel_7` launch smoke for the x86_64 debug APK,
including install, NativeActivity launch, safe-area log validation, screenshot
render check, onboarding input, and crash-log review.
- Added direct coverage for Android/touch card corner labels using Unicode suit
glyphs.
- Added schema-v3 persistence round-trip coverage, foundation-to-tableau
instruction coverage, expanded WASM unit tests, and Playwright E2E specs for
browser routes and game-canvas behavior.
## [0.39.0] — 2026-05-19
### Fixed
- **No-legal-moves detection and banner.** Corrected no-move detection across
engine, WASM, and web paths, then surfaced the state to players with an
in-game banner instead of silently leaving the board stuck.
- **Release/deploy automation.** Updated deployment automation so kustomization
changes are pushed to the deploy branch instead of the main development
branch.
## [0.38.0] — 2026-05-19
### Added
- **Klondike scoring parity.** Added tableau flip bonuses and stock recycle
penalties to align scoring with standard Klondike expectations.
### Fixed
- **Core rule enforcement.** Auto-complete now requires an empty waste pile,
waste-origin moves reject multi-card transfers, foundation-to-foundation moves
are blocked, and undo restores score from the snapshot baseline.
- **Modal lifecycle guards.** Added missing `ModalScrim` guards to New Game,
restore prompt, and no-moves modal spawn sites.
- **Runtime and server robustness.** Tokio runtime setup degrades gracefully
instead of panicking; web replay submission casing/date formatting now matches
server expectations; avatar routes are publicly reachable when intended.
- **Android token and sync merge correctness.** Android tokens are namespaced
under the application directory, stored per user, and migrated safely; sync
merges preserve draw-one / draw-three win invariants.
## [0.37.0] — 2026-05-19
### Fixed
- **Foundation-to-tableau default.** Made `take_from_foundation` default to true
across clients so restored, startup, and web games use the same supported move
rules.
## [0.36.12] — 2026-05-19
### Fixed
- **Foundation-to-tableau default.** Set `take_from_foundation` true by default
in core so every client inherits the intended house rule without special-case
setup.
## [0.36.11] — 2026-05-19
### Fixed
- **Web foundation moves.** Enabled take-from-foundation moves in the web game
client.
## [0.36.10] — 2026-05-19
### Added
- **Web resume flow.** Browser games now persist state across page refreshes and
can resume through a dialog instead of starting over.
## [0.36.9] — 2026-05-19
### Fixed
- **Settings sync connection flow.** Clicking Connect from Settings now opens the
sync-setup modal.
## [0.36.8] — 2026-05-19
### Fixed
- **Restored/startup foundation moves.** Enabled take-from-foundation behavior
for restored and startup games, not only newly-created sessions.
## [0.36.7] — 2026-05-19
### Fixed
- **Remaining Android UI issues.** Resolved the final Android UI defects from
the review pass, including action-bar/tableau interaction and safe visual
spacing.
## [0.36.6] — 2026-05-19
### Fixed
- **Action-bar layout reservation.** Reserved action-bar height in layout so
tableau columns do not extend behind bottom controls.
## [0.36.5] — 2026-05-19
### Added
- **Responsive Android action-bar glyphs.** Action-bar glyph font size now scales
dynamically on Android to fit available space.
## [0.36.4] — 2026-05-19
### Fixed
- **Classic card labels and HUD overlap.** Corrected classic-card corner-label
colors and fixed HUD-band overlap in the Android layout.
## [0.36.3] — 2026-05-19
### Fixed
- **Core, animation, and modal review fixes.** Added the foundation-to-tableau
score penalty, hardened solver win validation, guarded zero-duration card
animations, aligned initial and dynamic tableau fan spacing, and added missing
modal guards for play-by-seed and win-summary paths.
- **Pause, messages, credentials, and server validation.** Auto-complete respects
pause state, standalone plugins register their events, sync passwords are
cleared from ECS buffers after auth task spawn, and avatar MIME validation uses
exact matches.
- **Foundation pile rendering.** Raised stack fan z-order above corner labels to
prevent bleed-through.
- **Android release workflow.** Added a manual `workflow_dispatch` trigger to
the Android release workflow.
## [0.36.2] — 2026-05-19
### Fixed
- **Comprehensive review fixes.** Addressed 26 issues across core rules, replay
controls, modal guards, sync payload timing, server replay casing, time-attack
overlays, theme refresh, auth overlays, stats ordering, animations, cursor
fallbacks, achievements, server temp-file cleanup, and runtime fallback paths.
- **Animation and Android label polish.** Cancelled stale win-cascade animations
on new game, refreshed Android corner labels on resize, lifted animating cards
above lower z-layers, and froze the web timer when auto-complete starts.
- **Web package and tooling updates.** Rebuilt the WASM package for
foundation-to-tableau moves, added ruflo scaffolding, and ignored ruflo runtime
state files.
- **Leaderboard test stability.** Made opt-in / opt-out tests robust under
parallel test execution.
## [0.36.1] — 2026-05-18
### Fixed
- **Android HUD gesture conflict.** Stock taps no longer toggle HUD visibility on
Android.
## [0.36.0] — 2026-05-18
### Changed
- **Rank model cleanup.** `Rank` now uses explicit discriminants and checked
arithmetic, making rank conversions and sequencing more robust.
- **Instruction generation.** Refined `possible_instructions` alongside the rank
arithmetic cleanup.
- **Session handoff.** Recreated `SESSION_HANDOFF.md` to reflect the `0.35.1`
state.
## [0.35.1] — 2026-05-17
### Fixed
- **Leaderboard profile sync.** Fixed three leaderboard/profile issues: wrong
toast type for failures, stale display-name label after update, and display
name not syncing to the server.
## [0.35.0] — 2026-05-17
### Added
- **Reduced-motion support.** Decorative motion animations are now gated behind
`reduce_motion_mode`.
### Changed
- **Performance and runtime cleanup.** Shared a single Tokio runtime across
network tasks and gated frame-hot ECS systems on resource changes.
- **Core/data refactors.** Consolidated the application directory name, added
`#[must_use]` to pure helpers, derived `Copy` for `DrawMode`, removed
redundant clones, added missing derives to `AchievementContext`, and used
saturating move-count arithmetic.
- **HUD z-layer naming.** Replaced raw HUD popover z-index arithmetic with named
layer constants.
### Fixed
- **Android UI and font safety.** Wired FiraMono to stock-empty labels, removed
raw physical safe-area pixels from HUD spawns, replaced unsupported chevrons,
corrected the Android help hint label, and fixed touch/drop-zone behavior.
- **Engine modal and panic hardening.** Eliminated several runtime panics, added
required transforms to modal scrims, constrained dismiss hit-tests, and guarded
home overlay respawns.
- **Sync/data/server correctness.** Deterministic pile serialization, undo skip
handling, byte URL encoding, merge timestamp handling, auth-guarded avatar
serving, atomic server writes, and user-id assertions were corrected.
- **Display-name and token-file boundaries.** Enforced the 32-character display
name limit in the sync client and aligned Android keystore temp-file cleanup
with the cleanup glob.
- **WASM error reporting.** `state()` and `step()` now return `Result` so errors
surface as JavaScript exceptions.
- **Sync and leaderboard toasts.** Pull failures and leaderboard opt-in /
opt-out failures now produce the intended warning/error feedback.
### Documentation
- Corrected stale focus-ring color documentation.
## [0.34.0] — 2026-05-17
### Fixed
- **Android waste fan and resume layout.** Corrected Android waste-pile fan
overlap and a layout desynchronization after resume.
- **Card-face artwork.** Fixed the wrong bottom-right suit symbol on the jack,
queen, and king of spades.
- **Android corner-label font coverage.** Wired FiraMono into Android corner
labels and added `CardImageSet` tests to guard the asset path behavior.
## [0.33.0] — 2026-05-16
### Fixed
+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
+11 -4
View File
@@ -2083,11 +2083,13 @@ dependencies = [
[[package]]
name = "card_game"
version = "0.4.0"
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
version = "0.4.1"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "983728ead19f51d96931725706e62293bd133ac3d836097dd7d745e929f7811b"
dependencies = [
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
"serde",
"serde_derive",
]
[[package]]
@@ -4599,12 +4601,14 @@ dependencies = [
[[package]]
name = "klondike"
version = "0.3.0"
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
version = "0.4.0"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "d5c82b0c3abd7da07b4a1c4221a809e6e2ffd475ae0e67180fbfef35a9cfe769"
dependencies = [
"card_game",
"rand 0.10.1",
"serde",
"serde_derive",
]
[[package]]
@@ -7302,6 +7306,7 @@ name = "solitaire_app"
version = "0.1.0"
dependencies = [
"bevy",
"jni 0.21.1",
"keyring",
"solitaire_data",
"solitaire_engine",
@@ -7336,11 +7341,13 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"card_game",
"chrono",
"dirs",
"jni 0.21.1",
"jsonwebtoken",
"keyring-core",
"klondike",
"reqwest",
"serde",
"serde_json",
+18 -2
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"
@@ -38,8 +54,8 @@ solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" }
klondike = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
klondike = { version = "0.4.0", registry = "Quaternions", features = ["serde"] }
card_game = { version = "0.4.1", registry = "Quaternions", features = ["serde"] }
# Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
+67 -43
View File
@@ -1,45 +1,62 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-02 — Web e2e test suite complete; `/play` canvas bridge added and tested. All commits on origin/master.
**Last updated:** 2026-06-25v0.40.0 released (Android APK published); physical-device gate remains.
---
## Current state
- **HEAD:** `play_canvas.spec.js` added (Playwright tests for `/play` Bevy canvas route)
- **Latest tag:** `v0.35.1`
- **Working tree:** clean
- **Build:** `cargo clippy --workspace -- -D warnings` clean
- **Tests:** 1243 Rust tests passing; Playwright suite in `solitaire_server/e2e/`
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105#108).
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
---
## What shipped since the last handoff (v0.35.1 → present, 2026-06-02)
## v0.40.0 release (2026-06-25)
| Commit | Summary |
|--------|---------|
| `64f975e` | 14 cross-platform UX/UI fixes from 500-game audit |
| `763fdb4` | Fix input: hit-test deck at correct position; accept waste click |
| `1cdb78c` | cargo fmt; add analytics domain to CSP |
| `baf524e` | Rebuild Bevy canvas WASM; add SolitaireGame interactive API |
| `9ff0585` | Remove Quaternions registry auth; canvas WASM drift guard |
| `de7ae16` | Delay first-run modal until splash screen despawns |
| `8b736ca` | Debug drag failures (temp logging, removed in next commit) |
| `8b262af` | Clamp wgpu surface to CSS pixels on HiDPI (prevented WASM panic) |
| `d45b7cb` | Add Playwright e2e test suite for web routes |
| `2cf7282` | Add `window.__FERROUS_DEBUG__` bridge to `/play` for automation |
Released via tag push → `.gitea/workflows/android-release.yml` built and signed the
arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`,
29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo
pick it up automatically.
**Key audit bugs fixed (all 7 from 500-game UX audit):** timer-after-undo, radial-menu clamping, Android resume flash, tab-hidden timer, orphaned tmp files, drag threshold 4→6px, Draw-1 recycle doc comment.
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
**HiDPI wgpu fix:** `WindowResolution::default().with_scale_factor_override(1.0)` added to the Bevy canvas app. Root cause was physical pixels (CSS×DPR) exceeding WebGL2's 2048px per-dimension limit on HiDPI displays.
| PR | Summary |
|----|---------|
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
| #108 | **docs(android):** NDK reference updated `26.3.11579264``30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
**E2E test architecture:** three-tier — Rust unit tests → Playwright smoke/review specs → cycle regression gate. Debug bridge contract in `docs/testing-architecture.md`.
Pre-release validation performed locally this session: workspace clippy/test/build
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
proving the `build_android_apk.sh` packaging pipeline end-to-end.
---
## What shipped before v0.35.1
## What shipped since v0.39.0
See git log. CHANGELOG.md currently ends at v0.33.0 (documentation debt, low priority).
- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`.
- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up:
- `5e87358` integrates upstream deps cleanly.
- `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types.
- `d864d98` routes klondike/card imports through `solitaire_core`.
- `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs.
- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed.
- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`.
- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels.
- Native analytics and Android physical-device validation now have runbooks in
`docs/analytics-validation.md` and `docs/ANDROID.md`.
---
## Historical notes before v0.39.0
See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work.
---
@@ -110,31 +127,38 @@ Three bugs fixed:
## Open punch list
### 1. CHANGELOG documentation debt
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
CHANGELOG.md currently ends at v0.33.0. All post-v0.33.0 work is in git log. Low
priority — git log is authoritative.
This is the **single outstanding task** for the v0.40.0 Android release. Everything
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
clippy, release manifest sanity, a full local signed APK, the published release, and
Obtainium-facing delivery (public releases API, latest non-draft release, APK
downloadable anonymously). The only thing that cannot be done without hardware is
running the app on a real phone.
### 2. Android APK launch verification (Option A)
Install the published APK on a real Android device (not AVD) and run the checklist
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
Physical device test: install the latest APK on a real Android device (not AVD),
confirm:
- App launches without crash
- Safe area insets arrive and shift HUD correctly after ~3 frames
- All modal Done buttons are above the gesture bar
- Drag-and-drop works on all pile types
- Leaderboard panel opens and the "Public name" label updates correctly after
using "Set Name"
The signed release APK is published (grab it from the release page, or use the local
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real
touch events, so physical-device smoke testing is the only gate.
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR.
### 3. Matomo analytics wiring
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
engine code consumes them — the analytics toggle in Settings is a no-op. If
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
and wired to `GameStateResource` events.
Separate, ongoing task unrelated to the Android release. `Settings` has
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
validation checklist and the current web/WASM decision notes.
---
+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
+57 -22
View File
@@ -2,13 +2,13 @@
This doc captures the toolchain install + build invocation for the
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
later sections document what's known to compile, what's stubbed, and
the next milestones.
later sections document physical-device validation, supported platform
surfaces, and remaining Android follow-ups.
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
> NOT yet been verified to launch on a device or emulator — that's
> the next milestone.
> **Status (2026-06-09):** Android build plumbing, app-directory storage,
> JNI keystore wiring, and safe-area layout fixes have landed. The remaining
> release gate is a physical-device smoke test; AVD tap injection does not
> exercise the real touch path reliably enough for launch verification.
---
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
echo ''
echo '# Android dev'
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.14904198"'
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
} >> ~/.bashrc
@@ -49,10 +49,15 @@ sdkmanager \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0" \
"ndk;26.3.11579264" \
"ndk;30.0.14904198" \
"emulator" \
"system-images;android-34;google_apis;x86_64"
# The exact NDK/build-tools versions above are not load-bearing — newer ones
# work (verified on NDK 30.0.14904198 / build-tools 37.0.0). `scripts/build_android_apk.sh`
# auto-discovers the newest installed NDK and build-tools, so set ANDROID_NDK_HOME
# (step 3) to whatever version you actually install here.
# 6. AVD for testing (one-time).
echo no | avdmanager create avd \
-n bevy_test \
@@ -163,8 +168,8 @@ accepted workaround.
Physical device:
```bash
adb devices # confirm connection
adb install target/debug/apk/ferrous-solitaire.apk
adb devices # confirm connection
adb install -r target/debug/apk/ferrous-solitaire.apk
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
```
@@ -185,35 +190,65 @@ AVD.
---
## 4. What's wired vs. what's stubbed
## 4. Physical-device smoke test
The first build pass (commit `fb8b2ac`) gates four desktop-only
crates / call sites so the workspace cross-compiles. Each gate is
documented at its call site.
Run this on a real phone, preferably a modern 64-bit ARM device with gesture
navigation enabled.
Build and install:
```bash
cargo apk build -p solitaire_app --target aarch64-linux-android --lib
adb install -r target/debug/apk/ferrous-solitaire.apk
adb logcat -c
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic|WindowInsets"
```
Pass criteria:
- App launches without panic or ANR.
- Safe-area insets arrive after the first few frames and shift HUD/modal
content away from the status and gesture bars.
- Every modal's Done button remains above the gesture bar:
Settings, Help, Pause, Win Summary, and Leaderboard-related dialogs.
- Drag-and-drop works on tableau, waste, foundation, and stock/recycle paths.
- Tap-to-select and one-tap modes both respond correctly on card stacks.
- Leaderboard panel opens, "Set Name" saves, and the "Public name" label updates
while the panel remains open.
- Rotate the device once, then repeat one modal and one drag operation.
- Close and relaunch the app; settings/progress still load.
Record the device model, Android version, APK commit, and pass/fail notes in the
release notes or session handoff. If a failure occurs, keep the filtered logcat
and note the exact screen/control path that reproduced it.
---
## 5. Platform support matrix
Desktop-only crates and call sites are gated so the workspace cross-compiles.
Each gate is documented at its call site.
| Surface | Desktop | Android |
|---------|---------|---------|
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Android Keystore via JNI |
| Data directory | Platform data dir | Android app files dir |
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
What's NOT yet ported / not yet measured:
Remaining Android follow-ups:
- `dirs::data_dir()` returns `None` on Android. Callers in
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
`achievements.rs`, `settings.rs` all need an Android-aware
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
- Touch UX pass — hit-target sizes, modal scaling on small screens,
app lifecycle (suspend / resume), font scaling.
- Android Keystore via JNI for `auth_tokens`.
- JNI ClipboardManager for share links.
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
in older docs doesn't yet exist).
---
## 5. Iteration loop
## 6. Iteration loop
```bash
# Edit code…
+67
View File
@@ -0,0 +1,67 @@
# Analytics Validation Runbook
Ferrous Solitaire currently has two analytics paths:
- Native desktop/Android gameplay events use `solitaire_engine::AnalyticsPlugin`
and `solitaire_data::MatomoClient`.
- Hosted web pages include Matomo page-view snippets in
`solitaire_server/web/*.html`.
The Bevy `/play` WASM canvas does not emit the native gameplay events because
`AnalyticsPlugin` is intentionally gated out on `wasm32`; it depends on the
native Tokio/reqwest stack.
## Native Matomo Validation
Use this when a deployed Matomo instance and a native build are available.
1. Configure `settings.json` with a Matomo URL and site ID:
```json
{
"analytics_enabled": true,
"matomo_url": "https://analytics.example.com",
"matomo_site_id": 1
}
```
2. Launch the native app and open Settings.
3. Confirm the Privacy section appears and "Share usage data" is `ON`.
4. Start a new confirmed game.
5. Win or forfeit the game.
6. Unlock an achievement if practical, or use an existing achievement path that
is easy to trigger in a test profile.
7. Wait at least 60 seconds, or close after the win/forfeit path has fired its
immediate flush.
8. In Matomo, confirm the following custom events arrived:
| Category | Action | Name |
| --- | --- | --- |
| `Game` | `Start` | `classic`, `zen`, `challenge`, `time_attack`, or `difficulty` |
| `Game` | `Won` | empty |
| `Game` | `Forfeit` | empty |
| `Achievement` | `Unlocked` | achievement id |
## Web/WASM Decision
Keep the current split unless the project explicitly needs in-canvas gameplay
events for `/play`.
Current behavior:
- `/`, `/play-classic`, `/account`, `/leaderboard`, and `/replays` emit Matomo
page views through the hosted HTML snippets.
- `/play` hosts the Bevy canvas but does not emit gameplay events from the
engine.
- The browser Content-Security-Policy already allows the deployed Matomo host
for scripts, images, and connections.
If gameplay events are needed on `/play`, add a small `wasm32`-only analytics
bridge instead of trying to compile the native plugin:
- keep the same event contract as native (`Game / Start`, `Game / Won`,
`Game / Forfeit`, `Achievement / Unlocked`);
- read `Settings::analytics_enabled`, `matomo_url`, and `matomo_site_id`;
- send through browser APIs or the existing `_paq` queue;
- keep the Settings opt-in behavior identical to native;
- add Playwright coverage that stubs Matomo and verifies emitted payloads.
+29 -14
View File
@@ -2,7 +2,10 @@
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
**Approach:** Integration is complete. Upstream `card_game` / `klondike` now owns
authoritative Klondike rules, session history, undo snapshots, and solving.
Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes,
and typed UI errors in `solitaire_core`.
---
@@ -42,10 +45,12 @@
---
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
## What Ferrous Solitaire's `solitaire_core` Still Owns
### 1. Scoring — remaining adapter responsibilities
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
Ferrous uses **Windows XP Standard** scoring. The upstream library handles the
per-move counters and configurable deltas; Ferrous adds the product-specific
parts in `GameState` / `KlondikeAdapter`.
| Event | Delta | Handled by |
|---|---|---|
@@ -61,11 +66,13 @@ Ferrous uses **Windows XP Standard** scoring. The exact table already implemente
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The 15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty.
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
**In our wrapper:** `KlondikeAdapter::config_for` configures the upstream rules
and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor,
time bonus, game-mode suppression, and undo score restoration.
### 2. Game Modes
Ferrous has three modes that alter scoring and undo behaviour:
@@ -78,7 +85,9 @@ Ferrous has three modes that alter scoring and undo behaviour:
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
**In our wrapper:** `GameMode` lives on `solitaire_core::GameState`; undo and
scoring behavior are applied before/after delegating legal moves to the upstream
session.
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
@@ -87,9 +96,13 @@ Zen is intended for relaxed play where the score does not matter. Challenge is a
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
The old local DFS has been replaced. `solitaire_core::solver` is now a small
adapter around `Session::solve()` that preserves the engine-facing
`SolverResult`, `SolverConfig`, and first-move payload contract.
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested
budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget
errors → Inconclusive.
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
@@ -135,7 +148,9 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
### 8. Undo Stack Approach *(resolved — not an issue)*
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
**Resolution:** `GameState` uses `Session`'s built-in snapshot history. Ferrous
keeps parallel score/recycle metadata so undo can restore product-specific score
state that upstream snapshots do not own.
---
@@ -144,12 +159,12 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
1.**Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
3.**Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
2. **Map pile types** — project `klondike`'s stock face-up half as the engine's waste pile and expose renderer-facing pile snapshots.
3.**Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (gap 4, upstream).
4.**Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
5.**Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
7. **Implement `serde`**define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
6. **Replace solver** — call `session.solve()` with budgets from `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
7. **Implement `serde`**serialise schema v4 with upstream `KlondikeInstruction`; auto-migrate schema v3 via `SavedInstruction` compatibility types.
---
@@ -192,5 +207,5 @@ The script enforces:
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
- Upstream solver PR: #14
- `solitaire_core` source: `solitaire_core/src/`
- Scoring spec: `solitaire_core/src/scoring.rs`
- Scoring implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs`
- Architecture overview: `ARCHITECTURE.md`
+21 -1
View File
@@ -213,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
mkdir -p "$(dirname "$APK_OUT")"
echo ">>> apksigner sign -> $APK_OUT"
# Sign the schemes explicitly instead of relying on apksigner's auto behaviour.
# Left to "auto", this pipeline produced an APK carrying invalid v1 (JAR)
# signature files (META-INF/*.SF/.RSA present but failing v1 verification).
# Android installs it fine via v2/v3, but Obtainium parses the APK's legacy v1
# certificate at install time, gets an empty cert list, and crashes with
# "RangeError (length): Invalid value: valid value range is empty: 0".
# minSdk is 26 (solitaire_app/android/AndroidManifest.xml), so v1/JAR signing is
# not needed at all — disable it and ship a clean v2+v3 signature, matching what
# modern Android tooling produces for minSdk >= 24.
"$BT/apksigner" sign \
--ks "$KEYSTORE" \
--ks-pass "pass:$KEYSTORE_PASS" \
--ks-key-alias "$KEY_ALIAS" \
--key-pass "pass:$KEY_PASS" \
--min-sdk-version 26 \
--v1-signing-enabled false \
--v2-signing-enabled true \
--v3-signing-enabled true \
--out "$APK_OUT" \
"$STAGING/app-aligned.apk"
echo ">>> verify"
"$BT/apksigner" verify --verbose "$APK_OUT"
"$BT/apksigner" verify --min-sdk-version 26 --verbose "$APK_OUT"
# Guard: no leftover v1/JAR signature files may remain — their presence (valid or
# not) is what tripped Obtainium. Fail the build if any slipped through.
if unzip -l "$APK_OUT" 2>/dev/null | grep -qiE 'META-INF/.*\.(SF|RSA|DSA|EC)$'; then
echo "ERROR: APK still contains v1/JAR signature files; expected v2+v3 only" >&2
exit 1
fi
echo ">>> done: $APK_OUT"
+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
@@ -2,10 +2,10 @@
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
//! `solitaire_data/src/difficulty_seeds.rs`.
//!
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
//! provably-winnable seeds).
//! A seed's tier is determined by the **smallest** solve budget at which it is
//! proven winnable (`Ok(Some(_))`). Seeds proven dead (`Ok(None)`) at any budget
//! are discarded; seeds inconclusive (`Err`) at all budgets are also discarded
//! (we only emit provably-winnable seeds).
//!
//! # Usage
//!
@@ -19,12 +19,12 @@
//! --per-tier Seeds to emit per tier (default 40)
//! --help Print this message
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::GameState;
// Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable.
const BUDGETS: &[(&str, u64, usize)] = &[
const BUDGETS: &[(&str, u64, u64)] = &[
("Easy", 1_000, 1_000),
("Medium", 5_000, 5_000),
("Hard", 25_000, 25_000),
@@ -74,7 +74,7 @@ fn main() {
std::process::exit(1);
}
let draw_mode = DrawMode::DrawOne;
let draw_mode = DrawStockConfig::DrawOne;
let num_tiers = BUDGETS.len();
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
let mut tried: u64 = 0;
@@ -99,12 +99,8 @@ fn main() {
if buckets[i].len() >= per_tier {
continue;
}
let cfg = SolverConfig {
move_budget,
state_budget,
};
match try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable => {
match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) {
Ok(Some(_)) => {
buckets[i].push(seed);
eprintln!(
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
@@ -113,13 +109,13 @@ fn main() {
);
break 'tier; // assign to the cheapest tier that proves it winnable
}
SolverResult::Unwinnable => {
Ok(None) => {
// Definitely unsolvable — skip all remaining tiers.
break 'tier;
}
SolverResult::Inconclusive => {
Err(_) => {
// Budget exhausted without proof — try the next larger tier.
// If this is the last tier, the seed is discarded (Inconclusive
// If this is the last tier, the seed is discarded (inconclusive
// at max budget means "probably but not provably winnable").
if i == num_tiers - 1 {
break 'tier;
+14 -6
View File
@@ -1,7 +1,7 @@
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
//!
//! Walks seeds incrementally from `--start`, calls the solver on each, and
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
//! collects only those proven winnable (`Ok(Some(_))`; inconclusive is
//! rejected — the curated list wants proof). Prints Rust source suitable for
//! pasting into `solitaire_data/src/challenge.rs`.
//!
@@ -17,8 +17,9 @@
//! --count Number of Winnable seeds to emit (default 75)
//! --help Print this message
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::GameState;
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
fn main() {
let mut args = std::env::args().skip(1).peekable();
@@ -67,8 +68,7 @@ fn main() {
std::process::exit(1);
}
let cfg = SolverConfig::default();
let draw_mode = DrawMode::DrawOne;
let draw_mode = DrawStockConfig::DrawOne;
let mut found: Vec<u64> = Vec::with_capacity(count);
let mut tried: u64 = 0;
let mut seed = start;
@@ -77,7 +77,15 @@ fn main() {
while found.len() < count {
tried += 1;
if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) {
if matches!(
GameState::solve_fresh_deal(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET
),
Ok(Some(_))
) {
found.push(seed);
eprintln!(
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
+3
View File
@@ -16,3 +16,6 @@ serde = { workspace = true }
thiserror = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
[lints]
workspace = true
-110
View File
@@ -1,110 +0,0 @@
use serde::{Deserialize, Serialize};
pub use card_game::{Rank, Suit};
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card {
/// Unique identifier for this card within the deal. Stable across moves and undo.
pub id: u32,
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
pub suit: Suit,
/// The card's rank (Ace through King).
pub rank: Rank,
/// Whether the card is visible to the player. Face-down cards may not be moved.
pub face_up: bool,
}
impl Card {
/// Creates a card with explicit face orientation.
pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self {
Self {
id,
suit,
rank,
face_up,
}
}
/// Creates a face-up card.
pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self {
Self::new(id, suit, rank, true)
}
/// Creates a face-down card.
pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self {
Self::new(id, suit, rank, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rank_values_are_sequential() {
for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(r.value(), (i + 1) as u8);
}
}
#[test]
fn rank_as_u8_matches_value() {
for r in Rank::RANKS {
assert_eq!(r as u8, r.value());
}
}
#[test]
fn rank_checked_add_boundary() {
assert_eq!(Rank::King.checked_add(1), None);
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
}
#[test]
fn rank_checked_sub_boundary() {
assert_eq!(Rank::Ace.checked_sub(1), None);
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
}
#[test]
fn suit_suits_contains_all_four() {
assert_eq!(Suit::SUITS.len(), 4);
assert!(Suit::SUITS.contains(&Suit::Clubs));
assert!(Suit::SUITS.contains(&Suit::Diamonds));
assert!(Suit::SUITS.contains(&Suit::Hearts));
assert!(Suit::SUITS.contains(&Suit::Spades));
}
#[test]
fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
assert_ne!(
suit.is_red(),
suit.is_black(),
"{suit:?} must be exactly one of red/black"
);
}
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
}
#[test]
fn card_constructors_set_fields() {
let up = Card::face_up(10, Suit::Spades, Rank::Queen);
assert_eq!(up.id, 10);
assert_eq!(up.suit, Suit::Spades);
assert_eq!(up.rank, Rank::Queen);
assert!(up.face_up);
let down = Card::face_down(11, Suit::Diamonds, Rank::King);
assert_eq!(down.id, 11);
assert_eq!(down.suit, Suit::Diamonds);
assert_eq!(down.rank, Rank::King);
assert!(!down.face_up);
}
}
File diff suppressed because it is too large Load Diff
+10 -417
View File
@@ -3,39 +3,32 @@
//! [`KlondikeAdapter`] is a pure helper namespace for:
//! - building [`KlondikeConfig`] from Ferrous settings
//! - translating between local and upstream types
//! - applying Ferrous-specific scoring policy on top of upstream defaults
//!
//! Ferrous-specific scoring policy (the win-time bonus) lives in
//! [`crate::scoring`], not here.
//!
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
//! upstream `card_game` / `klondike` types live here so that the product modules
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
use card_game::Card as KlCard;
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
TableauStack,
DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
SkipCards, Tableau,
};
use serde::{Deserialize, Serialize};
use crate::card;
use crate::game_state::{DrawMode, GameMode};
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
/// Bridges `solitaire_core` game config to the upstream `klondike` crate.
///
/// This type is intentionally zero-sized: it does not carry mutable runtime
/// state, and exists only as a namespace for configuration, conversion, and
/// scoring helpers.
/// state, and exists only as a namespace for configuration and conversion
/// helpers.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KlondikeAdapter;
impl KlondikeAdapter {
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig {
KlondikeConfig {
draw_stock: match draw_mode {
DrawMode::DrawOne => DrawStockConfig::DrawOne,
DrawMode::DrawThree => DrawStockConfig::DrawThree,
},
draw_stock: draw_mode,
move_from_foundation: if take_from_foundation {
MoveFromFoundationConfig::Allowed
} else {
@@ -44,116 +37,6 @@ impl KlondikeAdapter {
scoring: ScoringConfig::DEFAULT,
}
}
// ── Scoring helpers ───────────────────────────────────────────────────
/// Score delta for a card move.
///
/// Reads from [`ScoringConfig`] (WXP Standard values):
/// - Any pile → Foundation: +10
/// - Waste → Tableau: +5
/// - Foundation → Tableau: 15
/// - All other moves: 0
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = ScoringConfig::DEFAULT;
match (from, to) {
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
_ => 0,
}
}
/// Score delta for exposing a face-down tableau card: +5.
pub fn score_for_flip() -> i32 {
ScoringConfig::DEFAULT.flip_up_bonus
}
/// Score delta for undo: 15.
///
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
/// defaults to 0; the solver overrides it to 0 explicitly. The 15 WXP penalty
/// is applied here by `GameState` on every undo.
pub fn score_for_undo() -> i32 {
-15
}
/// Score delta for recycling waste → stock.
///
/// [`ScoringConfig::recycle`] is a flat delta (default 0 = always free).
/// WXP allows a fixed number of free recycles before charging a penalty,
/// which the upstream library cannot express with a single delta:
///
/// | Mode | Free recycles | Penalty per extra recycle |
/// |---|---|---|
/// | Draw-1 | 1 | 100 |
/// | Draw-3 | 3 | 20 |
///
/// **Design note:** recycling is *never* blocked — only penalised.
/// This is intentional: Draw-1 can be played indefinitely with the score
/// dropping toward zero after the first free recycle. A hard cap would
/// create unwinnable positions when the solver cannot find a path without
/// additional recycling. Zen mode suppresses the penalty entirely.
///
/// `recycle_count` must be the new total **after** this recycle.
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
if is_draw_three {
if recycle_count > 3 { -20 } else { 0 }
} else if recycle_count > 1 {
-100
} else {
0
}
}
/// Score delta for a card move, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_move(from, to)
}
}
/// Score delta for exposing a face-down card, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`].
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_flip()
}
}
/// Compute the new score after an undo, accounting for game mode.
///
/// In [`GameMode::Zen`] the score is always 0. Otherwise applies the
/// 15 undo penalty and clamps to 0 via [`Self::score_for_undo`].
pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
(snapshot_score + Self::score_for_undo()).max(0)
}
}
/// Score delta for recycling, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`].
pub fn score_for_recycle_with_mode(
recycle_count: u32,
is_draw_three: bool,
mode: GameMode,
) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_recycle(recycle_count, is_draw_three)
}
}
}
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
@@ -200,293 +83,3 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
_ => None,
}
}
/// Convert a [`card_game::Card`] to a [`card::Card`], assigning a stable `id`
/// derived from suit and rank (051, Clubs-first ordering).
///
/// The id is consistent for the same logical card across all reconstructions.
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
let suit = kl_card.suit();
let rank = kl_card.rank();
let suit_index = match suit {
card::Suit::Clubs => 0,
card::Suit::Diamonds => 1,
card::Suit::Hearts => 2,
card::Suit::Spades => 3,
};
let id = suit_index * 13 + (rank.value() as u32 - 1);
card::Card {
id,
suit,
rank,
face_up: false,
}
}
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
//
// These types were introduced when upstream `klondike` had no serde feature.
// At rev 99b49e62, upstream provides full serde support, and `GameState`
// serialises `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
//
// The mirror types are retained for three reasons:
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
// files with u8 indices and replay them.
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
// type; changing it would break the on-disk replay format (schema v2).
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
// replay JSON is cross-compatible between the desktop and browser builds.
//
// These types should not be used for new serialisation concerns. If the
// ReplayMove format is ever bumped to a new schema, migrate those callers to
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableau(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedFoundation(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedSkipCards(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePile {
Tableau(SavedTableau),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableauStack {
pub tableau: SavedTableau,
pub skip_cards: SavedSkipCards,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePileStack {
Tableau(SavedTableauStack),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstFoundation {
pub src: SavedKlondikePile,
pub foundation: SavedFoundation,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstTableau {
pub src: SavedKlondikePileStack,
pub tableau: SavedTableau,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
///
/// Convert to/from the upstream type with:
/// ```ignore
/// let saved = SavedInstruction::from(instruction);
/// let instruction = KlondikeInstruction::try_from(saved)?;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedInstruction {
DstFoundation(SavedDstFoundation),
DstTableau(SavedDstTableau),
RotateStock,
}
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSavedInstruction {
#[error("invalid tableau index {0} (expected 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
}
+11 -8
View File
@@ -1,20 +1,23 @@
pub mod achievement;
pub mod card;
pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod pile;
pub mod solver;
pub mod scoring;
// Re-export the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct
// `klondike` / `card_game` dep.
//
// `KlondikePileStack`, `SkipCards`, and `TableauStack` are intentionally NOT
// re-exported — they are only used internally in `klondike_adapter.rs` and do
// not appear in any public method signature.
pub use card_game::Session;
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
// `KlondikePileStack`, `SkipCards` and `TableauStack` are intentionally NOT
// re-exported — they are only used internally (in `klondike_adapter.rs` and
// when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature.
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module.
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
#[cfg(test)]
mod proptest_tests;
-90
View File
@@ -1,90 +0,0 @@
use crate::card::{Card, Suit};
use klondike::KlondikePile;
/// Read-only projection of a single Klondike pile, rebuilt from [`GameState`] on every sync.
///
/// `Pile` is a **data-transfer type**, not a game-state owner. Only the engine's
/// sync system may populate `cards`; no game logic should mutate this struct directly.
/// [`GameState`] is always the authoritative source of truth.
///
/// [`GameState`]: crate::game_state::GameState
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pile {
/// Which logical Klondike pile this is.
pub pile_type: KlondikePile,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
/// Populated by the sync system; do not mutate from game-logic code.
pub cards: Vec<Card>,
}
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: KlondikePile) -> Self {
Self {
pile_type,
cards: Vec::new(),
}
}
/// Returns a reference to the top (last) card, or `None` if empty.
pub fn top(&self) -> Option<&Card> {
self.cards.last()
}
/// For foundation piles: returns `Some(suit)` once at least one card has
/// landed (the bottom card is always an Ace of the claimed suit).
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
KlondikePile::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.cards.is_empty());
}
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(KlondikePile::Stock);
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
assert_eq!(pile.top().unwrap().id, 1);
}
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.top().is_none());
}
#[test]
fn claimed_suit_is_none_for_empty_foundation() {
let pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation1));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_is_none_for_non_foundation() {
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
}
+42 -172
View File
@@ -1,25 +1,20 @@
use card_game::Game;
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
use card_game::{Card, Game};
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
use proptest::prelude::*;
use crate::game_state::{DrawMode, GameState};
use crate::klondike_adapter::{
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
SavedTableauStack,
};
use crate::game_state::GameState;
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Collect all card IDs across every pile in a fixed traversal order:
/// Collect all cards across every pile in a fixed traversal order:
/// stock → waste → foundations 14 → tableaux 17.
///
/// The order is deterministic for a given game state, so two calls on
/// equivalent states produce identical Vec outputs — the right fingerprint
/// for undo-reversibility checks.
fn all_card_ids(game: &GameState) -> Vec<u32> {
fn all_cards(game: &GameState) -> Vec<Card> {
let foundations = [
Foundation::Foundation1,
Foundation::Foundation2,
@@ -36,50 +31,46 @@ fn all_card_ids(game: &GameState) -> Vec<u32> {
Tableau::Tableau7,
];
let mut ids: Vec<u32> = game.stock_cards().iter().map(|c| c.id).collect();
ids.extend(game.waste_cards().iter().map(|c| c.id));
let mut cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
for f in &foundations {
ids.extend(
cards.extend(
game.pile(KlondikePile::Foundation(*f))
.iter()
.map(|c| c.id),
.map(|(c, _)| c.clone()),
);
}
for t in &tableaux {
ids.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|c| c.id));
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
}
ids
cards
}
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
}
/// Apply a sequence of random actions to a game, silently ignoring errors.
///
/// Each action is `(draw_flag, move_index)`:
/// - `draw_flag = true` → call `game.draw()`
/// - `draw_flag = false` → pick the `move_index % len`th legal move from
/// `possible_instructions()` and execute it.
/// - `draw_flag = false` → pick the `move_index % len`th legal instruction
/// from `possible_instructions()` and apply it via `apply_instruction()`.
///
/// `possible_instructions()` may return `(Stock, Stock, 1)` for the
/// RotateStock / draw action. `move_cards(Stock, Stock, 1)` is rejected by
/// the `from == to` guard, so those are dispatched to `game.draw()`.
/// `possible_instructions()` may return `RotateStock`, which
/// `apply_instruction()` dispatches to `game.draw()`; ordinary instructions
/// are equivalent to `move_cards(from, to, count)`.
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
for &(do_draw, idx) in actions {
if do_draw {
let _ = game.draw();
} else {
let instructions = game.possible_instructions();
if instructions.is_empty() {
let moves = game.possible_instructions();
if moves.is_empty() {
continue;
}
let (from, to, count) = instructions[idx % instructions.len()];
if from == to {
let _ = game.draw();
} else {
let _ = game.move_cards(from, to, count);
}
let instruction = moves[idx % moves.len()];
let _ = game.apply_instruction(instruction);
}
}
}
@@ -88,19 +79,15 @@ fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
/// available), using `move_idx` to select among the legal options.
/// Returns `true` when a move was successfully applied.
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
if game.is_won {
if game.is_won() {
return false;
}
let instructions = game.possible_instructions();
if instructions.is_empty() {
let moves = game.possible_instructions();
if moves.is_empty() {
return game.draw().is_ok();
}
let (from, to, count) = instructions[move_idx % instructions.len()];
if from == to {
game.draw().is_ok()
} else {
game.move_cards(from, to, count).is_ok()
}
let instruction = moves[move_idx % moves.len()];
game.apply_instruction(instruction).is_ok()
}
// ---------------------------------------------------------------------------
@@ -169,13 +156,12 @@ proptest! {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &actions);
let mut ids = all_card_ids(&game);
prop_assert_eq!(ids.len(), 52, "card count ≠ 52 (got {})", ids.len());
ids.sort_unstable();
ids.dedup();
let cards = all_cards(&game);
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
prop_assert_eq!(
ids.len(), 52,
"duplicate card IDs found after dedup — a card was cloned"
unique.len(), 52,
"duplicate cards found after dedup — a card was cloned"
);
}
@@ -192,8 +178,8 @@ proptest! {
let a = GameState::new(seed, draw_mode);
let b = GameState::new(seed, draw_mode);
prop_assert_eq!(
all_card_ids(&a),
all_card_ids(&b),
all_cards(&a),
all_cards(&b),
"same seed + draw_mode produced different deals",
);
}
@@ -217,11 +203,11 @@ proptest! {
apply_random_actions(&mut game, &setup_actions);
// Snapshot the state before the move.
let before_ids = all_card_ids(&game);
let before_move_count = game.move_count;
let before_ids = all_cards(&game);
let before_move_count = game.move_count();
// Apply one move.
if !apply_one_move(&mut game, move_idx) || game.is_won {
if !apply_one_move(&mut game, move_idx) || game.is_won() {
return Ok(()); // nothing to undo
}
@@ -231,12 +217,12 @@ proptest! {
"undo must succeed immediately after a successful move",
);
prop_assert_eq!(
all_card_ids(&game),
all_cards(&game),
before_ids,
"pile layout after undo differs from the pre-move snapshot",
);
prop_assert_eq!(
game.move_count,
game.move_count(),
before_move_count,
"move_count after undo must equal the pre-move value",
);
@@ -258,132 +244,16 @@ proptest! {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &setup_actions);
for (from, to, count) in game.possible_instructions() {
for instruction in game.possible_instructions() {
// Clone so each move is tried from the same starting state.
let mut trial = game.clone();
let result = if from == to {
trial.draw()
} else {
trial.move_cards(from, to, count)
};
let result = trial.apply_instruction(instruction);
prop_assert!(
result.is_ok(),
"possible_instructions() reported ({from:?} → {to:?} ×{count}) \
"possible_instructions() reported {instruction:?} \
as legal but the call returned Err: {result:?}",
);
}
}
// -------------------------------------------------------------------------
// SavedInstruction ↔ KlondikeInstruction round-trip
// -------------------------------------------------------------------------
/// Every valid `SavedInstruction` survives a round-trip through
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
///
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
/// and all legal sub-field ranges:
/// - `SavedTableau`: 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
}
-282
View File
@@ -1,282 +0,0 @@
//! Klondike solvability checker using upstream `card_game::Session::solve()`.
//!
//! Used by the engine to back the **Settings → Gameplay → "Winnable deals only"**
//! toggle and by the hint system when it wants the first move on a winning path.
use card_game::{Session, SessionConfig, SolveError, StateSnapshot};
use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack};
use crate::game_state::{DrawMode, GameState};
use crate::klondike_adapter::KlondikeAdapter;
/// Verdict returned by [`try_solve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolverResult {
/// The solver found a sequence of moves that wins the deal.
Winnable,
/// The solver exhaustively searched and confirmed no win exists.
Unwinnable,
/// The move / state budget was exceeded before a verdict could be reached.
Inconclusive,
}
/// Tunable budgets controlling how long [`try_solve`] is willing to search.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SolverConfig {
/// Maximum total moves to consider across the entire search tree.
pub move_budget: u64,
/// Maximum unique states to visit.
pub state_budget: usize,
}
impl Default for SolverConfig {
fn default() -> Self {
Self {
move_budget: 100_000,
state_budget: 200_000,
}
}
}
/// A single move the solver can recommend, expressed in engine-level pile terms.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolverMove {
/// Pile the move originates from.
pub source: KlondikePile,
/// Pile the move lands on.
pub dest: KlondikePile,
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
pub count: usize,
}
/// Solver verdict plus, when winnable, the first move on a winning path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolveOutcome {
/// The high-level verdict (Winnable / Unwinnable / Inconclusive).
pub result: SolverResult,
/// First move on the solution path when `result == Winnable`.
pub first_move: Option<SolverMove>,
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
try_solve_with_first_move(seed, draw_mode, config).result
}
/// Tries to solve a fresh Classic-mode game and, when winnable, returns the
/// first move on a winning path.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
/// take-from-foundation house rule stays disabled here.
pub fn try_solve_with_first_move(
seed: u64,
draw_mode: DrawMode,
config: &SolverConfig,
) -> SolveOutcome {
let mut game = GameState::new(seed, draw_mode);
game.take_from_foundation = false;
solve_game_state(&game, config)
}
/// Tries to solve from an existing in-progress [`GameState`].
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
solve_game_state(state, config)
}
fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome {
if config.state_budget == 0 {
return SolveOutcome {
result: SolverResult::Inconclusive,
first_move: None,
};
}
// Preserve the historical payload contract: winnable verdicts always carry
// a first move. An already-won state therefore returns no recommendation.
if initial.is_won {
return SolveOutcome {
result: SolverResult::Unwinnable,
first_move: None,
};
}
let solver_config = SessionConfig {
inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation),
undo_penalty: 0,
solve_moves_budget: config.move_budget,
solve_states_budget: config.state_budget as u64,
};
let solver_session = Session::new(initial.session().state().state().clone(), solver_config);
match solver_session.solve() {
Ok(Some(solution)) => {
let first_move = solution
.raw_solution()
.iter()
.find_map(snapshot_to_solver_move);
if let Some(first_move) = first_move {
SolveOutcome {
result: SolverResult::Winnable,
first_move: Some(first_move),
}
} else {
SolveOutcome {
result: SolverResult::Inconclusive,
first_move: None,
}
}
}
Ok(None) => SolveOutcome {
result: SolverResult::Unwinnable,
first_move: None,
},
Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome {
result: SolverResult::Inconclusive,
first_move: None,
},
}
}
fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverMove> {
let source_state = snapshot.state().state();
match *snapshot.instruction() {
KlondikeInstruction::RotateStock => Some(SolverMove {
source: KlondikePile::Stock,
dest: KlondikePile::Stock,
count: 1,
}),
KlondikeInstruction::DstFoundation(dst_foundation) => {
let source = match dst_foundation.src {
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
KlondikePile::Stock => KlondikePile::Stock,
KlondikePile::Foundation(_) => return None,
};
Some(SolverMove {
source,
dest: KlondikePile::Foundation(dst_foundation.foundation),
count: 1,
})
}
KlondikeInstruction::DstTableau(dst_tableau) => {
let (source, count) = match dst_tableau.src {
KlondikePileStack::Tableau(tableau_stack) => {
let face_up_count = source_state.tableau_face_up_cards(tableau_stack.tableau).len();
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
if count == 0 {
return None;
}
(KlondikePile::Tableau(tableau_stack.tableau), count)
}
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
KlondikePileStack::Foundation(foundation) => {
(KlondikePile::Foundation(foundation), 1)
}
};
Some(SolverMove {
source,
dest: KlondikePile::Tableau(dst_tableau.tableau),
count,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn try_solve_with_first_move_is_deterministic() {
let config = SolverConfig::default();
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
let b = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
let c = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
assert_eq!(a, b);
assert_eq!(b, c);
}
#[test]
fn try_solve_with_first_move_returns_consistent_payload() {
let config = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
match outcome.result {
SolverResult::Winnable => assert!(outcome.first_move.is_some()),
SolverResult::Unwinnable | SolverResult::Inconclusive => {
assert!(outcome.first_move.is_none())
}
}
}
#[test]
fn try_solve_from_state_uses_live_game_state() {
let mut game = GameState::new(42, DrawMode::DrawOne);
game.draw().expect("draw must succeed");
let config = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let outcome = try_solve_from_state(&game, &config);
match outcome.result {
SolverResult::Winnable => assert!(outcome.first_move.is_some()),
SolverResult::Unwinnable | SolverResult::Inconclusive => {
assert!(outcome.first_move.is_none())
}
}
}
#[test]
fn zero_state_budget_is_inconclusive() {
let config = SolverConfig {
move_budget: 5_000,
state_budget: 0,
};
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config);
assert_eq!(outcome.result, SolverResult::Inconclusive);
assert!(outcome.first_move.is_none());
}
#[test]
fn budget_is_passed_through_not_clamped() {
// 0xD1FF_0000_0000_0012 is a Medium-tier catalog seed: Inconclusive at
// the Easy budget (1 000 states) but Winnable at Medium (5 000 states).
// Differing results confirm solve_game_state passes the caller's
// state_budget unchanged to the underlying solver.
let easy = SolverConfig { move_budget: 1_000, state_budget: 1_000 };
let medium = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
assert_eq!(
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &easy),
SolverResult::Inconclusive,
);
assert_eq!(
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &medium),
SolverResult::Winnable,
);
}
#[test]
fn budget_above_five_thousand_is_not_clamped() {
// 0xD1FF_0000_0000_00DE is a hard catalog seed: Inconclusive at 5 000
// states but Winnable at 50 000. Before this fix, solve_game_state
// applied `config.state_budget.min(5_000)` internally, so a 50k config
// was silently reduced to 5k — making both calls return Inconclusive and
// preventing the generator from certifying Hard/Expert/Grandmaster seeds.
// This assertion fails if the cap is re-introduced.
let below_cap = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
let above_cap = SolverConfig { move_budget: 50_000, state_budget: 50_000 };
assert_eq!(
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &below_cap),
SolverResult::Inconclusive,
"seed must be Inconclusive at 5 000 states",
);
assert_eq!(
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &above_cap),
SolverResult::Winnable,
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this",
);
}
}
+6
View File
@@ -7,6 +7,8 @@ edition.workspace = true
[dependencies]
solitaire_core = { workspace = true }
solitaire_sync = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
@@ -37,6 +39,7 @@ keyring-core = { workspace = true }
jni = { workspace = true }
[dev-dependencies]
solitaire_core = { workspace = true, features = ["test-support"] }
solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { workspace = true }
axum = { workspace = true }
@@ -44,3 +47,6 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
[lints]
workspace = true
+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.
+12 -8
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
}
}
@@ -118,8 +118,8 @@ pub use achievements::{
pub mod progress;
pub use progress::{
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
save_progress_to, xp_for_win,
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
};
pub mod weekly;
@@ -144,9 +144,10 @@ pub use settings::{
};
#[cfg(target_os = "android")]
mod android_keystore;
pub mod android_jni;
#[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm;
mod android_keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens;
@@ -163,11 +164,14 @@ pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
ReplayHistory, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
};
// `latest_replay_path` is still consumed by the engine's one-shot legacy
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
// outside `replay.rs` and were dropped from the public surface.
#[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
pub use replay::latest_replay_path;
#[cfg(not(target_arch = "wasm32"))]
pub mod matomo_client;
+59
View File
@@ -114,3 +114,62 @@ fn url_encode(s: &str) -> String {
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn pending(client: &MatomoClient) -> Vec<String> {
client.pending.lock().expect("pending lock").clone()
}
#[test]
fn event_buffers_encoded_matomo_query() {
let client = MatomoClient::new(
"https://analytics.example.com/",
7,
Some("alice bob".into()),
);
client.event("Game Flow", "Won+Fast", Some("draw three"), Some(42.5));
let pending = pending(&client);
assert_eq!(pending.len(), 1);
let query = &pending[0];
assert!(query.contains("idsite=7"));
assert!(query.contains("rec=1"));
assert!(query.contains("e_c=Game%20Flow"));
assert!(query.contains("e_a=Won%2BFast"));
assert!(query.contains("e_n=draw%20three"));
assert!(query.contains("e_v=42.5"));
assert!(query.contains("uid=alice%20bob"));
}
#[test]
fn event_buffer_drops_oldest_entries_when_capacity_exceeded() {
let client = MatomoClient::new("https://analytics.example.com", 1, None);
for idx in 0..101 {
client.event("Game", "Start", Some(&format!("event-{idx}")), None);
}
let pending = pending(&client);
assert_eq!(pending.len(), 51);
assert!(
pending[0].contains("event-50"),
"oldest retained event should be event-50, got {}",
pending[0]
);
assert!(
pending[50].contains("event-100"),
"newest retained event should be event-100, got {}",
pending[50]
);
}
#[test]
fn url_encode_leaves_unreserved_bytes_and_escapes_everything_else() {
assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~");
assert_eq!(url_encode("a b+c/d?"), "a%20b%2Bc%2Fd%3F");
}
}
+35 -5
View File
@@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
y * 10_000 + m * 100 + d
}
/// XP awarded for winning a game.
/// Component breakdown of the XP awarded for a win.
///
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
/// it for the total, and UI that displays the individual lines (the win-summary
/// modal) reads the parts from here so the breakdown can never drift from the
/// total.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct XpBreakdown {
/// Flat base XP granted for any win.
pub base: u64,
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
pub speed_bonus: u64,
/// Bonus for winning without using undo (25, else 0).
pub no_undo_bonus: u64,
}
impl XpBreakdown {
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
pub fn total(self) -> u64 {
self.base + self.speed_bonus + self.no_undo_bonus
}
}
/// Component breakdown of the XP awarded for a win.
///
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
/// the player did not use undo.
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
let base: u64 = 50;
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
let speed_bonus: u64 = if time_seconds >= 120 {
0
} else {
@@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
scaled.max(10)
};
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
base + speed_bonus + no_undo_bonus
XpBreakdown {
base: 50,
speed_bonus,
no_undo_bonus: if used_undo { 0 } else { 25 },
}
}
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
xp_breakdown(time_seconds, used_undo).total()
}
/// Platform-specific default path for `progress.json`.
+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::game_state::{DrawMode, GameMode};
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 {
/// seeing a broken one.
///
/// History:
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
/// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input.
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
/// variant. The engine resolves draw-vs-recycle deterministically from
/// the current stock state, so the input alone is sufficient and the
/// replay model now stores atomic player inputs end-to-end.
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
/// are now stored directly as upstream
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
/// and are discarded by the loader.
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
/// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
@@ -81,32 +92,6 @@ fn schema_v0() -> u32 {
0
}
/// One atomic player input recorded during a winning game, in the order
/// it was applied to the live `GameState`.
///
/// `Undo` is intentionally absent — see the module-level docs.
///
/// The variants represent *inputs*, not outcomes. `StockClick` covers
/// every player click on the stock pile; the engine then resolves
/// draw-vs-recycle deterministically from the current state during both
/// recording and playback, so the same input always produces the same
/// effect on the same starting deal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call.
Move {
/// Source pile.
from: SavedKlondikePile,
/// Destination pile.
to: SavedKlondikePile,
/// Number of cards moved.
count: usize,
},
/// A click on the stock pile. Resolves to a draw when stock is
/// non-empty and to a waste→stock recycle when stock is empty.
StockClick,
}
/// A complete recording of a single winning game.
///
/// Replays are reconstructed by rebuilding a fresh
@@ -124,7 +109,7 @@ pub struct Replay {
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
pub seed: u64,
/// Draw mode the recorded game was played in.
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
/// Game mode the recorded game was played in.
pub mode: GameMode,
/// Total wall-clock seconds the win took. Used for the Stats UI
@@ -134,9 +119,11 @@ pub struct Replay {
pub final_score: i32,
/// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate,
/// Ordered move list. Each entry is what the player did, replayable
/// against a fresh `GameState` constructed from the seed.
pub moves: Vec<ReplayMove>,
/// Ordered move list. Each entry is the atomic
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
/// issued, replayable against a fresh `GameState` constructed from the
/// seed via `GameState::apply_instruction`.
pub moves: Vec<KlondikeInstruction>,
/// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only
@@ -180,12 +167,12 @@ impl Replay {
/// latter directly when the upload task resolves.
pub fn new(
seed: u64,
draw_mode: DrawMode,
draw_mode: DrawStockConfig,
mode: GameMode,
time_seconds: u64,
final_score: i32,
recorded_at: NaiveDate,
moves: Vec<ReplayMove>,
moves: Vec<KlondikeInstruction>,
) -> Self {
Self {
schema_version: REPLAY_SCHEMA_VERSION,
@@ -442,7 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
#[allow(deprecated)]
mod tests {
use super::*;
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
use klondike::{
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
};
use std::env;
fn tmp_path(name: &str) -> PathBuf {
@@ -453,24 +442,22 @@ mod tests {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
12345,
DrawMode::DrawThree,
DrawStockConfig::DrawThree,
GameMode::Classic,
134,
5_120,
date,
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1,
},
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Tableau(SavedTableau(3)),
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
count: 1,
},
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: Tableau::Tableau4,
}),
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(Tableau::Tableau4),
foundation: Foundation::Foundation1,
}),
],
)
}
@@ -596,12 +583,12 @@ mod tests {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
id as u64,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
60,
id,
date,
vec![ReplayMove::StockClick],
vec![KlondikeInstruction::RotateStock],
)
}
@@ -837,9 +824,11 @@ mod tests {
let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path);
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
let v2_no_field = r#"{
"schema_version": 2,
// Hand-rolled minimal current-schema replay JSON with no
// win_move_index field — the additive field must still default to None.
let no_field = format!(
r#"{{
"schema_version": {schema},
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
@@ -847,8 +836,10 @@ mod tests {
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}"#;
fs::write(&path, v2_no_field).expect("write fixture");
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
fs::write(&path, no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None);
+9 -9
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -101,7 +101,7 @@ pub struct WindowGeometry {
pub struct Settings {
/// Draw mode selected for new games.
#[serde(default = "default_draw_mode")]
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
#[serde(default = "default_sfx_volume")]
pub sfx_volume: f32,
@@ -200,7 +200,7 @@ pub struct Settings {
#[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// [`solitaire_core::solver`] cannot prove winnable, retrying
/// the solver cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the
@@ -288,8 +288,8 @@ pub struct Settings {
pub touch_input_mode: TouchInputMode,
}
fn default_draw_mode() -> DrawMode {
DrawMode::DrawOne
fn default_draw_mode() -> DrawStockConfig {
DrawStockConfig::DrawOne
}
fn default_sfx_volume() -> f32 {
@@ -381,9 +381,9 @@ pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
/// every retry comes back [`SolverResult::Unwinnable`] (which would
/// be very unusual) we'd rather hand the player a possibly-unwinnable
/// deal than spin forever on the main thread.
/// every retry comes back provably unwinnable (`Ok(None)` from the
/// solver, which would be very unusual) we'd rather hand the player a
/// possibly-unwinnable deal than spin forever on the main thread.
///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on.
@@ -392,7 +392,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
impl Default for Settings {
fn default() -> Self {
Self {
draw_mode: DrawMode::DrawOne,
draw_mode: DrawStockConfig::DrawOne,
sfx_volume: default_sfx_volume(),
music_volume: default_music_volume(),
animation_speed: AnimSpeed::Normal,
+26 -26
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::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
pub use solitaire_sync::StatsSnapshot;
@@ -18,9 +18,9 @@ pub trait StatsExt {
///
/// Tracks lifetime totals only — per-mode best scores and times are
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
/// long-standing call sites that only know about [`DrawMode`] keep
/// long-standing call sites that only know about [`DrawStockConfig`] keep
/// compiling.
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig);
/// Updates the per-mode best score and fastest-win-time fields for the
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
@@ -37,7 +37,7 @@ pub trait StatsExt {
}
impl StatsExt for StatsSnapshot {
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig) {
let prev_wins = self.games_won;
self.games_played += 1;
self.games_won += 1;
@@ -64,8 +64,8 @@ impl StatsExt for StatsSnapshot {
};
match draw_mode {
DrawMode::DrawOne => self.draw_one_wins += 1,
DrawMode::DrawThree => self.draw_three_wins += 1,
DrawStockConfig::DrawOne => self.draw_one_wins += 1,
DrawStockConfig::DrawThree => self.draw_three_wins += 1,
}
self.last_modified = Utc::now();
@@ -135,7 +135,7 @@ mod tests {
#[test]
fn first_win_sets_all_fields() {
let mut s = StatsSnapshot::default();
s.update_on_win(1500, 120, &DrawMode::DrawOne);
s.update_on_win(1500, 120, &DrawStockConfig::DrawOne);
assert_eq!(s.games_played, 1);
assert_eq!(s.games_won, 1);
assert_eq!(s.win_streak_current, 1);
@@ -152,7 +152,7 @@ mod tests {
fn streak_tracks_across_wins() {
let mut s = StatsSnapshot::default();
for _ in 0..3 {
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
}
assert_eq!(s.win_streak_current, 3);
assert_eq!(s.win_streak_best, 3);
@@ -161,8 +161,8 @@ mod tests {
#[test]
fn record_abandoned_resets_streak_and_increments_played() {
let mut s = StatsSnapshot::default();
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.win_streak_current, 2);
s.record_abandoned();
assert_eq!(s.games_played, 3);
@@ -174,35 +174,35 @@ mod tests {
#[test]
fn fastest_win_takes_minimum() {
let mut s = StatsSnapshot::default();
s.update_on_win(100, 300, &DrawMode::DrawOne);
s.update_on_win(100, 120, &DrawMode::DrawOne);
s.update_on_win(100, 500, &DrawMode::DrawOne);
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
s.update_on_win(100, 120, &DrawStockConfig::DrawOne);
s.update_on_win(100, 500, &DrawStockConfig::DrawOne);
assert_eq!(s.fastest_win_seconds, 120);
}
#[test]
fn avg_time_is_correct_rolling_average() {
let mut s = StatsSnapshot::default();
s.update_on_win(100, 100, &DrawMode::DrawOne);
s.update_on_win(100, 200, &DrawMode::DrawOne);
s.update_on_win(100, 300, &DrawMode::DrawOne);
s.update_on_win(100, 100, &DrawStockConfig::DrawOne);
s.update_on_win(100, 200, &DrawStockConfig::DrawOne);
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
assert_eq!(s.avg_time_seconds, 200);
}
#[test]
fn best_score_updates_only_on_higher_score() {
let mut s = StatsSnapshot::default();
s.update_on_win(500, 60, &DrawMode::DrawOne);
s.update_on_win(300, 60, &DrawMode::DrawOne);
s.update_on_win(500, 60, &DrawStockConfig::DrawOne);
s.update_on_win(300, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.best_single_score, 500);
s.update_on_win(800, 60, &DrawMode::DrawOne);
s.update_on_win(800, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.best_single_score, 800);
}
#[test]
fn negative_score_treated_as_zero() {
let mut s = StatsSnapshot::default();
s.update_on_win(-50, 60, &DrawMode::DrawOne);
s.update_on_win(-50, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.best_single_score, 0);
assert_eq!(s.lifetime_score, 0);
}
@@ -210,8 +210,8 @@ mod tests {
#[test]
fn draw_three_wins_tracked_separately() {
let mut s = StatsSnapshot::default();
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawMode::DrawThree);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawThree);
assert_eq!(s.draw_one_wins, 1);
assert_eq!(s.draw_three_wins, 1);
}
@@ -221,7 +221,7 @@ mod tests {
let mut s = StatsSnapshot::default();
// Build a streak of 5.
for _ in 0..5 {
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
}
assert_eq!(s.win_streak_best, 5);
// Lose (abandon), resetting current.
@@ -229,7 +229,7 @@ mod tests {
assert_eq!(s.win_streak_current, 0);
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
// Win once — current becomes 1, best must remain 5.
s.update_on_win(100, 60, &DrawMode::DrawOne);
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
assert_eq!(s.win_streak_current, 1);
assert_eq!(
s.win_streak_best, 5,
@@ -243,7 +243,7 @@ mod tests {
lifetime_score: u64::MAX - 100,
..Default::default()
};
s.update_on_win(200, 60, &DrawMode::DrawOne);
s.update_on_win(200, 60, &DrawStockConfig::DrawOne);
assert_eq!(
s.lifetime_score,
u64::MAX,
+68 -64
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
use solitaire_core::game_state::GameState;
use crate::stats::StatsSnapshot;
@@ -85,16 +85,13 @@ pub fn game_state_file_path() -> Option<PathBuf> {
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
let data = fs::read(path).ok()?;
let gs: GameState = serde_json::from_slice(&data).ok()?;
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
if gs.is_won { None } else { Some(gs) }
if gs.is_won() { None } else { Some(gs) }
}
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
/// because a completed game should not be resumed.
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
if gs.is_won {
if gs.is_won() {
return Ok(());
}
if let Some(parent) = path.parent() {
@@ -282,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
mod tests {
use super::*;
use crate::stats::{StatsExt, StatsSnapshot};
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawStockConfig;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
@@ -295,7 +292,7 @@ mod tests {
let _ = fs::remove_file(&path);
let mut stats = StatsSnapshot::default();
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne);
save_stats_to(&path, &stats).expect("save");
let loaded = load_stats_from(&path);
@@ -380,17 +377,17 @@ mod tests {
#[test]
fn game_state_round_trip() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::GameState;
let path = gs_path("round_trip");
let _ = fs::remove_file(&path);
let gs = GameState::new(12345, DrawMode::DrawOne);
let gs = GameState::new(12345, DrawStockConfig::DrawOne);
save_game_state_to(&path, &gs).expect("save");
let loaded = load_game_state_from(&path).expect("load");
assert_eq!(loaded.seed, gs.seed);
assert_eq!(loaded.draw_mode, gs.draw_mode);
assert!(!loaded.is_won);
assert_eq!(loaded.draw_mode(), gs.draw_mode());
assert!(!loaded.is_won());
}
#[test]
@@ -409,12 +406,12 @@ mod tests {
#[test]
fn save_game_state_skips_won_games() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::GameState;
let path = gs_path("won_skip");
let _ = fs::remove_file(&path);
let mut gs = GameState::new(99, DrawMode::DrawOne);
gs.is_won = true;
let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
gs.set_test_won(true);
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!(
!path.exists(),
@@ -424,9 +421,9 @@ mod tests {
#[test]
fn delete_game_state_removes_file() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::GameState;
let path = gs_path("delete");
let gs = GameState::new(1, DrawMode::DrawOne);
let gs = GameState::new(1, DrawStockConfig::DrawOne);
save_game_state_to(&path, &gs).expect("save");
assert!(path.exists());
delete_game_state_at(&path).expect("delete");
@@ -442,9 +439,9 @@ mod tests {
#[test]
fn save_game_state_is_atomic() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::GameState;
let path = gs_path("atomic");
let gs = GameState::new(55, DrawMode::DrawThree);
let gs = GameState::new(55, DrawStockConfig::DrawThree);
save_game_state_to(&path, &gs).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
@@ -500,22 +497,22 @@ mod tests {
/// replays all `saved_moves` to reconstruct every pile.
///
/// A fresh-game test (zero moves) never exercises that replay path, so this
/// test plays several real moves — including an undo — before saving, then
/// asserts the full pile layout round-trips exactly.
/// test plays several real moves — including an undo — before saving.
///
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
/// `recycle_count`. Any breakage in the upstream serde or replay path
/// will cause at least one pile to disagree.
/// Since schema v5 no longer persists `score`/`undo_count`/`recycle_count`
/// (they are derived from the replayed session stats), round-trip fidelity is
/// verified by **re-save idempotency**: reloading the save and serialising it
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
/// to 0 on load because only the forward instruction history is persisted.
#[test]
fn game_state_v4_mid_game_round_trip() {
use solitaire_core::KlondikePile;
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
fn game_state_v5_mid_game_round_trip() {
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState;
let path = gs_path("v4_mid_game");
let _ = fs::remove_file(&path);
let mut gs = GameState::new(42, DrawMode::DrawOne);
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
// Draw several times to populate the instruction history with
// RotateStock entries and expose waste cards for further moves.
@@ -527,11 +524,13 @@ mod tests {
// Execute the first available DstTableau or DstFoundation move so the
// instruction history contains a type other than RotateStock.
let moves = gs.possible_instructions();
if let Some((from, to, count)) = moves.iter().copied().find(|(_, to, _)| {
matches!(to, KlondikePile::Tableau(_) | KlondikePile::Foundation(_))
if let Some(instruction) = gs.possible_instructions().into_iter().find(|i| {
matches!(
i,
KlondikeInstruction::DstTableau(_) | KlondikeInstruction::DstFoundation(_)
)
}) {
let _ = gs.move_cards(from, to, count);
let _ = gs.apply_instruction(instruction);
}
// Undo once: verifies that `undo_count` is persisted and that the
@@ -547,41 +546,53 @@ mod tests {
save_game_state_to(&path, &gs).expect("save");
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
// Verify the file carries the v5 schema marker.
let json = fs::read_to_string(&path).expect("read json");
assert!(
json.contains("schema_version") && json.contains('4') && !json.contains(": 3"),
"saved file must use schema version 4",
json.contains("\"schema_version\"") && json.contains('5'),
"saved file must use schema version 5",
);
let loaded = load_game_state_from(&path)
.expect("a valid in-progress game must load without error");
assert_eq!(loaded.schema_version, GAME_STATE_SCHEMA_VERSION);
// The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON.
let path_reload = gs_path("v5_mid_game_reload");
let _ = fs::remove_file(&path_reload);
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
assert_eq!(
loaded, gs,
"all pile layouts and counters must be identical after schema-v4 round-trip",
fs::read_to_string(&path).expect("read original save"),
fs::read_to_string(&path_reload).expect("read re-saved"),
"re-saving the loaded game must reproduce the original save exactly",
);
// Derived board reads match the live game (move count + recycle count are
// both rebuilt from the replayed forward history).
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
assert_eq!(
loaded.recycle_count(),
gs.recycle_count(),
"recycle_count round-trips",
);
// undo_count is intentionally not persisted: it resets to 0 on load.
assert_eq!(
loaded.undo_count(),
0,
"undo_count resets across save/load under schema v5",
);
}
/// A schema v3 save (instruction history using u8 indices) must load
/// successfully and be transparently migrated to schema v4.
///
/// This verifies the `AnyInstruction` untagged deserialization migration
/// path. v3 files with `RotateStock` (unit variant, format-identical in
/// v3 and v4) load correctly and report `schema_version == 4` after load.
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
/// A schema v3 save (instruction history using the old u8-index mirror
/// types) is no longer loadable. The legacy migration path was dropped,
/// so any file claiming `schema_version: 3` must be rejected and the
/// player started on a fresh game.
#[test]
fn game_state_v3_migrates_to_v4() {
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
let path = gs_path("v3_migrate");
fn game_state_v3_is_rejected() {
let path = gs_path("v3_reject");
let _ = fs::remove_file(&path);
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
// so this exercises the schema version acceptance code path.
let v3_json = r#"{
"draw_mode": "DrawOne",
"mode": "Classic",
@@ -596,19 +607,12 @@ mod tests {
}"#;
fs::write(&path, v3_json).expect("write v3 fixture");
let loaded = load_game_state_from(&path)
.expect("schema v3 must be accepted and migrated to v4");
// After migration, the in-memory schema version must be current.
assert_eq!(
loaded.schema_version, GAME_STATE_SCHEMA_VERSION,
"migrated game must report current schema version",
assert!(
load_game_state_from(&path).is_none(),
"schema v3 must be rejected (no migration path)",
);
// The loaded game should match a fresh game that had one draw applied.
let mut expected = GameState::new(42, DrawMode::DrawOne);
expected.draw().expect("draw must succeed on a fresh game");
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
let _ = fs::remove_file(&path);
}
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
+5 -5
View File
@@ -4,7 +4,7 @@
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
use chrono::{Datelike, NaiveDate};
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawStockConfig;
/// XP awarded each time a weekly goal is just completed.
pub const WEEKLY_GOAL_XP: u64 = 75;
@@ -36,7 +36,7 @@ pub struct WeeklyGoalDef {
pub struct WeeklyGoalContext {
pub time_seconds: u64,
pub used_undo: bool,
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
}
impl WeeklyGoalDef {
@@ -47,7 +47,7 @@ impl WeeklyGoalDef {
WeeklyGoalKind::WinGame => true,
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawStockConfig::DrawThree,
}
}
}
@@ -106,7 +106,7 @@ mod tests {
WeeklyGoalContext {
time_seconds: time,
used_undo: undo,
draw_mode: DrawMode::DrawOne,
draw_mode: DrawStockConfig::DrawOne,
}
}
@@ -114,7 +114,7 @@ mod tests {
WeeklyGoalContext {
time_seconds: time,
used_undo: false,
draw_mode: DrawMode::DrawThree,
draw_mode: DrawStockConfig::DrawThree,
}
}
+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
+14 -14
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).
@@ -176,9 +176,9 @@ fn evaluate_on_win(
daily_challenge_streak: progress.0.daily_challenge_streak,
last_win_score: ev.score,
last_win_time_seconds: ev.time_seconds,
last_win_used_undo: game.0.undo_count > 0,
last_win_used_undo: game.0.undo_count() > 0,
wall_clock_hour: Some(Local::now().hour()),
last_win_recycle_count: game.0.recycle_count,
last_win_recycle_count: game.0.recycle_count(),
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
};
@@ -671,7 +671,7 @@ mod tests {
.add_plugins(AchievementPlugin::headless());
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered.
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
@@ -779,7 +779,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 1;
.force_test_undos(1);
app.world_mut().write_message(GameWonEvent {
score: 1000,
@@ -819,7 +819,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
.set_test_draw_mode(DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
.set_test_draw_mode(DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -912,7 +912,7 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
@@ -946,7 +946,7 @@ mod tests {
// Default GameMode is Classic; assert and rely on it.
assert_eq!(
app.world().resource::<GameStateResource>().0.mode,
solitaire_core::game_state::GameMode::Classic
GameMode::Classic
);
app.world_mut().write_message(GameWonEvent {
@@ -1250,7 +1250,7 @@ mod tests {
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
.add_plugins(AchievementPlugin::headless());
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
@@ -1393,8 +1393,8 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
use solitaire_data::Replay;
/// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
@@ -1409,12 +1409,12 @@ mod tests {
fn dummy_replay() -> Replay {
Replay::new(
1,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
vec![KlondikeInstruction::RotateStock],
)
}
+58
View File
@@ -204,3 +204,61 @@ fn mode_str(mode: GameMode) -> &'static str {
GameMode::Difficulty(_) => "difficulty",
}
}
#[cfg(test)]
mod tests {
use solitaire_core::game_state::DifficultyLevel;
use super::*;
#[test]
fn client_for_requires_analytics_opt_in() {
let settings = Settings {
analytics_enabled: false,
matomo_url: Some("https://analytics.example.com".into()),
..Settings::default()
};
assert!(client_for(&settings).is_none());
}
#[test]
fn client_for_requires_matomo_url() {
let settings = Settings {
analytics_enabled: true,
matomo_url: None,
..Settings::default()
};
assert!(client_for(&settings).is_none());
}
#[test]
fn client_for_creates_client_when_enabled_and_configured() {
let settings = Settings {
analytics_enabled: true,
matomo_url: Some("https://analytics.example.com".into()),
matomo_site_id: 2,
sync_backend: SyncBackend::SolitaireServer {
url: "https://solitaire.example.com".into(),
username: "alice".into(),
avatar_url: None,
},
..Settings::default()
};
assert!(client_for(&settings).is_some());
}
#[test]
fn mode_labels_match_analytics_payload_contract() {
assert_eq!(mode_str(GameMode::Classic), "classic");
assert_eq!(mode_str(GameMode::Zen), "zen");
assert_eq!(mode_str(GameMode::Challenge), "challenge");
assert_eq!(mode_str(GameMode::TimeAttack), "time_attack");
assert_eq!(
mode_str(GameMode::Difficulty(DifficultyLevel::Grandmaster)),
"difficulty"
);
}
}
+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>();
}
}
+12 -17
View File
@@ -76,14 +76,14 @@ fn detect_auto_complete(
}
changed.clear();
if game.0.is_won {
if game.0.is_won() {
state.active = false;
return;
}
if game.0.is_auto_completable && !state.active {
if game.0.is_auto_completable() && !state.active {
state.active = true;
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
} else if !game.0.is_auto_completable && state.active {
} else if !game.0.is_auto_completable() && state.active {
// `is_auto_completable` only becomes false after an explicit undo
// (which puts a card back on the tableau or re-fills the stock/waste)
// or a new-game reset — never as a transient gap during a normal
@@ -168,8 +168,8 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -177,13 +177,13 @@ mod tests {
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(AutoCompletePlugin);
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
let mut g = GameState::new(1, DrawMode::DrawOne);
let mut g = GameState::new(1, DrawStockConfig::DrawOne);
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
for foundation in [
@@ -207,14 +207,9 @@ mod tests {
}
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![solitaire_core::card::Card {
id: 7_001,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
g.is_auto_completable = true;
g.set_test_auto_completable(true);
let expected = (
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Foundation(Foundation::Foundation1),
@@ -232,8 +227,8 @@ mod tests {
#[test]
fn detect_activates_when_auto_completable() {
let mut app = headless_app();
let mut g = GameState::new(42, DrawMode::DrawOne);
g.is_auto_completable = true;
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
g.set_test_auto_completable(true);
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent);
app.update();
@@ -268,7 +263,7 @@ mod tests {
let mut app = headless_app();
// Inject a won game state — active should not be set.
let (mut gs, _) = seeded_state_with_auto_move();
gs.is_won = true;
gs.set_test_won(true);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().write_message(StateChangedEvent);
app.update();
+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,6 +33,7 @@ use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::Card;
use super::animation::CardAnimation;
use super::tuning::AnimationTuning;
@@ -72,7 +73,7 @@ pub struct HoverState {
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move {
from: crate::events::MoveRequestEvent,
from: MoveRequestEvent,
},
Draw,
Undo,
@@ -210,12 +211,12 @@ pub(crate) fn apply_drag_visual(
// Only lift cards that are in a *committed* drag. Pending drags (below
// threshold) must stay at scale 1.0 to avoid visible premature lift.
let (dragged_ids, committed): (&[u32], bool) = drag
let (dragged_cards, committed): (&[Card], bool) = drag
.as_ref()
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
for (_, card, mut transform) in &mut cards {
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
let is_active_drag = committed && dragged_cards.contains(&card.card);
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
File diff suppressed because it is too large Load Diff
+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::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -135,7 +135,7 @@ mod tests {
fn challenge_win_advances_index() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -224,7 +224,7 @@ mod tests {
.0
.challenge_index = 2;
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
app.world_mut().write_message(GameWonEvent {
score: 500,
+14 -23
View File
@@ -34,8 +34,9 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
@@ -79,7 +80,7 @@ impl Plugin for CursorPlugin {
Update,
(
update_cursor_icon,
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays,
),
);
@@ -185,7 +186,7 @@ fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> boo
let base = layout.pile_positions[&pile];
for (i, card) in pile_cards.iter().enumerate().rev() {
if !card.face_up {
if !card.1 {
continue;
}
// Only the topmost card is draggable on non-tableau piles.
@@ -436,7 +437,7 @@ fn tableau_or_stack_pos(
base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree {
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32;
@@ -446,7 +447,7 @@ fn tableau_or_stack_pos(
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<solitaire_core::card::Card> {
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
if matches!(pile, KlondikePile::Stock) {
game.waste_cards()
} else {
@@ -562,9 +563,9 @@ mod tests {
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use crate::layout::compute_layout;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
let game = GameState::new(42, DrawMode::DrawOne);
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(
@@ -579,8 +580,8 @@ mod tests {
// -----------------------------------------------------------------------
use crate::layout::compute_layout;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
/// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers
@@ -618,7 +619,7 @@ mod tests {
game.0.set_test_waste_cards(vec![dragged.clone()]);
}
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![dragged.id];
drag.cards = vec![dragged];
drag.origin_pile = Some(KlondikePile::Stock);
drag.committed = true;
}
@@ -628,23 +629,13 @@ mod tests {
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
// — same colour family, illegal. Tableau(2) must NOT be
// highlighted.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card {
id: 9101,
suit: Suit::Clubs,
rank: Rank::Six,
face_up: true,
},
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
);
let dragged = Card {
id: 9102,
suit: Suit::Spades,
rank: Rank::Five,
face_up: true,
};
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
@@ -362,7 +362,7 @@ mod tests {
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
#[allow(unused_imports)]
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -391,7 +391,7 @@ mod tests {
// Replace the GameState with one whose seed matches the daily seed.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed, DrawMode::DrawOne);
GameState::new(daily_seed, DrawStockConfig::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -419,7 +419,7 @@ mod tests {
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
// Use a deliberately different seed.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
GameState::new(daily_seed.wrapping_add(7777), DrawStockConfig::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -442,7 +442,7 @@ mod tests {
let mut app = headless_app();
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed, DrawMode::DrawOne);
GameState::new(daily_seed, DrawStockConfig::DrawOne);
app.world_mut().write_message(GameWonEvent {
score: 500,
+7 -7
View File
@@ -2,7 +2,7 @@
use bevy::prelude::Message;
use solitaire_core::KlondikePile;
use solitaire_core::card::Suit;
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode;
use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
@@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent {
}
/// Fired when a card's face-up state changes during gameplay.
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32);
#[derive(Message, Debug, Clone)]
pub struct CardFlippedEvent(pub Card);
/// Fired by the flip animation at its midpoint — the instant the card face
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
@@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub u32);
/// Audio systems should listen to this event rather than `CardFlippedEvent`
/// so the flip sound is synchronised with the visual reveal, not the move
/// that triggered the animation.
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFaceRevealedEvent(pub u32);
#[derive(Message, Debug, Clone)]
pub struct CardFaceRevealedEvent(pub Card);
/// Achievement unlocked notification carrying the full `AchievementRecord` for
/// the newly unlocked achievement. Consumed by the toast renderer and any
@@ -299,8 +299,8 @@ pub struct ScanThemesRequestEvent;
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
#[derive(Message, Debug, Clone)]
pub struct HintVisualEvent {
/// The `Card::id` of the source card to be highlighted.
pub source_card_id: u32,
/// The source card to be highlighted.
pub source_card: Card,
/// The destination pile whose `PileMarker` should be tinted gold.
pub dest_pile: KlondikePile,
}
+35 -39
View File
@@ -43,7 +43,9 @@ use std::hash::{Hash, Hasher};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_core::{Foundation, KlondikePile};
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim;
@@ -245,16 +247,16 @@ fn start_shake_anim(
continue;
}
let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile.
// Collect the cards that belong to the destination pile.
let dest_cards = pile_cards(&game.0, dest_pile);
let dest_card_ids: Vec<u32> = dest_cards.iter().map(|c| c.id).collect();
let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect();
if dest_card_ids.is_empty() {
if dest_card_set.is_empty() {
continue;
}
for (entity, card_marker, transform) in card_entities.iter() {
if dest_card_ids.contains(&card_marker.card_id) {
if dest_card_set.contains(&card_marker.card) {
commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0,
origin_x: transform.translation.x,
@@ -311,27 +313,27 @@ fn start_settle_anim(
card_entities: Query<(Entity, &CardEntity)>,
mut commands: Commands,
) {
// Build the list of card ids that should bounce this frame from every
// Build the list of cards that should bounce this frame from every
// queued request; multiple events can fire in the same frame (e.g. a move
// followed by a draw via keyboard accelerators).
let mut bounce_ids: Vec<u32> = Vec::new();
let mut bounce_ids: Vec<Card> = Vec::new();
for ev in moves.read() {
let pile = pile_cards(&game.0, &ev.to);
if !pile.is_empty() {
// The moved cards land on top — take the last `count` ids.
// The moved cards land on top — take the last `count` cards.
let n = ev.count.min(pile.len());
if n > 0 {
let start = pile.len() - n;
bounce_ids.extend(pile[start..].iter().map(|c| c.id));
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
}
}
}
if draws.read().next().is_some()
&& let Some(top) = game.0.waste_cards().last()
&& let Some((top, _)) = game.0.waste_cards().last()
{
bounce_ids.push(top.id);
bounce_ids.push(top.clone());
}
if bounce_ids.is_empty() {
@@ -339,7 +341,7 @@ fn start_settle_anim(
}
for (entity, card_marker) in card_entities.iter() {
if bounce_ids.contains(&card_marker.card_id) {
if bounce_ids.contains(&card_marker.card) {
commands.entity(entity).insert(SettleAnim::default());
}
}
@@ -393,7 +395,7 @@ fn start_deal_anim(
return;
}
// Only animate a fresh deal (no moves made yet).
if game.0.move_count != 0 {
if game.0.move_count() != 0 {
return;
}
let Some(layout) = layout else { return };
@@ -407,10 +409,14 @@ fn start_deal_anim(
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
let final_pos = transform.translation;
// ±10 % jitter, deterministic per card id, so the deal feels organic
// without losing reproducibility (a given seed still produces the
// same per-card stagger pattern across runs).
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
// ±10 % jitter, deterministic per card, so the deal feels organic
// without losing reproducibility (a given deal produces the same
// per-card stagger pattern across runs). The seed is a hash of the
// card's own identity — no separate numeric id needed.
let mut card_hasher = DefaultHasher::new();
card_marker.card.hash(&mut card_hasher);
let per_card_stagger =
stagger_secs * (1.0 + deal_stagger_jitter(card_hasher.finish() as u32));
commands.entity(entity).insert((
Transform::from_translation(stock_start.with_z(final_pos.z)),
CardAnim {
@@ -524,13 +530,13 @@ fn start_foundation_flourish(
let pile_type = KlondikePile::Foundation(foundation);
// Top card of the completed foundation is the King.
let cards = game.0.pile(pile_type);
let Some(king_id) = cards.last().map(|c| c.id) else {
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
continue;
};
// Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() {
if card_marker.card_id == king_id {
if card_marker.card == king_card {
commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot,
elapsed: 0.0,
@@ -633,23 +639,13 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<solitaire_core::card::Card> {
) -> Vec<(Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
fn foundation_from_slot(slot: u8) -> Option<Foundation> {
match slot {
0 => Some(Foundation::Foundation1),
1 => Some(Foundation::Foundation2),
2 => Some(Foundation::Foundation3),
3 => Some(Foundation::Foundation4),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required)
// ---------------------------------------------------------------------------
@@ -850,13 +846,13 @@ mod tests {
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::Tableau;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
@@ -865,19 +861,19 @@ mod tests {
// Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
let card_id = app
let card = app
.world()
.resource::<GameStateResource>()
.0
.pile(dest_pile)
.last()
.map(|c| c.id)
.map(|(c, _)| c.clone())
.expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that id so the system would
// Spawn a minimal CardEntity matching that card so the system would
// find it and insert ShakeAnim if the gate were absent.
app.world_mut()
.spawn((CardEntity { card_id }, Transform::default()));
.spawn((CardEntity { card }, Transform::default()));
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()
@@ -904,13 +900,13 @@ mod tests {
#[test]
fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
@@ -921,7 +917,7 @@ mod tests {
.resource_mut::<Messages<FoundationCompletedEvent>>()
.write(FoundationCompletedEvent {
slot: 0,
suit: solitaire_core::card::Suit::Spades,
suit: solitaire_core::Suit::Spades,
});
app.update();
+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::*;
+123 -161
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::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
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 {
@@ -154,15 +157,15 @@ impl Plugin for GamePlugin {
let saved = path.as_deref().and_then(load_game_state_from);
let prompt_worthy = saved
.as_ref()
.is_some_and(|g| g.move_count > 0 && !g.is_won);
.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>()
@@ -302,7 +305,7 @@ fn tick_elapsed_time(
*skip_next_delta = false;
return;
}
let is_won = game.0.is_won;
let is_won = game.0.is_won();
advance_elapsed(
&mut game.0.elapsed_seconds,
&mut accumulator,
@@ -316,18 +319,18 @@ fn seed_from_system_time() -> u64 {
}
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`solitaire_core::solver`] returns a verdict
/// arithmetic) until the [`GameState::solve_fresh_deal`] returns a verdict
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
/// attempts have elapsed.
///
/// The solver classifies each deal as one of three verdicts:
/// - [`SolverResult::Winnable`] — provably solvable; accept.
/// - [`SolverResult::Inconclusive`] — budget exceeded, no proof
/// either way; accept (we treat "we don't know" as winnable so
/// the toggle never silently drops a player into the retry cap).
/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed.
/// - `Ok(Some(_))` — winnable (provably solvable); accept.
/// - `Err(_)` — inconclusive (budget exceeded, no proof either way);
/// accept (we treat "we don't know" as winnable so the toggle never
/// silently drops a player into the retry cap).
/// - `Ok(None)` — provably dead; try the next seed.
///
/// If every seed in the retry window is `Unwinnable` (extremely
/// If every seed in the retry window is provably dead (extremely
/// unlikely on real inputs), the function returns the *last* tried
/// seed so the player still gets a deal — better a possibly-unwinnable
/// hand than an infinite loop.
@@ -388,13 +391,19 @@ 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 {
let cfg = SolverConfig::default();
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 try_solve(seed, draw_mode, &cfg) {
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
SolverResult::Unwinnable => {
match GameState::solve_fresh_deal(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
) {
// Winnable (`Ok(Some)`) or inconclusive (`Err`) → accept as
// "probably winnable"; only a proven dead deal (`Ok(None)`) retries.
Ok(Some(_)) | Err(_) => return seed,
Ok(None) => {
seed = seed.wrapping_add(1);
}
}
@@ -424,7 +433,7 @@ fn handle_new_game(
for ev in new_game.read() {
// If an active game is in progress, intercept and show a confirm dialog.
// A game is "active" when moves have been made and it is not yet won.
let needs_confirm = game.0.move_count > 0 && !game.0.is_won;
let needs_confirm = game.0.move_count() > 0 && !game.0.is_won();
// Skip confirmation if a ConfirmNewGameScreen already exists (prevents
// duplicates) or if the event itself was already confirmed by the
// player pressing Y on the modal — without the `confirmed` check the
@@ -464,7 +473,7 @@ fn handle_new_game(
// where SettingsPlugin is not installed.
let draw_mode = settings
.as_ref()
.map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode);
.map_or_else(|| game.0.draw_mode(), |s| s.0.draw_mode);
let mode = ev.mode.unwrap_or(game.0.mode);
// Solver-backed retry: when the player has opted in to
@@ -521,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;
@@ -818,26 +827,26 @@ 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_ids: Vec<u32> = {
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,
let draw_count = match game.0.draw_mode() {
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
let n = stock.len();
let take = n.min(draw_count);
stock[n - take..].iter().map(|c| c.id).collect()
stock[n - take..].iter().map(|c| c.0.clone()).collect()
}
};
match game.0.draw() {
Ok(()) => {
// Fire a flip event for each card that moved from stock to waste.
for id in drawn_ids {
flipped.write(CardFlippedEvent(id));
for card in drawn_cards {
flipped.write(CardFlippedEvent(card));
}
// Record the atomic player input. Whether the engine
// resolves this to a draw or a waste→stock recycle is
@@ -845,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}"),
@@ -859,21 +868,21 @@ 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>>,
) {
for ev in moves.read() {
let was_won = game.0.is_won;
let was_won = game.0.is_won();
// Identify the card that will be exposed (and may flip face-up) by the move.
// It's the card just below the bottom of the moving stack in the source pile.
let source_cards = pile_cards(&game.0, &ev.from);
let flip_candidate_id = {
let flip_candidate = {
let n = source_cards.len();
if n > ev.count {
let c = &source_cards[n - ev.count - 1];
if !c.face_up { Some(c.id) } else { None }
if !c.1 { Some(c.0.clone()) } else { None }
} else {
None
}
@@ -883,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(fid) = flip_candidate_id
if let Some(fcard) = flip_candidate
&& pile_cards(&game.0, &ev.from)
.last()
.is_some_and(|c| c.id == fid && c.face_up)
.is_some_and(|c| c.0 == fcard && c.1)
{
flipped.write(crate::events::CardFlippedEvent(fid));
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
@@ -905,14 +920,14 @@ fn handle_move(
if let KlondikePile::Foundation(slot) = ev.to
&& let Some(slot) = foundation_slot(slot)
&& game.0.pile(ev.to).len() == 13
&& let Some(suit) = game.0.pile(ev.to).first().map(|c| c.suit)
&& let Some(suit) = game.0.pile(ev.to).first().map(|c| c.0.suit())
{
foundation_done.write(FoundationCompletedEvent { slot, suit });
}
changed.write(StateChangedEvent);
if !was_won && game.0.is_won {
if !was_won && game.0.is_won() {
won.write(GameWonEvent {
score: game.0.score,
score: game.0.score(),
time_seconds: game.0.elapsed_seconds,
});
// Delete the saved state — a won game should not be resumed.
@@ -986,7 +1001,7 @@ pub fn record_replay_on_win(
let win_move_index = recording.moves.len().checked_sub(1);
let replay = Replay::new(
game.0.seed,
game.0.draw_mode,
game.0.draw_mode(),
game.0.mode,
ev.time_seconds,
ev.score,
@@ -1007,7 +1022,7 @@ pub fn record_replay_on_win(
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<solitaire_core::card::Card> {
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
@@ -1093,13 +1108,13 @@ fn check_no_moves(
// Despawn game-over overlay whenever moves become available again or game is won.
let moves_ok = has_legal_moves(&game.0);
if moves_ok || game.0.is_won {
if moves_ok || game.0.is_won() {
for entity in &game_over_screens {
commands.entity(entity).despawn();
}
}
if game.0.is_won {
if game.0.is_won() {
return;
}
@@ -1109,7 +1124,7 @@ fn check_no_moves(
// Only spawn the overlay if one does not already exist, and no other
// modal scrim is currently open (global ModalScrim guard).
if game_over_screens.is_empty() && scrims.is_empty() {
spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
spawn_game_over_screen(&mut commands, game.0.score(), font_res.as_deref());
}
}
}
@@ -1248,7 +1263,7 @@ fn auto_save_game_state(
// or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk.
if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 || pending.0.is_some()
if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
{
return;
}
@@ -1295,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
@@ -1318,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
}
@@ -1385,13 +1399,13 @@ mod tests {
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<u32> = app
let before: Vec<solitaire_core::Card> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.id)
.map(|c| c.0.clone())
.collect();
app.world_mut().write_message(NewGameRequestEvent {
@@ -1401,13 +1415,13 @@ mod tests {
});
app.update();
let after: Vec<u32> = app
let after: Vec<solitaire_core::Card> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.id)
.map(|c| c.0.clone())
.collect();
assert_ne!(before, after);
}
@@ -1516,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"))
}
@@ -1534,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();
@@ -1553,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);
@@ -1596,7 +1610,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
// Re-arm the timer past the threshold every frame and pump
// updates until the save fires. Caps at 16 iterations — a
@@ -1643,7 +1657,7 @@ mod tests {
#[test]
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let mut app = test_app(1);
// Build a tableau with two face-up cards.
{
@@ -1651,28 +1665,13 @@ mod tests {
gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card {
id: 910,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 911,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card::new(Deck::Deck1, Suit::Clubs, Rank::King),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen),
],
);
gs.0.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 912,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)],
);
}
@@ -1685,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!(
@@ -1702,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"
@@ -1715,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, 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,
@@ -1739,12 +1738,7 @@ mod tests {
game.set_test_waste_cards(Vec::new());
let mut stock = Vec::new();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
stock.push(Card {
id: 100 + r as u32,
suit: Suit::Hearts,
rank: r,
face_up: false,
});
stock.push(Card::new(Deck::Deck1, Suit::Hearts, r));
}
game.set_test_stock_cards(stock);
// Stock is non-empty, so drawing is always a valid move.
@@ -1756,8 +1750,8 @@ mod tests {
#[test]
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
use solitaire_core::card::{Card, 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());
@@ -1785,12 +1779,7 @@ mod tests {
}
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
assert!(
@@ -1805,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, 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());
@@ -1836,28 +1825,13 @@ mod tests {
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card {
id: 10,
suit: Suit::Spades,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 11,
suit: Suit::Hearts,
rank: Rank::Jack,
face_up: true,
},
Card::new(Deck::Deck1, Suit::Spades, Rank::Queen),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Jack),
],
);
game.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 12,
suit: Suit::Diamonds,
rank: Rank::King,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
);
assert!(
@@ -1885,7 +1859,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 5;
.set_test_move_count(5);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
@@ -1909,7 +1883,7 @@ mod tests {
let mut app = test_app_with_input(42);
// move_count stays at 0 (fresh game).
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
app.world().resource::<GameStateResource>().0.move_count(),
0,
"test assumes a fresh game with no moves"
);
@@ -2010,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, 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,
@@ -2042,12 +2016,7 @@ mod tests {
}
gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 7_000,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)],
);
}
@@ -2141,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
@@ -2193,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
@@ -2224,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!(
@@ -2236,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);
@@ -2268,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,
@@ -2280,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,
@@ -2365,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,
@@ -2407,7 +2369,7 @@ mod tests {
app.update();
// Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
}
#[test]
@@ -2442,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"
@@ -2481,7 +2443,7 @@ mod tests {
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
assert_eq!(
app.world()
.resource::<GameStateResource>()
@@ -2541,7 +2503,7 @@ mod tests {
);
// New game completed: a fresh deal carries 0 moves.
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
app.world().resource::<GameStateResource>().0.move_count(),
0,
"completed new game must be in fresh-deal state",
);
+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::game_state::{DifficultyLevel, DrawMode};
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
@@ -432,7 +432,7 @@ fn build_home_context<'a>(
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
@@ -620,9 +620,9 @@ fn handle_home_draw_mode_buttons(
return;
};
let target = if want_one {
DrawMode::DrawOne
DrawStockConfig::DrawOne
} else {
DrawMode::DrawThree
DrawStockConfig::DrawThree
};
if settings.0.draw_mode == target {
return; // already in this mode — avoid a redundant respawn.
@@ -857,7 +857,7 @@ struct HomeContext<'a> {
challenge_best: u32,
daily_streak: u32,
daily_today: Option<DailyToday>,
draw_mode: DrawMode,
draw_mode: DrawStockConfig,
font_res: Option<&'a FontResource>,
/// Whether the difficulty section header is currently expanded.
difficulty_expanded: bool,
@@ -1038,7 +1038,7 @@ fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>)
..default()
};
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne);
parent
.spawn(Node {
@@ -1878,7 +1878,7 @@ mod tests {
let states: Vec<(HomeMode, bool)> = app
.world_mut()
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
.query::<(&HomeModeCard, Has<Disabled>)>()
.iter(app.world())
.map(|(c, d)| (c.0, d))
.collect();
+55 -55
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::game_state::{DrawMode, 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>>,
@@ -1154,12 +1153,12 @@ fn handle_hint_button(
return;
}
let Some(ref g) = game else { return };
if g.0.is_won {
if g.0.is_won() {
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
return;
}
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
hint.spawn(g.0.clone(), cfg.0);
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
}
}
}
@@ -1818,7 +1817,7 @@ fn detect_score_change(
score_q: Query<Entity, With<HudScore>>,
mut commands: Commands,
) {
let current = game.0.score;
let current = game.0.score();
let delta = current - prev.0;
prev.0 = current;
if delta <= 0 {
@@ -2106,10 +2105,10 @@ fn update_won_previously(
let Ok(mut text) = q.single_mut() else {
return;
};
let won_before = !game.0.is_won
let won_before = !game.0.is_won()
&& history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| {
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode && r.mode == game.0.mode
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
})
});
let next = if won_before {
@@ -2275,17 +2274,17 @@ fn update_hud(
**t = if is_zen {
String::new()
} else {
format!("Score: {}", g.score)
format!("Score: {}", g.score())
};
}
if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count);
**t = format!("Moves: {}", g.move_count());
}
if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode {
GameMode::Classic => match g.draw_mode {
DrawMode::DrawOne => String::new(),
DrawMode::DrawThree => "Draw 3".to_string(),
GameMode::Classic => match g.draw_mode() {
DrawStockConfig::DrawOne => String::new(),
DrawStockConfig::DrawThree => "Draw 3".to_string(),
},
GameMode::Zen => "ZEN".to_string(),
GameMode::Challenge => "CHALLENGE".to_string(),
@@ -2296,7 +2295,7 @@ fn update_hud(
// --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won {
if g.is_won() {
**t = String::new();
} else if let Some(dc) = daily.as_deref() {
**t = challenge_hud_text(dc);
@@ -2311,7 +2310,7 @@ fn update_hud(
// --- Undo count ---
if let Ok((mut t, mut color)) = undos_q.single_mut() {
let count = g.undo_count;
let count = g.undo_count();
if count == 0 {
**t = String::new();
*color = TextColor(TEXT_PRIMARY);
@@ -2325,8 +2324,8 @@ fn update_hud(
// --- Recycle counter (both modes, hidden until first recycle) ---
if let Ok(mut t) = recycles_q.single_mut() {
**t = if g.recycle_count > 0 {
format!("Recycles: {}", g.recycle_count)
**t = if g.recycle_count() > 0 {
format!("Recycles: {}", g.recycle_count())
} else {
String::new()
};
@@ -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 {
@@ -2426,7 +2425,7 @@ fn foundation_selection_label(
let claimed = game
.pile(KlondikePile::Foundation(slot))
.first()
.map(|c| c.suit);
.map(|c| c.0.suit());
match claimed {
Some(suit) => {
let s = match suit {
@@ -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::game_state::{DrawMode, 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();
}
@@ -2763,9 +2764,9 @@ mod tests {
#[test]
fn score_reflects_game_state() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 750;
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
app.update();
assert_eq!(read_hud_text::<HudScore>(&mut app), "Score: 750");
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
}
#[test]
@@ -2774,7 +2775,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 42;
.set_test_move_count(42);
app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
}
@@ -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,8 +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);
app.world_mut().resource_mut::<GameStateResource>().0.score = 999;
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), "");
@@ -2916,7 +2916,7 @@ mod tests {
fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -2931,7 +2931,7 @@ mod tests {
target_score: None,
max_time_secs: Some(300),
});
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
}
@@ -2946,7 +2946,7 @@ mod tests {
target_score: Some(4000),
max_time_secs: None,
});
app.world_mut().resource_mut::<GameStateResource>().0.score = 1;
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
}
@@ -2962,7 +2962,7 @@ mod tests {
max_time_secs: Some(300),
});
// Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -2984,7 +2984,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 3;
.force_test_undos(3);
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
}
@@ -3012,7 +3012,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
}
@@ -3024,7 +3024,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
}
@@ -3038,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), "");
}
@@ -3048,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), "");
}
@@ -3056,8 +3056,8 @@ mod tests {
#[test]
fn recycles_hud_shows_count_draw_three() {
let mut app = headless_app();
let mut gs = GameState::new(42, DrawMode::DrawThree);
gs.recycle_count = 3;
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
gs.force_test_recycles(3);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
@@ -3067,8 +3067,8 @@ 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);
gs.recycle_count = 2;
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
gs.force_test_recycles(2);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
@@ -3108,7 +3108,7 @@ mod tests {
set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 50;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
// One floater should now exist.
@@ -3129,7 +3129,7 @@ mod tests {
#[test]
fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
@@ -3155,7 +3155,7 @@ mod tests {
let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant.
app.world_mut().resource_mut::<GameStateResource>().0.score = 5;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
app.update();
assert_eq!(
count_with::<ScoreFloater>(&mut app),
@@ -3231,7 +3231,7 @@ mod tests {
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
File diff suppressed because it is too large Load Diff
+89 -13
View File
@@ -7,6 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet};
use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Schedule labels for layout-related systems so cross-plugin ordering is
@@ -91,6 +92,15 @@ const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// this column inside the visible window.
const MAX_TABLEAU_CARDS: f32 = 13.0;
/// Upper bound for the dynamic tableau fan step (fraction of card height) chosen
/// by [`apply_dynamic_tableau_fan`]. The fan is spread to fill the available
/// height, but a near-empty column has tiny demand, so without a cap its few
/// cards would fling far apart on a tall viewport. At 0.6 the face-up cards keep
/// clear overlap (a readable stack) while still filling most of a near-square /
/// unfolded-foldable screen. Tunable purely for feel — no effect on correctness
/// or hit-testing.
pub(crate) const MAX_DYNAMIC_FAN_FRAC: f32 = 0.9;
/// Vertical pixel band reserved at the top of the play area for the HUD
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
/// below this band so the HUD doesn't bleed into the play surface.
@@ -275,13 +285,12 @@ pub fn compute_layout(
);
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
// height-based sizing already ensures a worst-case 13-card column fits at
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
// keeps it there — no change from prior behaviour. On width-limited
// (portrait phone) windows card_size is small and lots of vertical space
// is unused; we solve for the fraction that exactly fills the available
// space to the bottom margin.
// Adaptive tableau fan fraction. On height-limited windows the height-based
// sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
// so the formula returns the minimum and the clamp keeps it there. On
// width-limited (portrait phone) windows card_size is small and lots of
// vertical space is unused; solve for the fraction that fills the available
// space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
//
// avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps.
@@ -292,20 +301,87 @@ pub fn compute_layout(
} else {
TABLEAU_FAN_FRAC
};
// Never go below the desktop minimum — avoids shrinking the fan on
// degenerate near-square windows where the formula might undershoot.
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
// Scale the face-down fraction proportionally so rendering and hit-testing
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
let available_tableau_height = avail;
Layout {
card_size,
pile_positions,
tableau_fan_frac,
tableau_facedown_fan_frac,
available_tableau_height: avail,
available_tableau_height,
}
}
/// Spread the tableau fan so the deepest column fills the available vertical
/// height for the *current* deal, mutating `layout.tableau_fan_frac` and its
/// face-down companion in place.
///
/// `compute_layout` is pure geometry and sizes the fan for a worst-case 13-card
/// column, so early in a game (shallow columns) a tall or near-square viewport
/// — e.g. an unfolded foldable — is left with a large empty band below the
/// tableau. This refines the fan once the actual deal is known.
///
/// Depth is measured across *all* cards in a column, each face-down card
/// weighted by the fixed face-down/face-up step ratio. Counting the face-down
/// portion (not just the face-up tail) is what fills the lower screen on a fresh
/// deal, where the deepest column is several face-down cards under one face-up
/// one. Deeper columns drive the fraction down so everything still fits;
/// [`TABLEAU_FAN_FRAC`] floors it and [`MAX_DYNAMIC_FAN_FRAC`] caps it.
///
/// Called from card-sync at startup and on every `StateChangedEvent`, and from
/// the resize pipeline after `compute_layout`, so the cold-start deal, ongoing
/// play, and fold/unfold all stay filled. `card_position` / `card_positions`
/// read the same fractions, so rendering and hit-testing remain in sync.
pub(crate) fn apply_dynamic_tableau_fan(game: &GameState, layout: &mut Layout) {
let card_h = layout.card_size.y;
let avail = layout.available_tableau_height;
if card_h <= 0.0 {
return;
}
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
// "Step demand" of a column: the vertical offset of its bottom card from its
// top card, in units of the face-up fan step. Every card except the last
// contributes one step, weighted down to `facedown_ratio` while face-down.
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|tableau| {
let pile = game.pile(KlondikePile::Tableau(tableau));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, face_up)| if *face_up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
// No fannable column (every tableau pile has ≤ 1 card) — leave the fractions
// at the values compute_layout set.
if max_demand <= 0.0 {
return;
}
let ideal = avail / (max_demand * card_h);
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, MAX_DYNAMIC_FAN_FRAC);
let new_facedown_frac = new_frac * facedown_ratio;
if (layout.tableau_fan_frac - new_frac).abs() > 1e-4 {
layout.tableau_fan_frac = new_frac;
}
if (layout.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
layout.tableau_facedown_fan_frac = new_facedown_frac;
}
}
@@ -745,7 +821,7 @@ mod tests {
);
// The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| {
let card_top = |layout: &Layout| {
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
};
assert!(
+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
}
+23 -23
View File
@@ -21,7 +21,7 @@
//! active opens the overlay as normal.
use bevy::prelude::*;
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawStockConfig;
use solitaire_data::save_game_state_to;
use crate::events::{
@@ -86,10 +86,10 @@ struct ForfeitConfirmButton;
/// Returns the human-readable label for a draw mode.
///
/// Used on the pause overlay draw-mode toggle button.
pub fn draw_mode_label(mode: DrawMode) -> &'static str {
pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
match mode {
DrawMode::DrawOne => "Draw 1",
DrawMode::DrawThree => "Draw 3",
DrawStockConfig::DrawOne => "Draw 1",
DrawStockConfig::DrawThree => "Draw 3",
}
}
@@ -273,9 +273,9 @@ fn handle_pause_draw_buttons(
}
let Some(mut settings) = settings else { return };
let new_mode = if pressed_one {
DrawMode::DrawOne
DrawStockConfig::DrawOne
} else {
DrawMode::DrawThree
DrawStockConfig::DrawThree
};
if settings.0.draw_mode == new_mode {
return;
@@ -340,7 +340,7 @@ fn handle_forfeit_request(
if !forfeit_screens.is_empty() {
return;
}
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won);
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won());
if !game_in_progress {
toast.write(InfoToastEvent("No game to forfeit".to_string()));
return;
@@ -477,7 +477,7 @@ fn spawn_pause_screen(
commands: &mut Commands,
level: Option<u32>,
streak: Option<u32>,
draw_mode: Option<DrawMode>,
draw_mode: Option<DrawStockConfig>,
font_res: Option<&FontResource>,
) {
spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| {
@@ -516,7 +516,7 @@ fn spawn_pause_screen(
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
fn spawn_draw_mode_row(
parent: &mut ChildSpawnerCommands,
mode: DrawMode,
mode: DrawStockConfig,
font_res: Option<&FontResource>,
) {
let label_font = TextFont {
@@ -530,8 +530,8 @@ fn spawn_draw_mode_row(
..default()
};
let (one_variant, three_variant) = match mode {
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
DrawStockConfig::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawStockConfig::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
};
parent
.spawn(Node {
@@ -800,20 +800,20 @@ mod tests {
#[test]
fn draw_mode_label_draw_one() {
assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1");
assert_eq!(draw_mode_label(DrawStockConfig::DrawOne), "Draw 1");
}
#[test]
fn draw_mode_label_draw_three() {
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
assert_eq!(draw_mode_label(DrawStockConfig::DrawThree), "Draw 3");
}
/// Both variants are covered so the match is exhaustive — this test would
/// fail to compile if a new DrawMode variant were added without updating
/// fail to compile if a new DrawStockConfig variant were added without updating
/// `draw_mode_label`.
#[test]
fn draw_mode_label_covers_all_variants() {
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
for mode in [DrawStockConfig::DrawOne, DrawStockConfig::DrawThree] {
let label = draw_mode_label(mode);
assert!(
!label.is_empty(),
@@ -842,7 +842,7 @@ mod tests {
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.draw_mode = DrawMode::DrawOne;
.draw_mode = DrawStockConfig::DrawOne;
// Set paused so handle_pause_draw_toggle acts.
app.world_mut().resource_mut::<PausedResource>().0 = true;
@@ -856,7 +856,7 @@ mod tests {
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
assert_eq!(
*mode,
DrawMode::DrawThree,
DrawStockConfig::DrawThree,
"pressing Draw 3 must set mode to DrawThree"
);
@@ -869,7 +869,7 @@ mod tests {
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
assert_eq!(
*mode2,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
"pressing Draw 1 must set mode to DrawOne"
);
@@ -965,11 +965,11 @@ mod tests {
/// Provides a fresh `GameStateResource` (not won) so the modal can
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
fn forfeit_app() -> App {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.update();
app
}
@@ -1020,12 +1020,12 @@ mod tests {
/// hotkey was received but is currently a no-op.
#[test]
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
let mut game = GameState::new(1, DrawMode::DrawOne);
game.is_won = true;
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
game.set_test_won(true);
app.insert_resource(GameStateResource(game));
app.update();
+47 -65
View File
@@ -1,12 +1,10 @@
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in
//! `game_plugin`.
//!
//! The synchronous version (v0.17.0) called
//! `solitaire_core::solver::try_solve_from_state` on the main thread on
//! every H press. Median latency was ~2 ms but pathological positions
//! can hit the `SolverConfig::default()` cap at ~120 ms, which is a
//! noticeable input-stall on the same frame the player sees the hint
//! request.
//! The synchronous version (v0.17.0) called the solver on the main thread
//! on every H press. Median latency was ~2 ms but pathological positions
//! can hit the default solve budget at ~120 ms, which is a noticeable
//! input-stall on the same frame the player sees the hint request.
//!
//! This module hosts the resource and polling system that move the
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
@@ -26,13 +24,12 @@
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::KlondikePile;
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint};
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint, hint_piles};
use crate::resources::{GameStateResource, HintCycleIndex};
/// In-flight async work for the H-key hint.
@@ -60,23 +57,17 @@ impl PendingHintTask {
self.inner = None;
}
/// Spawn a new solver task for `state` with `config`. Drops any
/// previously in-flight task first (cancel-on-replace).
pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
let move_count_at_spawn = state.move_count;
/// Spawn a new solver task for `state` with the given solve budgets.
/// Drops any previously in-flight task first (cancel-on-replace).
pub fn spawn(&mut self, state: GameState, moves_budget: u64, states_budget: u64) {
let move_count_at_spawn = state.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move {
let outcome = try_solve_from_state(&state, &config);
match outcome.result {
SolverResult::Winnable => outcome
.first_move
.map(|mv| HintTaskOutput::SolverMove {
from: mv.source,
to: mv.dest,
})
.unwrap_or(HintTaskOutput::NeedsHeuristic),
SolverResult::Unwinnable | SolverResult::Inconclusive => {
HintTaskOutput::NeedsHeuristic
}
// Winnable (`Ok(Some)`) carries the first move on a winning path;
// unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
// to the live-state heuristic so H always produces feedback.
match state.solve_first_move(moves_budget, states_budget) {
Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
}
});
self.inner = Some(HintTask {
@@ -99,12 +90,10 @@ struct HintTask {
/// What the solver task carries back to the main thread.
enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the
/// solution path.
SolverMove {
from: KlondikePile,
to: KlondikePile,
},
/// Solver verdict was winnable; here is the first move on the solution
/// path. Converted to highlighted `(from, to)` piles by the poll system
/// via [`crate::input_plugin::hint_piles`].
SolverMove(KlondikeInstruction),
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists.
@@ -156,19 +145,22 @@ pub fn poll_pending_hint_task(
pending.inner = None;
let Some(g) = game else { return };
if g.0.move_count != move_count_at_spawn {
if g.0.move_count() != move_count_at_spawn {
return;
}
let (from, to) = match output {
HintTaskOutput::SolverMove { from, to } => (from, to),
HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) {
Some(pair) => pair,
None => {
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
},
// Resolve the solver's first move to highlighted piles; fall back to the
// live-state heuristic when there's no solver move or it maps to a no-op.
let solver_pair = match output {
HintTaskOutput::SolverMove(instruction) => hint_piles(&g.0, instruction),
HintTaskOutput::NeedsHeuristic => None,
};
let (from, to) = match solver_pair.or_else(|| find_heuristic_hint(&g.0, &mut hint_cycle)) {
Some(pair) => pair,
None => {
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
};
emit_hint_visuals(
&g.0,
@@ -186,9 +178,9 @@ mod tests {
use super::*;
use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches.
@@ -217,7 +209,7 @@ mod tests {
/// foundations hold A..Q for each suit, four Kings sit on
/// tableau columns 0..3, stock and waste empty.
fn near_finished_state() -> GameState {
let mut game = GameState::new(1, DrawMode::DrawOne);
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
game.set_test_stock_cards(Vec::new());
game.set_test_waste_cards(Vec::new());
for foundation in [
@@ -264,13 +256,8 @@ mod tests {
.zip(suits.iter())
{
let mut cards = Vec::new();
for (i, rank) in ranks_below_king.iter().enumerate() {
cards.push(Card {
id: (foundation as u32) * 13 + i as u32,
suit: *suit,
rank: *rank,
face_up: true,
});
for rank in ranks_below_king.iter() {
cards.push(Card::new(Deck::Deck1, *suit, *rank));
}
game.set_test_foundation_cards(foundation, cards);
}
@@ -285,12 +272,7 @@ mod tests {
{
game.set_test_tableau_cards(
tableau,
vec![Card {
id: 100 + tableau as u32,
suit: *suit,
rank: Rank::King,
face_up: true,
}],
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
}
game
@@ -305,10 +287,10 @@ mod tests {
fn winnable_solver_emits_hint_after_async_completes() {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0;
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() {
@@ -344,10 +326,10 @@ mod tests {
fn state_change_drops_in_flight_task() {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0;
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
assert!(
app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn",
@@ -380,12 +362,12 @@ mod tests {
fn second_spawn_drops_first_in_flight_task() {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0;
let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn.
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present);
@@ -394,7 +376,7 @@ mod tests {
// in flight.
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
// Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the
+17 -10
View File
@@ -11,7 +11,7 @@
//! 3. `handle_text_input` appends decimal digits / handles Backspace while
//! the modal is open, updating [`SeedInputBuffer`] each frame.
//! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at
//! 60 Hz) of no input before spawning a [`try_solve`] task on
//! 60 Hz) of no input before spawning a [`GameState::solve_fresh_deal`] task on
//! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task
//! by resetting the resource.
//! 5. `poll_solver_task` polls the in-flight task each frame and updates the
@@ -23,8 +23,9 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use solitaire_core::DrawStockConfig;
use solitaire_core::game_state::GameState;
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
use crate::font_plugin::FontResource;
@@ -83,7 +84,7 @@ struct SeedInputDisplay;
#[derive(Resource, Default)]
struct PendingVerification {
seed: Option<u64>,
handle: Option<Task<SolverResult>>,
handle: Option<Task<SolveOutcome>>,
}
// ---------------------------------------------------------------------------
@@ -339,9 +340,15 @@ fn tick_debounce_and_spawn_solver_task(
let draw_mode = settings
.as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let cfg = SolverConfig::default();
let task = AsyncComputeTaskPool::get().spawn(async move { try_solve(seed, draw_mode, &cfg) });
.map_or(DrawStockConfig::DrawOne, |s| s.0.draw_mode);
let task = AsyncComputeTaskPool::get().spawn(async move {
GameState::solve_fresh_deal(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
)
});
pending.seed = Some(seed);
pending.handle = Some(task);
@@ -369,15 +376,15 @@ fn poll_solver_task(
return;
};
match result {
SolverResult::Winnable => {
Ok(Some(_)) => {
text.0 = "\u{2713} Provably winnable".to_string();
color.0 = ACCENT_PRIMARY;
}
SolverResult::Inconclusive => {
Err(_) => {
text.0 = "? Likely winnable (search timed out)".to_string();
color.0 = TEXT_SECONDARY;
}
SolverResult::Unwinnable => {
Ok(None) => {
text.0 = "\u{2717} Provably unwinnable".to_string();
color.0 = TEXT_DISABLED;
}
+2 -2
View File
@@ -88,7 +88,7 @@ fn award_xp_on_win(
mut progress: ResMut<ProgressResource>,
) {
for ev in wins.read() {
let used_undo = game.0.undo_count > 0;
let used_undo = game.0.undo_count() > 0;
let amount = xp_for_win(ev.time_seconds, used_undo);
let prev_level = progress.0.add_xp(amount);
xp_awarded.write(XpAwardedEvent { amount });
@@ -151,7 +151,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 1;
.force_test_undos(1);
app.world_mut().write_message(GameWonEvent {
score: 500,
+19 -38
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
@@ -304,7 +304,7 @@ pub fn find_top_face_up_card_at(
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
for i in (0..pile_cards.len()).rev() {
let card = &pile_cards[i];
if !card.face_up {
if !card.1 {
continue;
}
// Only the top card is draggable on non-tableau piles.
@@ -320,7 +320,7 @@ pub fn find_top_face_up_card_at(
{
continue;
}
return Some((pile, card.clone()));
return Some((pile, card.0.clone()));
}
}
None
@@ -339,7 +339,7 @@ fn card_position(
if matches!(pile, KlondikePile::Tableau(_)) {
let mut y_offset = 0.0_f32;
for card in pile_cards(game, pile).iter().take(stack_index) {
let step = if card.face_up {
let step = if card.1 {
TABLEAU_FAN_FRAC
} else {
TABLEAU_FACEDOWN_FAN_FRAC
@@ -352,13 +352,14 @@ fn card_position(
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
@@ -498,7 +499,7 @@ fn radial_open_on_right_click(
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card.id],
cards: vec![card.clone()],
legal_destinations,
centre: world,
hovered_index: None,
@@ -571,7 +572,7 @@ fn radial_open_on_long_press(
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card.id],
cards: vec![card.clone()],
legal_destinations,
centre: world,
hovered_index: None,
@@ -794,8 +795,8 @@ mod tests {
use super::*;
use crate::layout::compute_layout;
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card as CoreCard, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{Card as CoreCard, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
/// resources / messages it depends on. No window, no camera — the
@@ -818,7 +819,7 @@ mod tests {
/// destination — Foundation(0) — under the standard rules
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
fn ace_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
// Wipe everything.
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
@@ -844,12 +845,7 @@ mod tests {
// Ace of Clubs on Tableau(0).
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
vec![CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
g
}
@@ -857,7 +853,7 @@ mod tests {
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
/// must skip it.
fn face_down_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
for foundation in [
@@ -879,14 +875,9 @@ mod tests {
] {
g.set_test_tableau_cards(tableau, Vec::new());
}
g.set_test_tableau_cards(
g.set_test_tableau_cards_with_face(
Tableau::Tableau1,
vec![CoreCard {
id: 100,
suit: Suit::Spades,
rank: Rank::King,
face_up: false,
}],
vec![(CoreCard::new(Deck::Deck1, Suit::Spades, Rank::King), false)],
);
g
}
@@ -979,12 +970,7 @@ mod tests {
#[test]
fn legal_destinations_for_ace_includes_only_first_empty_foundation() {
let g = ace_only_state();
let card = CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
};
let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace);
let dests =
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
// Ace can be placed on every empty foundation. We only need
@@ -999,12 +985,7 @@ mod tests {
#[test]
fn legal_destinations_excludes_source_pile() {
let g = ace_only_state();
let card = CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
};
let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace);
let dests = legal_destinations_for_card(
&card,
&KlondikePile::Foundation(Foundation::Foundation1),
+31 -23
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))
)
}
}
@@ -236,9 +244,9 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
/// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&Card>) -> String {
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card {
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
None => "--".to_string(),
}
}
+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::game_state::{DrawMode, GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use solitaire_data::{Replay, ReplayMove};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{Rank, Suit};
use solitaire_data::Replay;
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
/// `RotateStock` entries. Tests only ever read `replay.moves.len()`
/// (denominator of the progress indicator), so the move kind is
/// irrelevant beyond producing the right count.
fn synthetic_replay(move_count: usize) -> Replay {
Replay::new(
42,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
120,
1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
(0..move_count)
.map(|_| KlondikeInstruction::RotateStock)
.collect(),
)
}
@@ -1123,20 +1124,17 @@ fn format_pile_uses_one_indexed_lowercase_names() {
);
}
/// Move-body formatter renders `StockClick` as a label and
/// `Move` as a `from → to` arrow. The `count` field is
/// deliberately omitted — at row scale it adds noise.
/// Move-body formatter renders `RotateStock` as a label. The
/// `Dst*` variants render as a `→ to` arrow, but their pile-stack
/// source types are runtime-only and not constructible from this
/// crate, so only the stock-cycle label is asserted here; the
/// arrow path is exercised end-to-end through the move-log
/// integration tests.
#[test]
fn format_move_body_handles_both_variants() {
assert_eq!(format_move_body(&ReplayMove::StockClick), "stock cycle");
fn format_move_body_handles_stock_cycle() {
assert_eq!(
format_move_body(&ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(4)),
count: 1,
}),
"waste \u{2192} tableau 5",
"Move variant must render as `{{from}} → {{to}}` with 1-indexed pile numbers",
format_move_body(&KlondikeInstruction::RotateStock),
"stock cycle"
);
}
@@ -2314,8 +2312,8 @@ fn format_suit_glyph_all_suits() {
fn format_foundations_row_empty_board() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
DrawStockConfig::DrawOne,
GameMode::Classic,
);
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
}
@@ -2326,8 +2324,8 @@ fn format_foundations_row_empty_board() {
fn format_stock_waste_row_initial_state() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
DrawStockConfig::DrawOne,
GameMode::Classic,
);
let text = format_stock_waste_row(&game);
assert!(
+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::game_state::{DrawMode, GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use solitaire_core::KlondikeInstruction;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -592,25 +598,24 @@ mod tests {
}
}
/// A 3-move replay covering both `Move` and `StockClick` variants.
/// Seed 12345 is arbitrary — the test asserts on event counts and
/// move shapes, not on board positions.
/// A 3-move replay of `RotateStock` inputs. Pile-position types are
/// runtime-only and intentionally not constructible from the engine
/// crate, so a `Dst*` fixture can't be hand-built here; `RotateStock`
/// exercises the dispatch path (it fires a `DrawRequestEvent` without
/// needing a live state to decode piles). Seed 12345 is arbitrary —
/// the test asserts on event counts, not board positions.
fn sample_replay_three_moves() -> Replay {
Replay::new(
12345,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
60,
500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1,
},
ReplayMove::StockClick,
KlondikeInstruction::RotateStock,
KlondikeInstruction::RotateStock,
KlondikeInstruction::RotateStock,
],
)
}
@@ -748,20 +753,17 @@ mod tests {
let captured_moves = app.world().resource::<CapturedMoves>();
let captured_draws = app.world().resource::<CapturedDraws>();
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
// Sample replay: three `RotateStock` inputs — each dispatches a
// `DrawRequestEvent` and never a `MoveRequestEvent`.
assert_eq!(
captured_draws.0, 2,
"expected 2 DrawRequestEvent (two StockClicks)",
captured_draws.0, 3,
"expected 3 DrawRequestEvent (one per RotateStock)",
);
assert_eq!(
captured_moves.0.len(),
1,
"expected 1 MoveRequestEvent (the single Move variant)",
0,
"RotateStock inputs must not produce MoveRequestEvent",
);
let m = &captured_moves.0[0];
assert!(matches!(m.from, KlondikePile::Stock));
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
assert_eq!(m.count, 1);
}
/// Driving past one interval on a single-move replay must
@@ -771,12 +773,12 @@ mod tests {
let mut app = headless_app();
let one_move = Replay::new(
42,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
vec![KlondikeInstruction::RotateStock],
);
start_playback(&mut app, one_move);
app.update();
@@ -822,7 +824,7 @@ mod tests {
// Replay — their in-flight recording must not get clobbered.
{
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
rec.moves.push(ReplayMove::StockClick);
rec.moves.push(KlondikeInstruction::RotateStock);
}
start_playback(&mut app, sample_replay_three_moves());
app.update();
@@ -880,12 +882,12 @@ mod tests {
fn ten_draws_replay() -> Replay {
Replay::new(
7,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick; 10],
vec![KlondikeInstruction::RotateStock; 10],
)
}
+3 -2
View File
@@ -7,6 +7,7 @@ use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
@@ -27,8 +28,8 @@ pub struct GameStateResource(pub GameState);
/// This prevents accidental drags on quick taps, especially on touch screens.
#[derive(Resource, Debug, Clone)]
pub struct DragState {
/// IDs of the cards being dragged (bottom-to-top stacking order).
pub cards: Vec<u32>,
/// Cards being dragged (bottom-to-top stacking order).
pub cards: Vec<Card>,
/// Pile the drag originated from.
pub origin_pile: Option<KlondikePile>,
/// World-space offset from the cursor/touch to the bottom card's centre.
+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}"))
})
}
}
+74 -131
View File
@@ -38,10 +38,10 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use crate::card_plugin::CardEntity;
use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
@@ -91,9 +91,9 @@ pub enum KeyboardDragState {
/// Number of cards lifted (1 for waste / foundation, full face-up
/// run length for a tableau column).
count: usize,
/// Card ids being lifted, in the same bottom-to-top order
/// Cards being lifted, in the same bottom-to-top order
/// `DragState.cards` expects.
cards: Vec<u32>,
cards: Vec<Card>,
/// Pre-computed list of piles the lifted stack can legally be
/// placed on. Always at least one entry while in this variant —
/// if no legal destinations exist the state machine refuses to
@@ -147,8 +147,12 @@ pub struct SelectionPlugin;
impl Plugin for SelectionPlugin {
fn build(&self, app: &mut App) {
// `CardEntityIndex` is owned and kept current by `CardPlugin`; this
// call is a no-op there. It is declared here so `update_selection_highlight`
// can read it via `Res<>` even in harnesses that omit `CardPlugin`.
app.init_resource::<SelectionState>()
.init_resource::<KeyboardDragState>()
.init_resource::<CardEntityIndex>()
.add_systems(
Update,
(
@@ -159,7 +163,7 @@ impl Plugin for SelectionPlugin {
update_selection_highlight.after(GameMutation).run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>),
.or(resource_changed::<GameStateResource>),
),
),
);
@@ -393,7 +397,7 @@ fn handle_selection_keys(
KlondikePile::Tableau(Tableau::Tableau7),
];
all.into_iter()
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.1))
.collect()
};
@@ -424,7 +428,7 @@ fn handle_selection_keys(
&& let Some(ref pile) = selection.selected_pile
{
let selected_cards = pile_cards(&game.0, pile);
let Some(card) = selected_cards.last().filter(|c| c.face_up) else {
let Some((card, _)) = selected_cards.last().filter(|c| c.1) else {
return;
};
// Priority 1: foundation move (single card).
@@ -441,7 +445,7 @@ fn handle_selection_keys(
let run_len = face_up_run_len(&selected_cards);
let bottom_card = selected_cards
.get(selected_cards.len().saturating_sub(run_len))
.cloned();
.map(|(c, _)| c.clone());
if let Some(bottom) = bottom_card
&& let Some((dest, count)) =
best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len)
@@ -483,8 +487,9 @@ fn handle_selection_keys(
1
};
let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> = source_cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = source_cards.get(start) else {
let lifted_cards: Vec<Card> =
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
let Some((bottom, _)) = source_cards.get(start) else {
return;
};
let legal = legal_destinations_for(bottom, source, &game.0, count);
@@ -529,7 +534,7 @@ fn handle_selection_keys(
/// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for(
_bottom: &solitaire_core::card::Card,
_bottom: &Card,
source: &KlondikePile,
game: &GameState,
stack_count: usize,
@@ -574,10 +579,10 @@ pub(crate) fn legal_destinations_for(
/// Walks backwards from the last element and stops at the first face-down card
/// (or when the slice is exhausted). Returns at least `1` when the top card is
/// face-up; returns `0` for an empty slice or when the top card is face-down.
fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
let mut count = 0;
for card in cards.iter().rev() {
if card.face_up {
for (_, face_up) in cards.iter().rev() {
if *face_up {
count += 1;
} else {
break;
@@ -593,10 +598,10 @@ fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
/// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest(
card: &solitaire_core::card::Card,
game: &solitaire_core::game_state::GameState,
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.id)?;
let source = game.pile_containing_card(card.clone())?;
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
@@ -656,7 +661,7 @@ fn update_selection_highlight(
kbd_drag: Res<KeyboardDragState>,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
card_entities: Query<(Entity, &CardEntity)>,
card_index: Res<CardEntityIndex>,
highlights: Query<Entity, With<SelectionHighlight>>,
) {
// Always despawn any existing highlight first.
@@ -694,8 +699,8 @@ fn update_selection_highlight(
{
spawn_highlight_on_card(
&mut commands,
&card_entities,
card.id,
&card_index,
&card,
card_size,
source_color,
);
@@ -711,8 +716,8 @@ fn update_selection_highlight(
if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card(
&mut commands,
&card_entities,
card.id,
&card_index,
&card,
card_size,
dest_color,
);
@@ -723,10 +728,13 @@ fn update_selection_highlight(
/// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down.
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
pile_cards(game, pile)
.last()
.filter(|(_, up)| *up)
.map(|(c, _)| c.clone())
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
@@ -734,30 +742,27 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
}
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
/// the matching `CardEntity::card_id`. No-op if no entity matches.
/// the matching `CardEntity::card`. No-op if no entity matches.
fn spawn_highlight_on_card(
commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>,
card_id: u32,
card_index: &CardEntityIndex,
card: &Card,
card_size: Vec2,
color: Color,
) {
for (entity, card_entity) in card_entities {
if card_entity.card_id == card_id {
commands.entity(entity).with_children(|b| {
b.spawn((
SelectionHighlight,
Sprite {
color,
custom_size: Some(card_size + Vec2::splat(4.0)),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(),
));
});
break;
}
if let Some(entity) = card_index.get(card) {
commands.entity(entity).with_children(|b| {
b.spawn((
SelectionHighlight,
Sprite {
color,
custom_size: Some(card_size + Vec2::splat(4.0)),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(),
));
});
}
}
@@ -881,58 +886,23 @@ mod tests {
#[test]
fn face_up_run_len_all_face_up() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let cards = vec![
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 2,
suit: Suit::Spades,
rank: Rank::Jack,
face_up: true,
},
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
(Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
];
assert_eq!(face_up_run_len(&cards), 3);
}
#[test]
fn face_up_run_len_mixed_stops_at_face_down() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let cards = vec![
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: false,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
Card {
id: 2,
suit: Suit::Spades,
rank: Rank::Jack,
face_up: true,
},
Card {
id: 3,
suit: Suit::Diamonds,
rank: Rank::Ten,
face_up: true,
},
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
(Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
(Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ten), true),
];
// Only the top two cards are face-up.
assert_eq!(face_up_run_len(&cards), 2);
@@ -940,33 +910,18 @@ mod tests {
#[test]
fn face_up_run_len_top_card_face_down_is_zero() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{Card, Deck, Rank, Suit};
let cards = vec![
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
];
assert_eq!(face_up_run_len(&cards), 0);
}
#[test]
fn face_up_run_len_single_face_up_card() {
use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
}];
use solitaire_core::{Card, Deck, Rank, Suit};
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
assert_eq!(face_up_run_len(&cards), 1);
}
@@ -979,8 +934,8 @@ mod tests {
// -----------------------------------------------------------------------
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
@@ -1013,7 +968,7 @@ mod tests {
/// Ace first). It cannot go to an empty tableau (only Kings).
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
fn deterministic_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
// Clear stock, waste, all tableaus.
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
@@ -1031,30 +986,15 @@ mod tests {
// Place test cards.
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)],
);
g.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Hearts, Rank::Six)],
);
g.set_test_tableau_cards(
Tableau::Tableau3,
vec![Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::Six)],
);
g
}
@@ -1150,7 +1090,7 @@ mod tests {
} => {
assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert_eq!(cards, vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]);
assert!(
!legal_destinations.is_empty(),
"lifted stack must have at least one legal destination"
@@ -1162,7 +1102,10 @@ mod tests {
// DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>();
assert_eq!(drag.cards, vec![100]);
assert_eq!(
drag.cards,
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]
);
assert_eq!(
drag.origin_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1))
@@ -1267,7 +1210,7 @@ mod tests {
// keyboard sentinel.
{
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![100];
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)];
drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
drag.committed = true;
drag.active_touch_id = None;
+16 -17
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::game_state::DrawMode;
use solitaire_core::DrawStockConfig;
use solitaire_data::{
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
@@ -241,7 +241,7 @@ enum SettingsButton {
ToggleTouchInputMode,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_core::solver::try_solve`] until one is provably
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
/// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly,
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
@@ -252,8 +252,7 @@ enum SettingsButton {
/// player's last window size always wins.
ToggleSmartDefaultSize,
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
/// sync server is configured — there is no server to send to in
/// local-only mode.
/// Matomo URL is configured.
ToggleAnalytics,
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))]
@@ -380,8 +379,8 @@ impl Plugin for SettingsPlugin {
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
.add_message::<MouseWheel>()
.add_message::<TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher
@@ -1087,8 +1086,8 @@ fn handle_settings_buttons(
}
SettingsButton::ToggleDrawMode => {
settings.0.draw_mode = match settings.0.draw_mode {
DrawMode::DrawOne => DrawMode::DrawThree,
DrawMode::DrawThree => DrawMode::DrawOne,
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
};
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
@@ -1311,10 +1310,10 @@ fn handle_sync_buttons(
}
}
fn draw_mode_label(mode: &DrawMode) -> String {
fn draw_mode_label(mode: &DrawStockConfig) -> String {
match mode {
DrawMode::DrawOne => "Draw 1".into(),
DrawMode::DrawThree => "Draw 3".into(),
DrawStockConfig::DrawOne => "Draw 1".into(),
DrawStockConfig::DrawThree => "Draw 3".into(),
}
}
@@ -2663,7 +2662,7 @@ fn handle_scan_themes(
let themes_dir = user_theme_dir();
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) {
let zips: Vec<PathBuf> = match std::fs::read_dir(&themes_dir) {
Ok(entries) => entries
.flatten()
.map(|e| e.path())
@@ -3020,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.
@@ -3053,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
@@ -3365,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,
});
@@ -3373,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),
});
}
@@ -3495,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>>();
+17 -17
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();
};
@@ -534,7 +534,7 @@ fn update_stats_on_win(
let prev_streak = stats.0.win_streak_current;
stats
.0
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode());
// Per-mode best score / fastest win — additive on top of the
// lifetime totals tracked by `update_on_win`. TimeAttack is a
// no-op inside the helper because it has its own session-level
@@ -588,7 +588,7 @@ fn update_stats_on_new_game(
mut toast: MessageWriter<InfoToastEvent>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
if game.0.move_count() > 0 && !game.0.is_won() {
let streak = stats.0.win_streak_current;
stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game");
@@ -614,7 +614,7 @@ fn handle_forfeit(
mut auto_complete: Option<ResMut<AutoCompleteState>>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
if game.0.move_count() > 0 && !game.0.is_won() {
let streak = stats.0.win_streak_current;
stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit");
@@ -1325,9 +1325,9 @@ mod tests {
fn draw_three_win_increments_draw_three_wins_only() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -1371,9 +1371,9 @@ mod tests {
let mut app = headless_app();
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.move_count = 3;
.set_test_move_count(3);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
@@ -1501,7 +1501,7 @@ mod tests {
fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
@@ -1697,9 +1697,9 @@ mod tests {
stats.0.win_streak_current = 3;
}
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent);
app.update();
@@ -1723,9 +1723,9 @@ mod tests {
stats.0.win_streak_current = 1;
}
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.resource_mut::<GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent);
app.update();
@@ -1948,11 +1948,11 @@ mod tests {
///
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
/// `time_seconds` and `share_url` are the only varying fields across tests.
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay {
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> Replay {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
let mut r = solitaire_data::Replay::new(
let mut r = Replay::new(
1,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::DrawStockConfig::DrawOne,
solitaire_core::game_state::GameMode::Classic,
time_seconds,
0,
+7 -7
View File
@@ -331,7 +331,7 @@ fn push_replay_on_win(
}
let replay = Replay::new(
game.0.seed,
game.0.draw_mode,
game.0.draw_mode(),
game.0.mode,
ev.time_seconds,
ev.score,
@@ -496,7 +496,7 @@ mod tests {
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
.add_plugins(SyncPlugin::new(provider));
// MinimalPlugins does not register keyboard input.
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
@@ -604,7 +604,7 @@ mod tests {
/// would silently drop the link.
#[test]
fn upload_result_writes_share_url_into_replay_and_persists() {
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_data::{
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
};
@@ -617,7 +617,7 @@ mod tests {
// share_url — the upload-poll path must populate it.
let initial = Replay::new(
42,
DrawMode::DrawOne,
DrawStockConfig::DrawOne,
GameMode::Classic,
60,
500,
@@ -629,8 +629,8 @@ mod tests {
replays: vec![initial],
};
save_replay_history_to(&path, &history).expect("seed history on disk");
app.insert_resource(crate::stats_plugin::ReplayHistoryResource(history));
app.insert_resource(crate::stats_plugin::LatestReplayPath(Some(path.clone())));
app.insert_resource(ReplayHistoryResource(history));
app.insert_resource(LatestReplayPath(Some(path.clone())));
// Pre-resolved task carrying the URL the production path would
// get back from the server.
@@ -659,7 +659,7 @@ mod tests {
// In-memory contract: replays[0].share_url is now Some(url).
let live = app
.world()
.resource::<crate::stats_plugin::ReplayHistoryResource>();
.resource::<ReplayHistoryResource>();
assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()),
+20 -10
View File
@@ -7,13 +7,13 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit;
use solitaire_core::Suit;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
#[cfg(test)]
use crate::layout::TABLE_COLOUR;
use crate::layout::{Layout, LayoutResource, LayoutSystem, compute_layout};
use crate::layout::{
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
};
use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -177,9 +177,9 @@ fn setup_table(
Camera2d,
Camera {
clear_color: ClearColorConfig::Custom(Color::srgb(
crate::layout::TABLE_COLOUR[0],
crate::layout::TABLE_COLOUR[1],
crate::layout::TABLE_COLOUR[2],
TABLE_COLOUR[0],
TABLE_COLOUR[1],
TABLE_COLOUR[2],
)),
..default()
},
@@ -350,12 +350,13 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
}
}
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn on_window_resized(
mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
hud_vis: Option<Res<HudVisibility>>,
game: Option<Res<GameStateResource>>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
@@ -372,7 +373,16 @@ fn on_window_resized(
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
// compute_layout sizes the fan for a worst-case column; refine it to the
// current deal so a resize (incl. the Android safe-area-inset resize that
// fires in the first few frames, and fold/unfold on foldables) keeps the
// tableau filling the viewport instead of snapping back to the sparse
// worst-case fan.
if let Some(game) = game.as_ref() {
apply_dynamic_tableau_fan(&game.0, &mut new_layout);
}
if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone();
@@ -520,7 +530,7 @@ fn sync_pile_marker_visibility(
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<solitaire_core::card::Card> {
) -> Vec<(solitaire_core::Card, bool)> {
match pile {
KlondikePile::Stock => {
let stock = game.stock_cards();
+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,
+6 -6
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};
@@ -43,11 +43,11 @@ pub use registry::{
/// Hashable lookup key into [`CardTheme::faces`].
///
/// Distinct from `solitaire_core::Card`: the core type carries an `id`
/// and a `face_up` flag that vary per deal, neither of which is
/// relevant to image lookup. `CardKey` is just the (suit, rank) pair
/// that uniquely identifies which artwork to draw.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
/// Distinct from `card_game::Card`, which also encodes a deck id: `CardKey`
/// is just the (suit, rank) pair that uniquely identifies which artwork to
/// draw. Serialised theme manifests address faces by
/// [`CardKey::manifest_name`] strings, not by serialising `CardKey` itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CardKey {
pub suit: Suit,
pub rank: Rank,
+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");
+5 -5
View File
@@ -182,7 +182,7 @@ fn advance_time_attack(
// No shared screen-state enum currently covers every overlay. Pause the
// countdown whenever gameplay is blocked by a modal, the pause flag, or a
// just-won board state.
if paused.is_some_and(|p| p.0) || game.0.is_won || !modal_scrims.is_empty() {
if paused.is_some_and(|p| p.0) || game.0.is_won() || !modal_scrims.is_empty() {
return;
}
session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0);
@@ -299,7 +299,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -430,7 +430,7 @@ mod tests {
};
// The current game must be in TimeAttack mode for auto-deal to fire.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -454,7 +454,7 @@ mod tests {
let mut app = headless_app();
// Default session is inactive. Game is TimeAttack mode — still no count.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -521,7 +521,7 @@ mod tests {
// the session timer or the running win count.
// -----------------------------------------------------------------------
fn tmp_ta_path(name: &str) -> std::path::PathBuf {
fn tmp_ta_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("engine_test_ta_{name}.json"))
}
+39 -16
View File
@@ -29,6 +29,7 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;
@@ -49,8 +50,8 @@ use crate::ui_theme::ACCENT_PRIMARY;
/// card ids that will be moved (1 for a single card, multiple for a face-up run).
#[derive(Resource, Debug, Default)]
pub struct TouchSelectionState {
/// Currently selected source pile and the card ids to move (bottom-to-top).
pub selected: Option<(KlondikePile, Vec<u32>)>,
/// Currently selected source pile and the cards to move (bottom-to-top).
pub selected: Option<(KlondikePile, Vec<Card>)>,
}
impl TouchSelectionState {
@@ -60,12 +61,12 @@ impl TouchSelectionState {
}
/// Takes the current selection, leaving `selected` as `None`.
pub fn take(&mut self) -> Option<(KlondikePile, Vec<u32>)> {
pub fn take(&mut self) -> Option<(KlondikePile, Vec<Card>)> {
self.selected.take()
}
/// Sets the current selection.
pub fn set(&mut self, pile: KlondikePile, cards: Vec<u32>) {
pub fn set(&mut self, pile: KlondikePile, cards: Vec<Card>) {
self.selected = Some((pile, cards));
}
@@ -142,7 +143,7 @@ pub(crate) fn update_touch_selection_highlight(
commands.entity(entity).despawn();
}
let Some((_, ref card_ids)) = selection.selected else {
let Some((_, ref cards)) = selection.selected else {
return;
};
let Some(layout) = layout else {
@@ -154,8 +155,8 @@ pub(crate) fn update_touch_selection_highlight(
// but highlighting the whole run gives the player clear confirmation
// of how many cards are involved in the move.
let card_size = layout.0.card_size;
for &card_id in card_ids {
spawn_touch_highlight(&mut commands, &card_entities, card_id, card_size);
for card in cards {
spawn_touch_highlight(&mut commands, &card_entities, card, card_size);
}
}
@@ -163,11 +164,11 @@ pub(crate) fn update_touch_selection_highlight(
fn spawn_touch_highlight(
commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>,
card_id: u32,
card: &Card,
card_size: Vec2,
) {
for (entity, card_entity) in card_entities {
if card_entity.card_id == card_id {
if card_entity.card == *card {
commands.entity(entity).with_children(|b| {
b.spawn((
TouchSelectionHighlight,
@@ -193,6 +194,17 @@ fn spawn_touch_highlight(
mod tests {
use super::*;
use solitaire_core::Tableau;
use solitaire_core::{Card, Deck, Rank, Suit};
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
/// numeric ids. Identity is now the `Card` value.
fn test_cards() -> [Card; 3] {
[
Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Two),
Card::new(Deck::Deck1, Suit::Spades, Rank::Three),
]
}
#[test]
fn selection_state_default_is_idle() {
@@ -204,20 +216,24 @@ mod tests {
#[test]
fn set_and_take_roundtrip() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1, 2, 3]);
let cards = test_cards().to_vec();
state.set(KlondikePile::Tableau(Tableau::Tableau1), cards.clone());
assert!(state.has_selection());
let taken = state.take();
assert!(taken.is_some());
let (pile, cards) = taken.unwrap();
let (pile, taken_cards) = taken.unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(cards, vec![1, 2, 3]);
assert_eq!(taken_cards, cards);
assert!(!state.has_selection());
}
#[test]
fn clear_removes_selection() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Stock, vec![42]);
state.set(
KlondikePile::Stock,
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
);
state.clear();
assert!(!state.has_selection());
}
@@ -232,10 +248,17 @@ mod tests {
#[test]
fn set_overwrites_previous_selection() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1]);
state.set(KlondikePile::Tableau(Tableau::Tableau4), vec![7, 8]);
state.set(
KlondikePile::Tableau(Tableau::Tableau1),
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
let second = vec![
Card::new(Deck::Deck1, Suit::Hearts, Rank::Seven),
Card::new(Deck::Deck1, Suit::Spades, Rank::Eight),
];
state.set(KlondikePile::Tableau(Tableau::Tableau4), second.clone());
let (pile, cards) = state.take().unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4));
assert_eq!(cards, vec![7, 8]);
assert_eq!(cards, second);
}
}
+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,
+3 -3
View File
@@ -82,8 +82,8 @@ fn evaluate_weekly_goals(
for ev in events.drain(..) {
let ctx = WeeklyGoalContext {
time_seconds: ev.time_seconds,
used_undo: game.0.undo_count > 0,
draw_mode: game.0.draw_mode,
used_undo: game.0.undo_count() > 0,
draw_mode: game.0.draw_mode(),
};
for def in WEEKLY_GOALS {
if !def.matches(&ctx) {
@@ -177,7 +177,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 1;
.force_test_undos(1);
app.world_mut().write_message(GameWonEvent {
score: 500,
+19 -24
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;
@@ -90,28 +90,23 @@ pub struct WinSummaryPending {
/// Builds a human-readable XP breakdown string for the win modal.
///
/// Mirrors the logic in `solitaire_data::xp_for_win` so the breakdown always
/// matches the total shown on the `XpAwardedEvent`.
/// Reads the components from `solitaire_data::xp_breakdown` the single source
/// of truth shared with `xp_for_win` — so the breakdown can never drift from
/// the total shown on the `XpAwardedEvent`.
///
/// Examples:
/// - slow win, no undo → `"+50 base +25 no-undo"`
/// - fast win, undo → `"+50 base +30 speed"`
/// - fast win, no undo → `"+50 base +25 no-undo +30 speed"`
fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
let speed_bonus: u64 = if time_seconds >= 120 {
0
} else {
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
scaled.max(10)
};
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
let xp = solitaire_data::xp_breakdown(time_seconds, used_undo);
let mut parts = vec!["+50 base".to_string()];
if no_undo_bonus > 0 {
parts.push("+25 no-undo".to_string());
let mut parts = vec![format!("+{} base", xp.base)];
if xp.no_undo_bonus > 0 {
parts.push(format!("+{} no-undo", xp.no_undo_bonus));
}
if speed_bonus > 0 {
parts.push(format!("+{speed_bonus} speed"));
if xp.speed_bonus > 0 {
parts.push(format!("+{} speed", xp.speed_bonus));
}
parts.join(" ")
}
@@ -477,14 +472,14 @@ fn cache_win_data(
None
};
let used_undo = game.0.undo_count > 0;
let used_undo = game.0.undo_count() > 0;
pending.score = ev.score;
pending.time_seconds = ev.time_seconds;
pending.xp = 0; // reset; XP event follows
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
pending.new_record = is_new_record;
pending.challenge_level = challenge_level;
pending.undo_count = game.0.undo_count;
pending.undo_count = game.0.undo_count();
pending.mode = game.0.mode;
if is_new_record {
@@ -556,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;
@@ -1210,7 +1205,7 @@ mod tests {
.insert_resource(StatsResource(StatsSnapshot::default()))
.insert_resource(GameStateResource(GameState::new(
0,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::DrawStockConfig::DrawOne,
)))
.insert_resource(ProgressResource(PlayerProgress::default()));
app.update();
@@ -1539,9 +1534,9 @@ mod tests {
.challenge_index = 4;
// Switch game mode to Challenge.
{
use solitaire_core::game_state::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 {
@@ -1585,14 +1580,14 @@ mod tests {
/// mode-multiplier rows.
#[test]
fn cache_win_data_captures_undo_count_and_mode() {
use solitaire_core::game_state::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.undo_count = 2;
game.0 = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Zen);
game.0.force_test_undos(2);
}
app.world_mut().write_message(GameWonEvent {
+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);

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