Compare commits
43 Commits
da165f1622
...
v0.40.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 26283b5478 | |||
| 3627e9f9cf | |||
| b81a79c51c | |||
| 968721eeb4 | |||
| 780e82ca4b | |||
| 207747db4b | |||
| c66baceb10 | |||
| 329f224ffd | |||
| 2fc190ee42 | |||
| 060efaee7b | |||
| ef599ffa17 | |||
| 22334e0dd5 | |||
| 942b9c2161 | |||
| fde863a4e4 | |||
| 0cf5fc4293 | |||
| 79ddfbc034 | |||
| 7919365775 | |||
| f88b6f61d0 | |||
| 189e0afd24 | |||
| a32d666751 | |||
| a5902ac0af | |||
| c3b83f30d1 | |||
| a46505fe45 | |||
| 1602f1952d | |||
| 81893788c1 | |||
| c232444ef0 | |||
| 56e05caaa9 | |||
| 090b5e789e | |||
| 1e0619897a | |||
| 1b5dfa3e27 | |||
| 4850e9417e | |||
| 8a2a22ff1b | |||
| b3b282ec2d | |||
| 7b5d69e164 | |||
| 923a67dc7b | |||
| d4ad184324 | |||
| 0b7a24a75b | |||
| fc45b6d261 | |||
| 2328643223 | |||
| 5b2b234c54 | |||
| 9c473d6a51 | |||
| b3a8575bbd | |||
| 5091d1b397 |
@@ -36,12 +36,12 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# WASM artifact freshness is enforced precisely by the dedicated
|
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
|
||||||
# `web-wasm-freshness` workflow (rebuild-and-diff on every PR + master
|
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
|
||||||
# push), which supersedes the previous changed-files heuristic here.
|
# and commits it back (CI is the single source of truth — the artifacts
|
||||||
# That heuristic only hard-failed on direct solitaire_web/ edits and
|
# aren't byte-reproducible on contributor machines). That pkg/ commit then
|
||||||
# treated solitaire_engine/_core changes as a non-blocking notice, which
|
# triggers this workflow, so the deployed image always ships fresh wasm.
|
||||||
# let the whole card_game migration ship a stale pkg/.
|
# No drift check is needed here.
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
- name: Log in to Gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
name: Web WASM Freshness
|
|
||||||
|
|
||||||
# Guards against the committed solitaire_server/web/pkg/ artifacts going stale.
|
|
||||||
#
|
|
||||||
# A fresh build on a pinned toolchain is byte-for-byte reproducible (verified),
|
|
||||||
# so this job rebuilds the artifacts and fails if they differ from what is
|
|
||||||
# committed. Unlike a changed-files heuristic, this catches *any* drift in the
|
|
||||||
# wasm-feeding crates — solitaire_core / _engine / _data / _sync / _wasm / _web —
|
|
||||||
# including gameplay-logic changes that do not alter the JS API surface (e.g.
|
|
||||||
# the v4->v5 save-schema change that the old heuristic let through as a
|
|
||||||
# non-blocking "notice", leaving the deployed web build stale for ~3 weeks).
|
|
||||||
#
|
|
||||||
# Tool versions are pinned to whatever produced the committed artifacts. When
|
|
||||||
# you regenerate the artifacts with a newer toolchain, bump these in lockstep:
|
|
||||||
# rust 1.95.0 · wasm-bindgen 0.2.120 · wasm-pack 0.14.0 · binaryen 130
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
paths:
|
|
||||||
- 'solitaire_core/**'
|
|
||||||
- 'solitaire_engine/**'
|
|
||||||
- 'solitaire_data/**'
|
|
||||||
- 'solitaire_sync/**'
|
|
||||||
- 'solitaire_wasm/**'
|
|
||||||
- 'solitaire_web/**'
|
|
||||||
- 'solitaire_server/web/pkg/**'
|
|
||||||
- 'Cargo.toml'
|
|
||||||
- 'Cargo.lock'
|
|
||||||
- 'build_wasm.sh'
|
|
||||||
- '.gitea/workflows/web-wasm-freshness.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'solitaire_core/**'
|
|
||||||
- 'solitaire_engine/**'
|
|
||||||
- 'solitaire_data/**'
|
|
||||||
- 'solitaire_sync/**'
|
|
||||||
- 'solitaire_wasm/**'
|
|
||||||
- 'solitaire_web/**'
|
|
||||||
- 'solitaire_server/web/pkg/**'
|
|
||||||
- 'Cargo.toml'
|
|
||||||
- 'Cargo.lock'
|
|
||||||
- 'build_wasm.sh'
|
|
||||||
- '.gitea/workflows/web-wasm-freshness.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
freshness:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust 1.95.0 (pinned for reproducible wasm output)
|
|
||||||
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: Fail if committed pkg differs from a fresh build
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "$(git status --porcelain -- solitaire_server/web/pkg/)" ]; then
|
|
||||||
echo "pkg/ artifacts match a fresh build."
|
|
||||||
else
|
|
||||||
echo "::error::solitaire_server/web/pkg/ is stale — a wasm-feeding crate changed without regenerating the artifacts."
|
|
||||||
echo "Fix: run ./build_wasm.sh and commit the updated solitaire_server/web/pkg/."
|
|
||||||
git status --porcelain -- solitaire_server/web/pkg/
|
|
||||||
git diff --stat -- solitaire_server/web/pkg/
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -47,6 +47,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
|
||||||
|
|||||||
+52
-17
@@ -1,16 +1,40 @@
|
|||||||
# Ferrous Solitaire — Session Handoff
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-06-09 — AVD Android launch smoke passed; physical-device gate remains.
|
**Last updated:** 2026-06-25 — v0.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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -908,34 +938,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: 114706, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114855, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9850, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9852, 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: 9847, 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: 9850, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9850, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9850, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9850, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9850, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9850, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9850, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9860, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9843, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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: 9854, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9839, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__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.
Binary file not shown.
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user