Compare commits

..

62 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
40 changed files with 1142 additions and 485 deletions
+6 -41
View File
@@ -36,47 +36,12 @@ jobs:
id: meta id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: Check wasm pkg drift # WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
run: | # which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
set -euo pipefail # and commits it back (CI is the single source of truth — the artifacts
BASE_SHA="${{ github.event.before }}" # aren't byte-reproducible on contributor machines). That pkg/ commit then
HEAD_SHA="${{ github.sha }}" # triggers this workflow, so the deployed image always ships fresh wasm.
if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then # No drift check is needed here.
RANGE="$BASE_SHA..$HEAD_SHA"
else
RANGE="HEAD~1..HEAD"
fi
CHANGED="$(git diff --name-only "$RANGE")"
echo "Changed files:"
echo "$CHANGED"
if echo "$CHANGED" | grep -Eq '^(solitaire_wasm/|solitaire_core/|Cargo\.toml|Cargo\.lock)$|^(solitaire_wasm/|solitaire_core/)'; then
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/solitaire_wasm\.js$|^solitaire_server/web/pkg/solitaire_wasm_bg\.wasm$'; then
echo "error: wasm/core/Cargo changed but committed web pkg artifacts are missing."
echo "Run: wasm-pack build --target web --out-dir solitaire_server/web/pkg --no-typescript solitaire_wasm"
exit 1
fi
fi
# Hard check: solitaire_web/ is the direct Bevy WASM source — any
# change there MUST rebuild canvas_bg.wasm or the binary goes stale.
if echo "$CHANGED" | grep -Eq '^solitaire_web/'; then
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
echo "error: solitaire_web/ changed but canvas_bg.wasm not updated."
echo "Run: ./build_wasm.sh (requires wasm-bindgen-cli + wasm32-unknown-unknown target)"
exit 1
fi
fi
# Advisory notice: solitaire_engine/ and solitaire_core/ changes often
# require a Bevy WASM rebuild but are not enforced (formatting-only
# commits should not be blocked).
if echo "$CHANGED" | grep -Eq '^(solitaire_engine/|solitaire_core/)' && \
! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
echo "notice: solitaire_engine/core changed without a canvas_bg.wasm rebuild."
echo " If the change affects gameplay run ./build_wasm.sh before pushing."
fi
- name: Log in to Gitea registry - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
+13
View File
@@ -25,6 +25,19 @@ jobs:
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable 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 - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: 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
}
+17
View File
@@ -37,6 +37,18 @@ project follows [Semantic Versioning](https://semver.org/).
### Fixed ### 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 - **Android and modal safe-area layout.** Modal cards now center within the
usable area between status and gesture bars, additional modal-spawn guards were 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 added, and Android build scripts now auto-discover SDK/NDK paths and strip
@@ -47,6 +59,11 @@ project follows [Semantic Versioning](https://semver.org/).
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste - **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
clicks, delayed first-run onboarding until splash teardown, and kept dragged clicks, delayed first-run onboarding until splash teardown, and kept dragged
stacks above all piles. 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 - **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior. sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched - **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
+7 -2
View File
@@ -208,9 +208,14 @@ Embed via `include_bytes!()` only when ALL of the following are true:
Currently embedded: Currently embedded:
* **Audio** — all `.wav` files in `audio_plugin.rs` * **Audio** — all `.wav` files in `audio_plugin.rs`
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin` * **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
* **Bundled UI font**`assets/fonts/main.ttf` (FiraMono) via `include_bytes!`
in `font_plugin.rs` and `assets/svg_loader.rs`; it is the canonical UI face
and must always be present, so it is embedded rather than `AssetServer`-loaded
Do NOT embed card face PNGs, background images, or user fonts — Do NOT embed card face PNGs or background images — these are loaded via
these are loaded via `AssetServer` so art can be swapped without recompile. `AssetServer` so art can be swapped without recompile. User-supplied fonts
(if ever added) likewise go through `AssetServer`; only the bundled FiraMono
face above is embedded.
--- ---
Generated
+1
View File
@@ -7306,6 +7306,7 @@ name = "solitaire_app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bevy", "bevy",
"jni 0.21.1",
"keyring", "keyring",
"solitaire_data", "solitaire_data",
"solitaire_engine", "solitaire_engine",
+7 -6
View File
@@ -19,13 +19,14 @@ license = "MIT"
rust-version = "1.95" rust-version = "1.95"
# Pedantic correctness lints applied across every member crate via # Pedantic correctness lints applied across every member crate via
# `[lints] workspace = true`. `unsafe_code` is "deny" rather than "forbid" # `[lints] workspace = true`.
# so the three Android JNI FFI modules can opt back in with a scoped
# `#![allow(unsafe_code)]` — `forbid` cannot be locally overridden, which
# would break the Android build. Pure crates (core, sync) carry no `unsafe`
# and so remain effectively forbidden in practice.
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "deny" # 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" single_use_lifetimes = "warn"
trivial_casts = "warn" trivial_casts = "warn"
unused_lifetimes = "warn" unused_lifetimes = "warn"
+52 -17
View File
@@ -1,16 +1,40 @@
# Ferrous Solitaire — Session Handoff # Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-09AVD Android launch smoke passed; physical-device gate remains. **Last updated:** 2026-06-25v0.40.0 released (Android APK published); physical-device gate remains.
--- ---
## Current state ## Current state
- **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes. - **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.39.0` - **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 are excluded through `.git/info/exclude` and intentionally not committed. - **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
- **Latest verification in this follow-up:** `cargo test -p solitaire_core`; `cargo test -p solitaire_data matomo_client`; `cargo test -p solitaire_engine analytics_plugin`; `cargo test -p solitaire_engine settings_plugin`; `cargo test -p solitaire_engine card_plugin`; `cargo apk build -p solitaire_app --target x86_64-linux-android --lib`; AVD `Pixel_7` install/launch/input smoke. - **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:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up. - **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
---
## v0.40.0 release (2026-06-25)
Released via tag push → `.gitea/workflows/android-release.yml` built and signed the
arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`,
29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo
pick it up automatically.
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
| PR | Summary |
|----|---------|
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
| #108 | **docs(android):** NDK reference updated `26.3.11579264``30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
Pre-release validation performed locally this session: workspace clippy/test/build
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
proving the `build_android_apk.sh` packaging pipeline end-to-end.
--- ---
@@ -103,12 +127,23 @@ Three bugs fixed:
## Open punch list ## Open punch list
### 1. Android APK launch verification (Option A) ### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
Physical device test: install the latest APK on a real Android device (not AVD), This is the **single outstanding task** for the v0.40.0 Android release. Everything
and run the checklist in `docs/ANDROID.md`. This has never been gated in CI. else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
AVD `adb shell input tap` doesn't deliver real touch events, so physical-device clippy, release manifest sanity, a full local signed APK, the published release, and
smoke testing is the only gate. Obtainium-facing delivery (public releases API, latest non-draft release, APK
downloadable anonymously). The only thing that cannot be done without hardware is
running the app on a real phone.
Install the published APK on a real Android device (not AVD) and run the checklist
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
The signed release APK is published (grab it from the release page, or use the local
`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.
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed `target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
@@ -117,13 +152,13 @@ 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 right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR. filtered logcat showed no Ferrous panic/fatal/ANR.
### 2. Matomo analytics live validation ### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
`Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine Separate, ongoing task unrelated to the Android release. `Settings` has
consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
validation against the deployed Matomo instance. Use `AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
`docs/analytics-validation.md` for the native validation checklist and the deployed Matomo instance. Use `docs/analytics-validation.md` for the native
current web/WASM decision notes. 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)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg" OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
# Reproducible builds. The wasm artifacts otherwise bake in machine-specific
# absolute source paths (the cargo registry, the rustup std sources, and this
# checkout), so a rebuild on a different machine produces different bytes and
# the CI freshness gate (rebuild-and-diff) false-positives. Remap all three
# prefixes to fixed names so the output is byte-identical anywhere.
#
# We must use CARGO_ENCODED_RUSTFLAGS (not RUSTFLAGS) and re-state the
# getrandom backend cfg here: a `*_RUSTFLAGS` env var *replaces* — does not
# merge with — the `[target.wasm32-unknown-unknown] rustflags` in
# .cargo/config.toml, so dropping that cfg would break the wasm getrandom build.
# Keep this `--cfg` in sync with .cargo/config.toml.
CARGO_HOME_DIR="${CARGO_HOME:-$HOME/.cargo}"
RUSTUP_HOME_DIR="${RUSTUP_HOME:-$HOME/.rustup}"
US=$'\x1f' # unit separator: CARGO_ENCODED_RUSTFLAGS arg delimiter
export CARGO_ENCODED_RUSTFLAGS="--cfg${US}getrandom_backend=\"wasm_js\"${US}--remap-path-prefix=${CARGO_HOME_DIR}=/cargo${US}--remap-path-prefix=${RUSTUP_HOME_DIR}=/rustup${US}--remap-path-prefix=${REPO_ROOT}=/build"
if ! command -v wasm-pack &> /dev/null; then if ! command -v wasm-pack &> /dev/null; then
echo "error: wasm-pack not found." >&2 echo "error: wasm-pack not found." >&2
echo " Install with: cargo install wasm-pack" >&2 echo " Install with: cargo install wasm-pack" >&2
+7 -2
View File
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
echo '' echo ''
echo '# Android dev' echo '# Android dev'
echo 'export ANDROID_HOME="$HOME/Android/Sdk"' echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/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 JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"' echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
} >> ~/.bashrc } >> ~/.bashrc
@@ -49,10 +49,15 @@ sdkmanager \
"platform-tools" \ "platform-tools" \
"platforms;android-34" \ "platforms;android-34" \
"build-tools;34.0.0" \ "build-tools;34.0.0" \
"ndk;26.3.11579264" \ "ndk;30.0.14904198" \
"emulator" \ "emulator" \
"system-images;android-34;google_apis;x86_64" "system-images;android-34;google_apis;x86_64"
# The exact NDK/build-tools versions above are not load-bearing — newer ones
# work (verified on NDK 30.0.14904198 / build-tools 37.0.0). `scripts/build_android_apk.sh`
# auto-discovers the newest installed NDK and build-tools, so set ANDROID_NDK_HOME
# (step 3) to whatever version you actually install here.
# 6. AVD for testing (one-time). # 6. AVD for testing (one-time).
echo no | avdmanager create avd \ echo no | avdmanager create avd \
-n bevy_test \ -n bevy_test \
+21 -1
View File
@@ -213,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
mkdir -p "$(dirname "$APK_OUT")" mkdir -p "$(dirname "$APK_OUT")"
echo ">>> apksigner sign -> $APK_OUT" echo ">>> apksigner sign -> $APK_OUT"
# Sign the schemes explicitly instead of relying on apksigner's auto behaviour.
# Left to "auto", this pipeline produced an APK carrying invalid v1 (JAR)
# signature files (META-INF/*.SF/.RSA present but failing v1 verification).
# Android installs it fine via v2/v3, but Obtainium parses the APK's legacy v1
# certificate at install time, gets an empty cert list, and crashes with
# "RangeError (length): Invalid value: valid value range is empty: 0".
# minSdk is 26 (solitaire_app/android/AndroidManifest.xml), so v1/JAR signing is
# not needed at all — disable it and ship a clean v2+v3 signature, matching what
# modern Android tooling produces for minSdk >= 24.
"$BT/apksigner" sign \ "$BT/apksigner" sign \
--ks "$KEYSTORE" \ --ks "$KEYSTORE" \
--ks-pass "pass:$KEYSTORE_PASS" \ --ks-pass "pass:$KEYSTORE_PASS" \
--ks-key-alias "$KEY_ALIAS" \ --ks-key-alias "$KEY_ALIAS" \
--key-pass "pass:$KEY_PASS" \ --key-pass "pass:$KEY_PASS" \
--min-sdk-version 26 \
--v1-signing-enabled false \
--v2-signing-enabled true \
--v3-signing-enabled true \
--out "$APK_OUT" \ --out "$APK_OUT" \
"$STAGING/app-aligned.apk" "$STAGING/app-aligned.apk"
echo ">>> verify" echo ">>> verify"
"$BT/apksigner" verify --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" echo ">>> done: $APK_OUT"
+21 -2
View File
@@ -22,6 +22,13 @@ bevy = { workspace = true }
solitaire_engine = { workspace = true } solitaire_engine = { workspace = true }
solitaire_data = { workspace = true } solitaire_data = { workspace = true }
# Android-only: the entry point reconstructs the raw `JavaVM` / activity
# handles and registers the safe `solitaire_data::android_jni` bridge. This
# is the one crate in the workspace that performs `unsafe` FFI, so it is also
# the only one that depends on `jni` directly at the app layer.
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
# Desktop-only deps. `keyring`'s default-store init only matters on # Desktop-only deps. `keyring`'s default-store init only matters on
# platforms with a real keychain backend (Linux Secret Service, # platforms with a real keychain backend (Linux Secret Service,
# macOS Keychain, Windows Credential Store), and its transitive # macOS Keychain, Windows Credential Store), and its transitive
@@ -100,5 +107,17 @@ icon = "@mipmap/ic_launcher"
# enabling auto-rotate. # enabling auto-rotate.
orientation = "portrait" orientation = "portrait"
[lints] # `solitaire_app` is the one crate that cannot inherit the workspace
workspace = true # `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"
+44 -3
View File
@@ -363,16 +363,57 @@ fn set_window_icon(
/// works on a function named `main`; our shared entry point is `run`, so /// works on a function named `main`; our shared entry point is `run`, so
/// we emit the equivalent expansion manually. /// we emit the equivalent expansion manually.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[allow(unsafe_code)]
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) { fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let vm_ptr = android_app.vm_as_ptr().cast(); if let Err(e) = init_android_jni(&android_app) {
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) { eprintln!("warn: could not initialise Android JNI bridge ({e})");
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
} }
let _ = bevy::android::ANDROID_APP.set(android_app); let _ = bevy::android::ANDROID_APP.set(android_app);
run(); run();
} }
/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
/// Android runtime and registers safe wrappers with `solitaire_data`.
///
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
/// reconstruction. Every other crate consumes the safe
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
/// function and the `#[unsafe(no_mangle)]` entry point above.
#[cfg(target_os = "android")]
#[allow(unsafe_code)]
fn init_android_jni(
android_app: &bevy::android::android_activity::AndroidApp,
) -> Result<(), String> {
use jni::JavaVM;
use jni::objects::JObject;
let vm_ptr = android_app.vm_as_ptr();
if vm_ptr.is_null() {
return Err("JavaVM pointer is null".into());
}
// SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
// Android runtime; it is valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;
let env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
// valid for the lifetime of the process. Promote it to a global reference
// so the safe bridge can hand it to any thread.
let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
let activity_ref = env
.new_global_ref(&activity)
.map_err(|e| format!("activity global ref: {e}"))?;
solitaire_data::android_jni::set_jvm(vm);
solitaire_data::android_jni::set_activity(activity_ref);
Ok(())
}
/// Wraps the default panic hook with one that also appends a crash log /// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook /// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are /// still runs afterwards, so stderr output and debugger integration are
+6 -13
View File
@@ -3,7 +3,9 @@
//! [`KlondikeAdapter`] is a pure helper namespace for: //! [`KlondikeAdapter`] is a pure helper namespace for:
//! - building [`KlondikeConfig`] from Ferrous settings //! - building [`KlondikeConfig`] from Ferrous settings
//! - translating between local and upstream types //! - 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 //! All `From` / `TryFrom` conversions between `solitaire_core` product types and
//! upstream `card_game` / `klondike` types live here so that the product modules //! upstream `card_game` / `klondike` types live here so that the product modules
@@ -14,11 +16,11 @@ use klondike::{
SkipCards, Tableau, SkipCards, Tableau,
}; };
/// 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 /// This type is intentionally zero-sized: it does not carry mutable runtime
/// state, and exists only as a namespace for configuration, conversion, and /// state, and exists only as a namespace for configuration and conversion
/// scoring helpers. /// helpers.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KlondikeAdapter; pub struct KlondikeAdapter;
@@ -81,12 +83,3 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
_ => None, _ => None,
} }
} }
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
if elapsed_seconds == 0 {
return 0;
}
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
}
+1
View File
@@ -2,6 +2,7 @@ pub mod achievement;
pub mod error; pub mod error;
pub mod game_state; pub mod game_state;
pub mod klondike_adapter; pub mod klondike_adapter;
pub mod scoring;
// Re-export the upstream types that cross the solitaire_core API boundary so // 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 // downstream crates (engine, wasm) can import from one place without a direct
+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
}
+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 -72
View File
@@ -1,8 +1,3 @@
// JNI FFI requires `unsafe` to reconstruct `JavaVM` / `JByteArray` handles
// from raw pointers handed over by the Android runtime. Scoped to this
// module so the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
/// Android Keystore token storage via JNI. /// Android Keystore token storage via JNI.
/// ///
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a /// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
@@ -19,19 +14,16 @@
/// ///
/// Only compiled and linked on `target_os = "android"`. /// Only compiled and linked on `target_os = "android"`.
use jni::{ use jni::{
JNIEnv, JavaVM, JNIEnv,
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned}, objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::c_void;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock;
use crate::auth_tokens::TokenError; use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "ferrous_solitaire_token_key"; const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct TokenBlob { struct TokenBlob {
@@ -44,43 +36,15 @@ struct TokenBlob {
// JVM helper // JVM helper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Initialise Android Keystore access with the process-wide `JavaVM*`. /// Run `f` with an attached `JNIEnv`, delegating thread attach and the
/// /// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
/// This is called by `solitaire_app` from Android startup code. Keeping the /// initialised once from Android startup, so the keystore never touches a raw
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or /// pointer and this module stays `forbid(unsafe_code)`.
/// engine layer just to reach platform startup state.
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
if vm_ptr.is_null() {
return Err(TokenError::KeychainUnavailable(
"JavaVM pointer is null".into(),
));
}
if ANDROID_JVM.get().is_some() {
return Ok(());
}
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
// the process lifetime.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
let _ = ANDROID_JVM.set(vm);
Ok(())
}
fn with_jvm<F, R>(f: F) -> Result<R, TokenError> fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
where where
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>, F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
{ {
let vm = ANDROID_JVM crate::android_jni::with_env(f).map_err(TokenError::Keyring)
.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}")))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -235,9 +199,10 @@ fn encrypt_gcm(
.v()?; .v()?;
// IV is generated by Android's provider; read it back after init. // IV is generated by Android's provider; read it back after init.
// `getIV()` returns `[B`; the safe `From<JObject>` reinterprets the
// returned object reference as a typed byte array.
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?; let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
// SAFETY: the method signature guarantees a byte array return. let iv_arr = JByteArray::from(iv_jobj);
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
let iv = env.convert_byte_array(&iv_arr)?; let iv = env.convert_byte_array(&iv_arr)?;
let pt_arr = env.byte_array_from_slice(plaintext)?; let pt_arr = env.byte_array_from_slice(plaintext)?;
@@ -245,8 +210,7 @@ fn encrypt_gcm(
let ct_jobj = env let ct_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])? .call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
.l()?; .l()?;
// SAFETY: doFinal([B) returns [B. let ct_arr = JByteArray::from(ct_jobj);
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
let ciphertext = env.convert_byte_array(&ct_arr)?; let ciphertext = env.convert_byte_array(&ct_arr)?;
let mut out = Vec::with_capacity(iv.len() + ciphertext.len()); let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
@@ -297,8 +261,7 @@ fn decrypt_gcm(
let pt_jobj = env let pt_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])? .call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
.l()?; .l()?;
// SAFETY: doFinal([B) returns [B. let pt_arr = JByteArray::from(pt_jobj);
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
env.convert_byte_array(&pt_arr) env.convert_byte_array(&pt_arr)
} }
@@ -385,29 +348,29 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
} }
// --- 2. Legacy path migration --- // --- 2. Legacy path migration ---
if let Some(ref lpath) = legacy_path { if let Some(ref lpath) = legacy_path
if lpath.exists() { && lpath.exists()
let data = read_file_bytes_from(lpath).map_err(|e| match e { {
TokenError::NotFound(_) => TokenError::NotFound(String::new()), let data = read_file_bytes_from(lpath).map_err(|e| match e {
other => other, 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 { if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
let plaintext = with_jvm(|env| { let mut map = HashMap::new();
let key = load_or_create_key(env)?; map.insert(blob.username.clone(), blob);
decrypt_gcm(env, &key, &data) // Write to the new location, then remove the legacy file.
})?; if write_map_inner(&map).is_ok() {
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) { let _ = std::fs::remove_file(lpath);
let mut map = HashMap::new();
map.insert(blob.username.clone(), blob);
// Write to the new location, then remove the legacy file.
if write_map_inner(&map).is_ok() {
let _ = std::fs::remove_file(lpath);
}
return Ok(map);
} }
return Ok(map);
} }
// Legacy file corrupt or unrecognised — treat as empty.
} }
// Legacy file corrupt or unrecognised — treat as empty.
} }
// --- 3. No file found --- // --- 3. No file found ---
@@ -496,11 +459,11 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
if map.is_empty() { if map.is_empty() {
// No more users — remove the file and the Keystore key. // No more users — remove the file and the Keystore key.
if let Some(path) = token_file_path() { if let Some(path) = token_file_path()
if path.exists() { && path.exists()
std::fs::remove_file(&path) {
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?; 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. // 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` //! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic //! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android this module delegates to an Android Keystore //! doesn't expose). On Android this module delegates to an Android Keystore
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm` //! JNI backend. `solitaire_app` must initialise the safe
//! from Android startup before token operations can succeed. //! [`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. //! # Note: no unit tests — requires live OS keychain.
+3 -2
View File
@@ -144,9 +144,10 @@ pub use settings::{
}; };
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod android_keystore; pub mod android_jni;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm; mod android_keystore;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens; pub mod auth_tokens;
+7 -31
View File
@@ -1,42 +1,19 @@
// JNI FFI requires `unsafe` to reconstruct `JavaVM` / `JObject` handles from
// raw pointers handed over by the Android runtime. Scoped to this module so
// the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
/// Android clipboard bridge via JNI. /// Android clipboard bridge via JNI.
/// ///
/// Writes text to the system clipboard by calling into `ClipboardManager` /// Writes text to the system clipboard by calling into `ClipboardManager`
/// through the 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")] #[cfg(target_os = "android")]
pub fn set_text(text: &str) -> Result<(), String> { pub fn set_text(text: &str) -> Result<(), String> {
use bevy::android::ANDROID_APP; use jni::objects::JValueOwned;
use jni::{ use solitaire_data::android_jni;
JavaVM,
objects::{JObject, JValueOwned},
};
let app = ANDROID_APP android_jni::with_activity_env(|env, activity| {
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
// valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<()> {
// ClipboardManager cm = activity.getSystemService("clipboard") // ClipboardManager cm = activity.getSystemService("clipboard")
let svc_name = JValueOwned::from(env.new_string("clipboard")?); let svc_name = JValueOwned::from(env.new_string("clipboard")?);
let cm = env let cm = env
.call_method( .call_method(
&activity, activity,
"getSystemService", "getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;", "(Ljava/lang/String;)Ljava/lang/Object;",
&[svc_name.borrow()], &[svc_name.borrow()],
@@ -65,6 +42,5 @@ pub fn set_text(text: &str) -> Result<(), String> {
&[clip_val.borrow()], &[clip_val.borrow()],
)? )?
.v() .v()
})() })
.map_err(|e| format!("clipboard JNI: {e}"))
} }
+329 -122
View File
@@ -25,7 +25,7 @@ use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource, LayoutSystem, TABLEAU_FAN_FRAC}; use crate::layout::{Layout, LayoutResource, LayoutSystem};
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT; use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
@@ -39,7 +39,7 @@ use crate::ui_theme::{
}; };
/// Per-card vertical step for face-down tableau cards, as a fraction of /// Per-card vertical step for face-down tableau cards, as a fraction of
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards /// card height. Smaller than [`crate::layout::TABLEAU_FAN_FRAC`] because face-down cards
/// don't need their full body shown — only the back-pattern strip is /// don't need their full body shown — only the back-pattern strip is
/// visible. Public so `input_plugin` can mirror the exact sprite layout /// visible. Public so `input_plugin` can mirror the exact sprite layout
/// when hit-testing tableau columns; any drift between this and the /// when hit-testing tableau columns; any drift between this and the
@@ -63,6 +63,36 @@ pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
// foundation piles bleeding through when a 2 sits on an Ace. // foundation piles bleeding through when a 2 sits on an Ace.
pub const STACK_FAN_FRAC: f32 = 0.025; pub const STACK_FAN_FRAC: f32 = 0.025;
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
///
/// Derived from the actual tableau column spacing (`Tableau2.x Tableau1.x`)
/// rather than a fixed fraction of card width, so the fan scales with the
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
/// land on the card beneath it.
pub fn waste_fan_step(layout: &Layout) -> f32 {
tableau_col_step(layout) * 0.224
}
/// Horizontal distance between adjacent tableau columns (`Tableau2.x
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
pub fn tableau_col_step(layout: &Layout) -> f32 {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
}
/// Font size as a fraction of card width. /// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28; const FONT_SIZE_FRAC: f32 = 0.28;
@@ -165,6 +195,40 @@ pub struct CardEntity {
pub card: Card, pub card: Card,
} }
/// Cached signature of the inputs that determine a card entity's *child*
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
/// the appearance changed — the common case during a move, where only the
/// card's position changes. Without this guard every `StateChangedEvent`
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
/// glyph re-layouts) in a single frame, which stutters the slide animation on
/// high-resolution devices.
///
/// The children depend only on these four inputs; the card identity is fixed
/// per entity, and the face/back *image* is handled by the always-refreshed
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
/// rebuild.
#[derive(Component, Clone, Copy, PartialEq)]
struct CardChildrenKey {
face_up: bool,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
}
/// Query data read by the card-sync systems for each live card entity:
/// its id, card identity, current transform, any in-flight curve animation,
/// and its cached child-appearance key. Factored into an alias to keep the
/// system signatures readable (and satisfy clippy's `type_complexity`).
type CardSyncData = (
Entity,
&'static CardEntity,
&'static Transform,
Option<&'static CardAnimation>,
Option<&'static CardChildrenKey>,
);
/// Render-side index mapping each live board card to its [`CardEntity`]. /// Render-side index mapping each live board card to its [`CardEntity`].
/// ///
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`, /// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
@@ -461,7 +525,13 @@ impl Plugin for CardPlugin {
.add_systems(Startup, load_card_images) .add_systems(Startup, load_card_images)
.add_systems( .add_systems(
PostStartup, PostStartup,
(sync_cards_startup, update_stock_empty_indicator_startup), (
// Fill the tableau fan before the first render so the
// cold-start deal already uses the full viewport height.
fill_tableau_fan_on_startup.before(sync_cards_startup),
sync_cards_startup,
update_stock_empty_indicator_startup,
),
) )
.add_systems( .add_systems(
Update, Update,
@@ -702,7 +772,7 @@ fn sync_cards_startup(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>, slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>, card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -737,7 +807,7 @@ fn sync_cards_on_change(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>, slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>, card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -776,7 +846,7 @@ fn sync_cards(
back_colour: Color, back_colour: Color,
color_blind: bool, color_blind: bool,
high_contrast: bool, high_contrast: bool,
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, entities: &Query<CardSyncData>,
card_images: Option<&CardImageSet>, card_images: Option<&CardImageSet>,
selected_back: usize, selected_back: usize,
font_handle: Option<&Handle<Font>>, font_handle: Option<&Handle<Font>>,
@@ -810,18 +880,24 @@ fn sync_cards(
// • end ≠ target → the game state has changed (e.g. a new game started // • end ≠ target → the game state has changed (e.g. a new game started
// while the win-cascade was mid-flight); cancel the // while the win-cascade was mid-flight); cancel the
// stale `CardAnimation` and apply the new position. // stale `CardAnimation` and apply the new position.
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>)> = HashMap::new(); let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
for (entity, marker, transform, anim) in entities.iter() { HashMap::new();
for (entity, marker, transform, anim, children_key) in entities.iter() {
existing.insert( existing.insert(
marker.card.clone(), marker.card.clone(),
(entity, transform.translation, anim.map(|a| a.end)), (
entity,
transform.translation,
anim.map(|a| a.end),
children_key.copied(),
),
); );
} }
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect(); let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
// Despawn any entity whose card is no longer tracked. // Despawn any entity whose card is no longer tracked.
for (card, (entity, _, _)) in &existing { for (card, (entity, _, _, _)) in &existing {
if !live_ids.contains(card) { if !live_ids.contains(card) {
commands.entity(*entity).despawn(); commands.entity(*entity).despawn();
} }
@@ -832,7 +908,7 @@ fn sync_cards(
// behind the incoming top card during the draw slide animation. // behind the incoming top card during the draw slide animation.
for ((card, face_up), position, z) in positions { for ((card, face_up), position, z) in positions {
let entity = match existing.get(&card) { let entity = match existing.get(&card) {
Some(&(entity, cur, anim_end)) => { Some(&(entity, cur, anim_end, children_key)) => {
// If a CardAnimation is in flight, check whether its destination // If a CardAnimation is in flight, check whether its destination
// still matches the game-state target. If the game moved the card // still matches the game-state target. If the game moved the card
// elsewhere (e.g. new game started during a win-cascade scatter), // elsewhere (e.g. new game started during a win-cascade scatter),
@@ -859,6 +935,7 @@ fn sync_cards(
high_contrast, high_contrast,
cur, cur,
has_anim, has_anim,
children_key,
card_images, card_images,
selected_back, selected_back,
font_handle, font_handle,
@@ -908,34 +985,18 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
(KlondikePile::Tableau(Tableau::Tableau7), false), (KlondikePile::Tableau(Tableau::Tableau7), false),
]; ];
// Compute the Draw-Three waste fan step proportional to the column spacing // Draw-Three waste fan step, proportional to the column spacing so it scales
// (waste_x stock_x = card_width + h_gap) rather than a fixed fraction of // with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and // `waste_fan_step` so the two never drift (a drift puts the top fanned card's
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android // click target on the card beneath it).
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping let waste_fan_step = waste_fan_step(layout);
// the top fanned card's centre within the waste column's own horizontal
// footprint instead of spilling into the adjacent gap.
let tableau_col_step = {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
};
let waste_fan_step = tableau_col_step * 0.224;
for (pile_type, is_stock_area) in piles { for (pile_type, is_stock_area) in piles {
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else { let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
continue; continue;
}; };
if matches!(pile_type, KlondikePile::Stock) && is_stock_area { if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
base.x -= tableau_col_step; base.x -= tableau_col_step(layout);
} }
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_)); let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area; let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
@@ -1100,6 +1161,14 @@ fn spawn_card_entity(
); );
}); });
} }
// Record the appearance signature so subsequent `update_card_entity` calls
// can skip rebuilding these children until one of the inputs changes.
entity.insert(CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
});
entity_id entity_id
} }
@@ -1118,6 +1187,7 @@ fn update_card_entity(
high_contrast: bool, high_contrast: bool,
cur: Vec3, cur: Vec3,
has_card_animation: bool, has_card_animation: bool,
existing_children_key: Option<CardChildrenKey>,
card_images: Option<&CardImageSet>, card_images: Option<&CardImageSet>,
selected_back: usize, selected_back: usize,
font_handle: Option<&Handle<Font>>, font_handle: Option<&Handle<Font>>,
@@ -1166,44 +1236,58 @@ fn update_card_entity(
} }
} }
// Despawn any stale children and re-add the per-card drop shadow plus, // Rebuild the card's child visuals (drop-shadow, border frame, and the
// in solid-colour fallback mode, the label overlay. In image mode the // rank/suit label / large-print corner overlay) only when an input that
// rank/suit are baked into the PNG; on Android we also add a large-print // affects them actually changed. The child set depends solely on
// corner overlay so they are legible at phone scale. // `CardChildrenKey`; the face/back image is carried by the always-refreshed
commands.entity(entity).despawn_related::<Children>(); // `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
commands.entity(entity).with_children(|b| { // this on a position-only move avoids despawning and respawning the child
add_card_shadow_child(b, layout.card_size); // entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
}); // `StateChangedEvent` — the spike that stuttered the slide animation on
commands.entity(entity).with_children(|b| { // high-resolution devices.
add_card_back_frame_child(b, layout.card_size); let new_children_key = CardChildrenKey {
}); face_up,
if card_images.is_none() { card_size: layout.card_size,
color_blind,
high_contrast,
};
if existing_children_key != Some(new_children_key) {
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| { commands.entity(entity).with_children(|b| {
b.spawn(( add_card_shadow_child(b, layout.card_size);
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
}); });
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
commands.entity(entity).with_children(|b| { commands.entity(entity).with_children(|b| {
add_android_corner_label( add_card_back_frame_child(b, layout.card_size);
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
}); });
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
});
}
commands.entity(entity).insert(new_children_key);
} }
} }
@@ -2395,15 +2479,21 @@ fn resize_android_corner_labels(
} }
} }
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum /// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
/// face-up column depth. Runs after every `StateChangedEvent` so the fan /// the deepest tableau column fills the available vertical space at every stage
/// expands as the player reveals cards while staying within the window. /// of play. Runs after every `StateChangedEvent`.
/// ///
/// On fresh deal (max face-up depth = 1) the function returns early, leaving /// Depth is measured across *all* cards in a column, weighting each face-down
/// both fracs at the window-size-adaptive values that `compute_layout` already /// card by the fixed face-down/face-up step ratio. Counting the face-down
/// computed for the current viewport. Previously it overwrote the adaptive /// portion — not just the face-up tail — is what fills the lower screen on a
/// value with the desktop minimum (0.25) — the wrong behaviour on portrait /// fresh deal (the deepest column is then six face-down cards under one face-up
/// phones where the adaptive value is much larger. /// one): the earlier face-up-only depth was 1, so the fan never spread and the
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
///
/// Deeper columns drive the fraction down so everything still fits the window;
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
/// cards far apart.
fn update_tableau_fan_frac( fn update_tableau_fan_frac(
mut events: MessageReader<StateChangedEvent>, mut events: MessageReader<StateChangedEvent>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
@@ -2418,59 +2508,35 @@ fn update_tableau_fan_frac(
let Some(layout) = layout.as_mut() else { let Some(layout) = layout.as_mut() else {
return; return;
}; };
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
let max_depth = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|tableau| {
game.0
.pile(KlondikePile::Tableau(tableau))
.into_iter()
.filter(|(_, face_up)| *face_up)
.count()
})
.max()
.unwrap_or(0);
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
// With ≤ 1 face-up card per column (fresh deal, or completely face-down
// piles) the face-up fan fraction has no visible effect. Leave both fracs
// at the adaptive values set by compute_layout rather than snapping them
// to the desktop minimum.
if max_depth <= 1 || card_h <= 0.0 {
return;
}
let ideal = avail / ((max_depth - 1) as f32 * card_h);
let max_frac = if card_h > 0.0 {
avail / (12.0 * card_h)
} else {
TABLEAU_FAN_FRAC
};
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, max_frac.max(TABLEAU_FAN_FRAC));
let new_facedown_frac = new_frac * (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC);
if (layout.0.tableau_fan_frac - new_frac).abs() > 1e-4 {
layout.0.tableau_fan_frac = new_frac;
}
if (layout.0.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
layout.0.tableau_facedown_fan_frac = new_facedown_frac;
}
} }
/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
/// inserted directly as `GameStateResource` at startup without a
/// `StateChangedEvent`, so the event-driven system never fires for it. This
/// runs once, before [`sync_cards_startup`] renders, so the very first board
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
/// and a large empty band below the tableau until the first move.
fn fill_tableau_fan_on_startup(
game: Option<Res<GameStateResource>>,
mut layout: Option<ResMut<LayoutResource>>,
) {
let Some(game) = game else {
return;
};
let Some(layout) = layout.as_mut() else {
return;
};
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::Deck; use solitaire_core::Deck;
@@ -2897,6 +2963,59 @@ mod tests {
} }
} }
#[test]
fn cold_start_deal_fills_tableau_fan() {
// The initial deal is inserted at startup without a StateChangedEvent, so
// the event-driven fan update never fires for it. The PostStartup fill
// must spread the fan from the deepest column's *total* depth (face-down
// included) so a fresh deal already fills the viewport — otherwise a
// near-square / unfolded-foldable screen renders with a large empty band
// below the tableau. Mirrors apply_dynamic_tableau_fan's formula against
// the actual dealt state so it fails if the startup fill is dropped or
// reverts to face-up-only depth.
let app = app();
let game = app.world().resource::<GameStateResource>();
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|t| {
let pile = game.0.pile(KlondikePile::Tableau(t));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, up)| if *up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
assert!(
max_demand > 1.0,
"a fresh deal's deepest column should contribute several fan steps, got {max_demand}"
);
let layout = app.world().resource::<LayoutResource>();
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
let expected = (avail / (max_demand * card_h))
.clamp(TABLEAU_FAN_FRAC, crate::layout::MAX_DYNAMIC_FAN_FRAC);
assert!(
(layout.0.tableau_fan_frac - expected).abs() < 1e-3,
"cold-start fan {} should equal the demand-filled value {} \
(card_h={card_h}, avail={avail}, demand={max_demand})",
layout.0.tableau_fan_frac,
expected,
);
}
#[test] #[test]
fn flip_half_secs_is_positive() { fn flip_half_secs_is_positive() {
const { const {
@@ -3162,6 +3281,56 @@ mod tests {
}); });
} }
#[test]
fn resize_keeps_tableau_fan_filled() {
// The Android safe-area-inset update (frames 1-3) and fold/unfold both
// fire a WindowResized that recomputes the layout, resetting the fan to
// compute_layout's sparse worst-case value. on_window_resized must
// re-apply the dynamic fill so the tableau keeps filling the viewport
// after a resize — not only at deal time. Without the fill in the resize
// path the fan would snap back to the worst-case value and the lower
// screen would empty out on the first resize.
let mut app = app();
// A tall near-square window (like an unfolded foldable) where the dynamic
// fill clearly exceeds the worst-case fan.
fire_window_resize(&mut app, 1400.0, 1500.0);
advance_past_resize_throttle(&mut app);
let game = app.world().resource::<GameStateResource>();
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|t| {
let pile = game.0.pile(KlondikePile::Tableau(t));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, up)| if *up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
let layout = app.world().resource::<LayoutResource>();
let expected = (layout.0.available_tableau_height / (max_demand * layout.0.card_size.y))
.clamp(TABLEAU_FAN_FRAC, crate::layout::MAX_DYNAMIC_FAN_FRAC);
assert!(
(layout.0.tableau_fan_frac - expected).abs() < 1e-3,
"after resize the fan {} should be re-filled to {} (the resize path must \
re-apply apply_dynamic_tableau_fan)",
layout.0.tableau_fan_frac,
expected,
);
}
#[test] #[test]
fn resize_does_not_despawn_card_labels() { fn resize_does_not_despawn_card_labels() {
// Spawn a fresh app, capture the current set of CardLabel entity IDs, // Spawn a fresh app, capture the current set of CardLabel entity IDs,
@@ -3202,6 +3371,44 @@ mod tests {
} }
} }
#[test]
fn appearance_neutral_state_change_does_not_rebuild_card_children() {
// A StateChangedEvent that doesn't alter any card's appearance — the
// common case during a move, for the ~50 cards that didn't move or flip
// — must NOT despawn and respawn child entities. Before the
// CardChildrenKey guard every StateChangedEvent rebuilt all 52 cards'
// children (incl. a Text2d glyph re-layout each), the per-move spike
// that stuttered the slide animation on high-resolution devices.
let mut app = app();
let labels_before: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert!(
!labels_before.is_empty(),
"fixture should have spawned CardLabel children in the fallback path"
);
// Fire a StateChangedEvent without mutating the game: no card moves or
// flips, so every card's CardChildrenKey is unchanged.
app.world_mut().write_message(StateChangedEvent);
app.update();
let labels_after: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert_eq!(
labels_before, labels_after,
"an appearance-neutral StateChangedEvent must not despawn/respawn card \
children the CardChildrenKey guard should have skipped the rebuild"
);
}
#[test] #[test]
fn resize_in_place_updates_card_label_font_size() { fn resize_in_place_updates_card_label_font_size() {
// Capture an arbitrary CardLabel's TextFont.font_size before resize, // Capture an arbitrary CardLabel's TextFont.font_size before resize,
+3 -2
View File
@@ -2,8 +2,9 @@
//! //!
//! Bundling rather than runtime-loading guarantees the canonical UI face is //! Bundling rather than runtime-loading guarantees the canonical UI face is
//! always available regardless of install or platform. The bytes are //! always available regardless of install or platform. The bytes are
//! validated at startup; a parse failure aborts the program with a clear //! validated at startup; a parse failure logs a warning and continues with
//! error because it means the binary is corrupt. //! glyph-less UI rather than aborting, since crashing on a corrupt embed is
//! worse than degraded text.
use bevy::prelude::*; use bevy::prelude::*;
+13 -12
View File
@@ -36,7 +36,6 @@ use crate::game_plugin::GameMutation;
use crate::input_plugin::TouchDragSet; use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT; use crate::layout::HUD_BAND_HEIGHT;
use crate::layout::LayoutSystem; use crate::layout::LayoutSystem;
#[cfg(target_os = "android")]
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT}; use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
@@ -174,7 +173,7 @@ pub enum HudVisibility {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[derive(Resource, Debug, Default)] #[derive(Resource, Debug, Default)]
struct HudTapTracker { struct HudTapTracker {
start_pos: Option<bevy::math::Vec2>, start_pos: Option<Vec2>,
/// Set `true` when the finger-down hit an action button so the /// Set `true` when the finger-down hit an action button so the
/// finger-up never toggles bar visibility. /// finger-up never toggles bar visibility.
started_on_button: bool, started_on_button: bool,
@@ -529,7 +528,7 @@ impl Plugin for HudPlugin {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
app.init_resource::<HudTapTracker>() app.init_resource::<HudTapTracker>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
.add_systems( .add_systems(
Update, Update,
toggle_hud_on_tap toggle_hud_on_tap
@@ -1140,7 +1139,7 @@ fn handle_help_button(
fn handle_hint_button( fn handle_hint_button(
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>, interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
paused: Option<Res<crate::PausedResource>>, paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>, solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>, mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
@@ -2658,8 +2657,9 @@ fn resize_action_bar_labels(
} }
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[allow(clippy::too_many_arguments)]
fn toggle_hud_on_tap( fn toggle_hud_on_tap(
mut touch_events: MessageReader<bevy::input::touch::TouchInput>, mut touch_events: MessageReader<TouchInput>,
drag: Res<DragState>, drag: Res<DragState>,
scrims: Query<(), With<ModalScrim>>, scrims: Query<(), With<ModalScrim>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
@@ -2697,13 +2697,14 @@ fn toggle_hud_on_tap(
// regardless of whether we toggle. // regardless of whether we toggle.
let on_button = tracker.started_on_button || game_consumed.0; let on_button = tracker.started_on_button || game_consumed.0;
game_consumed.0 = false; game_consumed.0 = false;
if let Some(start) = tracker.start_pos.take() { if let Some(start) = tracker.start_pos.take()
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX { && !on_button
*hud_vis = match *hud_vis { && (event.position - start).length() < HUD_TAP_SLOP_PX
HudVisibility::Visible => HudVisibility::Hidden, {
HudVisibility::Hidden => HudVisibility::Visible, *hud_vis = match *hud_vis {
}; HudVisibility::Visible => HudVisibility::Hidden,
} HudVisibility::Hidden => HudVisibility::Visible,
};
} }
tracker.started_on_button = false; tracker.started_on_button = false;
} }
+101 -14
View File
@@ -10,7 +10,8 @@
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag) //! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
//! //!
//! Mouse: //! Mouse:
//! - Left-click on the stock pile (face-down deck) or waste slot → `DrawRequestEvent` //! - Left-click on the stock pile (face-down deck) → `DrawRequestEvent`
//! (the waste card is left free to play: double-click to auto-move, or drag)
//! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between //! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between
//! the origin pile and whatever pile the cursor is over at release. //! the origin pile and whatever pile the cursor is over at release.
//! On rejection, the drag cards snap back to their origin via a //! On rejection, the drag cards snap back to their origin via a
@@ -34,7 +35,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve}; use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{ use crate::card_plugin::{
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, waste_fan_step,
}; };
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
@@ -536,8 +537,9 @@ fn handle_stock_click(
// `pile_positions[Stock]` is the waste column (col_x(1)). card_plugin renders the // `pile_positions[Stock]` is the waste column (col_x(1)). card_plugin renders the
// face-down deck one column to the left via `base.x -= tableau_col_step`, placing it // face-down deck one column to the left via `base.x -= tableau_col_step`, placing it
// at Tableau1's x (col_x(0)). Hit-test both the deck AND the waste slot: in standard // at Tableau1's x (col_x(0)). Only the deck draws — clicking the waste card must
// Klondike UX clicking either card draws from the deck. // leave it free to be played (double-click to auto-move, or drag); hit-testing the
// waste slot here would intercept that click and draw the next card instead.
let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else { let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
return; return;
}; };
@@ -549,9 +551,7 @@ fn handle_stock_click(
return; return;
}; };
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y); let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
if point_in_rect(world, deck_pos, layout.0.card_size) if point_in_rect(world, deck_pos, layout.0.card_size) {
|| point_in_rect(world, waste_pos, layout.0.card_size)
{
draw.write(DrawRequestEvent); draw.write(DrawRequestEvent);
} }
} }
@@ -596,9 +596,9 @@ fn handle_touch_stock_tap(
continue; continue;
}; };
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y); let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
if point_in_rect(world, deck_pos, layout.0.card_size) // Only the face-down deck draws; tapping the waste card leaves it free to
|| point_in_rect(world, waste_pos, layout.0.card_size) // play (double-tap to auto-move, or drag).
{ if point_in_rect(world, deck_pos, layout.0.card_size) {
draw.write(DrawRequestEvent); draw.write(DrawRequestEvent);
game_consumed.0 = true; game_consumed.0 = true;
break; // one draw per tap frame break; // one draw per tap frame
@@ -1175,12 +1175,15 @@ fn card_position(
Vec2::new(base.x, base.y + y_offset) Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree { } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
// In Draw-Three mode the top 3 waste cards are fanned in X to match // In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing must use the same offsets // card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers. // so clicking the visually rightmost (top) card actually registers — a
// fixed `card_size.x * 0.28` matched the renderer on desktop but drifted
// on Android (tighter column spacing), shifting the top card's hit target
// onto the card beneath it.
let pile_len = game.waste_cards().len(); let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = stack_index.saturating_sub(visible_start) as f32; let slot = stack_index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) Vec2::new(base.x + slot * waste_fan_step(layout), base.y)
} else { } else {
base base
} }
@@ -1829,7 +1832,7 @@ const _VEC3_REFERENCED: Option<Vec3> = None;
mod tests { mod tests {
use super::*; use super::*;
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::{Foundation, Tableau}; use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
fn clear_test_piles(game: &mut GameState) { fn clear_test_piles(game: &mut GameState) {
@@ -1910,6 +1913,90 @@ mod tests {
assert_eq!(result.2.len(), 1); assert_eq!(result.2.len(), 1);
} }
#[test]
fn find_draggable_picks_waste_top_with_multiple_cards() {
// Reproduces the reported "drags the wrong waste card" bug: with several
// cards in the waste, clicking the visible top must pick the actual top
// (last index), not the buffer card underneath it.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
}
#[test]
fn find_draggable_picks_lone_waste_card() {
// "can't play the first card in the stock" — a waste of one card must
// still be draggable.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
game.set_test_waste_cards(vec![card.clone()]);
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
assert_eq!(result.0, KlondikePile::Stock);
assert_eq!(result.1, 0);
assert_eq!(result.2, vec![card]);
}
#[test]
fn draw_three_waste_hit_test_matches_render_fan_step() {
// Regression: the Draw-Three waste hit-test must use the same fan step as
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
// 1.25*cw); under tighter Android-style spacing the two drift and the top
// fanned card's click target lands on the card beneath it — so dragging
// the visible top card plays the wrong one.
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Force tight (Android-like) column spacing: ~1.03 * card_width.
let cw = layout.card_size.x;
let base = layout.pile_positions[&KlondikePile::Stock];
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
layout.pile_positions.insert(
KlondikePile::Tableau(Tableau::Tableau2),
Vec2::new(t1.x + cw * 1.03, t1.y),
);
clear_test_piles(&mut game);
let waste = vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
];
game.set_test_waste_cards(waste.clone());
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
let top_index = waste.len() - 1;
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let expected = base.x + 2.0 * waste_fan_step(&layout);
assert!(
(pos.x - expected).abs() < 1e-3,
"hit-test must use the shared waste fan step"
);
// The old fixed constant would have drifted from the renderer here.
let old = base.x + 2.0 * cw * 0.28;
assert!(
(pos.x - old).abs() > 1.0,
"shared step must differ from the old fixed step under tight spacing"
);
}
#[test] #[test]
fn find_draggable_skips_face_down_cards() { fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawStockConfig::DrawOne); let game = GameState::new(42, DrawStockConfig::DrawOne);
+88 -12
View File
@@ -7,6 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet}; use bevy::prelude::{Resource, SystemSet};
use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Schedule labels for layout-related systems so cross-plugin ordering is /// Schedule labels for layout-related systems so cross-plugin ordering is
@@ -91,6 +92,15 @@ const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// this column inside the visible window. /// this column inside the visible window.
const MAX_TABLEAU_CARDS: f32 = 13.0; const MAX_TABLEAU_CARDS: f32 = 13.0;
/// Upper bound for the dynamic tableau fan step (fraction of card height) chosen
/// by [`apply_dynamic_tableau_fan`]. The fan is spread to fill the available
/// height, but a near-empty column has tiny demand, so without a cap its few
/// cards would fling far apart on a tall viewport. At 0.6 the face-up cards keep
/// clear overlap (a readable stack) while still filling most of a near-square /
/// unfolded-foldable screen. Tunable purely for feel — no effect on correctness
/// or hit-testing.
pub(crate) const MAX_DYNAMIC_FAN_FRAC: f32 = 0.9;
/// Vertical pixel band reserved at the top of the play area for the HUD /// Vertical pixel band reserved at the top of the play area for the HUD
/// (action buttons, Score / Moves / Timer readouts). The card grid starts /// (action buttons, Score / Moves / Timer readouts). The card grid starts
/// below this band so the HUD doesn't bleed into the play surface. /// below this band so the HUD doesn't bleed into the play surface.
@@ -275,13 +285,12 @@ pub fn compute_layout(
); );
} }
// Adaptive tableau fan fraction. On height-limited (desktop) windows the // Adaptive tableau fan fraction. On height-limited windows the height-based
// height-based sizing already ensures a worst-case 13-card column fits at // sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp // so the formula returns the minimum and the clamp keeps it there. On
// keeps it there — no change from prior behaviour. On width-limited // width-limited (portrait phone) windows card_size is small and lots of
// (portrait phone) windows card_size is small and lots of vertical space // vertical space is unused; solve for the fraction that fills the available
// is unused; we solve for the fraction that exactly fills the available // space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
// space to the bottom margin.
// //
// avail = distance from the top of the first tableau card to the bottom // avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps. // margin — i.e. the space available for 12 fan steps.
@@ -292,20 +301,87 @@ pub fn compute_layout(
} else { } else {
TABLEAU_FAN_FRAC TABLEAU_FAN_FRAC
}; };
// Never go below the desktop minimum — avoids shrinking the fan on
// degenerate near-square windows where the formula might undershoot.
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC); let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
// Scale the face-down fraction proportionally so rendering and hit-testing
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC; let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale; let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
let available_tableau_height = avail;
Layout { Layout {
card_size, card_size,
pile_positions, pile_positions,
tableau_fan_frac, tableau_fan_frac,
tableau_facedown_fan_frac, tableau_facedown_fan_frac,
available_tableau_height: 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;
} }
} }
+4 -28
View File
@@ -1,10 +1,5 @@
//! Safe-area insets. //! Safe-area insets.
//! //!
// JNI FFI (Android only) requires `unsafe` to reconstruct `JavaVM` /
// `JObject` handles from raw pointers handed over by the runtime. Scoped to
// this module so the rest of the workspace stays `deny(unsafe_code)`.
#![allow(unsafe_code)]
//!
//! Reports the OS-reserved regions around the playable surface (status //! Reports the OS-reserved regions around the playable surface (status
//! bar at the top, gesture / navigation bar at the bottom on Android, //! bar at the top, gesture / navigation bar at the bottom on Android,
//! display cutouts, etc.) so UI anchored to a screen edge can avoid //! display cutouts, etc.) so UI anchored to a screen edge can avoid
@@ -296,30 +291,12 @@ mod android {
} }
fn query_insets() -> Result<SafeAreaInsets, String> { fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP; use solitaire_data::android_jni;
use jni::{JavaVM, objects::JObject};
let app = ANDROID_APP android_jni::with_activity_env(|env, activity| {
.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> {
// Window window = activity.getWindow(); // Window window = activity.getWindow();
let window = env let window = env
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])? .call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?; .l()?;
// View decor = window.getDecorView(); // View decor = window.getDecorView();
@@ -371,8 +348,7 @@ mod android {
left, left,
right, right,
}) })
})() })
.map_err(|e| format!("safe-area JNI: {e}"))
} }
} }
+15 -3
View File
@@ -11,7 +11,9 @@ use solitaire_core::Suit;
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility; use crate::hud_plugin::HudVisibility;
use crate::layout::{Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, compute_layout}; use crate::layout::{
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets; use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -348,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( fn on_window_resized(
mut events: MessageReader<WindowResized>, mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>, safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>, windows: Query<&Window>,
hud_vis: Option<Res<HudVisibility>>, hud_vis: Option<Res<HudVisibility>>,
game: Option<Res<GameStateResource>>,
mut layout_res: Option<ResMut<LayoutResource>>, mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query< mut backgrounds: Query<
(&mut Sprite, &mut Transform), (&mut Sprite, &mut Transform),
@@ -370,7 +373,16 @@ fn on_window_resized(
let safe_area_top = insets.top / scale; let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale; let safe_area_bottom = insets.bottom / scale;
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible; 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() { if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone(); layout_res.0 = new_layout.clone();
+1 -1
View File
@@ -12,7 +12,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameMode; 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 solitaire_data::AnimSpeed;
use crate::achievement_plugin::display_name_for; use crate::achievement_plugin::display_name_for;
+5 -1
View File
@@ -35,7 +35,11 @@ module.exports = defineConfig({
`cargo run -p solitaire_server --quiet`, `cargo run -p solitaire_server --quiet`,
cwd: repoRoot, cwd: repoRoot,
url: `http://127.0.0.1:${serverPort}/health`, 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, reuseExistingServer: !process.env.CI,
}, },
}); });
+27 -22
View File
@@ -142,10 +142,32 @@ async function main() {
const page = await context.newPage(); const page = await context.newPage();
const results = []; 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++) { for (let i = 0; i < games; i++) {
const seed = i; const seed = i;
const draw3 = i % 2 === 1; const draw3 = i % 2 === 1;
const suffix = draw3 ? "&draw3=" : "";
const pageErrors = []; const pageErrors = [];
const consoleErrors = []; const consoleErrors = [];
@@ -158,27 +180,10 @@ async function main() {
} }
}); });
await page.goto(`${baseUrl}/${route}?seed=${seed}${suffix}`, { // Reset to a fresh seeded game without navigating.
waitUntil: "domcontentloaded", await page.evaluate(
}); ({ seed, draw3 }) => window.__FERROUS_DEBUG__.newGame(seed, draw3),
{ seed, draw3 }
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 }
); );
const run = await page.evaluate(({ stepCap, policyName, maxVisits }) => { 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 }) => { test("timer stops accumulating while tab is hidden", async ({ page }) => {
// Install the fake clock before navigation so the game's setInterval is // 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.clock.install();
await page.goto("/play-classic?seed=42"); await page.goto("/play-classic?seed=42");
@@ -144,7 +144,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
await waitForBridge(page); await waitForBridge(page);
// Advance 3 fake seconds to get a non-zero timer reading. // 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(); const timerAfter3s = await page.locator("#hud-timer").textContent();
expect(timerAfter3s).toBe("0:03"); expect(timerAfter3s).toBe("0:03");
@@ -152,7 +152,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
await setTabHidden(page, true); await setTabHidden(page, true);
// Advance 10 fake seconds while hidden. // 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(); const timerWhileHidden = await page.locator("#hud-timer").textContent();
expect(timerWhileHidden).toBe("0:03"); // must not have advanced 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); await setTabHidden(page, false);
// Advance 2 more fake seconds. // 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(); const timerAfterResume = await page.locator("#hud-timer").textContent();
expect(timerAfterResume).toBe("0:05"); // only 3 + 2 visible seconds counted 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. // the snap state correctly gates the restart.
// //
// Advance 2 s, then hide+show — timer should continue normally. // 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 setTabHidden(page, true);
await page.clock.tick(5_000); await page.clock.runFor(5_000);
await setTabHidden(page, false); await setTabHidden(page, false);
await page.clock.tick(2_000); await page.clock.runFor(2_000);
const timerText = await page.locator("#hud-timer").textContent(); const timerText = await page.locator("#hud-timer").textContent();
// 2 visible + 0 hidden + 2 visible = 4 total // 2 visible + 0 hidden + 2 visible = 4 total
+22 -1
View File
@@ -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 // and the wasm-bindgen-generated `web/pkg/`). The HTML page is the
// same regardless of `:id` — it reads the path from `location` in JS // same regardless of `:id` — it reads the path from `location` in JS
// and fetches the replay JSON from `/api/replays/:id`. // 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( .route(
"/", "/",
get(|| async { Html(include_str!("../web/home.html")) }), get(|| async { Html(include_str!("../web/home.html")) }),
@@ -233,6 +238,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
"/replays", "/replays",
get(|| async { Html(include_str!("../web/replays.html")) }), 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("/web", ServeDir::new("solitaire_server/web"))
.nest_service("/assets", ServeDir::new("assets")) .nest_service("/assets", ServeDir::new("assets"))
.layer(axum_middleware::from_fn(security_headers)); .layer(axum_middleware::from_fn(security_headers));
@@ -270,6 +279,18 @@ async fn security_headers(req: Request<axum::body::Body>, next: axum_middleware:
res 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. /// `GET /health` — simple liveness probe, no auth required.
async fn health() -> axum::Json<serde_json::Value> { async fn health() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
+16
View File
@@ -270,6 +270,11 @@ function startGame(seed) {
// ── Timer ──────────────────────────────────────────────────────────────────── // ── Timer ────────────────────────────────────────────────────────────────────
function startTimer() { 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(() => { timerInterval = setInterval(() => {
elapsedSecs++; elapsedSecs++;
updateTimerDisplay(); updateTimerDisplay();
@@ -986,6 +991,17 @@ window.__FERROUS_DEBUG__ = {
snapshot() { snapshot() {
return game ? game.debug_snapshot() : null; 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) { applyLegalMove(index) {
if (!game) return { ok: false, error: "game_not_ready" }; if (!game) return { ok: false, error: "game_not_ready" };
const result = game.debug_apply_legal_move(index); const result = game.debug_apply_legal_move(index);
+48 -48
View File
@@ -1649,63 +1649,63 @@ function __wbg_get_imports() {
return ret; return ret;
}, },
__wbindgen_cast_0000000000000001: function(arg0, arg1) { __wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114621, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114846, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf0188236128725a8); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000002: function(arg0, arg1) { __wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000003: function(arg0, arg1) { __wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9767, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9838, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000004: function(arg0, arg1) { __wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000005: function(arg0, arg1) { __wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000006: function(arg0, arg1) { __wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000007: function(arg0, arg1) { __wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000008: function(arg0, arg1) { __wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000009: function(arg0, arg1) { __wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000a: function(arg0, arg1) { __wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9765, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000b: function(arg0, arg1) { __wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9775, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9834, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000c: function(arg0, arg1) { __wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9769, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000d: function(arg0) { __wbindgen_cast_000000000000000d: function(arg0) {
@@ -1769,55 +1769,55 @@ function __wbg_get_imports() {
}; };
} }
function wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871(arg0, arg1) { function wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h277d9d6b389a2871(arg0, arg1); wasm.wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9(arg0, arg1);
} }
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e(arg0, arg1, arg2);
} }
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_3(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3(arg0, arg1, arg2);
} }
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_4(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4(arg0, arg1, arg2);
} }
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_5(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5(arg0, arg1, arg2);
} }
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_6(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6(arg0, arg1, arg2);
} }
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_7(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7(arg0, arg1, arg2);
} }
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_8(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8(arg0, arg1, arg2);
} }
function wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h038e9392efba509b_9(arg0, arg1, arg2); wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9(arg0, arg1, arg2);
} }
function wasm_bindgen__convert__closures_____invoke__hf0188236128725a8(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf0188236128725a8(arg0, arg1, arg2); const ret = wasm.wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded(arg0, arg1, arg2);
if (ret[1]) { if (ret[1]) {
throw takeFromExternrefTable0(ret[0]); throw takeFromExternrefTable0(ret[0]);
} }
} }
function wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1(arg0, arg1, arg2, arg3) { function wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__hb8334c8e03ee5ee1(arg0, arg1, arg2, arg3); wasm.wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8(arg0, arg1, arg2, arg3);
} }
function wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93(arg0, arg1, arg2) { function wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h618c0cad9a289a93(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2)); wasm.wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
} }
Binary file not shown.
+4 -4
View File
@@ -244,11 +244,11 @@ export class SolitaireGame {
return this; return this;
} }
/** /**
* Returns replay moves encoded in the `solitaire_data::Replay` wire format. * Returns replay moves encoded in the `solitaire_data::Replay` wire format
* a list of upstream [`KlondikeInstruction`]s.
* *
* This derives move counts from the deterministic instruction history and * This is the deterministic instruction history; together with `seed()`
* validates that the resulting move stream replays cleanly from the current * and the draw mode it replays cleanly via `apply_instruction`.
* game's seed/draw mode.
* @returns {any} * @returns {any}
*/ */
replay_moves() { replay_moves() {
Binary file not shown.
+5 -2
View File
@@ -6,8 +6,11 @@
<title>Ferrous Solitaire</title> <title>Ferrous Solitaire</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #000; overflow: hidden; } html, body { height: 100%; background: #000; overflow: hidden; }
#bevy-canvas { display: block; width: 100vw; height: 100vh; } /* No size cap: the wgpu device now takes its max_texture_dimension from
the adapter (see solitaire_web/src/lib.rs), so the surface can match
the full viewport. fit_canvas_to_parent sizes the canvas to 100%. */
#bevy-canvas { display: block; width: 100%; height: 100%; }
</style> </style>
</head> </head>
<body> <body>
+18 -12
View File
@@ -34,12 +34,13 @@ pub fn start() {
fit_canvas_to_parent: true, fit_canvas_to_parent: true,
// Prevent the browser stealing keyboard events and scroll. // Prevent the browser stealing keyboard events and scroll.
prevent_default_event_handling: true, prevent_default_event_handling: true,
// Force scale_factor = 1.0 so the wgpu surface is sized in // Render at CSS/logical pixels (scale_factor 1.0) rather
// CSS/logical pixels rather than physical pixels. Without this, // than physical (CSS × devicePixelRatio). This keeps the
// HiDPI displays (devicePixelRatio ≥ 2) produce a framebuffer // surface smaller on HiDPI displays — lighter GPU load and
// whose physical width can exceed WebGL2's 2048-pixel per- // stable sizing — at the cost of some crispness. The wgpu
// dimension limit, causing a wgpu validation panic on the first // texture-dimension limit is now taken from the adapter (see
// resize event and killing the WASM thread. // the RenderPlugin below), so this is purely a quality/perf
// choice, no longer a crash-avoidance hack.
resolution: WindowResolution::default() resolution: WindowResolution::default()
.with_scale_factor_override(1.0), .with_scale_factor_override(1.0),
..default() ..default()
@@ -53,14 +54,19 @@ pub fn start() {
meta_check: AssetMetaCheck::Never, meta_check: AssetMetaCheck::Never,
..default() ..default()
}) })
// WebGL2 priority constrains naga (the shader translator) to emit // `Functionality` makes wgpu adopt the *adapter's* real limits
// GLES 300es-compatible GLSL. Without this, Chromium's ANGLE driver // instead of the conservative `downlevel_webgl2_defaults()` that
// rejects certain shader constructs (storage buffers, tight component // `WebGL2` priority forces. On the WebGL2 (Gl) backend the adapter
// limits) causing a fatal wgpu "Shader translation error". Firefox is // already reports WebGL2-constrained features/limits — no storage
// more lenient; this setting makes both browsers work identically. // buffers, etc., so shaders stay GLES-compatible on both Firefox and
// Chromium — but it reports the GPU's *true* `max_texture_dimension`
// (e.g. 16384) rather than 2048. The device is requested with exactly
// what the adapter offers, so creation can't fail, and the surface is
// no longer capped at 2048: large viewports (4K, etc.) render natively
// with no letterbox and no hardcoded cap.
.set(RenderPlugin { .set(RenderPlugin {
render_creation: RenderCreation::Automatic(WgpuSettings { render_creation: RenderCreation::Automatic(WgpuSettings {
priority: WgpuSettingsPriority::WebGL2, priority: WgpuSettingsPriority::Functionality,
..default() ..default()
}), }),
..default() ..default()