Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d864d985c8 | |||
| ae1ecc8559 | |||
| 5e8735886f | |||
| 8bd2fb89eb | |||
| 2b1ad2161a | |||
| 2cf728210e | |||
| 8b262afcd2 | |||
| 8b736cae3c | |||
| de7ae16830 | |||
| d45b7cb82b | |||
| 763fdb486f | |||
| 1cdb78caf2 | |||
| baf524ec75 | |||
| 9ff0585454 | |||
| 64f975ed6d | |||
| 20e5222148 | |||
| 44e90ff582 | |||
| 0bae839e3b | |||
| c68cf96488 | |||
| a92ac066a6 | |||
| f464aab543 | |||
| 835a48fe9d | |||
| 9260ca7994 | |||
| ca612f51f1 | |||
| dba154cf92 | |||
| 258abd198e | |||
| 389fdd1fb0 | |||
| 6309d3325f | |||
| 862f7e4b48 | |||
| 6496e130f3 | |||
| d4796fa252 | |||
| 57c4b5aacf | |||
| f1914b4398 | |||
| 0a6eb8c610 | |||
| bb92bb333b | |||
| 38e4c0341e | |||
| ccf280ea50 |
@@ -0,0 +1,5 @@
|
||||
[registries.Quaternions]
|
||||
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
|
||||
@@ -6,10 +6,14 @@ on:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'solitaire_server/**'
|
||||
- 'solitaire_wasm/**'
|
||||
- 'solitaire_web/**'
|
||||
- 'solitaire_sync/**'
|
||||
- 'solitaire_core/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'solitaire_server/Dockerfile'
|
||||
- '.gitea/workflows/docker-build.yml'
|
||||
|
||||
env:
|
||||
@@ -32,6 +36,48 @@ jobs:
|
||||
id: meta
|
||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check wasm pkg drift
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_SHA="${{ github.event.before }}"
|
||||
HEAD_SHA="${{ github.sha }}"
|
||||
if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
|
||||
RANGE="$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
RANGE="HEAD~1..HEAD"
|
||||
fi
|
||||
|
||||
CHANGED="$(git diff --name-only "$RANGE")"
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
if echo "$CHANGED" | grep -Eq '^(solitaire_wasm/|solitaire_core/|Cargo\.toml|Cargo\.lock)$|^(solitaire_wasm/|solitaire_core/)'; then
|
||||
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/solitaire_wasm\.js$|^solitaire_server/web/pkg/solitaire_wasm_bg\.wasm$'; then
|
||||
echo "error: wasm/core/Cargo changed but committed web pkg artifacts are missing."
|
||||
echo "Run: wasm-pack build --target web --out-dir solitaire_server/web/pkg --no-typescript solitaire_wasm"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hard check: solitaire_web/ is the direct Bevy WASM source — any
|
||||
# change there MUST rebuild canvas_bg.wasm or the binary goes stale.
|
||||
if echo "$CHANGED" | grep -Eq '^solitaire_web/'; then
|
||||
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
|
||||
echo "error: solitaire_web/ changed but canvas_bg.wasm not updated."
|
||||
echo "Run: ./build_wasm.sh (requires wasm-bindgen-cli + wasm32-unknown-unknown target)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Advisory notice: solitaire_engine/ and solitaire_core/ changes often
|
||||
# require a Bevy WASM rebuild but are not enforced (formatting-only
|
||||
# commits should not be blocked).
|
||||
if echo "$CHANGED" | grep -Eq '^(solitaire_engine/|solitaire_core/)' && \
|
||||
! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
|
||||
echo "notice: solitaire_engine/core changed without a canvas_bg.wasm rebuild."
|
||||
echo " If the change affects gameplay run ./build_wasm.sh before pushing."
|
||||
fi
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Web E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'solitaire_server/web/**'
|
||||
- 'solitaire_server/src/**'
|
||||
- 'solitaire_server/e2e/**'
|
||||
- 'solitaire_wasm/**'
|
||||
- 'solitaire_core/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/web-e2e.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
web-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: solitaire_server/e2e/package-lock.json
|
||||
|
||||
- name: Install e2e dependencies
|
||||
working-directory: solitaire_server/e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
working-directory: solitaire_server/e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run web e2e tests
|
||||
working-directory: solitaire_server/e2e
|
||||
run: npm test
|
||||
|
||||
- name: Run cycle regression gate
|
||||
working-directory: solitaire_server/e2e
|
||||
run: npm run review:cycles:regression
|
||||
@@ -15,6 +15,11 @@ agentdb.rvf.lock
|
||||
# IDE project files
|
||||
.idea/
|
||||
|
||||
# Browser e2e harness artifacts
|
||||
solitaire_server/e2e/node_modules/
|
||||
solitaire_server/e2e/playwright-report/
|
||||
solitaire_server/e2e/test-results/
|
||||
|
||||
# Android signing keystores — never commit
|
||||
*.jks
|
||||
*.jks.bak
|
||||
|
||||
Generated
+411
-15
@@ -364,6 +364,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4"
|
||||
|
||||
[[package]]
|
||||
name = "as-raw-xcb-connection"
|
||||
version = "1.0.1"
|
||||
@@ -717,6 +723,28 @@ dependencies = [
|
||||
"android-activity",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_anti_alias"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_derive",
|
||||
"bevy_diagnostic",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_math",
|
||||
"bevy_reflect",
|
||||
"bevy_render",
|
||||
"bevy_shader",
|
||||
"bevy_utils",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_app"
|
||||
version = "0.18.1"
|
||||
@@ -878,6 +906,35 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_dev_tools"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_diagnostic",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_input",
|
||||
"bevy_math",
|
||||
"bevy_picking",
|
||||
"bevy_reflect",
|
||||
"bevy_render",
|
||||
"bevy_shader",
|
||||
"bevy_state",
|
||||
"bevy_text",
|
||||
"bevy_time",
|
||||
"bevy_transform",
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
"bevy_window",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_diagnostic"
|
||||
version = "0.18.1"
|
||||
@@ -901,7 +958,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bevy_ecs_macros",
|
||||
"bevy_platform",
|
||||
"bevy_ptr",
|
||||
@@ -945,6 +1002,36 @@ dependencies = [
|
||||
"encase_derive_impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_feathers"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"bevy_a11y",
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_derive",
|
||||
"bevy_ecs",
|
||||
"bevy_input_focus",
|
||||
"bevy_log",
|
||||
"bevy_math",
|
||||
"bevy_picking",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_render",
|
||||
"bevy_shader",
|
||||
"bevy_text",
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
"bevy_ui_widgets",
|
||||
"bevy_window",
|
||||
"smol_str",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_gizmos"
|
||||
version = "0.18.1"
|
||||
@@ -1067,14 +1154,17 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754"
|
||||
dependencies = [
|
||||
"bevy_a11y",
|
||||
"bevy_android",
|
||||
"bevy_anti_alias",
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_derive",
|
||||
"bevy_dev_tools",
|
||||
"bevy_diagnostic",
|
||||
"bevy_ecs",
|
||||
"bevy_feathers",
|
||||
"bevy_gizmos_render",
|
||||
"bevy_image",
|
||||
"bevy_input",
|
||||
@@ -1082,6 +1172,7 @@ dependencies = [
|
||||
"bevy_log",
|
||||
"bevy_math",
|
||||
"bevy_mesh",
|
||||
"bevy_pbr",
|
||||
"bevy_platform",
|
||||
"bevy_ptr",
|
||||
"bevy_reflect",
|
||||
@@ -1101,6 +1192,27 @@ dependencies = [
|
||||
"bevy_winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_light"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_math",
|
||||
"bevy_mesh",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_log"
|
||||
version = "0.18.1"
|
||||
@@ -1138,7 +1250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bevy_reflect",
|
||||
"derive_more",
|
||||
"glam 0.30.10",
|
||||
@@ -1161,7 +1273,9 @@ dependencies = [
|
||||
"bevy_asset",
|
||||
"bevy_derive",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_math",
|
||||
"bevy_mikktspace",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_transform",
|
||||
@@ -1174,6 +1288,71 @@ dependencies = [
|
||||
"wgpu-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_mikktspace"
|
||||
version = "0.17.0-dev"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59"
|
||||
|
||||
[[package]]
|
||||
name = "bevy_pbr"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_color",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_derive",
|
||||
"bevy_diagnostic",
|
||||
"bevy_ecs",
|
||||
"bevy_image",
|
||||
"bevy_light",
|
||||
"bevy_log",
|
||||
"bevy_math",
|
||||
"bevy_mesh",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_render",
|
||||
"bevy_shader",
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"bitflags 2.11.1",
|
||||
"bytemuck",
|
||||
"derive_more",
|
||||
"fixedbitset",
|
||||
"nonmax",
|
||||
"offset-allocator",
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_picking"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_asset",
|
||||
"bevy_camera",
|
||||
"bevy_derive",
|
||||
"bevy_ecs",
|
||||
"bevy_input",
|
||||
"bevy_math",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_time",
|
||||
"bevy_transform",
|
||||
"bevy_window",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_platform"
|
||||
version = "0.18.1"
|
||||
@@ -1500,6 +1679,7 @@ dependencies = [
|
||||
"bevy_input",
|
||||
"bevy_input_focus",
|
||||
"bevy_math",
|
||||
"bevy_picking",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"bevy_sprite",
|
||||
@@ -1512,6 +1692,7 @@ dependencies = [
|
||||
"taffy",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1545,6 +1726,26 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_ui_widgets"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"bevy_a11y",
|
||||
"bevy_app",
|
||||
"bevy_camera",
|
||||
"bevy_ecs",
|
||||
"bevy_input",
|
||||
"bevy_input_focus",
|
||||
"bevy_log",
|
||||
"bevy_math",
|
||||
"bevy_picking",
|
||||
"bevy_reflect",
|
||||
"bevy_ui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_utils"
|
||||
version = "0.18.1"
|
||||
@@ -1672,6 +1873,7 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -1703,7 +1905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
@@ -1879,6 +2081,15 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "card_game"
|
||||
version = "0.4.0"
|
||||
source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3"
|
||||
dependencies = [
|
||||
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
@@ -1939,6 +2150,17 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -3457,6 +3679,17 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
|
||||
dependencies = [
|
||||
"khronos_api",
|
||||
"log",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.30.10"
|
||||
@@ -3485,6 +3718,27 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"slotmap",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin_wgl_sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
|
||||
dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
@@ -4051,7 +4305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
"quick-error 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4309,6 +4563,23 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libloading",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos_api"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kira"
|
||||
version = "0.12.0"
|
||||
@@ -4326,13 +4597,22 @@ dependencies = [
|
||||
"triple_buffer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "klondike"
|
||||
version = "0.3.0"
|
||||
source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3"
|
||||
dependencies = [
|
||||
"card_game",
|
||||
"rand 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"euclid",
|
||||
"smallvec",
|
||||
]
|
||||
@@ -4740,7 +5020,7 @@ version = "27.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bit-set",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
@@ -5778,6 +6058,25 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.11.1",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.3"
|
||||
@@ -5822,6 +6121,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -5947,6 +6252,16 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -5985,6 +6300,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.5.1"
|
||||
@@ -6004,6 +6325,15 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -6493,6 +6823,18 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
@@ -6980,7 +7322,9 @@ dependencies = [
|
||||
name = "solitaire_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rand 0.9.4",
|
||||
"card_game",
|
||||
"klondike",
|
||||
"proptest",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -6991,7 +7335,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
@@ -7090,6 +7433,18 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solitaire_web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"console_error_panic_hook",
|
||||
"getrandom 0.3.4",
|
||||
"solitaire_data",
|
||||
"solitaire_engine",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -7502,7 +7857,7 @@ version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
@@ -7601,7 +7956,7 @@ version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"grid",
|
||||
"serde",
|
||||
"slotmap",
|
||||
@@ -7870,7 +8225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
@@ -7884,7 +8239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
@@ -8533,6 +8888,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "uncased"
|
||||
version = "0.9.10"
|
||||
@@ -8739,6 +9100,15 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -9044,12 +9414,13 @@ version = "27.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"document-features",
|
||||
"hashbrown 0.16.1",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
"portable-atomic",
|
||||
@@ -9057,6 +9428,8 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"wgpu-core",
|
||||
"wgpu-hal",
|
||||
"wgpu-types",
|
||||
@@ -9068,7 +9441,7 @@ version = "27.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.11.1",
|
||||
@@ -9088,6 +9461,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"wgpu-core-deps-apple",
|
||||
"wgpu-core-deps-wasm",
|
||||
"wgpu-core-deps-windows-linux-android",
|
||||
"wgpu-hal",
|
||||
"wgpu-types",
|
||||
@@ -9102,6 +9476,15 @@ dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-wasm"
|
||||
version = "27.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "27.0.0"
|
||||
@@ -9118,7 +9501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"ash",
|
||||
"bit-set",
|
||||
"bitflags 2.11.1",
|
||||
@@ -9127,15 +9510,20 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"core-graphics-types 0.2.0",
|
||||
"glow",
|
||||
"glutin_wgl_sys",
|
||||
"gpu-alloc",
|
||||
"gpu-allocator",
|
||||
"gpu-descriptor",
|
||||
"hashbrown 0.16.1",
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"metal",
|
||||
"naga",
|
||||
"ndk-sys",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
@@ -9148,6 +9536,8 @@ dependencies = [
|
||||
"renderdoc-sys",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"wgpu-types",
|
||||
"windows 0.58.0",
|
||||
"windows-core 0.58.0",
|
||||
@@ -10030,6 +10420,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
|
||||
[[package]]
|
||||
name = "xmlwriter"
|
||||
version = "0.1.0"
|
||||
|
||||
+4
-1
@@ -8,6 +8,7 @@ members = [
|
||||
"solitaire_app",
|
||||
"solitaire_assetgen",
|
||||
"solitaire_wasm",
|
||||
"solitaire_web",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -21,7 +22,7 @@ rust-version = "1.95"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||
thiserror = "2"
|
||||
rand = "0.9"
|
||||
async-trait = "0.1"
|
||||
@@ -37,6 +38,8 @@ solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
klondike = { git = "https://git.aleshym.co/Quaternions/card_game" }
|
||||
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", features = ["serde"] }
|
||||
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
|
||||
@@ -118,8 +118,28 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
|
||||
|
||||
# Lint
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Browser e2e smoke (starts solitaire_server automatically)
|
||||
cd solitaire_server/e2e
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
npm test
|
||||
|
||||
# Seed-batch cycle regression gate (thresholded)
|
||||
npm run review:cycles:regression
|
||||
|
||||
# Loop-aware candidate benchmark (writes test-results/cycle-candidate.json)
|
||||
npm run review:cycles:candidate
|
||||
```
|
||||
|
||||
For layered engine-vs-UI automation design (Rust unit tests, wasm debug-API
|
||||
integration tests, and Playwright UI validation), see
|
||||
[docs/testing-architecture.md](docs/testing-architecture.md).
|
||||
|
||||
For Quaternions (`klondike` / `card_game`) dependency upgrades, use
|
||||
[`scripts/update_quaternions_deps.sh`](scripts/update_quaternions_deps.sh) and
|
||||
the runbook in [docs/card-game-integration.md](docs/card-game-integration.md).
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||
|
||||
+49
-6
@@ -1,16 +1,45 @@
|
||||
# Ferrous Solitaire — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
|
||||
**Last updated:** 2026-06-02 — Web e2e test suite complete; `/play` canvas bridge added and tested. All commits on origin/master.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
|
||||
- **HEAD:** `play_canvas.spec.js` added (Playwright tests for `/play` Bevy canvas route)
|
||||
- **Latest tag:** `v0.35.1`
|
||||
- **Working tree:** clean
|
||||
- **Build:** `cargo clippy --workspace -- -D warnings` clean
|
||||
- **Tests:** 1277 passing / 0 failing across the workspace
|
||||
- **Tests:** 1243 Rust tests passing; Playwright suite in `solitaire_server/e2e/`
|
||||
|
||||
---
|
||||
|
||||
## What shipped since the last handoff (v0.35.1 → present, 2026-06-02)
|
||||
|
||||
| Commit | Summary |
|
||||
|--------|---------|
|
||||
| `64f975e` | 14 cross-platform UX/UI fixes from 500-game audit |
|
||||
| `763fdb4` | Fix input: hit-test deck at correct position; accept waste click |
|
||||
| `1cdb78c` | cargo fmt; add analytics domain to CSP |
|
||||
| `baf524e` | Rebuild Bevy canvas WASM; add SolitaireGame interactive API |
|
||||
| `9ff0585` | Remove Quaternions registry auth; canvas WASM drift guard |
|
||||
| `de7ae16` | Delay first-run modal until splash screen despawns |
|
||||
| `8b736ca` | Debug drag failures (temp logging, removed in next commit) |
|
||||
| `8b262af` | Clamp wgpu surface to CSS pixels on HiDPI (prevented WASM panic) |
|
||||
| `d45b7cb` | Add Playwright e2e test suite for web routes |
|
||||
| `2cf7282` | Add `window.__FERROUS_DEBUG__` bridge to `/play` for automation |
|
||||
|
||||
**Key audit bugs fixed (all 7 from 500-game UX audit):** timer-after-undo, radial-menu clamping, Android resume flash, tab-hidden timer, orphaned tmp files, drag threshold 4→6px, Draw-1 recycle doc comment.
|
||||
|
||||
**HiDPI wgpu fix:** `WindowResolution::default().with_scale_factor_override(1.0)` added to the Bevy canvas app. Root cause was physical pixels (CSS×DPR) exceeding WebGL2's 2048px per-dimension limit on HiDPI displays.
|
||||
|
||||
**E2E test architecture:** three-tier — Rust unit tests → Playwright smoke/review specs → cycle regression gate. Debug bridge contract in `docs/testing-architecture.md`.
|
||||
|
||||
---
|
||||
|
||||
## What shipped before v0.35.1
|
||||
|
||||
See git log. CHANGELOG.md currently ends at v0.33.0 (documentation debt, low priority).
|
||||
|
||||
---
|
||||
|
||||
@@ -83,9 +112,8 @@ Three bugs fixed:
|
||||
|
||||
### 1. CHANGELOG documentation debt
|
||||
|
||||
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
|
||||
are missing. Low priority (git log is authoritative) but worth closing before the
|
||||
next release.
|
||||
CHANGELOG.md currently ends at v0.33.0. All post-v0.33.0 work is in git log. Low
|
||||
priority — git log is authoritative.
|
||||
|
||||
### 2. Android APK launch verification (Option A)
|
||||
|
||||
@@ -128,3 +156,18 @@ and wired to `GameStateResource` events.
|
||||
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
||||
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
||||
with `input.release(key); input.clear()` between updates.
|
||||
|
||||
- **`/play` debug bridge design:** `play.html` runs two independent WASM instances in
|
||||
`Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__`
|
||||
(logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge
|
||||
operates its own `SolitaireGame` — moves applied through the bridge do NOT affect
|
||||
the Bevy visual game. This is intentional for automation/invariant checking.
|
||||
|
||||
- **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)`
|
||||
is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit
|
||||
on HiDPI displays, causing an immediate wgpu panic on the first resize event.
|
||||
|
||||
- **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js`
|
||||
target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play`
|
||||
using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js`
|
||||
supports both via `--route play-classic|play`.
|
||||
|
||||
+48
-7
@@ -1,18 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild the solitaire_wasm crate and install the output into
|
||||
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||
# Rebuild WASM artifacts and install them into solitaire_server/web/pkg/.
|
||||
#
|
||||
# Two artifacts are produced:
|
||||
# solitaire_wasm.* — thin replay-viewer + interactive JS API (wasm-pack)
|
||||
# canvas.* — full Bevy WASM app for play.html (cargo + wasm-bindgen)
|
||||
#
|
||||
# Prerequisites:
|
||||
# cargo install wasm-pack
|
||||
# cargo install wasm-pack wasm-bindgen-cli
|
||||
# rustup target add wasm32-unknown-unknown
|
||||
# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm
|
||||
#
|
||||
# Run from the repo root:
|
||||
# ./build_wasm.sh
|
||||
#
|
||||
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||
# committed to git so self-hosters who don't touch the WASM crate can
|
||||
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||
# solitaire_core/.
|
||||
# The generated pkg/ files are committed to git so self-hosters who don't
|
||||
# touch the WASM crates can skip this step. Regenerate after any change to
|
||||
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -36,5 +39,43 @@ wasm-pack build \
|
||||
# Remove them — we manage the output directory ourselves.
|
||||
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bevy WASM app (solitaire_web → canvas.js + canvas_bg.wasm)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ! command -v wasm-bindgen &> /dev/null; then
|
||||
echo "error: wasm-bindgen not found." >&2
|
||||
echo " Install with: cargo install wasm-bindgen-cli" >&2
|
||||
echo " The CLI version must match the wasm-bindgen crate dep." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building solitaire_web (Bevy WASM app)..."
|
||||
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
|
||||
|
||||
echo "Running wasm-bindgen for solitaire_web..."
|
||||
wasm-bindgen \
|
||||
--out-dir "$OUT_DIR" \
|
||||
--out-name canvas \
|
||||
--target web \
|
||||
--no-typescript \
|
||||
"$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm"
|
||||
|
||||
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||
if command -v wasm-opt &> /dev/null; then
|
||||
echo "Running wasm-opt on canvas_bg.wasm..."
|
||||
# Use -O2 (not -Oz): Bevy's render pipeline uses deep call stacks and
|
||||
# complex memory patterns that wasm-opt -Oz can miscompile, resulting
|
||||
# in a grey screen on first load. -O2 is speed-optimised and avoids
|
||||
# the size-focused transforms that trigger the regression.
|
||||
wasm-opt -O2 \
|
||||
-o "$OUT_DIR/canvas_bg.wasm" \
|
||||
"$OUT_DIR/canvas_bg.wasm"
|
||||
else
|
||||
echo "note: wasm-opt not found; skipping size optimisation."
|
||||
echo " Install with: cargo install wasm-opt (or via binaryen)"
|
||||
fi
|
||||
|
||||
echo "Done. Output:"
|
||||
ls -lh "$OUT_DIR"
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Integrating `card_game` / `klondike` as the Solitaire Core
|
||||
|
||||
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
|
||||
|
||||
**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
|
||||
|
||||
---
|
||||
|
||||
## What `card_game` + `klondike` Already Has
|
||||
|
||||
### `card_game` crate (generic primitives) — v0.4.0
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation |
|
||||
| `Suit`, `Rank`, `Deck` enums | Full A→K, 4 suits, up to 4 deck IDs |
|
||||
| `Stack<CAP>` | Const-generic `ArrayVec` wrapper |
|
||||
| `Pile<DN, UP>` | Face-down + face-up stacks; `flip_up`, `pop_flip_up` |
|
||||
| `Game` trait | `possible_instructions`, `is_instruction_valid`, `process_instruction`, `is_win` |
|
||||
| `Session` | Wraps a `Game`; snapshot-based undo (O(1)), score including undo penalty |
|
||||
| `Session::solve()` | Built-in DFS solver with move/state budgets; returns `Solution<G>` or `SolveError` |
|
||||
| `StateSnapshot<G>` | Pre-move state + instruction; used by snapshot history and `Solution` |
|
||||
| `SessionState::score()` | = `game_score + undos × undo_penalty` (−15 by default via `SessionConfig`) |
|
||||
| `SessionConfig` | `undo_penalty`, `solve_moves_budget`, `solve_states_budget` |
|
||||
|
||||
### `klondike` crate (Klondike rules) — v0.3.0
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| 7 tableau + 4 foundation + 1 stock | Fully dealt from a seeded RNG |
|
||||
| Draw-1 / Draw-3 config | `KlondikeConfig::draw_stock` (`DrawStockConfig`) |
|
||||
| `MoveFromFoundationConfig` | `Allowed` (upstream default) / `Disallowed`; controls foundation → tableau rule |
|
||||
| `ScoringConfig` | Configurable deltas: `move_to_foundation` (+10), `flip_up_bonus` (+5), `move_to_tableau` (+5), `move_from_foundation` (−15), `recycle` (0 by default) |
|
||||
| `KlondikeStats::score(&config)` | Computes score from per-event counters × `ScoringConfig` deltas |
|
||||
| `KlondikeStats` counters | `move_to_foundation_count`, `flip_up_bonus_count`, `move_to_tableau_count`, `move_from_foundation_count`, `recycle_count`, `moves` |
|
||||
| Foundation placement (Ace start, suit-matched A→K) | ✅ |
|
||||
| Tableau placement (alternating colour, K on empty) | ✅ |
|
||||
| Multi-card stack moves (via `SkipCards`) | ✅ |
|
||||
| `RotateStock` (recycle waste → stock) | ✅ |
|
||||
| `is_win_trivial` (all face-down cards cleared) | Auto-complete trigger |
|
||||
| `get_auto_move` / `get_sorted_moves` | Priority-ranked move suggestion (take `&KlondikeConfig`) |
|
||||
| Benchmark suite (`klondike-bench`) | 1 000-game throughput test |
|
||||
| CLI display (`klondike-cli`) | Terminal renderer |
|
||||
|
||||
---
|
||||
|
||||
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
|
||||
|
||||
### 1. Scoring — remaining adapter responsibilities
|
||||
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
|
||||
|
||||
| Event | Delta | Handled by |
|
||||
|---|---|---|
|
||||
| Any card → foundation | +10 | `KlondikeStats` / `ScoringConfig::move_to_foundation` ✅ |
|
||||
| Waste → tableau | +5 | `KlondikeStats` / `ScoringConfig::move_to_tableau` ✅ |
|
||||
| Flip face-down tableau card | +5 | `KlondikeStats` / `ScoringConfig::flip_up_bonus` ✅ |
|
||||
| Foundation → tableau | −15 | `KlondikeStats` / `ScoringConfig::move_from_foundation` ✅ |
|
||||
| Undo | −15 | `SessionStats` / `SessionConfig::undo_penalty` ✅ |
|
||||
| Recycle (Draw-1, after 1st free) | −100 | **Our adapter** — see below |
|
||||
| Recycle (Draw-3, after 3rd free) | −20 | **Our adapter** — see below |
|
||||
| Score floor | `score.max(0)` always | **Our adapter** |
|
||||
| Time bonus on win | `700_000 / elapsed_seconds` | **Our adapter** (not wasm-portable) |
|
||||
|
||||
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
||||
|
||||
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The −15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
|
||||
|
||||
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
||||
|
||||
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
|
||||
|
||||
### 2. Game Modes
|
||||
Ferrous has three modes that alter scoring and undo behaviour:
|
||||
|
||||
| Mode | Scoring | Undo |
|
||||
|---|---|---|
|
||||
| **Classic** | Full WXP scoring (table above) | Allowed (−15 penalty) |
|
||||
| **Zen** | All deltas suppressed — score stays 0 | Allowed (no penalty) |
|
||||
| **Challenge** | Full WXP scoring | **Disabled** — returns an error |
|
||||
|
||||
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
|
||||
|
||||
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
|
||||
|
||||
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
||||
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
||||
- `MovesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
||||
- `StatesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
||||
|
||||
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
|
||||
|
||||
Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
|
||||
|
||||
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
|
||||
|
||||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
||||
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
||||
|
||||
**Important:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in. Our adapter explicitly sets `Disallowed` in the default `KlondikeConfig` and switches to `Allowed` only when the user toggles the house-rule option.
|
||||
|
||||
**In our wrapper:** Construct `KlondikeConfig { move_from_foundation: MoveFromFoundationConfig::Disallowed, .. }` by default; mirror the user's settings toggle to `Allowed`. No custom intercept needed — `klondike` enforces the rule automatically.
|
||||
|
||||
### 5. JSON Serialisation / Persistence
|
||||
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
|
||||
|
||||
**Current verification (2026-06-01):** `klondike v0.3.0` and `card_game v0.4.0`
|
||||
crate manifests expose no `serde` dependency/feature, and source exports no
|
||||
serde derives for instruction/state snapshot types. Keep Ferrous'
|
||||
`SavedInstruction` bridge in place.
|
||||
|
||||
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
|
||||
|
||||
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
|
||||
|
||||
### 6. Typed Move Errors
|
||||
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
|
||||
|
||||
```
|
||||
GameAlreadyWon
|
||||
UndoStackEmpty
|
||||
StockEmpty
|
||||
InvalidSource
|
||||
InvalidDestination
|
||||
RuleViolation(String)
|
||||
```
|
||||
|
||||
`KlondikeInstruction` is always constructed by game code from valid entity layout, so invalid moves are only detectable at `solitaire_core`'s construction boundary — the error lives there, not inside `klondike`.
|
||||
|
||||
**In our wrapper:** `MoveError` variants are generated when `solitaire_core` fails to construct a `KlondikeInstruction` from the player's requested move. No translation of `is_instruction_valid`'s bool return is required; by the time an instruction reaches `klondike`, it is already known to be structurally valid.
|
||||
|
||||
### 7. Waste Pile as Separate Concept
|
||||
Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into `Stock` (the face-up half of the stock `Pile`). The engine's UI and scoring logic reference the waste pile directly; the mapping needs to be explicit.
|
||||
|
||||
**In our wrapper:** Project the face-up half of `klondike`'s stock `Pile` as `PileType::Waste` when building pile snapshots for the engine.
|
||||
|
||||
### 8. Undo Stack Approach *(resolved — not an issue)*
|
||||
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
|
||||
|
||||
**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
|
||||
|
||||
---
|
||||
|
||||
## Integration Path (All work in `solitaire_core`)
|
||||
|
||||
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
||||
|
||||
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
|
||||
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
|
||||
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
|
||||
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
|
||||
5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
|
||||
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
||||
7. **Implement `serde`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
|
||||
|
||||
---
|
||||
|
||||
## Quaternions Upgrade Runbook
|
||||
|
||||
Use this sequence whenever upgrading `klondike` / `card_game` from the
|
||||
Quaternions registry:
|
||||
|
||||
1. Review upstream changes/releases:
|
||||
- <https://git.aleshym.co/Quaternions/card_game>
|
||||
- <https://git.aleshym.co/Quaternions/klondike>
|
||||
2. Run:
|
||||
```bash
|
||||
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
|
||||
```
|
||||
3. If the script passes, inspect the resulting `Cargo.lock` diff and land the
|
||||
upgrade with the normal PR flow.
|
||||
|
||||
The script enforces:
|
||||
- lockfile update to requested versions
|
||||
- `cargo test --workspace`
|
||||
- `cargo clippy --workspace -- -D warnings`
|
||||
- deterministic replay/debug-API smoke tests in `solitaire_wasm`
|
||||
|
||||
---
|
||||
|
||||
## What Does NOT Need to Change
|
||||
|
||||
- The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`.
|
||||
- The `solitaire_sync` merge logic — operates on a `SyncPayload` DTO, independent of core card types.
|
||||
- The `solitaire_server` — speaks only `SyncPayload` JSON, unaffected.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Quaternions' repo: <https://git.aleshym.co/Quaternions/card_game>
|
||||
- `card_game v0.4.0` release commit: `fa098f0d`
|
||||
- `klondike v0.3.0` release commit: `f4c4e350`
|
||||
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
||||
- Upstream solver PR: #14
|
||||
- `solitaire_core` source: `solitaire_core/src/`
|
||||
- Scoring spec: `solitaire_core/src/scoring.rs`
|
||||
- Architecture overview: `ARCHITECTURE.md`
|
||||
@@ -0,0 +1,115 @@
|
||||
# Testing Architecture — Engine-first Validation
|
||||
|
||||
Ferrous Solitaire validation is split into three layers with clear ownership:
|
||||
|
||||
1. **Rust unit tests (`solitaire_core`)**
|
||||
- move generation and legality
|
||||
- deal generation determinism
|
||||
- scoring and penalties
|
||||
- undo semantics
|
||||
- win detection
|
||||
|
||||
2. **Engine integration tests (`solitaire_wasm` debug API)**
|
||||
- autonomous game execution without UI/pointer simulation
|
||||
- invariant checks after every move
|
||||
- deterministic seed replay
|
||||
- high-volume seeded runs (including long-running soak tests)
|
||||
|
||||
3. **Playwright UI tests**
|
||||
- verify rendering vs engine state
|
||||
- drag/drop and keyboard UX behavior
|
||||
- responsive layout behavior
|
||||
- browser-compatibility checks
|
||||
|
||||
## Source of truth
|
||||
|
||||
The Rust engine is authoritative. Browser tests must interact with the game via
|
||||
debug API hooks, not via pixel/OCR solving or hardcoded screen coordinates.
|
||||
|
||||
## Debug API surfaces
|
||||
|
||||
Two automation surfaces are exposed:
|
||||
|
||||
- `solitaire_wasm::SolitaireGame` methods:
|
||||
- `debug_snapshot()`
|
||||
- `debug_legal_moves()`
|
||||
- `debug_move_history()`
|
||||
- `debug_apply_legal_move(index)`
|
||||
- `debug_apply_move_json(json)`
|
||||
- Browser bridge on `game.html`:
|
||||
- `window.__FERROUS_DEBUG__.snapshot()`
|
||||
- `window.__FERROUS_DEBUG__.legalMoves()`
|
||||
- `window.__FERROUS_DEBUG__.moveHistory()`
|
||||
- `window.__FERROUS_DEBUG__.applyLegalMove(index)`
|
||||
- `window.__FERROUS_DEBUG__.applyMove(move)`
|
||||
- `window.__FERROUS_DEBUG__.failureReport()`
|
||||
- `window.__FERROUS_DEBUG__.runAutoplay(options)`
|
||||
|
||||
## Required failure payload
|
||||
|
||||
Every automation failure should capture:
|
||||
|
||||
- seed
|
||||
- move history
|
||||
- current game state
|
||||
- screenshot
|
||||
- browser trace
|
||||
- console logs
|
||||
|
||||
`failureReport()` provides the engine-side fields (`seed`, `moveHistory`,
|
||||
`currentState`) so UI harnesses only need to attach browser artifacts.
|
||||
|
||||
## Execution guidance
|
||||
|
||||
- Fast verification:
|
||||
- `cargo test -p solitaire_core -p solitaire_wasm`
|
||||
- Full verification:
|
||||
- `cargo test --workspace`
|
||||
- `cargo clippy --workspace -- -D warnings`
|
||||
- Long unattended soak:
|
||||
- `cargo test -p solitaire_wasm debug_api_autonomous_thousands_seed_soak -- --ignored`
|
||||
|
||||
### Browser e2e harness
|
||||
|
||||
The Playwright suite lives under `solitaire_server/e2e/` and boots
|
||||
`solitaire_server` via Playwright `webServer` config.
|
||||
|
||||
- Install + run:
|
||||
- `cd solitaire_server/e2e`
|
||||
- `npm ci`
|
||||
- `npx playwright install chromium`
|
||||
- `npm test`
|
||||
- Cycle metrics batch run:
|
||||
- `cd solitaire_server/e2e`
|
||||
- `npm run review:cycles -- --games 1000 --steps 350 --policy baseline --max-visits 1 --out /tmp/cycle-baseline.json`
|
||||
- `npm run review:cycles -- --games 1000 --steps 350 --policy loop_aware --max-visits 2 --out /tmp/cycle-loop-aware.json`
|
||||
- `npm run review:cycles:regression` (thresholded gate, writes `test-results/cycle-regression.json`)
|
||||
- `npm run review:cycles:candidate` (loop-aware candidate run, writes `test-results/cycle-candidate.json`)
|
||||
|
||||
### Cycle-risk regression baseline and guardrails
|
||||
|
||||
- Current regression gate command:
|
||||
- `npm run review:cycles:regression`
|
||||
- config: `games=240`, `steps=350`, `policy=baseline`, `max-visits=1`
|
||||
- Current guardrail thresholds:
|
||||
- `all.cycle_rate_pct <= 86`
|
||||
- `draw1.cycle_rate_pct <= 76`
|
||||
- `draw3.cycle_rate_pct <= 95`
|
||||
- `all.win_rate_pct >= 14`
|
||||
- zero invariant/apply/page/console issue counts
|
||||
- Baseline sample (240 games):
|
||||
- overall: `win_rate=15.8%`, `cycle_rate=84.2%`
|
||||
- draw-one: `win_rate=25.8%`, `cycle_rate=74.2%`
|
||||
- draw-three: `win_rate=5.8%`, `cycle_rate=94.2%`
|
||||
- Candidate loop-aware sample (240 games, lookahead via simulated move + restore):
|
||||
- overall: `win_rate=20.4%`, `cycle_rate=32.5%`
|
||||
- draw-one: `win_rate=33.3%`, `cycle_rate=16.7%`
|
||||
- draw-three: `win_rate=7.5%`, `cycle_rate=48.3%`
|
||||
- no invariant/apply/page/console issues in the sampled run
|
||||
- Additional 500-game candidate soak:
|
||||
- overall: `win_rate=20.2%`, `cycle_rate=28.6%`, `step_budget=51.2%`
|
||||
- draw-three remains the dominant risk (`cycle_rate=45.2%`)
|
||||
- Fix applied: cycle metrics regression now supports explicit
|
||||
`max_step_budget_rate_*` thresholds. Candidate command now enforces
|
||||
`max_step_budget_rate_all <= 60` to prevent silent drift from cycles into
|
||||
step-budget stalls.
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update Quaternions registry dependencies and run the full safety gate.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
|
||||
#
|
||||
# Example:
|
||||
# scripts/update_quaternions_deps.sh 0.3.1 0.4.1
|
||||
#
|
||||
# This script updates Cargo.lock to the requested versions (within the semver
|
||||
# ranges already declared in Cargo.toml), then runs the project's required
|
||||
# verification steps plus deterministic replay checks.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: $0 <klondike_version> <card_game_version>"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
KLONDIKE_VERSION="$1"
|
||||
CARD_GAME_VERSION="$2"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo ">>> Quaternions registry:"
|
||||
echo " https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
echo
|
||||
echo ">>> Review upstream release notes / changelogs before proceeding:"
|
||||
echo " - https://git.aleshym.co/Quaternions/card_game"
|
||||
echo " - https://git.aleshym.co/Quaternions/klondike"
|
||||
echo
|
||||
|
||||
echo ">>> Updating lockfile to klondike=$KLONDIKE_VERSION card_game=$CARD_GAME_VERSION"
|
||||
cargo update -p klondike --precise "$KLONDIKE_VERSION"
|
||||
cargo update -p card_game --precise "$CARD_GAME_VERSION"
|
||||
|
||||
echo ">>> Verifying dependency graph"
|
||||
cargo tree -p solitaire_core --depth 2 | cat
|
||||
|
||||
echo ">>> Running workspace tests"
|
||||
cargo test --workspace
|
||||
|
||||
echo ">>> Running workspace clippy"
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
echo ">>> Running deterministic replay / debug-api smoke checks"
|
||||
cargo test -p solitaire_wasm debug_snapshot_exposes_replayable_seed_and_history -- --exact
|
||||
cargo test -p solitaire_wasm debug_api_autonomous_seed_batch_smoke -- --exact
|
||||
|
||||
echo ">>> Quaternions dependency upgrade gate passed"
|
||||
@@ -25,7 +25,10 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
use bevy::winit::WinitWindows;
|
||||
#[cfg(target_os = "android")]
|
||||
use bevy::winit::{UpdateMode, WinitSettings};
|
||||
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
|
||||
use solitaire_data::{
|
||||
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
|
||||
settings_file_path,
|
||||
};
|
||||
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
||||
|
||||
fn load_settings() -> Settings {
|
||||
@@ -49,6 +52,12 @@ pub fn run() {
|
||||
// and any debugger attached still sees the panic).
|
||||
install_crash_log_hook();
|
||||
|
||||
// Remove any *.tmp files left behind by a crash between an atomic write
|
||||
// and its rename. Safe to call unconditionally — missing data dir is a
|
||||
// no-op. Must run before GamePlugin loads saved state so orphaned files
|
||||
// don't accumulate across launches.
|
||||
let _ = cleanup_orphaned_tmp_files();
|
||||
|
||||
// Initialise the platform keyring store before any token operations.
|
||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||
@@ -172,13 +181,16 @@ fn build_app_with_settings(
|
||||
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
||||
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
||||
//
|
||||
// The focused mode stays Continuous so that card-slide animations remain
|
||||
// smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the
|
||||
// display refresh rate (~60 Hz) when foregrounded, which already prevents
|
||||
// the GPU from spinning at 200+ fps between vsync intervals.
|
||||
// focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when
|
||||
// an event arrives (touch, resize, etc.) or an animation system writes
|
||||
// RequestRedraw. The 100 ms ceiling is a fallback that ensures the game
|
||||
// timer ticks at least 10×/s even with no input, while keeping the GPU
|
||||
// completely idle between frames when the board is static.
|
||||
// PresentMode::AutoVsync (set above) still caps the GPU at the display
|
||||
// refresh rate when frames do render.
|
||||
#[cfg(target_os = "android")]
|
||||
app.insert_resource(WinitSettings {
|
||||
focused_mode: UpdateMode::Continuous,
|
||||
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
|
||||
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,15 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-support = []
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
|
||||
+38
-97
@@ -1,102 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Card suit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Suit {
|
||||
Clubs,
|
||||
Diamonds,
|
||||
Hearts,
|
||||
Spades,
|
||||
}
|
||||
|
||||
impl Suit {
|
||||
/// All four suits in declaration order.
|
||||
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
|
||||
|
||||
/// Returns `true` for red suits (Diamonds, Hearts).
|
||||
pub fn is_red(self) -> bool {
|
||||
matches!(self, Suit::Diamonds | Suit::Hearts)
|
||||
}
|
||||
|
||||
/// Returns `true` for black suits (Clubs, Spades).
|
||||
pub fn is_black(self) -> bool {
|
||||
!self.is_red()
|
||||
}
|
||||
}
|
||||
|
||||
/// Card rank, Ace through King.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Rank {
|
||||
Ace = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6,
|
||||
Seven = 7,
|
||||
Eight = 8,
|
||||
Nine = 9,
|
||||
Ten = 10,
|
||||
Jack = 11,
|
||||
Queen = 12,
|
||||
King = 13,
|
||||
}
|
||||
|
||||
impl Rank {
|
||||
/// All thirteen ranks in ascending order.
|
||||
pub const RANKS: [Self; 13] = [
|
||||
Self::Ace,
|
||||
Self::Two,
|
||||
Self::Three,
|
||||
Self::Four,
|
||||
Self::Five,
|
||||
Self::Six,
|
||||
Self::Seven,
|
||||
Self::Eight,
|
||||
Self::Nine,
|
||||
Self::Ten,
|
||||
Self::Jack,
|
||||
Self::Queen,
|
||||
Self::King,
|
||||
];
|
||||
|
||||
/// Numeric value: Ace = 1, King = 13.
|
||||
pub fn value(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
const fn new(n: u8) -> Option<Self> {
|
||||
match n {
|
||||
1 => Some(Self::Ace),
|
||||
2 => Some(Self::Two),
|
||||
3 => Some(Self::Three),
|
||||
4 => Some(Self::Four),
|
||||
5 => Some(Self::Five),
|
||||
6 => Some(Self::Six),
|
||||
7 => Some(Self::Seven),
|
||||
8 => Some(Self::Eight),
|
||||
9 => Some(Self::Nine),
|
||||
10 => Some(Self::Ten),
|
||||
11 => Some(Self::Jack),
|
||||
12 => Some(Self::Queen),
|
||||
13 => Some(Self::King),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
|
||||
pub const fn checked_add(self, n: u8) -> Option<Self> {
|
||||
Self::new((self as u8).saturating_add(n))
|
||||
}
|
||||
|
||||
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
|
||||
pub const fn checked_sub(self, n: u8) -> Option<Self> {
|
||||
match (self as u8).checked_sub(n) {
|
||||
Some(v) => Self::new(v),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use card_game::{Rank, Suit};
|
||||
|
||||
/// A single playing card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -111,6 +15,28 @@ pub struct Card {
|
||||
pub face_up: bool,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// Creates a card with explicit face orientation.
|
||||
pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a face-up card.
|
||||
pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self {
|
||||
Self::new(id, suit, rank, true)
|
||||
}
|
||||
|
||||
/// Creates a face-down card.
|
||||
pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self {
|
||||
Self::new(id, suit, rank, false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -166,4 +92,19 @@ mod tests {
|
||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_constructors_set_fields() {
|
||||
let up = Card::face_up(10, Suit::Spades, Rank::Queen);
|
||||
assert_eq!(up.id, 10);
|
||||
assert_eq!(up.suit, Suit::Spades);
|
||||
assert_eq!(up.rank, Rank::Queen);
|
||||
assert!(up.face_up);
|
||||
|
||||
let down = Card::face_down(11, Suit::Diamonds, Rank::King);
|
||||
assert_eq!(down.id, 11);
|
||||
assert_eq!(down.suit, Suit::Diamonds);
|
||||
assert_eq!(down.rank, Rank::King);
|
||||
assert!(!down.face_up);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
use crate::pile::{Pile, PileType};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{SeedableRng, seq::SliceRandom};
|
||||
|
||||
const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
const ALL_RANKS: [Rank; 13] = [
|
||||
Rank::Ace,
|
||||
Rank::Two,
|
||||
Rank::Three,
|
||||
Rank::Four,
|
||||
Rank::Five,
|
||||
Rank::Six,
|
||||
Rank::Seven,
|
||||
Rank::Eight,
|
||||
Rank::Nine,
|
||||
Rank::Ten,
|
||||
Rank::Jack,
|
||||
Rank::Queen,
|
||||
Rank::King,
|
||||
];
|
||||
|
||||
/// A standard 52-card deck.
|
||||
pub struct Deck {
|
||||
/// All 52 cards in the deck, in deal order.
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Creates an unshuffled deck with all 52 unique cards (id 0–51).
|
||||
pub fn new() -> Self {
|
||||
let mut cards = Vec::with_capacity(52);
|
||||
let mut id = 0u32;
|
||||
for &suit in &ALL_SUITS {
|
||||
for &rank in &ALL_RANKS {
|
||||
cards.push(Card {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up: false,
|
||||
});
|
||||
id += 1;
|
||||
}
|
||||
}
|
||||
Self { cards }
|
||||
}
|
||||
|
||||
/// Shuffles the deck in-place using Fisher-Yates with a seeded `StdRng`.
|
||||
/// The same seed always produces the same order on any platform.
|
||||
pub fn shuffle(&mut self, seed: u64) {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
self.cards.shuffle(&mut rng);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Deck {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deals a standard Klondike layout from a pre-shuffled deck.
|
||||
///
|
||||
/// Returns 7 tableau piles and the remaining stock pile.
|
||||
/// Column `i` contains `i + 1` cards; only the top card is face-up.
|
||||
/// Stock receives the remaining 24 cards, all face-down.
|
||||
pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) {
|
||||
debug_assert_eq!(
|
||||
deck.cards.len(),
|
||||
52,
|
||||
"deal_klondike requires a full 52-card deck"
|
||||
);
|
||||
let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i)));
|
||||
// Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded.
|
||||
let mut idx = 0usize;
|
||||
|
||||
for (col, pile) in tableau.iter_mut().enumerate() {
|
||||
for row in 0..=col {
|
||||
let mut card = deck.cards[idx].clone();
|
||||
card.face_up = row == col;
|
||||
pile.cards.push(card);
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stock = Pile::new(PileType::Stock);
|
||||
stock.cards.extend(deck.cards.into_iter().skip(idx));
|
||||
(tableau, stock)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deck_new_has_52_cards() {
|
||||
assert_eq!(Deck::new().cards.len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_new_has_unique_ids() {
|
||||
let deck = Deck::new();
|
||||
let mut ids: Vec<u32> = deck.cards.iter().map(|c| c.id).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_new_has_all_suits_and_ranks() {
|
||||
let deck = Deck::new();
|
||||
for suit in ALL_SUITS {
|
||||
for rank in ALL_RANKS {
|
||||
assert!(
|
||||
deck.cards.iter().any(|c| c.suit == suit && c.rank == rank),
|
||||
"missing {rank:?} {suit:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_produces_same_order() {
|
||||
let mut d1 = Deck::new();
|
||||
d1.shuffle(42);
|
||||
let mut d2 = Deck::new();
|
||||
d2.shuffle(42);
|
||||
assert_eq!(d1.cards, d2.cards);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_produce_different_orders() {
|
||||
let mut d1 = Deck::new();
|
||||
d1.shuffle(1);
|
||||
let mut d2 = Deck::new();
|
||||
d2.shuffle(2);
|
||||
assert_ne!(d1.cards, d2.cards);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_correct_tableau_sizes() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(0);
|
||||
let (tableau, stock) = deal_klondike(deck);
|
||||
for (i, pile) in tableau.iter().enumerate() {
|
||||
assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size");
|
||||
}
|
||||
assert_eq!(stock.cards.len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_top_cards_are_face_up() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(0);
|
||||
let (tableau, _) = deal_klondike(deck);
|
||||
for pile in &tableau {
|
||||
assert!(pile.cards.last().unwrap().face_up);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_non_top_cards_are_face_down() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(0);
|
||||
let (tableau, _) = deal_klondike(deck);
|
||||
for pile in &tableau {
|
||||
for card in &pile.cards[..pile.cards.len().saturating_sub(1)] {
|
||||
assert!(!card.face_up);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_stock_is_face_down() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(0);
|
||||
let (_, stock) = deal_klondike(deck);
|
||||
assert!(stock.cards.iter().all(|c| !c.face_up));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_klondike_all_52_cards_present() {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(99);
|
||||
let (tableau, stock) = deal_klondike(deck);
|
||||
let mut ids: Vec<u32> = stock.cards.iter().map(|c| c.id).collect();
|
||||
for pile in &tableau {
|
||||
ids.extend(pile.cards.iter().map(|c| c.id));
|
||||
}
|
||||
ids.sort_unstable();
|
||||
assert_eq!(ids, (0u32..52).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
+720
-1936
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
|
||||
//!
|
||||
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
||||
//! - building [`KlondikeConfig`] from Ferrous settings
|
||||
//! - translating between local and upstream types
|
||||
//! - applying Ferrous-specific scoring policy on top of upstream defaults
|
||||
//!
|
||||
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
|
||||
//! upstream `card_game` / `klondike` types live here so that the product modules
|
||||
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
||||
|
||||
use card_game::Card as KlCard;
|
||||
use klondike::{
|
||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
|
||||
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
|
||||
TableauStack,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::card;
|
||||
use crate::game_state::{DrawMode, GameMode};
|
||||
|
||||
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
||||
///
|
||||
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
||||
/// state, and exists only as a namespace for configuration, conversion, and
|
||||
/// scoring helpers.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct KlondikeAdapter;
|
||||
|
||||
impl KlondikeAdapter {
|
||||
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
||||
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
|
||||
KlondikeConfig {
|
||||
draw_stock: match draw_mode {
|
||||
DrawMode::DrawOne => DrawStockConfig::DrawOne,
|
||||
DrawMode::DrawThree => DrawStockConfig::DrawThree,
|
||||
},
|
||||
move_from_foundation: if take_from_foundation {
|
||||
MoveFromFoundationConfig::Allowed
|
||||
} else {
|
||||
MoveFromFoundationConfig::Disallowed
|
||||
},
|
||||
scoring: ScoringConfig::DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scoring helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Score delta for a card move.
|
||||
///
|
||||
/// Reads from [`ScoringConfig`] (WXP Standard values):
|
||||
/// - Any pile → Foundation: +10
|
||||
/// - Waste → Tableau: +5
|
||||
/// - Foundation → Tableau: −15
|
||||
/// - All other moves: 0
|
||||
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
|
||||
let sc = ScoringConfig::DEFAULT;
|
||||
match (from, to) {
|
||||
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
||||
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
||||
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for exposing a face-down tableau card: +5.
|
||||
pub fn score_for_flip() -> i32 {
|
||||
ScoringConfig::DEFAULT.flip_up_bonus
|
||||
}
|
||||
|
||||
/// Score delta for undo: −15.
|
||||
///
|
||||
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
|
||||
/// defaults to 0; the solver overrides it to 0 explicitly. The −15 WXP penalty
|
||||
/// is applied here by `GameState` on every undo.
|
||||
pub fn score_for_undo() -> i32 {
|
||||
-15
|
||||
}
|
||||
|
||||
/// Score delta for recycling waste → stock.
|
||||
///
|
||||
/// [`ScoringConfig::recycle`] is a flat delta (default 0 = always free).
|
||||
/// WXP allows a fixed number of free recycles before charging a penalty,
|
||||
/// which the upstream library cannot express with a single delta:
|
||||
///
|
||||
/// | Mode | Free recycles | Penalty per extra recycle |
|
||||
/// |---|---|---|
|
||||
/// | Draw-1 | 1 | −100 |
|
||||
/// | Draw-3 | 3 | −20 |
|
||||
///
|
||||
/// **Design note:** recycling is *never* blocked — only penalised.
|
||||
/// This is intentional: Draw-1 can be played indefinitely with the score
|
||||
/// dropping toward zero after the first free recycle. A hard cap would
|
||||
/// create unwinnable positions when the solver cannot find a path without
|
||||
/// additional recycling. Zen mode suppresses the penalty entirely.
|
||||
///
|
||||
/// `recycle_count` must be the new total **after** this recycle.
|
||||
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||
if is_draw_three {
|
||||
if recycle_count > 3 { -20 } else { 0 }
|
||||
} else if recycle_count > 1 {
|
||||
-100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for a card move, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
||||
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_move(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for exposing a face-down card, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`].
|
||||
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_flip()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the new score after an undo, accounting for game mode.
|
||||
///
|
||||
/// In [`GameMode::Zen`] the score is always 0. Otherwise applies the
|
||||
/// −15 undo penalty and clamps to 0 via [`Self::score_for_undo`].
|
||||
pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
(snapshot_score + Self::score_for_undo()).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for recycling, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`].
|
||||
pub fn score_for_recycle_with_mode(
|
||||
recycle_count: u32,
|
||||
is_draw_three: bool,
|
||||
mode: GameMode,
|
||||
) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_recycle(recycle_count, is_draw_three)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||||
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
|
||||
match index {
|
||||
0 => Some(Tableau::Tableau1),
|
||||
1 => Some(Tableau::Tableau2),
|
||||
2 => Some(Tableau::Tableau3),
|
||||
3 => Some(Tableau::Tableau4),
|
||||
4 => Some(Tableau::Tableau5),
|
||||
5 => Some(Tableau::Tableau6),
|
||||
6 => Some(Tableau::Tableau7),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
|
||||
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
|
||||
match slot {
|
||||
0 => Some(Foundation::Foundation1),
|
||||
1 => Some(Foundation::Foundation2),
|
||||
2 => Some(Foundation::Foundation3),
|
||||
3 => Some(Foundation::Foundation4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
|
||||
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
||||
match skip {
|
||||
0 => Some(SkipCards::Skip0),
|
||||
1 => Some(SkipCards::Skip1),
|
||||
2 => Some(SkipCards::Skip2),
|
||||
3 => Some(SkipCards::Skip3),
|
||||
4 => Some(SkipCards::Skip4),
|
||||
5 => Some(SkipCards::Skip5),
|
||||
6 => Some(SkipCards::Skip6),
|
||||
7 => Some(SkipCards::Skip7),
|
||||
8 => Some(SkipCards::Skip8),
|
||||
9 => Some(SkipCards::Skip9),
|
||||
10 => Some(SkipCards::Skip10),
|
||||
11 => Some(SkipCards::Skip11),
|
||||
12 => Some(SkipCards::Skip12),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`card_game::Card`] to a [`card::Card`], assigning a stable `id`
|
||||
/// derived from suit and rank (0–51, Clubs-first ordering).
|
||||
///
|
||||
/// The id is consistent for the same logical card across all reconstructions.
|
||||
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
|
||||
let suit = kl_card.suit();
|
||||
let rank = kl_card.rank();
|
||||
let suit_index = match suit {
|
||||
card::Suit::Clubs => 0,
|
||||
card::Suit::Diamonds => 1,
|
||||
card::Suit::Hearts => 2,
|
||||
card::Suit::Spades => 3,
|
||||
};
|
||||
let id = suit_index * 13 + (rank.value() as u32 - 1);
|
||||
card::Card {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
|
||||
//
|
||||
// `klondike::KlondikeInstruction` (and its sub-types) do not derive
|
||||
// `Serialize` / `Deserialize`. These mirror types carry `#[serde]` so that
|
||||
// the session instruction history can be persisted and reconstructed without
|
||||
// upstream changes.
|
||||
//
|
||||
// Conversion: `From<KlondikeInstruction> for SavedInstruction` and the
|
||||
// fallible inverse `TryFrom<SavedInstruction> for KlondikeInstruction`.
|
||||
// Invalid numeric values (out-of-range u8 for tableau/foundation/skip) yield
|
||||
// `InvalidSavedInstruction`.
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedTableau(pub u8);
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedFoundation(pub u8);
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedSkipCards(pub u8);
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavedKlondikePile {
|
||||
Tableau(SavedTableau),
|
||||
Stock,
|
||||
Foundation(SavedFoundation),
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedTableauStack {
|
||||
pub tableau: SavedTableau,
|
||||
pub skip_cards: SavedSkipCards,
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavedKlondikePileStack {
|
||||
Tableau(SavedTableauStack),
|
||||
Stock,
|
||||
Foundation(SavedFoundation),
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedDstFoundation {
|
||||
pub src: SavedKlondikePile,
|
||||
pub foundation: SavedFoundation,
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedDstTableau {
|
||||
pub src: SavedKlondikePileStack,
|
||||
pub tableau: SavedTableau,
|
||||
}
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
|
||||
///
|
||||
/// Convert to/from the upstream type with:
|
||||
/// ```ignore
|
||||
/// let saved = SavedInstruction::from(instruction);
|
||||
/// let instruction = KlondikeInstruction::try_from(saved)?;
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavedInstruction {
|
||||
DstFoundation(SavedDstFoundation),
|
||||
DstTableau(SavedDstTableau),
|
||||
RotateStock,
|
||||
}
|
||||
|
||||
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
|
||||
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum InvalidSavedInstruction {
|
||||
#[error("invalid tableau index {0} (expected 0–6)")]
|
||||
Tableau(u8),
|
||||
#[error("invalid foundation index {0} (expected 0–3)")]
|
||||
Foundation(u8),
|
||||
#[error("invalid skip_cards value {0} (expected 0–12)")]
|
||||
SkipCards(u8),
|
||||
}
|
||||
|
||||
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
|
||||
|
||||
impl From<Tableau> for SavedTableau {
|
||||
fn from(t: Tableau) -> Self {
|
||||
Self(t as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Foundation> for SavedFoundation {
|
||||
fn from(f: Foundation) -> Self {
|
||||
Self(f as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SkipCards> for SavedSkipCards {
|
||||
fn from(s: SkipCards) -> Self {
|
||||
Self(s as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KlondikePile> for SavedKlondikePile {
|
||||
fn from(p: KlondikePile) -> Self {
|
||||
match p {
|
||||
KlondikePile::Tableau(t) => Self::Tableau(t.into()),
|
||||
KlondikePile::Stock => Self::Stock,
|
||||
KlondikePile::Foundation(f) => Self::Foundation(f.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TableauStack> for SavedTableauStack {
|
||||
fn from(ts: TableauStack) -> Self {
|
||||
Self {
|
||||
tableau: ts.tableau.into(),
|
||||
skip_cards: ts.skip_cards.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KlondikePileStack> for SavedKlondikePileStack {
|
||||
fn from(ps: KlondikePileStack) -> Self {
|
||||
match ps {
|
||||
KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()),
|
||||
KlondikePileStack::Stock => Self::Stock,
|
||||
KlondikePileStack::Foundation(f) => Self::Foundation(f.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DstFoundation> for SavedDstFoundation {
|
||||
fn from(df: DstFoundation) -> Self {
|
||||
Self {
|
||||
src: df.src.into(),
|
||||
foundation: df.foundation.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DstTableau> for SavedDstTableau {
|
||||
fn from(dt: DstTableau) -> Self {
|
||||
Self {
|
||||
src: dt.src.into(),
|
||||
tableau: dt.tableau.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KlondikeInstruction> for SavedInstruction {
|
||||
fn from(i: KlondikeInstruction) -> Self {
|
||||
match i {
|
||||
KlondikeInstruction::RotateStock => Self::RotateStock,
|
||||
KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()),
|
||||
KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TryFrom impls: Saved* → KlondikeInstruction ──────────────────────────────
|
||||
|
||||
impl TryFrom<SavedTableau> for Tableau {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
|
||||
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedFoundation> for Foundation {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
||||
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedSkipCards> for SkipCards {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
||||
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedKlondikePile> for KlondikePile {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
|
||||
Ok(match s {
|
||||
SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?),
|
||||
SavedKlondikePile::Stock => KlondikePile::Stock,
|
||||
SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedTableauStack> for TableauStack {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
|
||||
Ok(TableauStack {
|
||||
tableau: s.tableau.try_into()?,
|
||||
skip_cards: s.skip_cards.try_into()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
|
||||
Ok(match s {
|
||||
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
|
||||
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
|
||||
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedDstFoundation> for DstFoundation {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
|
||||
Ok(DstFoundation {
|
||||
src: s.src.try_into()?,
|
||||
foundation: s.foundation.try_into()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedDstTableau> for DstTableau {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
|
||||
Ok(DstTableau {
|
||||
src: s.src.try_into()?,
|
||||
tableau: s.tableau.try_into()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SavedInstruction> for KlondikeInstruction {
|
||||
type Error = InvalidSavedInstruction;
|
||||
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
|
||||
Ok(match s {
|
||||
SavedInstruction::RotateStock => KlondikeInstruction::RotateStock,
|
||||
SavedInstruction::DstFoundation(df) => {
|
||||
KlondikeInstruction::DstFoundation(df.try_into()?)
|
||||
}
|
||||
SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
if elapsed_seconds == 0 {
|
||||
return 0;
|
||||
}
|
||||
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
pub mod achievement;
|
||||
pub mod card;
|
||||
pub mod deck;
|
||||
pub mod error;
|
||||
pub mod game_state;
|
||||
pub mod klondike_adapter;
|
||||
pub mod pile;
|
||||
pub mod rules;
|
||||
pub mod scoring;
|
||||
pub mod solver;
|
||||
|
||||
// Re-export upstream types that cross the solitaire_core API boundary so
|
||||
// callers can import from one place without a direct `klondike` / `card_game` dep.
|
||||
pub use card_game::Session;
|
||||
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptest_tests;
|
||||
|
||||
+25
-68
@@ -1,33 +1,25 @@
|
||||
use crate::card::{Card, Suit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use klondike::KlondikePile;
|
||||
|
||||
/// Identifies which pile on the board a set of cards belongs to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum PileType {
|
||||
/// The face-down draw pile.
|
||||
Stock,
|
||||
/// The face-up discard pile drawn to.
|
||||
Waste,
|
||||
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||||
/// is derived from the bottom card of the pile (always an Ace by
|
||||
/// construction).
|
||||
Foundation(u8),
|
||||
/// One of the seven tableau columns (0–6).
|
||||
Tableau(usize),
|
||||
}
|
||||
|
||||
/// A named collection of cards in a specific board position.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// Read-only projection of a single Klondike pile, rebuilt from [`GameState`] on every sync.
|
||||
///
|
||||
/// `Pile` is a **data-transfer type**, not a game-state owner. Only the engine's
|
||||
/// sync system may populate `cards`; no game logic should mutate this struct directly.
|
||||
/// [`GameState`] is always the authoritative source of truth.
|
||||
///
|
||||
/// [`GameState`]: crate::game_state::GameState
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pile {
|
||||
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
||||
pub pile_type: PileType,
|
||||
/// Which logical Klondike pile this is.
|
||||
pub pile_type: KlondikePile,
|
||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||
/// Populated by the sync system; do not mutate from game-logic code.
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
impl Pile {
|
||||
/// Creates a new empty pile of the given type.
|
||||
pub fn new(pile_type: PileType) -> Self {
|
||||
pub fn new(pile_type: KlondikePile) -> Self {
|
||||
Self {
|
||||
pile_type,
|
||||
cards: Vec::new(),
|
||||
@@ -44,7 +36,7 @@ impl Pile {
|
||||
/// Returns `None` for empty foundations or non-foundation piles.
|
||||
pub fn claimed_suit(&self) -> Option<Suit> {
|
||||
match self.pile_type {
|
||||
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
|
||||
KlondikePile::Foundation(_) => self.cards.first().map(|c| c.suit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -57,77 +49,42 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn new_pile_is_empty() {
|
||||
let pile = Pile::new(PileType::Stock);
|
||||
let pile = Pile::new(KlondikePile::Stock);
|
||||
assert!(pile.cards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_top_returns_last_card() {
|
||||
let mut pile = Pile::new(PileType::Waste);
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
let mut pile = Pile::new(KlondikePile::Stock);
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
|
||||
assert_eq!(pile.top().unwrap().id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_top_on_empty_is_none() {
|
||||
let pile = Pile::new(PileType::Waste);
|
||||
let pile = Pile::new(KlondikePile::Stock);
|
||||
assert!(pile.top().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_foundation_uses_slot_index() {
|
||||
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_tableau_uses_index() {
|
||||
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_empty_foundation() {
|
||||
let pile = Pile::new(PileType::Foundation(0));
|
||||
let pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation1));
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_non_foundation() {
|
||||
let mut pile = Pile::new(PileType::Tableau(0));
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_returns_bottom_card_suit() {
|
||||
let mut pile = Pile::new(PileType::Foundation(2));
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
|
||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::game_state::{DrawMode, GameState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Collect all card IDs across every pile in a fixed traversal order:
|
||||
/// stock → waste → foundations 1–4 → tableaux 1–7.
|
||||
///
|
||||
/// The order is deterministic for a given game state, so two calls on
|
||||
/// equivalent states produce identical Vec outputs — the right fingerprint
|
||||
/// for undo-reversibility checks.
|
||||
fn all_card_ids(game: &GameState) -> Vec<u32> {
|
||||
let foundations = [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
];
|
||||
let tableaux = [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
];
|
||||
|
||||
let mut ids: Vec<u32> = game.stock_cards().iter().map(|c| c.id).collect();
|
||||
ids.extend(game.waste_cards().iter().map(|c| c.id));
|
||||
for f in &foundations {
|
||||
ids.extend(
|
||||
game.pile(KlondikePile::Foundation(*f))
|
||||
.iter()
|
||||
.map(|c| c.id),
|
||||
);
|
||||
}
|
||||
for t in &tableaux {
|
||||
ids.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|c| c.id));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
|
||||
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
|
||||
}
|
||||
|
||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||
///
|
||||
/// Each action is `(draw_flag, move_index)`:
|
||||
/// - `draw_flag = true` → call `game.draw()`
|
||||
/// - `draw_flag = false` → pick the `move_index % len`th legal move from
|
||||
/// `possible_instructions()` and execute it.
|
||||
///
|
||||
/// `possible_instructions()` may return `(Stock, Stock, 1)` for the
|
||||
/// RotateStock / draw action. `move_cards(Stock, Stock, 1)` is rejected by
|
||||
/// the `from == to` guard, so those are dispatched to `game.draw()`.
|
||||
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
|
||||
for &(do_draw, idx) in actions {
|
||||
if do_draw {
|
||||
let _ = game.draw();
|
||||
} else {
|
||||
let instructions = game.possible_instructions();
|
||||
if instructions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (from, to, count) = instructions[idx % instructions.len()];
|
||||
if from == to {
|
||||
let _ = game.draw();
|
||||
} else {
|
||||
let _ = game.move_cards(from, to, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one move from `possible_instructions()` (or a draw if no move is
|
||||
/// available), using `move_idx` to select among the legal options.
|
||||
/// Returns `true` when a move was successfully applied.
|
||||
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
|
||||
if game.is_won {
|
||||
return false;
|
||||
}
|
||||
let instructions = game.possible_instructions();
|
||||
if instructions.is_empty() {
|
||||
return game.draw().is_ok();
|
||||
}
|
||||
let (from, to, count) = instructions[move_idx % instructions.len()];
|
||||
if from == to {
|
||||
game.draw().is_ok()
|
||||
} else {
|
||||
game.move_cards(from, to, count).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Properties
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
proptest! {
|
||||
/// All 52 card IDs must be present exactly once across every pile after
|
||||
/// any reachable sequence of draw + move_cards actions.
|
||||
///
|
||||
/// Catches two bug classes at once:
|
||||
/// - Card loss (fewer than 52 unique IDs after the sequence).
|
||||
/// - Card duplication (52 total but deduplication reduces the set).
|
||||
#[test]
|
||||
fn all_52_cards_always_present(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
|
||||
let mut ids = all_card_ids(&game);
|
||||
prop_assert_eq!(ids.len(), 52, "card count ≠ 52 (got {})", ids.len());
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
prop_assert_eq!(
|
||||
ids.len(), 52,
|
||||
"duplicate card IDs found after dedup — a card was cloned"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GameState::new(seed, draw_mode)` must be deterministic: two calls
|
||||
/// with the same arguments must produce identical initial pile layouts.
|
||||
///
|
||||
/// Pins that the deal is seeded from `seed` alone and not from any
|
||||
/// implicit source like wall-clock time or global state.
|
||||
#[test]
|
||||
fn deal_is_deterministic(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
) {
|
||||
let a = GameState::new(seed, draw_mode);
|
||||
let b = GameState::new(seed, draw_mode);
|
||||
prop_assert_eq!(
|
||||
all_card_ids(&a),
|
||||
all_card_ids(&b),
|
||||
"same seed + draw_mode produced different deals",
|
||||
);
|
||||
}
|
||||
|
||||
/// After applying any single legal move and immediately undoing it, the
|
||||
/// pile layout and move_count must be identical to their pre-move values.
|
||||
///
|
||||
/// `setup_actions` drives the game to an arbitrary mid-game position;
|
||||
/// `move_idx` selects which legal move to apply and then undo.
|
||||
///
|
||||
/// The score is intentionally excluded: `undo()` applies a −15 penalty
|
||||
/// that is by design, not a regression.
|
||||
#[test]
|
||||
fn undo_restores_pile_layout_and_move_count(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
||||
move_idx in 0usize..200,
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
// Snapshot the state before the move.
|
||||
let before_ids = all_card_ids(&game);
|
||||
let before_move_count = game.move_count;
|
||||
|
||||
// Apply one move.
|
||||
if !apply_one_move(&mut game, move_idx) || game.is_won {
|
||||
return Ok(()); // nothing to undo
|
||||
}
|
||||
|
||||
// Undo and verify.
|
||||
prop_assert!(
|
||||
game.undo().is_ok(),
|
||||
"undo must succeed immediately after a successful move",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
all_card_ids(&game),
|
||||
before_ids,
|
||||
"pile layout after undo differs from the pre-move snapshot",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
game.move_count,
|
||||
before_move_count,
|
||||
"move_count after undo must equal the pre-move value",
|
||||
);
|
||||
}
|
||||
|
||||
/// Every move returned by `possible_instructions()` must succeed when
|
||||
/// applied via `move_cards()`.
|
||||
///
|
||||
/// `possible_instructions()` and `move_cards()` both validate moves
|
||||
/// through the same upstream rule engine. This property ensures no
|
||||
/// drift has opened up between what the engine reports as legal and
|
||||
/// what it actually accepts.
|
||||
#[test]
|
||||
fn legal_moves_always_succeed(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
for (from, to, count) in game.possible_instructions() {
|
||||
// Clone so each move is tried from the same starting state.
|
||||
let mut trial = game.clone();
|
||||
let result = if from == to {
|
||||
trial.draw()
|
||||
} else {
|
||||
trial.move_cards(from, to, count)
|
||||
};
|
||||
prop_assert!(
|
||||
result.is_ok(),
|
||||
"possible_instructions() reported ({from:?} → {to:?} ×{count}) \
|
||||
as legal but the call returned Err: {result:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::pile::Pile;
|
||||
|
||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||
///
|
||||
/// Foundation rules:
|
||||
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
||||
/// becomes the pile's claimed suit (derived from the bottom card via
|
||||
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||
/// - When the pile is non-empty, the next card must match the top card's
|
||||
/// suit and be exactly one rank higher.
|
||||
#[must_use]
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank == Rank::Ace,
|
||||
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
||||
///
|
||||
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
||||
#[must_use]
|
||||
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank == Rank::King,
|
||||
Some(top) => {
|
||||
top.face_up
|
||||
&& card.rank.checked_add(1) == Some(top.rank)
|
||||
&& card.suit.is_red() != top.suit.is_red()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `cards` is a legal tableau run on its own — every
|
||||
/// adjacent pair descends by one rank and alternates colour. A single
|
||||
/// card is trivially valid. The destination check is separate; this
|
||||
/// only validates the sequence's *internal* structure, which the tableau
|
||||
/// move path must enforce so a player can't smuggle an arbitrary stack
|
||||
/// onto another column when the bottom card happens to land legally.
|
||||
#[must_use]
|
||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||
cards.windows(2).all(|w| {
|
||||
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
use crate::pile::{Pile, PileType};
|
||||
|
||||
fn card(suit: Suit, rank: Rank) -> Card {
|
||||
Card {
|
||||
id: 0,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
|
||||
Pile { pile_type, cards }
|
||||
}
|
||||
|
||||
// Foundation tests
|
||||
#[test]
|
||||
fn foundation_ace_on_empty_is_valid() {
|
||||
// Every suit's Ace must land on an empty foundation slot regardless of
|
||||
// its slot index; the slot claims the suit only after the Ace lands.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let c = card(suit, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(
|
||||
can_place_on_foundation(&c, &p),
|
||||
"Ace of {suit:?} must land on empty slot 0",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_non_ace_on_empty_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Two);
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_two_on_ace_same_suit_is_valid() {
|
||||
let c = card(Suit::Clubs, Rank::Two);
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_second_card_must_match_claimed_suit() {
|
||||
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
||||
// because the slot's claimed suit is Hearts after the Ace lands.
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
|
||||
let c = card(Suit::Spades, Rank::Two);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_skipping_rank_is_invalid() {
|
||||
let c = card(Suit::Diamonds, Rank::Three);
|
||||
let p = pile_with(
|
||||
PileType::Foundation(0),
|
||||
vec![card(Suit::Diamonds, Rank::Ace)],
|
||||
);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
// Tableau tests
|
||||
#[test]
|
||||
fn tableau_king_on_empty_is_valid() {
|
||||
let c = card(Suit::Hearts, Rank::King);
|
||||
let p = Pile::new(PileType::Tableau(0));
|
||||
assert!(can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_non_king_on_empty_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Queen);
|
||||
let p = Pile::new(PileType::Tableau(0));
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_red_on_black_one_lower_is_valid() {
|
||||
let c = card(Suit::Hearts, Rank::Nine);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||
assert!(can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_same_color_is_invalid() {
|
||||
let c = card(Suit::Clubs, Rank::Nine);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_wrong_rank_difference_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Eight);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_black_on_red_one_lower_is_valid() {
|
||||
let c = card(Suit::Clubs, Rank::Six);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
|
||||
assert!(can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_king_on_queen_completes_suit() {
|
||||
// The last card placed to complete a foundation is always King on Queen.
|
||||
let c = card(Suit::Spades, Rank::King);
|
||||
let p = pile_with(
|
||||
PileType::Foundation(0),
|
||||
vec![card(Suit::Spades, Rank::Queen)],
|
||||
);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_king_wrong_suit_is_invalid() {
|
||||
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
|
||||
let c = card(Suit::Hearts, Rank::King);
|
||||
let p = pile_with(
|
||||
PileType::Foundation(0),
|
||||
vec![card(Suit::Spades, Rank::Queen)],
|
||||
);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_ace_on_two_different_color_is_valid() {
|
||||
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
|
||||
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
|
||||
assert!(can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_same_rank_different_color_is_invalid() {
|
||||
// Two cards of the same rank cannot be stacked regardless of colour.
|
||||
let c = card(Suit::Hearts, Rank::Nine);
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_face_down_destination_top_is_invalid() {
|
||||
// A face-down top card must never be a valid placement target.
|
||||
let c = card(Suit::Hearts, Rank::Nine);
|
||||
let mut top = card(Suit::Spades, Rank::Ten);
|
||||
top.face_up = false;
|
||||
let p = pile_with(PileType::Tableau(0), vec![top]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_sequence_validation() {
|
||||
// Single card is trivially a valid sequence.
|
||||
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
|
||||
// Valid descending alternating-colour run K♠ Q♥ J♣.
|
||||
assert!(is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Hearts, Rank::Queen),
|
||||
card(Suit::Clubs, Rank::Jack),
|
||||
]));
|
||||
// Same colour twice (Q♠ on K♠) — invalid.
|
||||
assert!(!is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Spades, Rank::Queen),
|
||||
]));
|
||||
// Rank gap (K♠ → J♥) — invalid.
|
||||
assert!(!is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Hearts, Rank::Jack),
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
use crate::pile::PileType;
|
||||
|
||||
/// Score delta for moving cards from `from` to `to`.
|
||||
///
|
||||
/// Windows XP Standard scoring:
|
||||
/// - +10 for any card reaching a foundation pile
|
||||
/// - +5 for a waste → tableau move
|
||||
/// - -15 for a foundation → tableau (take-from-foundation) move
|
||||
/// - 0 for all other moves
|
||||
///
|
||||
/// Note: the +5 flip bonus for exposing a face-down tableau card is applied
|
||||
/// separately in `game_state::move_cards` because it depends on post-move state.
|
||||
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
|
||||
match to {
|
||||
PileType::Foundation(_) => 10,
|
||||
PileType::Tableau(_) => match from {
|
||||
PileType::Waste => 5,
|
||||
PileType::Foundation(_) => -15,
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Score penalty applied when the player uses undo: -15.
|
||||
pub fn score_undo() -> i32 {
|
||||
-15
|
||||
}
|
||||
|
||||
/// Score bonus awarded when a face-down tableau card is flipped face-up: +5.
|
||||
pub fn score_flip() -> i32 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Score penalty for recycling the waste pile back to stock.
|
||||
///
|
||||
/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3).
|
||||
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
|
||||
/// `recycle_count` is the new total count **after** this recycle.
|
||||
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||
let (free, penalty) = if is_draw_three {
|
||||
(3_u32, -20_i32)
|
||||
} else {
|
||||
(1_u32, -100_i32)
|
||||
};
|
||||
if recycle_count > free { penalty } else { 0 }
|
||||
}
|
||||
|
||||
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
if elapsed_seconds == 0 {
|
||||
return 0;
|
||||
}
|
||||
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn move_to_foundation_scores_ten() {
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
||||
assert_eq!(
|
||||
score_move(&PileType::Tableau(0), &PileType::Foundation(0)),
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waste_to_tableau_scores_five() {
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Tableau(3)), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_to_tableau_scores_zero() {
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Tableau(1)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_penalty_is_negative_fifteen() {
|
||||
assert_eq!(score_undo(), -15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bonus_at_100_seconds() {
|
||||
assert_eq!(compute_time_bonus(100), 7000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bonus_at_zero_is_zero() {
|
||||
assert_eq!(compute_time_bonus(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bonus_at_one_second() {
|
||||
assert_eq!(compute_time_bonus(1), 700_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_to_tableau_penalises_fifteen() {
|
||||
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
|
||||
assert_eq!(
|
||||
score_move(&PileType::Foundation(0), &PileType::Tableau(0)),
|
||||
-15
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_to_stock_or_waste_scores_zero() {
|
||||
// These destinations are illegal moves in practice, but the function
|
||||
// must not panic and should return 0.
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Stock), 0);
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Waste), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
||||
// Very short elapsed time would overflow without the .min() guard.
|
||||
let bonus = compute_time_bonus(1);
|
||||
assert!(
|
||||
bonus >= 0,
|
||||
"time bonus must be non-negative after u64→i32 cast"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_bonus_is_five() {
|
||||
assert_eq!(score_flip(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw1_first_pass_free() {
|
||||
assert_eq!(score_recycle(1, false), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw1_second_pass_penalised() {
|
||||
assert_eq!(score_recycle(2, false), -100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw3_third_pass_free() {
|
||||
assert_eq!(score_recycle(3, true), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw3_fourth_pass_penalised() {
|
||||
assert_eq!(score_recycle(4, true), -20);
|
||||
}
|
||||
}
|
||||
+184
-1285
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,16 @@ serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# These deps are not available / not needed on wasm32:
|
||||
# dirs — platform data directories (no filesystem on browser)
|
||||
# reqwest — native HTTP client (sync/analytics gated out on wasm32)
|
||||
# tokio — OS-threaded async runtime (mio doesn't compile on wasm32)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
dirs = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# `keyring-core` is the typed Entry/Error API used by
|
||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||
@@ -24,15 +30,11 @@ uuid = { workspace = true }
|
||||
# on bionic). On Android `auth_tokens` falls back to a stub
|
||||
# implementation that always returns `KeychainUnavailable`; the
|
||||
# real backend lands when we wire Android Keystore via JNI.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
|
||||
keyring-core = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = { workspace = true }
|
||||
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
|
||||
# process-wide JavaVM handle for JNI. Must be listed here so the
|
||||
# symbol resolves when cross-compiling for Android targets.
|
||||
bevy = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
solitaire_server = { path = "../solitaire_server" }
|
||||
|
||||
@@ -146,13 +146,17 @@ pub use settings::{
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_keystore;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod auth_tokens;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use auth_tokens::{
|
||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
||||
};
|
||||
|
||||
pub mod sync_client;
|
||||
pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend};
|
||||
pub use sync_client::LocalOnlyProvider;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
||||
|
||||
pub mod replay;
|
||||
pub use replay::{
|
||||
@@ -163,7 +167,9 @@ pub use replay::{
|
||||
#[allow(deprecated)]
|
||||
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod matomo_client;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use matomo_client::MatomoClient;
|
||||
|
||||
pub mod platform;
|
||||
|
||||
@@ -55,7 +55,15 @@ pub fn data_dir() -> Option<PathBuf> {
|
||||
{
|
||||
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// No filesystem on the browser; all persistence goes through
|
||||
// WasmStorage (localStorage-backed). Return None so every caller
|
||||
// degrades gracefully (the same path they take on a
|
||||
// misconfigured desktop environment).
|
||||
None
|
||||
}
|
||||
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
|
||||
{
|
||||
dirs::data_dir()
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
@@ -96,9 +96,9 @@ pub enum ReplayMove {
|
||||
/// A successful `move_cards(from, to, count)` call.
|
||||
Move {
|
||||
/// Source pile.
|
||||
from: PileType,
|
||||
from: SavedKlondikePile,
|
||||
/// Destination pile.
|
||||
to: PileType,
|
||||
to: SavedKlondikePile,
|
||||
/// Number of cards moved.
|
||||
count: usize,
|
||||
},
|
||||
@@ -442,6 +442,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
#[allow(deprecated)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
@@ -460,14 +461,14 @@ mod tests {
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Tableau(3),
|
||||
from: SavedKlondikePile::Stock,
|
||||
to: SavedKlondikePile::Tableau(SavedTableau(3)),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Tableau(3),
|
||||
to: PileType::Foundation(0),
|
||||
from: SavedKlondikePile::Tableau(SavedTableau(3)),
|
||||
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
||||
//! loss during a write never corrupts the saved data.
|
||||
|
||||
use chrono::Utc;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
||||
@@ -234,9 +234,7 @@ pub fn load_time_attack_session_from_at(
|
||||
/// See [`load_time_attack_session_from_at`] for the rules under which
|
||||
/// the call returns `None` (missing file, corrupt JSON, expired window).
|
||||
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let now = Utc::now().timestamp().max(0) as u64;
|
||||
load_time_attack_session_from_at(path, now)
|
||||
}
|
||||
|
||||
@@ -254,9 +252,7 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
||||
/// current wall-clock time. Equivalent to constructing the struct
|
||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let now = Utc::now().timestamp().max(0) as u64;
|
||||
TimeAttackSession {
|
||||
remaining_secs,
|
||||
wins,
|
||||
@@ -426,23 +422,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_game_state_ignores_won_games() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
let path = gs_path("won_load");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Write a won game directly (bypassing save_game_state_to's guard).
|
||||
let mut gs = GameState::new(77, DrawMode::DrawOne);
|
||||
gs.is_won = true;
|
||||
let json = serde_json::to_string_pretty(&gs).unwrap();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes()).unwrap();
|
||||
fs::rename(&tmp, &path).unwrap();
|
||||
|
||||
assert!(load_game_state_from(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_game_state_removes_file() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
|
||||
use solitaire_sync::{SyncPayload, SyncResponse};
|
||||
|
||||
use crate::{SyncError, SyncProvider};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
SyncError, SyncProvider,
|
||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||
replay::Replay,
|
||||
settings::SyncBackend,
|
||||
@@ -54,12 +58,17 @@ impl SyncProvider for LocalOnlyProvider {
|
||||
// ---------------------------------------------------------------------------
|
||||
// SolitaireServerClient
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native-only: HTTP sync client and factory function.
|
||||
// On wasm32 these are gated out because reqwest uses native OS networking
|
||||
// (mio + hyper) which does not compile for wasm32-unknown-unknown.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
|
||||
///
|
||||
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
||||
/// client automatically attempts a token refresh and retries the request once
|
||||
/// before returning an error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct SolitaireServerClient {
|
||||
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||
/// Trailing slashes are stripped on construction.
|
||||
@@ -70,6 +79,7 @@ pub struct SolitaireServerClient {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl SolitaireServerClient {
|
||||
/// Construct a new client for the given server URL and username.
|
||||
///
|
||||
@@ -201,6 +211,7 @@ impl SolitaireServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[async_trait]
|
||||
impl SyncProvider for SolitaireServerClient {
|
||||
/// Fetch the latest sync payload from the server.
|
||||
@@ -486,6 +497,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl SolitaireServerClient {
|
||||
/// Pulled out of `push_replay` so both the first attempt and the
|
||||
/// post-401-retry attempt go through the same parse path.
|
||||
@@ -581,9 +593,10 @@ impl SolitaireServerClient {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response extraction helpers
|
||||
// Response extraction helpers (native-only, use reqwest::Response)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
||||
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
||||
///
|
||||
@@ -607,6 +620,7 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
|
||||
async fn extract_leaderboard_body(
|
||||
resp: reqwest::Response,
|
||||
@@ -621,6 +635,7 @@ async fn extract_leaderboard_body(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
||||
/// statuses to the appropriate [`SyncError`].
|
||||
///
|
||||
@@ -652,6 +667,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
|
||||
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
||||
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
||||
/// and remains backend-agnostic.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||
match backend {
|
||||
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||
|
||||
+16
-11
@@ -7,14 +7,11 @@ edition.workspace = true
|
||||
[dependencies]
|
||||
bevy = { workspace = true }
|
||||
image = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
kira = { workspace = true }
|
||||
solitaire_core = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
solitaire_sync = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -22,17 +19,24 @@ usvg = { workspace = true }
|
||||
resvg = { workspace = true }
|
||||
tiny-skia = { workspace = true }
|
||||
ron = { workspace = true }
|
||||
|
||||
# These deps are not available / not needed on wasm32:
|
||||
# reqwest — uses mio/hyper native networking (sync plugin is gated out)
|
||||
# kira — uses cpal OS audio (audio plugin is gated out)
|
||||
# tokio — multi-threaded runtime (TokioRuntimeResource is gated out)
|
||||
# dirs — platform data directories (storage uses WasmStorage instead)
|
||||
# zip — theme ZIP importer (importer is gated out on wasm32)
|
||||
# arboard — clipboard (no wasm backend; stats copy-link uses localStorage)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
reqwest = { workspace = true }
|
||||
kira = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
# `arboard` provides clipboard access for the Stats overlay's
|
||||
# "Copy share link" button. The crate has no Android backend
|
||||
# (its `platform::Clipboard` module is unimplemented for the
|
||||
# android target — `cargo apk build` fails with E0433 if this is
|
||||
# left unconditional). On Android the same button surfaces an
|
||||
# informational toast instead; see
|
||||
# `stats_plugin::handle_copy_share_link_button`.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
# `arboard` has no Android backend and no wasm32 backend. Gate it out for
|
||||
# both; the copy-share-link button surfaces an informational toast instead.
|
||||
[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
|
||||
arboard = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
@@ -47,3 +51,4 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
solitaire_core = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
use solitaire_data::{AnimSpeed, Settings};
|
||||
|
||||
use crate::achievement_plugin::display_name_for;
|
||||
@@ -180,6 +181,7 @@ impl Plugin for AnimationPlugin {
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.init_resource::<EffectiveSlideDuration>()
|
||||
.init_resource::<ToastQueue>()
|
||||
.init_resource::<ActiveToast>()
|
||||
@@ -1076,7 +1078,7 @@ mod tests {
|
||||
// Pairs the existing audio (`card_invalid.wav`) and visual
|
||||
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
|
||||
// with an accessibility-focused readable text cue.
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||
|
||||
@@ -1088,8 +1090,8 @@ mod tests {
|
||||
.count();
|
||||
|
||||
app.world_mut().write_message(MoveRejectedEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Tableau(1),
|
||||
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
@@ -47,12 +47,16 @@
|
||||
//! comments on each call out the pairing so a future reader doesn't
|
||||
//! accidentally drop one half.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::asset::AssetApp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::asset::io::AssetSourceBuilder;
|
||||
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::asset::io::file::FileAssetReader;
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::assets::user_dir::user_theme_dir;
|
||||
|
||||
/// `AssetSourceId` of the user-themes asset source. Use it as
|
||||
@@ -235,11 +239,16 @@ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
/// Returns the `&mut App` so the call can be chained from the binary
|
||||
/// entry point.
|
||||
pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
|
||||
let root = user_theme_dir();
|
||||
app.register_asset_source(
|
||||
USER_THEMES,
|
||||
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
||||
);
|
||||
// User themes are stored on the filesystem; wasm32 has no filesystem and
|
||||
// `FileAssetReader` is not available on that target.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let root = user_theme_dir();
|
||||
app.register_asset_source(
|
||||
USER_THEMES,
|
||||
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
||||
);
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
|
||||
@@ -82,13 +82,23 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
||||
/// the panic message names the supported workaround.
|
||||
fn detected_platform_data_dir() -> PathBuf {
|
||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"user_theme_dir(): platform data directory is unavailable. \
|
||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||
the OS reported no Application Support / AppData path. \
|
||||
As a workaround call solitaire_engine::assets::user_dir::\
|
||||
set_user_theme_dir() before App::run()."
|
||||
)
|
||||
// On wasm32, data_dir() always returns None — there is no filesystem.
|
||||
// User themes are not supported in the browser build; return an empty
|
||||
// path so callers produce a benign empty dir rather than panicking.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
PathBuf::new()
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
panic!(
|
||||
"user_theme_dir(): platform data directory is unavailable. \
|
||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||
the OS reported no Application Support / AppData path. \
|
||||
As a workaround call solitaire_engine::assets::user_dir::\
|
||||
set_user_theme_dir() before App::run()."
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ use crate::events::{
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||
const RECYCLE_VOLUME: f64 = 0.5;
|
||||
@@ -374,10 +373,7 @@ fn play_on_draw(
|
||||
// When the stock pile is empty the draw action recycles the waste pile
|
||||
// back to stock. Play the flip sound at half volume to give audible
|
||||
// feedback that distinguishes a recycle from a normal draw.
|
||||
let stock_len = game
|
||||
.as_ref()
|
||||
.and_then(|g| g.0.piles.get(&PileType::Stock))
|
||||
.map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound
|
||||
let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
|
||||
|
||||
if is_recycle(stock_len) {
|
||||
let mut data = lib.flip.clone();
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
@@ -20,11 +22,18 @@ use crate::resources::GameStateResource;
|
||||
///
|
||||
/// Plays the win fanfare at half volume so it is clearly distinguishable from
|
||||
/// both normal card-place sounds and the full win fanfare that fires later.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Seconds between consecutive auto-complete moves.
|
||||
const STEP_INTERVAL: f32 = 0.12;
|
||||
|
||||
/// Seconds to wait after detection before firing the first auto-complete move.
|
||||
///
|
||||
/// This pause gives the player a moment to register that the game is
|
||||
/// transitioning into auto-complete mode before cards start moving.
|
||||
const AUTO_COMPLETE_INITIAL_DELAY: f32 = 0.75;
|
||||
|
||||
/// Tracks whether auto-complete is active and when the next move fires.
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct AutoCompleteState {
|
||||
@@ -39,16 +48,18 @@ pub struct AutoCompletePlugin;
|
||||
|
||||
impl Plugin for AutoCompletePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<AutoCompleteState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
detect_auto_complete,
|
||||
on_auto_complete_start,
|
||||
drive_auto_complete,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
app.init_resource::<AutoCompleteState>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
detect_auto_complete,
|
||||
on_auto_complete_start,
|
||||
drive_auto_complete,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,15 +82,22 @@ fn detect_auto_complete(
|
||||
}
|
||||
if game.0.is_auto_completable && !state.active {
|
||||
state.active = true;
|
||||
state.cooldown = 0.0; // fire first move immediately
|
||||
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
||||
} else if !game.0.is_auto_completable && state.active {
|
||||
// `is_auto_completable` only becomes false after an explicit undo
|
||||
// (which puts a card back on the tableau or re-fills the stock/waste)
|
||||
// or a new-game reset — never as a transient gap during a normal
|
||||
// auto-complete sequence. Deactivate here so `drive_auto_complete`
|
||||
// does not keep retrying indefinitely after the player undoes out of
|
||||
// the sequence.
|
||||
//
|
||||
// Note: the transient-`None` case mentioned in older versions of this
|
||||
// comment referred to `next_auto_complete_move()` returning `None`, not
|
||||
// to `is_auto_completable` being false. Those are independent fields;
|
||||
// `drive_auto_complete` still retries on a transient `None` return from
|
||||
// `next_auto_complete_move` because that check happens there, not here.
|
||||
state.active = false;
|
||||
}
|
||||
// Intentionally no `else if !is_auto_completable` branch here.
|
||||
// Deactivating on every frame where `is_auto_completable` is false
|
||||
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
|
||||
// transiently returns `None` (e.g. while the previous move is still
|
||||
// in-flight). The `is_won` check above already handles the definitive
|
||||
// end-of-game case; `drive_auto_complete` simply retries next tick
|
||||
// when no move is available yet.
|
||||
}
|
||||
|
||||
/// Plays a distinct chime the moment auto-complete first activates.
|
||||
@@ -88,6 +106,7 @@ fn detect_auto_complete(
|
||||
/// exactly once on the `false → true` edge. The win fanfare is played at half
|
||||
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
|
||||
/// not overwhelm the card-place sounds that follow immediately.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn on_auto_complete_start(
|
||||
state: Res<AutoCompleteState>,
|
||||
mut was_active: Local<bool>,
|
||||
@@ -108,6 +127,12 @@ fn on_auto_complete_start(
|
||||
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
|
||||
}
|
||||
|
||||
// No audio on wasm — stub keeps the system registration unconditional.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn on_auto_complete_start(state: Res<AutoCompleteState>, mut was_active: Local<bool>) {
|
||||
*was_active = state.active;
|
||||
}
|
||||
|
||||
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
||||
fn drive_auto_complete(
|
||||
mut state: ResMut<AutoCompleteState>,
|
||||
@@ -142,9 +167,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -157,31 +182,45 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
/// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other
|
||||
/// tableau piles empty, stock/waste empty, Clubs foundation empty.
|
||||
fn nearly_won_state() -> GameState {
|
||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
||||
let mut g = GameState::new(1, DrawMode::DrawOne);
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
g.set_test_foundation_cards(foundation, Vec::new());
|
||||
}
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 99,
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
g.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![solitaire_core::card::Card {
|
||||
id: 7_001,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
}],
|
||||
);
|
||||
g.is_auto_completable = true;
|
||||
g
|
||||
let expected = (
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
);
|
||||
assert_eq!(g.next_auto_complete_move(), Some(expected));
|
||||
(g, expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,8 +232,9 @@ mod tests {
|
||||
#[test]
|
||||
fn detect_activates_when_auto_completable() {
|
||||
let mut app = headless_app();
|
||||
// Install a nearly-won state and fire StateChangedEvent.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||
g.is_auto_completable = true;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
@@ -204,9 +244,14 @@ mod tests {
|
||||
#[test]
|
||||
fn drive_fires_move_request_when_active() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update(); // detect runs, sets active
|
||||
|
||||
// Zero out the cooldown so drive fires on the next update regardless
|
||||
// of the initial delay constant.
|
||||
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
|
||||
app.update(); // drive fires the move
|
||||
|
||||
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||
@@ -214,16 +259,15 @@ mod tests {
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
// At least one MoveRequestEvent should have been fired.
|
||||
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
||||
assert_eq!(fired[0].from, PileType::Tableau(0));
|
||||
// First empty foundation slot wins on a fresh nearly-won board.
|
||||
assert_eq!(fired[0].to, PileType::Foundation(0));
|
||||
assert_eq!(fired[0].from, expected_from);
|
||||
assert_eq!(fired[0].to, expected_to);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_deactivates_on_win() {
|
||||
let mut app = headless_app();
|
||||
// Inject a won game state — active should not be set.
|
||||
let mut gs = nearly_won_state();
|
||||
let (mut gs, _) = seeded_state_with_auto_move();
|
||||
gs.is_won = true;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
|
||||
@@ -92,6 +92,7 @@ pub use timing::{
|
||||
pub use tuning::{AnimationTuning, InputPlatform};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||
@@ -125,6 +126,7 @@ impl Plugin for CardAnimationPlugin {
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.init_resource::<DragState>()
|
||||
.init_resource::<HoverState>()
|
||||
.init_resource::<InputBuffer>()
|
||||
|
||||
@@ -100,7 +100,7 @@ impl AnimationTuning {
|
||||
platform: InputPlatform::Mouse,
|
||||
duration_scale: 1.0,
|
||||
overshoot_scale: 1.0,
|
||||
drag_threshold_px: 4.0,
|
||||
drag_threshold_px: 6.0,
|
||||
drag_scale: 1.08,
|
||||
hover_scale: 1.04,
|
||||
hover_lerp_speed: 14.0,
|
||||
|
||||
+165
-148
@@ -16,11 +16,9 @@ use bevy::color::Color;
|
||||
use bevy::prelude::*;
|
||||
use bevy::sprite::Anchor;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||
use crate::card_animation::CardAnimation;
|
||||
@@ -232,7 +230,7 @@ pub struct StockEmptyLabel;
|
||||
/// The badge is spawned as a *top-level* world entity (not parented to the
|
||||
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
|
||||
/// `LayoutResource` so it tracks the stock pile through window resizes.
|
||||
/// The chip sits in the top-right corner of the stock pile and is hidden
|
||||
/// The chip sits in the bottom-right corner of the stock pile and is hidden
|
||||
/// while the stock is empty — the existing `↺` overlay
|
||||
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
|
||||
/// indicators never render simultaneously.
|
||||
@@ -303,13 +301,18 @@ pub struct CardShadow;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardBackFrame;
|
||||
|
||||
/// Fill colour for the face-down card border frame. Medium gray so it reads as
|
||||
/// a neutral "edge" without competing with the suit colours on face-up cards.
|
||||
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.38, 0.38, 0.38);
|
||||
/// Fill colour for the face-down card border frame. Light-medium gray so it
|
||||
/// reads as a clear "edge" without competing with the suit colours on face-up
|
||||
/// cards. Brightened from `0.38` to `0.48` (≈ #7a7a7a) after a Pixel_7 smoke
|
||||
/// test showed face-down `back_0.png` (≈ #1a1a1a) was nearly invisible against
|
||||
/// the very dark `#151515` felt — the old gray was too close to the back fill
|
||||
/// to define a crisp perimeter.
|
||||
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.48, 0.48, 0.48);
|
||||
|
||||
/// Extra width/height (in world units) added to each side of the card to form
|
||||
/// the visible border. 3 world units ≈ 3 dp on a 1× screen.
|
||||
const CARD_BACK_FRAME_PADDING: f32 = 3.0;
|
||||
/// the visible border. Widened from `3.0` to `6.0` so the frame peeks out as a
|
||||
/// clearly readable perimeter at phone density (420 dpi) rather than a hairline.
|
||||
const CARD_BACK_FRAME_PADDING: f32 = 6.0;
|
||||
|
||||
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||
/// shadow given whether its parent card is currently part of the dragged
|
||||
@@ -734,10 +737,10 @@ fn sync_cards(
|
||||
DrawMode::DrawOne => 1_usize,
|
||||
DrawMode::DrawThree => 3_usize,
|
||||
};
|
||||
game.piles
|
||||
.get(&PileType::Waste)
|
||||
.filter(|w| w.cards.len() > visible)
|
||||
.and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1)))
|
||||
let waste_cards = game.waste_cards();
|
||||
(waste_cards.len() > visible)
|
||||
.then_some(waste_cards)
|
||||
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
|
||||
.map(|c| c.id)
|
||||
};
|
||||
|
||||
@@ -790,7 +793,7 @@ fn sync_cards(
|
||||
update_card_entity(
|
||||
&mut commands,
|
||||
entity,
|
||||
card,
|
||||
&card,
|
||||
position,
|
||||
z,
|
||||
layout,
|
||||
@@ -808,7 +811,7 @@ fn sync_cards(
|
||||
}
|
||||
None => spawn_card_entity(
|
||||
&mut commands,
|
||||
card,
|
||||
&card,
|
||||
position,
|
||||
z,
|
||||
layout,
|
||||
@@ -830,22 +833,22 @@ fn sync_cards(
|
||||
}
|
||||
|
||||
/// Returns an ordered vec of (card, position, z) for every card in the game.
|
||||
fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> {
|
||||
let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52);
|
||||
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
|
||||
let piles = [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
(KlondikePile::Stock, true),
|
||||
(KlondikePile::Stock, false),
|
||||
(KlondikePile::Foundation(Foundation::Foundation1), false),
|
||||
(KlondikePile::Foundation(Foundation::Foundation2), false),
|
||||
(KlondikePile::Foundation(Foundation::Foundation3), false),
|
||||
(KlondikePile::Foundation(Foundation::Foundation4), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau1), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau2), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau3), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau4), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau5), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau6), false),
|
||||
(KlondikePile::Tableau(Tableau::Tableau7), false),
|
||||
];
|
||||
|
||||
// Compute the Draw-Three waste fan step proportional to the column spacing
|
||||
@@ -855,29 +858,39 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
|
||||
// the top fanned card's centre within the waste column's own horizontal
|
||||
// footprint instead of spilling into the adjacent gap.
|
||||
let waste_fan_step = {
|
||||
let s = layout
|
||||
let tableau_col_step = {
|
||||
let t1 = layout
|
||||
.pile_positions
|
||||
.get(&PileType::Stock)
|
||||
.get(&KlondikePile::Tableau(Tableau::Tableau1))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let w = layout
|
||||
let t2 = layout
|
||||
.pile_positions
|
||||
.get(&PileType::Waste)
|
||||
.get(&KlondikePile::Tableau(Tableau::Tableau2))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
(w.x - s.x).abs() * 0.224
|
||||
(t2.x - t1.x).abs()
|
||||
};
|
||||
let waste_fan_step = tableau_col_step * 0.224;
|
||||
|
||||
for pile_type in piles {
|
||||
let Some(base) = layout.pile_positions.get(&pile_type) else {
|
||||
for (pile_type, is_stock_area) in piles {
|
||||
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
|
||||
continue;
|
||||
};
|
||||
let Some(pile) = game.piles.get(&pile_type) else {
|
||||
continue;
|
||||
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
|
||||
base.x -= tableau_col_step;
|
||||
}
|
||||
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
|
||||
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
|
||||
let cards = if matches!(pile_type, KlondikePile::Stock) {
|
||||
if is_stock_area {
|
||||
game.stock_cards()
|
||||
} else {
|
||||
game.waste_cards()
|
||||
}
|
||||
} else {
|
||||
game.pile(pile_type)
|
||||
};
|
||||
let is_tableau = matches!(pile_type, PileType::Tableau(_));
|
||||
let is_waste = matches!(pile_type, PileType::Waste);
|
||||
|
||||
// Tableau uses a two-speed fan: face-down cards are packed tighter
|
||||
// than face-up cards so the visible (playable) portion stands out.
|
||||
@@ -886,7 +899,6 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
// Waste pile: only the top N cards are rendered to prevent bleed-through
|
||||
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
|
||||
// shows up to 3 fanned in X (matching the standard Klondike presentation).
|
||||
let cards = &pile.cards;
|
||||
let render_start = if is_waste {
|
||||
let visible = match game.draw_mode {
|
||||
DrawMode::DrawOne => 1_usize,
|
||||
@@ -916,7 +928,7 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
};
|
||||
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
|
||||
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
|
||||
out.push((card, pos, z));
|
||||
out.push((card.clone(), pos, z));
|
||||
if is_tableau {
|
||||
let step = if card.face_up {
|
||||
layout.tableau_fan_frac
|
||||
@@ -930,6 +942,32 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
out
|
||||
}
|
||||
|
||||
fn all_cards(game: &GameState) -> Vec<Card> {
|
||||
let mut cards = Vec::with_capacity(52);
|
||||
cards.extend(game.stock_cards());
|
||||
cards.extend(game.waste_cards());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
|
||||
}
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
|
||||
}
|
||||
cards
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_card_entity(
|
||||
commands: &mut Commands,
|
||||
@@ -1508,11 +1546,8 @@ fn tick_hint_highlight(
|
||||
sprite.color = if use_images {
|
||||
Color::WHITE
|
||||
} else {
|
||||
let is_face_up = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
let is_face_up = all_cards(&game.0)
|
||||
.iter()
|
||||
.find(|c| c.id == card_entity.card_id)
|
||||
.is_some_and(|c| c.face_up);
|
||||
if is_face_up {
|
||||
@@ -1683,17 +1718,13 @@ fn handle_right_click(
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(source_pile) = game.0.pile_containing_card(card.id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Tint piles that legally accept the card.
|
||||
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
||||
let pile_type = &pile_marker.0;
|
||||
let Some(pile) = game.0.piles.get(pile_type) else {
|
||||
continue;
|
||||
};
|
||||
let legal = match pile_type {
|
||||
PileType::Foundation(_) => can_place_on_foundation(&card, pile),
|
||||
PileType::Tableau(_) => can_place_on_tableau(&card, pile),
|
||||
_ => false,
|
||||
};
|
||||
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
|
||||
if legal {
|
||||
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
||||
commands
|
||||
@@ -1735,12 +1766,9 @@ fn find_top_card_at(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let card = game
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == card_entity.card_id && c.face_up)
|
||||
.cloned();
|
||||
let card = all_cards(game)
|
||||
.into_iter()
|
||||
.find(|c| c.id == card_entity.card_id && c.face_up);
|
||||
if let Some(card) = card {
|
||||
let z = transform.translation.z;
|
||||
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
||||
@@ -1782,13 +1810,10 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||
layout: &Layout,
|
||||
font: Handle<Font>,
|
||||
) {
|
||||
let stock_empty = game
|
||||
.piles
|
||||
.get(&PileType::Stock)
|
||||
.is_none_or(|p| p.cards.is_empty());
|
||||
let stock_empty = game.stock_cards().is_empty();
|
||||
|
||||
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
|
||||
if pile_marker.0 != PileType::Stock {
|
||||
if pile_marker.0 != KlondikePile::Stock {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1881,19 +1906,22 @@ fn update_stock_empty_indicator(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock-pile remaining-count badge
|
||||
//
|
||||
// Shows a small "N" chip pinned to the top-right corner of the stock pile so
|
||||
// Shows a small "N" chip pinned to the bottom-right corner of the stock pile so
|
||||
// the player can see how many cards remain before the next recycle. The
|
||||
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
|
||||
// the badge hides itself when the stock has zero cards — the two indicators
|
||||
// never render at the same time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
|
||||
/// the centre of the count badge. Must satisfy `|x| >= STOCK_BADGE_SIZE.x / 2`
|
||||
/// so the badge right edge stays inside the stock pile and never overlaps the
|
||||
/// adjacent waste pile — critical on Android where `H_GAP_DIVISOR = 32` gives
|
||||
/// an inter-pile gap of only ~4 px.
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-20.0, -8.0);
|
||||
/// Inset (in pixels) from the bottom-right corner of the stock pile sprite to
|
||||
/// the centre of the count badge. Anchoring to the bottom-right keeps the chip
|
||||
/// clear of the rank/suit pip in the card's top-left corner. Both components
|
||||
/// move the centre *inward* from that corner: `x` is subtracted from the right
|
||||
/// edge, `y` is added to the bottom edge. The `x` magnitude must satisfy
|
||||
/// `x >= STOCK_BADGE_SIZE.x / 2` so the badge right edge stays inside the stock
|
||||
/// pile and never overlaps the adjacent waste pile — critical on Android where
|
||||
/// `H_GAP_DIVISOR = 32` gives an inter-pile gap of only ~4 px.
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(20.0, 8.0);
|
||||
|
||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||
@@ -1904,25 +1932,25 @@ const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
|
||||
/// Pure helper extracted so the count source is identical between the spawn
|
||||
/// system, the update system, and the unit tests.
|
||||
fn stock_card_count(game: &GameState) -> usize {
|
||||
game.piles
|
||||
.get(&PileType::Stock)
|
||||
.map_or(0, |p| p.cards.len())
|
||||
game.stock_cards().len()
|
||||
}
|
||||
|
||||
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
|
||||
/// given the current `Layout`. The badge sits at the top-right corner of
|
||||
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`].
|
||||
/// given the current `Layout`. The badge sits at the bottom-right corner of
|
||||
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`], so it stays clear of
|
||||
/// the rank/suit pip in the card's top-left corner.
|
||||
fn stock_badge_translation(layout: &Layout) -> Vec3 {
|
||||
// Empty layouts don't contain a Stock entry — fall back to origin so
|
||||
// the badge stays in a deterministic spot until the layout is filled.
|
||||
let pile_pos = layout
|
||||
.pile_positions
|
||||
.get(&PileType::Stock)
|
||||
.get(&KlondikePile::Stock)
|
||||
.copied()
|
||||
.unwrap_or(Vec2::ZERO);
|
||||
let half = layout.card_size * 0.5;
|
||||
let x = pile_pos.x + half.x + STOCK_BADGE_INSET.x;
|
||||
let y = pile_pos.y + half.y + STOCK_BADGE_INSET.y;
|
||||
// Anchor to the bottom-right corner, then move the centre inward.
|
||||
let x = pile_pos.x + half.x - STOCK_BADGE_INSET.x;
|
||||
let y = pile_pos.y - half.y + STOCK_BADGE_INSET.y;
|
||||
Vec3::new(x, y, Z_STOCK_BADGE)
|
||||
}
|
||||
|
||||
@@ -2327,15 +2355,25 @@ fn update_tableau_fan_frac(
|
||||
return;
|
||||
};
|
||||
|
||||
let max_depth = (0..7_usize)
|
||||
.filter_map(|i| {
|
||||
game.0
|
||||
.piles
|
||||
.get(&solitaire_core::pile::PileType::Tableau(i))
|
||||
})
|
||||
.map(|pile| pile.cards.iter().filter(|c| c.face_up).count())
|
||||
.max()
|
||||
.unwrap_or(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(solitaire_core::KlondikePile::Tableau(tableau))
|
||||
.into_iter()
|
||||
.filter(|c| c.face_up)
|
||||
.count()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let card_h = layout.0.card_size.y;
|
||||
let avail = layout.0.available_tableau_height;
|
||||
@@ -2502,11 +2540,8 @@ mod tests {
|
||||
for _ in 0..3 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
assert_eq!(waste_ids.len(), 3);
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
@@ -2528,7 +2563,7 @@ mod tests {
|
||||
"at least the top waste card must be rendered"
|
||||
);
|
||||
// The top (last) waste card must always be among the rendered cards.
|
||||
let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id;
|
||||
let top_id = g.waste_cards().last().unwrap().id;
|
||||
assert!(
|
||||
waste_rendered.iter().any(|(c, _, _)| c.id == top_id),
|
||||
"top waste card must be rendered"
|
||||
@@ -2543,7 +2578,7 @@ mod tests {
|
||||
for _ in 0..5 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let waste_pile = &g.piles[&PileType::Waste].cards;
|
||||
let waste_pile = g.waste_cards();
|
||||
assert!(
|
||||
waste_pile.len() >= 3,
|
||||
"need at least 3 waste cards for this test"
|
||||
@@ -2595,7 +2630,7 @@ mod tests {
|
||||
// Draw exactly once — in Draw-Three mode with a full stock this gives
|
||||
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
|
||||
let _ = g.draw();
|
||||
let waste_pile = &g.piles[&PileType::Waste].cards;
|
||||
let waste_pile = g.waste_cards();
|
||||
// We need exactly 2 or 3 waste cards to hit the small-pile path.
|
||||
// One draw in Draw-Three adds up to 3 cards; take the first 2 if needed.
|
||||
let count = waste_pile.len();
|
||||
@@ -2638,11 +2673,8 @@ mod tests {
|
||||
for _ in 0..3 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
let waste_rendered: Vec<_> = positions
|
||||
@@ -2671,7 +2703,7 @@ mod tests {
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
// Collect positions for Tableau(6) (should have 7 cards).
|
||||
let tableau_6_base = layout.pile_positions[&PileType::Tableau(6)];
|
||||
let tableau_6_base = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)];
|
||||
let mut ys: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(_, pos, _)| (pos.x - tableau_6_base.x).abs() < 1e-3)
|
||||
@@ -3058,7 +3090,7 @@ mod tests {
|
||||
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
|
||||
// Each face-down card contributes TABLEAU_FACEDOWN_FAN_FRAC to the column span.
|
||||
// Total span should be 6 * FACEDOWN < 6 * TABLEAU_FAN_FRAC (the old uniform value).
|
||||
let col6_base = layout.pile_positions[&PileType::Tableau(6)];
|
||||
let col6_base = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)];
|
||||
let mut col6_ys: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(_, pos, _)| (pos.x - col6_base.x).abs() < 1e-3)
|
||||
@@ -3468,9 +3500,7 @@ mod tests {
|
||||
let mut app = app();
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
|
||||
stock.cards.clear();
|
||||
}
|
||||
game.0.set_test_stock_cards(Vec::new());
|
||||
}
|
||||
app.update();
|
||||
assert!(matches!(
|
||||
@@ -3488,9 +3518,9 @@ mod tests {
|
||||
assert_eq!(stock_badge_text(&mut app), "24");
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
|
||||
let _ = stock.cards.pop();
|
||||
}
|
||||
let mut stock = game.0.stock_cards();
|
||||
let _ = stock.pop();
|
||||
game.0.set_test_stock_cards(stock);
|
||||
}
|
||||
app.update();
|
||||
assert_eq!(stock_badge_text(&mut app), "23");
|
||||
@@ -3501,15 +3531,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_card_count_helper_reads_zero_when_pile_missing() {
|
||||
// If the stock pile entry is somehow absent (defensive path), the
|
||||
// helper must return 0 rather than panicking — the badge then
|
||||
// renders as hidden via the count-zero branch in the update system.
|
||||
fn stock_card_count_helper_reads_zero_for_empty_stock() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
let mut g_no_stock = g.clone();
|
||||
g_no_stock.piles.remove(&PileType::Stock);
|
||||
assert_eq!(stock_card_count(&g_no_stock), 0);
|
||||
// Sanity: a fresh game with stock present reports 24.
|
||||
let mut g_empty_stock = g.clone();
|
||||
g_empty_stock.set_test_stock_cards(Vec::new());
|
||||
assert_eq!(stock_card_count(&g_empty_stock), 0);
|
||||
assert_eq!(stock_card_count(&g), 24);
|
||||
}
|
||||
|
||||
@@ -3799,11 +3825,8 @@ mod tests {
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
@@ -3850,27 +3873,24 @@ mod tests {
|
||||
let window = Vec2::new(900.0, 2000.0);
|
||||
let layout = crate::layout::compute_layout(window, 32.0, 110.0, true);
|
||||
|
||||
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||
let stock_right_edge = stock_x + layout.card_size.x / 2.0;
|
||||
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
|
||||
let positions = card_positions(&g, &layout);
|
||||
for (card, pos, _) in positions
|
||||
.iter()
|
||||
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
|
||||
.into_iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
{
|
||||
let left_edge = pos.x - layout.card_size.x / 2.0;
|
||||
.collect();
|
||||
waste_positions.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
|
||||
let visible_count = waste_positions.len().min(3);
|
||||
for (card, pos, _) in waste_positions.iter().rev().take(visible_count) {
|
||||
assert!(
|
||||
left_edge >= stock_right_edge - 1e-3,
|
||||
"waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window",
|
||||
pos.x >= stock_x - 1e-3,
|
||||
"waste card {} x {:.2} drifted left of stock origin {:.2} on portrait window",
|
||||
card.id,
|
||||
left_edge,
|
||||
stock_right_edge,
|
||||
pos.x,
|
||||
stock_x,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3885,11 +3905,8 @@ mod tests {
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
|
||||
@@ -13,16 +13,18 @@ use crate::platform::{
|
||||
default_storage_backend,
|
||||
};
|
||||
use crate::{
|
||||
AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin,
|
||||
AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||
SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider,
|
||||
SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
|
||||
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
||||
};
|
||||
|
||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||
@@ -45,6 +47,7 @@ impl Plugin for CoreGamePlugin {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
|
||||
let sync_provider = sync_provider
|
||||
.take()
|
||||
.expect("CoreGamePlugin::build called twice");
|
||||
@@ -104,21 +107,26 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(AvatarPlugin)
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(SyncSetupPlugin)
|
||||
.add_plugins(AnalyticsPlugin)
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin)
|
||||
.add_plugins(DiagnosticsHudPlugin);
|
||||
|
||||
// Plugins that use kira/cpal audio or multi-threaded Tokio are not
|
||||
// compatible with the single-threaded wasm32 runtime. Gate them out
|
||||
// so the browser build boots silently and without a sync backend.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.add_plugins(AvatarPlugin)
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(SyncSetupPlugin)
|
||||
.add_plugins(AnalyticsPlugin)
|
||||
.add_plugins(LeaderboardPlugin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,8 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::RightClickHighlight;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
@@ -66,10 +65,10 @@ const MARKER_VALID: Color = Color::srgba(0.675, 0.761, 0.404, 0.55);
|
||||
|
||||
/// Marker component on a parent entity that owns one drop-target overlay
|
||||
/// (a translucent fill plus four outline edges as children). The wrapped
|
||||
/// `PileType` identifies which pile this overlay highlights, so test
|
||||
/// `KlondikePile` identifies which pile this overlay highlights, so test
|
||||
/// queries and the despawn-on-target-change logic can filter by pile.
|
||||
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DropTargetOverlay(pub PileType);
|
||||
pub struct DropTargetOverlay(pub KlondikePile);
|
||||
|
||||
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
||||
pub struct CursorPlugin;
|
||||
@@ -163,33 +162,34 @@ fn update_cursor_icon(
|
||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
KlondikePile::Foundation(Foundation::Foundation2),
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
KlondikePile::Foundation(Foundation::Foundation4),
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
KlondikePile::Tableau(Tableau::Tableau3),
|
||||
KlondikePile::Tableau(Tableau::Tableau4),
|
||||
KlondikePile::Tableau(Tableau::Tableau5),
|
||||
KlondikePile::Tableau(Tableau::Tableau6),
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
];
|
||||
|
||||
for pile in piles {
|
||||
let Some(pile_cards) = game.piles.get(&pile) else {
|
||||
let pile_cards = pile_cards(game, &pile);
|
||||
if pile_cards.is_empty() {
|
||||
continue;
|
||||
};
|
||||
let is_tableau = matches!(pile, PileType::Tableau(_));
|
||||
}
|
||||
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
|
||||
let base = layout.pile_positions[&pile];
|
||||
|
||||
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
|
||||
for (i, card) in pile_cards.iter().enumerate().rev() {
|
||||
if !card.face_up {
|
||||
continue;
|
||||
}
|
||||
// Only the topmost card is draggable on non-tableau piles.
|
||||
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
||||
if !is_tableau && i != pile_cards.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
|
||||
@@ -226,38 +226,14 @@ fn update_drop_highlights(
|
||||
|
||||
let Some(game) = game else { return };
|
||||
|
||||
// The first element of drag.cards is the bottom card that lands on the target.
|
||||
let Some(&bottom_id) = drag.cards.first() else {
|
||||
return;
|
||||
};
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else {
|
||||
return;
|
||||
};
|
||||
let drag_count = drag.cards.len();
|
||||
|
||||
let Some(origin) = drag.origin_pile.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (marker, mut sprite, _rch) in &mut markers {
|
||||
let valid = match &marker.0 {
|
||||
PileType::Foundation(slot) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
let pile = game.0.piles.get(&PileType::Foundation(*slot));
|
||||
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(idx) => {
|
||||
let pile = game.0.piles.get(&PileType::Tableau(*idx));
|
||||
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
|
||||
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
||||
}
|
||||
}
|
||||
@@ -297,20 +273,7 @@ fn update_drop_target_overlays(
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve the bottom card of the dragged stack — same logic as
|
||||
// `update_drop_highlights` so rules can't drift between the marker
|
||||
// tint and the overlay.
|
||||
let Some(&bottom_id) = drag.cards.first() else {
|
||||
return;
|
||||
};
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else {
|
||||
let Some(origin) = drag.origin_pile.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let drag_count = drag.cards.len();
|
||||
@@ -318,44 +281,24 @@ fn update_drop_target_overlays(
|
||||
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
||||
// Waste are excluded because they are never legal drop targets.
|
||||
let candidates = [
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
KlondikePile::Foundation(Foundation::Foundation2),
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
KlondikePile::Foundation(Foundation::Foundation4),
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
KlondikePile::Tableau(Tableau::Tableau3),
|
||||
KlondikePile::Tableau(Tableau::Tableau4),
|
||||
KlondikePile::Tableau(Tableau::Tableau5),
|
||||
KlondikePile::Tableau(Tableau::Tableau6),
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
];
|
||||
|
||||
// Compute the new set of valid piles for this frame.
|
||||
let mut valid: Vec<PileType> = Vec::new();
|
||||
let mut valid: Vec<KlondikePile> = Vec::new();
|
||||
for pile in &candidates {
|
||||
let is_valid = match pile {
|
||||
PileType::Foundation(_) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
game.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(_) => game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
|
||||
_ => false,
|
||||
};
|
||||
// Don't highlight the origin pile — dropping onto the source is
|
||||
// a no-op.
|
||||
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
|
||||
valid.push(pile.clone());
|
||||
if game.0.can_move_cards(origin, pile, drag_count) {
|
||||
valid.push(*pile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,9 +310,9 @@ fn update_drop_target_overlays(
|
||||
}
|
||||
|
||||
// Spawn overlays for piles that are now valid but don't yet have one.
|
||||
let already_overlaid: Vec<PileType> = overlays
|
||||
let already_overlaid: Vec<KlondikePile> = overlays
|
||||
.iter()
|
||||
.map(|(_, m)| m.0.clone())
|
||||
.map(|(_, m)| m.0)
|
||||
.filter(|p| valid.contains(p))
|
||||
.collect();
|
||||
|
||||
@@ -388,10 +331,14 @@ fn update_drop_target_overlays(
|
||||
/// for everything else it is card-sized. Replicated here rather than
|
||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||
/// this overlay is the only other consumer.
|
||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
|
||||
fn drop_overlay_rect(
|
||||
pile: &KlondikePile,
|
||||
layout: &Layout,
|
||||
game: &GameState,
|
||||
) -> Option<(Vec2, Vec2)> {
|
||||
let centre = layout.pile_positions.get(pile).copied()?;
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||
let card_count = game.pile(*pile).len();
|
||||
if card_count > 1 {
|
||||
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||
@@ -412,7 +359,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Opti
|
||||
/// the appropriate world position for `pile`.
|
||||
fn spawn_drop_target_overlay(
|
||||
commands: &mut Commands,
|
||||
pile: &PileType,
|
||||
pile: &KlondikePile,
|
||||
layout: &Layout,
|
||||
game: &GameState,
|
||||
) {
|
||||
@@ -430,7 +377,7 @@ fn spawn_drop_target_overlay(
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
||||
DropTargetOverlay(pile.clone()),
|
||||
DropTargetOverlay(*pile),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
// Top edge.
|
||||
@@ -479,7 +426,7 @@ fn spawn_drop_target_overlay(
|
||||
fn tableau_or_stack_pos(
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
pile: &PileType,
|
||||
pile: &KlondikePile,
|
||||
index: usize,
|
||||
base: Vec2,
|
||||
is_tableau: bool,
|
||||
@@ -489,8 +436,8 @@ fn tableau_or_stack_pos(
|
||||
base.x,
|
||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||
)
|
||||
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree {
|
||||
let pile_len = game.waste_cards().len();
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = index.saturating_sub(visible_start) as f32;
|
||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||
@@ -499,6 +446,14 @@ fn tableau_or_stack_pos(
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<solitaire_core::card::Card> {
|
||||
if matches!(pile, KlondikePile::Stock) {
|
||||
game.waste_cards()
|
||||
} else {
|
||||
game.pile(*pile)
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
let half = size / 2.0;
|
||||
point.x >= center.x - half.x
|
||||
@@ -649,12 +604,8 @@ mod tests {
|
||||
/// card. Used to make a specific tableau column accept a chosen
|
||||
/// drag stack.
|
||||
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(idx))
|
||||
.expect("tableau pile exists");
|
||||
pile.cards.clear();
|
||||
pile.cards.push(card);
|
||||
let tableau = GameState::tableau_from_index(idx).expect("tableau pile exists");
|
||||
game.set_test_tableau_cards(tableau, vec![card]);
|
||||
}
|
||||
|
||||
/// Inserts a single face-up dragged card into the waste pile and
|
||||
@@ -664,59 +615,14 @@ mod tests {
|
||||
// Place the dragged card on the waste pile (origin).
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let waste = game
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Waste)
|
||||
.expect("waste pile exists");
|
||||
waste.cards.clear();
|
||||
waste.cards.push(dragged.clone());
|
||||
game.0.set_test_waste_cards(vec![dragged.clone()]);
|
||||
}
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![dragged.id];
|
||||
drag.origin_pile = Some(PileType::Waste);
|
||||
drag.origin_pile = Some(KlondikePile::Stock);
|
||||
drag.committed = true;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
|
||||
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
|
||||
// (black, rank 6) — alternating colour, one rank lower → legal.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card {
|
||||
id: 9001,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
},
|
||||
);
|
||||
let dragged = Card {
|
||||
id: 9002,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
overlays.contains(&PileType::Tableau(2)),
|
||||
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||
@@ -745,67 +651,15 @@ mod tests {
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
let overlays: Vec<KlondikePile> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.map(|o| o.0)
|
||||
.collect();
|
||||
assert!(
|
||||
!overlays.contains(&PileType::Tableau(2)),
|
||||
!overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)),
|
||||
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlays_despawn_on_drag_end() {
|
||||
// Set up a scenario that produces at least one valid overlay,
|
||||
// confirm it spawns, then clear the drag and confirm every
|
||||
// overlay is despawned.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card {
|
||||
id: 9201,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
},
|
||||
);
|
||||
let dragged = Card {
|
||||
id: 9202,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
app.update();
|
||||
|
||||
let count_during_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
count_during_drag >= 1,
|
||||
"expected ≥1 overlay during drag, got {count_during_drag}"
|
||||
);
|
||||
|
||||
// End the drag — every overlay should despawn next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
|
||||
let count_after_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count_after_drag, 0,
|
||||
"all overlays must despawn when the drag ends"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use solitaire_sync::ChallengeGoal;
|
||||
|
||||
use crate::events::{
|
||||
@@ -25,6 +27,7 @@ use crate::events::{
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
|
||||
/// Bonus XP awarded for completing today's daily challenge.
|
||||
@@ -77,8 +80,13 @@ pub struct DailyChallengeCompletedEvent {
|
||||
/// Holds the in-flight server challenge fetch so the result can be polled
|
||||
/// each frame without blocking the main thread.
|
||||
#[derive(Resource, Default)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
struct DailyChallengeTask;
|
||||
|
||||
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
|
||||
/// already fired for, so the toast spawns at most once per day.
|
||||
///
|
||||
@@ -116,17 +124,21 @@ impl Plugin for DailyChallengePlugin {
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_systems(Startup, fetch_server_challenge)
|
||||
.add_systems(Update, poll_server_challenge)
|
||||
// record/award after the base ProgressUpdate so we don't fight
|
||||
// ProgressPlugin's add_xp on the same frame.
|
||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
||||
.add_systems(Update, check_daily_expiry_warning)
|
||||
.add_systems(Update, check_date_rollover);
|
||||
|
||||
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.add_systems(Startup, fetch_server_challenge)
|
||||
.add_systems(Update, poll_server_challenge);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Startup system: spawns an async task to fetch the server's daily challenge.
|
||||
///
|
||||
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
|
||||
@@ -142,6 +154,7 @@ fn fetch_server_challenge(
|
||||
task_res.0 = Some(task);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Update system: polls the server-challenge fetch task.
|
||||
///
|
||||
/// On success, replaces the locally-computed seed in `DailyChallengeResource`
|
||||
@@ -341,7 +354,6 @@ fn check_date_rollover(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! because the starting position is effectively random (player-chosen timing
|
||||
//! determines which seed in the 40-entry catalog they start at).
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use chrono::Utc;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
||||
@@ -104,10 +104,9 @@ fn handle_difficulty_request(
|
||||
}
|
||||
|
||||
fn seed_from_system_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
|
||||
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
|
||||
// std::time::SystemTime panics on wasm32-unknown-unknown).
|
||||
Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Cross-system events used by the engine's plugins.
|
||||
|
||||
use bevy::prelude::Message;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::AchievementRecord;
|
||||
use solitaire_sync::SyncResponse;
|
||||
|
||||
@@ -11,8 +11,8 @@ use solitaire_sync::SyncResponse;
|
||||
/// consumed by `GamePlugin`.
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct MoveRequestEvent {
|
||||
pub from: PileType,
|
||||
pub to: PileType,
|
||||
pub from: KlondikePile,
|
||||
pub to: KlondikePile,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ pub struct StateChangedEvent;
|
||||
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct MoveRejectedEvent {
|
||||
pub from: PileType,
|
||||
pub to: PileType,
|
||||
pub from: KlondikePile,
|
||||
pub to: KlondikePile,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
@@ -302,5 +302,5 @@ pub struct HintVisualEvent {
|
||||
/// The `Card::id` of the source card to be highlighted.
|
||||
pub source_card_id: u32,
|
||||
/// The destination pile whose `PileMarker` should be tinted gold.
|
||||
pub dest_pile: solitaire_core::pile::PileType,
|
||||
pub dest_pile: KlondikePile,
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ use std::f32::consts::PI;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::pile::PileType;
|
||||
use bevy::window::RequestRedraw;
|
||||
use solitaire_core::{Foundation, KlondikePile};
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::animation_plugin::CardAnim;
|
||||
@@ -204,6 +205,7 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -244,10 +246,8 @@ fn start_shake_anim(
|
||||
}
|
||||
let dest_pile = &ev.to;
|
||||
// Collect the card ids that belong to the destination pile.
|
||||
let Some(pile) = game.0.piles.get(dest_pile) else {
|
||||
continue;
|
||||
};
|
||||
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
|
||||
let dest_cards = pile_cards(&game.0, dest_pile);
|
||||
let dest_card_ids: Vec<u32> = dest_cards.iter().map(|c| c.id).collect();
|
||||
|
||||
if dest_card_ids.is_empty() {
|
||||
continue;
|
||||
@@ -317,19 +317,19 @@ fn start_settle_anim(
|
||||
let mut bounce_ids: Vec<u32> = Vec::new();
|
||||
|
||||
for ev in moves.read() {
|
||||
if let Some(pile) = game.0.piles.get(&ev.to) {
|
||||
let pile = pile_cards(&game.0, &ev.to);
|
||||
if !pile.is_empty() {
|
||||
// The moved cards land on top — take the last `count` ids.
|
||||
let n = ev.count.min(pile.cards.len());
|
||||
let n = ev.count.min(pile.len());
|
||||
if n > 0 {
|
||||
let start = pile.cards.len() - n;
|
||||
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
|
||||
let start = pile.len() - n;
|
||||
bounce_ids.extend(pile[start..].iter().map(|c| c.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if draws.read().next().is_some()
|
||||
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
|
||||
&& let Some(top) = pile.cards.last()
|
||||
&& let Some(top) = game.0.waste_cards().last()
|
||||
{
|
||||
bounce_ids.push(top.id);
|
||||
}
|
||||
@@ -397,7 +397,7 @@ fn start_deal_anim(
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else { return };
|
||||
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
|
||||
let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
|
||||
return;
|
||||
};
|
||||
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
||||
@@ -518,15 +518,13 @@ fn start_foundation_flourish(
|
||||
if reduce_motion {
|
||||
continue;
|
||||
}
|
||||
let pile_type = PileType::Foundation(ev.slot);
|
||||
let Some(foundation) = foundation_from_slot(ev.slot) else {
|
||||
continue;
|
||||
};
|
||||
let pile_type = KlondikePile::Foundation(foundation);
|
||||
// Top card of the completed foundation is the King.
|
||||
let Some(king_id) = game
|
||||
.0
|
||||
.piles
|
||||
.get(&pile_type)
|
||||
.and_then(|p| p.cards.last())
|
||||
.map(|c| c.id)
|
||||
else {
|
||||
let cards = game.0.pile(pile_type);
|
||||
let Some(king_id) = cards.last().map(|c| c.id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -632,6 +630,26 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
||||
)
|
||||
}
|
||||
|
||||
fn pile_cards(
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
pile: &KlondikePile,
|
||||
) -> Vec<solitaire_core::card::Card> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
fn foundation_from_slot(slot: u8) -> Option<Foundation> {
|
||||
match slot {
|
||||
0 => Some(Foundation::Foundation1),
|
||||
1 => Some(Foundation::Foundation2),
|
||||
2 => Some(Foundation::Foundation3),
|
||||
3 => Some(Foundation::Foundation4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (pure functions only — no Bevy world required)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -831,6 +849,7 @@ mod tests {
|
||||
#[test]
|
||||
fn shake_anim_skipped_under_reduce_motion() {
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::Tableau;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
@@ -845,14 +864,13 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||
let dest_pile = PileType::Tableau(0);
|
||||
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
|
||||
let card_id = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get(&dest_pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.pile(dest_pile)
|
||||
.last()
|
||||
.map(|c| c.id)
|
||||
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||
|
||||
@@ -864,7 +882,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||
.write(MoveRejectedEvent {
|
||||
from: PileType::Stock,
|
||||
from: KlondikePile::Stock,
|
||||
to: dest_pile,
|
||||
count: 1,
|
||||
});
|
||||
|
||||
+317
-740
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,19 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
||||
// Option<Res<AvatarResource>> parameters below compile without changes.
|
||||
// The resource is never inserted on wasm, so every call resolves to None.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(bevy::prelude::Resource)]
|
||||
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::{
|
||||
@@ -308,17 +315,17 @@ pub struct HintButton;
|
||||
/// Android HUD label for the Hint button — shared with the help screen's
|
||||
/// controls reference so both always agree.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
|
||||
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
||||
"\u{2261}",
|
||||
"\u{2190}",
|
||||
"||",
|
||||
"?",
|
||||
"Menu",
|
||||
"Undo",
|
||||
"Pause",
|
||||
"Help",
|
||||
ANDROID_HINT_LABEL,
|
||||
"M",
|
||||
"+",
|
||||
"Mode",
|
||||
"New",
|
||||
];
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
||||
@@ -823,6 +830,8 @@ fn spawn_avatar_child(
|
||||
) {
|
||||
const SIZE: f32 = 32.0;
|
||||
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
||||
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
||||
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
|
||||
// Image fills the circle container; border_radius clips it to a disc.
|
||||
commands.entity(parent).with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -843,6 +852,15 @@ fn spawn_avatar_child(
|
||||
})
|
||||
.and_then(|c| c.to_uppercase().next())
|
||||
.unwrap_or('?');
|
||||
// Real initial (logged in) keeps the red accent disc; the '?'
|
||||
// unauthenticated fallback uses a neutral grey so it reads as a
|
||||
// "tap to log in" affordance rather than an error.
|
||||
let disc_bg = if initial == '?' {
|
||||
BG_ELEVATED_HI
|
||||
} else {
|
||||
ACCENT_PRIMARY
|
||||
};
|
||||
commands.entity(parent).insert(BackgroundColor(disc_bg));
|
||||
commands.entity(parent).with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(initial.to_string()),
|
||||
@@ -1644,11 +1662,13 @@ impl Default for HudActionFade {
|
||||
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
|
||||
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
|
||||
/// cursor approaches, not only when it crosses into the band itself.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||
|
||||
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
||||
/// transition — fast enough to feel responsive without flashing on
|
||||
/// brief cursor wanders into the reveal zone.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||
|
||||
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
||||
@@ -1656,6 +1676,7 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
||||
/// `target` at a fixed rate so the visual transition is smooth across
|
||||
/// variable framerates.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
@@ -1680,6 +1701,7 @@ fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut
|
||||
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
||||
/// same frame doesn't override the fade with an opaque idle / hover
|
||||
/// colour.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn apply_action_fade(
|
||||
fade: Res<HudActionFade>,
|
||||
@@ -2316,7 +2338,7 @@ fn update_hud(
|
||||
// Hide when not in Draw-Three or after the game is won.
|
||||
String::new()
|
||||
} else {
|
||||
let stock_len = g.piles[&solitaire_core::pile::PileType::Stock].cards.len();
|
||||
let stock_len = g.stock_cards().len();
|
||||
let next_draw = stock_len.min(3);
|
||||
format!("Cycle: {next_draw}/3")
|
||||
};
|
||||
@@ -2380,15 +2402,14 @@ fn update_selection_hud(
|
||||
let Ok(mut t) = q.single_mut() else { return };
|
||||
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
||||
None => String::new(),
|
||||
Some(PileType::Waste) => "▶ Waste".to_string(),
|
||||
Some(PileType::Stock) => "▶ Stock".to_string(),
|
||||
Some(PileType::Foundation(slot)) => match game.as_deref() {
|
||||
Some(KlondikePile::Stock) => "▶ Waste".to_string(),
|
||||
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
|
||||
Some(g) => foundation_selection_label(*slot, &g.0),
|
||||
// No game resource means we can't probe claimed_suit; show the
|
||||
// slot-based placeholder so the HUD still surfaces the selection.
|
||||
None => format!("▶ Foundation {}", slot + 1),
|
||||
None => format!("▶ Foundation {}", foundation_number(*slot)),
|
||||
},
|
||||
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
|
||||
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
|
||||
};
|
||||
**t = label;
|
||||
}
|
||||
@@ -2398,11 +2419,14 @@ fn update_selection_hud(
|
||||
/// When the slot has a claimed suit (any card has landed) the announcement is
|
||||
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
||||
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
||||
fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String {
|
||||
fn foundation_selection_label(
|
||||
slot: Foundation,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> String {
|
||||
let claimed = game
|
||||
.piles
|
||||
.get(&PileType::Foundation(slot))
|
||||
.and_then(|p| p.claimed_suit());
|
||||
.pile(KlondikePile::Foundation(slot))
|
||||
.first()
|
||||
.map(|c| c.suit);
|
||||
match claimed {
|
||||
Some(suit) => {
|
||||
let s = match suit {
|
||||
@@ -2413,7 +2437,28 @@ fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameS
|
||||
};
|
||||
format!("▶ {s} Foundation")
|
||||
}
|
||||
None => format!("▶ Foundation {}", slot + 1),
|
||||
None => format!("▶ Foundation {}", foundation_number(slot)),
|
||||
}
|
||||
}
|
||||
|
||||
const fn foundation_number(foundation: Foundation) -> u8 {
|
||||
match foundation {
|
||||
Foundation::Foundation1 => 1,
|
||||
Foundation::Foundation2 => 2,
|
||||
Foundation::Foundation3 => 3,
|
||||
Foundation::Foundation4 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||
match tableau {
|
||||
Tableau::Tableau1 => 1,
|
||||
Tableau::Tableau2 => 2,
|
||||
Tableau::Tableau3 => 3,
|
||||
Tableau::Tableau4 => 4,
|
||||
Tableau::Tableau5 => 5,
|
||||
Tableau::Tableau6 => 6,
|
||||
Tableau::Tableau7 => 7,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2537,10 +2582,18 @@ fn restore_hud_on_modal(
|
||||
/// Returns the action-bar label font size for a given logical window width.
|
||||
fn action_bar_font_size(window_width: f32) -> f32 {
|
||||
if USE_TOUCH_UI_LAYOUT {
|
||||
// ~1/40 of the window width gives ~22 px on a 900 logical-px phone.
|
||||
// Clamped so it never goes too tiny on narrow viewports or too large
|
||||
// on landscape tablets.
|
||||
(window_width / 40.0).clamp(16.0, 30.0)
|
||||
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
|
||||
// must share one row. The widest characters are in FiraMono (a
|
||||
// monospace whose advance is ~0.62 of the font size). On a 900
|
||||
// logical-px phone the row budget after bar padding (2*12) and six
|
||||
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
|
||||
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
|
||||
// labels are advance-bound only on very narrow viewports; the real
|
||||
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
|
||||
// at 900 px — comfortably one row with margin to spare — clamped so it
|
||||
// never drops below the 12 px legibility floor or grows past 18 px on
|
||||
// landscape tablets where it would crowd the row again.
|
||||
(window_width / 60.0).clamp(12.0, 18.0)
|
||||
} else {
|
||||
TYPE_BODY
|
||||
}
|
||||
@@ -2548,9 +2601,14 @@ fn action_bar_font_size(window_width: f32) -> f32 {
|
||||
|
||||
fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||
if USE_TOUCH_UI_LAYOUT {
|
||||
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
||||
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
||||
// shortest labels ("New", "Help") shrink to their text rather than
|
||||
// padding the row out past the 900 logical-px viewport. min_height
|
||||
// stays at 44 px to preserve the comfortable touch target.
|
||||
(
|
||||
UiRect::axes(Val::Px(4.0), Val::Px(4.0)),
|
||||
Val::Px(52.0),
|
||||
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||
Val::Px(44.0),
|
||||
Val::Px(44.0),
|
||||
)
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+107
-52
@@ -7,7 +7,7 @@ use std::collections::HashMap;
|
||||
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::{Resource, SystemSet};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
/// Schedule labels for layout-related systems so cross-plugin ordering is
|
||||
/// explicit instead of relying on Bevy's automatic resource-conflict ordering
|
||||
@@ -138,9 +138,9 @@ pub struct Layout {
|
||||
/// Centre position of each pile, in 2D world coordinates.
|
||||
///
|
||||
/// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up.
|
||||
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
|
||||
/// Every `KlondikePile` (Stock, Waste, four Foundations, seven Tableaux) has an
|
||||
/// entry. The map always contains exactly 13 entries after `compute_layout`.
|
||||
pub pile_positions: HashMap<PileType, Vec2>,
|
||||
pub pile_positions: HashMap<KlondikePile, Vec2>,
|
||||
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
||||
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
||||
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
|
||||
@@ -241,21 +241,38 @@ pub fn compute_layout(
|
||||
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0;
|
||||
let tableau_y = top_y - card_height - vertical_gap;
|
||||
|
||||
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
|
||||
let mut pile_positions: HashMap<KlondikePile, Vec2> = HashMap::with_capacity(13);
|
||||
|
||||
pile_positions.insert(PileType::Stock, Vec2::new(col_x(0), top_y));
|
||||
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
|
||||
pile_positions.insert(KlondikePile::Stock, Vec2::new(col_x(1), top_y));
|
||||
|
||||
// Column 2 is skipped — visual separation between waste and foundations.
|
||||
for slot in 0..4_u8 {
|
||||
let foundation = match slot {
|
||||
0 => Foundation::Foundation1,
|
||||
1 => Foundation::Foundation2,
|
||||
2 => Foundation::Foundation3,
|
||||
_ => Foundation::Foundation4,
|
||||
};
|
||||
pile_positions.insert(
|
||||
PileType::Foundation(slot),
|
||||
KlondikePile::Foundation(foundation),
|
||||
Vec2::new(col_x(3 + slot as usize), top_y),
|
||||
);
|
||||
}
|
||||
|
||||
for i in 0..7 {
|
||||
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
|
||||
let tableau = match i {
|
||||
0 => Tableau::Tableau1,
|
||||
1 => Tableau::Tableau2,
|
||||
2 => Tableau::Tableau3,
|
||||
3 => Tableau::Tableau4,
|
||||
4 => Tableau::Tableau5,
|
||||
5 => Tableau::Tableau6,
|
||||
_ => Tableau::Tableau7,
|
||||
};
|
||||
pile_positions.insert(
|
||||
KlondikePile::Tableau(tableau),
|
||||
Vec2::new(col_x(i), tableau_y),
|
||||
);
|
||||
}
|
||||
|
||||
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
|
||||
@@ -301,23 +318,37 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_all_piles_present(layout: &Layout) {
|
||||
assert!(layout.pile_positions.contains_key(&PileType::Stock));
|
||||
assert!(layout.pile_positions.contains_key(&PileType::Waste));
|
||||
for slot in 0..4_u8 {
|
||||
assert!(layout.pile_positions.contains_key(&KlondikePile::Stock));
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
assert!(
|
||||
layout
|
||||
.pile_positions
|
||||
.contains_key(&PileType::Foundation(slot)),
|
||||
"missing foundation slot {slot}",
|
||||
.contains_key(&KlondikePile::Foundation(foundation)),
|
||||
"missing foundation slot {foundation:?}",
|
||||
);
|
||||
}
|
||||
for i in 0..7 {
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
assert!(
|
||||
layout.pile_positions.contains_key(&PileType::Tableau(i)),
|
||||
"missing tableau {i}"
|
||||
layout
|
||||
.pile_positions
|
||||
.contains_key(&KlondikePile::Tableau(tableau)),
|
||||
"missing tableau {tableau:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(layout.pile_positions.len(), 13);
|
||||
assert_eq!(layout.pile_positions.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -376,9 +407,18 @@ mod tests {
|
||||
#[test]
|
||||
fn tableau_columns_are_sorted_left_to_right() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for i in 0..6 {
|
||||
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
|
||||
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
|
||||
let tableaus = [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
];
|
||||
for i in 0..tableaus.len() - 1 {
|
||||
let lhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i])].x;
|
||||
let rhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i + 1])].x;
|
||||
assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1);
|
||||
}
|
||||
}
|
||||
@@ -386,8 +426,8 @@ mod tests {
|
||||
#[test]
|
||||
fn top_row_is_above_tableau_row() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||
let stock_y = layout.pile_positions[&KlondikePile::Stock].y;
|
||||
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
|
||||
assert!(stock_y > tableau_y);
|
||||
}
|
||||
|
||||
@@ -399,7 +439,7 @@ mod tests {
|
||||
fn top_row_clears_hud_band() {
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let stock_y = layout.pile_positions[&KlondikePile::Stock].y;
|
||||
let card_top = stock_y + layout.card_size.y / 2.0;
|
||||
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
||||
assert!(
|
||||
@@ -411,24 +451,35 @@ mod tests {
|
||||
#[test]
|
||||
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||
let waste_x = layout.pile_positions[&PileType::Waste].x;
|
||||
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
|
||||
let t1_x = layout.pile_positions[&PileType::Tableau(1)].x;
|
||||
assert!((stock_x - t0_x).abs() < 1e-5);
|
||||
assert!((waste_x - t1_x).abs() < 1e-5);
|
||||
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
||||
let t1_x = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau2)].x;
|
||||
assert!((stock_x - t1_x).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundations_align_with_tableau_cols_3_to_6() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for slot in 0..4_u8 {
|
||||
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
|
||||
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
|
||||
let target_tableaus = [
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
];
|
||||
for (idx, foundation) in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let f_x = layout.pile_positions[&KlondikePile::Foundation(*foundation)].x;
|
||||
let t_x = layout.pile_positions[&KlondikePile::Tableau(target_tableaus[idx])].x;
|
||||
assert!(
|
||||
(f_x - t_x).abs() < 1e-5,
|
||||
"foundation slot {slot} should align with tableau {}",
|
||||
3 + slot as usize,
|
||||
"foundation slot {idx} should align with tableau {}",
|
||||
3 + idx,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -470,7 +521,7 @@ mod tests {
|
||||
// Default app resolution (see solitaire_app/src/main.rs).
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
// Bottom edge of the 13th fanned face-up card.
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||
@@ -489,7 +540,7 @@ mod tests {
|
||||
// The bug originally reproduced at 1920x1080. Lock in a regression test.
|
||||
let window = Vec2::new(1920.0, 1080.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||
let h_gap = layout.card_size.x / 4.0;
|
||||
@@ -520,7 +571,7 @@ mod tests {
|
||||
fn expanded_fan_fits_phone_viewport() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
let h_gap = layout.card_size.x / 4.0;
|
||||
// Bottom of the 13th (worst-case) fanned face-up card.
|
||||
@@ -579,8 +630,8 @@ mod tests {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0, 0.0, true);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0, true);
|
||||
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
|
||||
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
|
||||
let stock_no_inset = without.pile_positions[&KlondikePile::Stock].y;
|
||||
let stock_with_inset = with_inset.pile_positions[&KlondikePile::Stock].y;
|
||||
assert!(
|
||||
stock_with_inset < stock_no_inset,
|
||||
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
|
||||
@@ -602,10 +653,10 @@ mod tests {
|
||||
let without = compute_layout(window, 0.0, 0.0, true);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0, true);
|
||||
for pile in [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(6),
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
] {
|
||||
assert!(
|
||||
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
||||
@@ -628,7 +679,7 @@ mod tests {
|
||||
with_inset.tableau_fan_frac,
|
||||
);
|
||||
let card_h = with_inset.card_size.y;
|
||||
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
|
||||
let tableau_y = with_inset.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
|
||||
let h_gap = with_inset.card_size.x / 4.0;
|
||||
let margin = -window.y / 2.0 + 48.0 + h_gap;
|
||||
@@ -661,8 +712,8 @@ mod tests {
|
||||
|
||||
// Verify the "wrong" layout actually differs — the bug would push the
|
||||
// top card row upward by exactly safe_top pixels.
|
||||
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
|
||||
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
|
||||
let fresh_stock_y = fresh.pile_positions[&KlondikePile::Stock].y;
|
||||
let wrong_stock_y = wrong.pile_positions[&KlondikePile::Stock].y;
|
||||
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
|
||||
// downward (−y direction). So wrong_stock_y > fresh_stock_y by safe_top.
|
||||
assert!(
|
||||
@@ -680,14 +731,14 @@ mod tests {
|
||||
"card size must be preserved after resume",
|
||||
);
|
||||
assert!(
|
||||
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
|
||||
(corrected.pile_positions[&KlondikePile::Stock].y - fresh_stock_y).abs() < 1e-3,
|
||||
"stock y must match fresh launch after resume: \
|
||||
corrected={:.2} fresh={fresh_stock_y:.2}",
|
||||
corrected.pile_positions[&PileType::Stock].y,
|
||||
corrected.pile_positions[&KlondikePile::Stock].y,
|
||||
);
|
||||
assert!(
|
||||
(corrected.pile_positions[&PileType::Stock].x
|
||||
- fresh.pile_positions[&PileType::Stock].x)
|
||||
(corrected.pile_positions[&KlondikePile::Stock].x
|
||||
- fresh.pile_positions[&KlondikePile::Stock].x)
|
||||
.abs()
|
||||
< 1e-3,
|
||||
"stock x must be unchanged after resume",
|
||||
@@ -695,7 +746,7 @@ mod tests {
|
||||
// The HUD band top clearance (distance from window top to card top)
|
||||
// must match as well — this is the quantity directly visible in Bug 2.
|
||||
let card_top = |layout: &super::Layout| {
|
||||
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
|
||||
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
|
||||
};
|
||||
assert!(
|
||||
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
|
||||
@@ -712,7 +763,11 @@ mod tests {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0, 0.0, true);
|
||||
let with_inset = compute_layout(window, 0.0, 48.0, true);
|
||||
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
|
||||
for pile in [
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
] {
|
||||
assert!(
|
||||
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
||||
"{pile:?} x-position must not change with safe_area_bottom",
|
||||
|
||||
@@ -191,6 +191,7 @@ fn toggle_leaderboard_screen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
|
||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
|
||||
data: Res<LeaderboardResource>,
|
||||
provider: Option<Res<SyncProviderResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
@@ -208,6 +209,11 @@ fn toggle_leaderboard_screen(
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't stack a second modal scrim over one that is already open.
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn the panel immediately with whatever data we have so far.
|
||||
let remote_available = provider
|
||||
.as_ref()
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Bevy integration layer for Ferrous Solitaire.
|
||||
|
||||
pub mod achievement_plugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod analytics_plugin;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_clipboard;
|
||||
pub mod animation_plugin;
|
||||
pub mod assets;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod audio_plugin;
|
||||
pub mod auto_complete_plugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod avatar_plugin;
|
||||
pub mod card_animation;
|
||||
pub mod card_plugin;
|
||||
@@ -26,6 +29,7 @@ pub mod home_plugin;
|
||||
pub mod hud_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod leaderboard_plugin;
|
||||
pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
@@ -43,7 +47,9 @@ pub mod selection_plugin;
|
||||
pub mod settings_plugin;
|
||||
pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod sync_plugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod sync_setup_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod theme;
|
||||
@@ -57,14 +63,17 @@ pub mod weekly_goals_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
|
||||
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
|
||||
pub use assets::{
|
||||
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url,
|
||||
populate_embedded_dark_theme, register_theme_asset_sources,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
|
||||
pub use card_animation::{
|
||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||
@@ -117,6 +126,7 @@ pub use hud_plugin::{
|
||||
};
|
||||
pub use input_plugin::InputPlugin;
|
||||
pub use layout::{Layout, LayoutResource, compute_layout};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
||||
@@ -143,7 +153,6 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
|
||||
pub use selection_plugin::{
|
||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||
};
|
||||
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
|
||||
pub use settings_plugin::{
|
||||
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
|
||||
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||
@@ -155,7 +164,9 @@ pub use stats_plugin::{
|
||||
ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen,
|
||||
StatsUpdate, WatchReplayButton, format_replay_caption,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use sync_setup_plugin::SyncSetupPlugin;
|
||||
pub use table_plugin::{
|
||||
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
|
||||
@@ -167,6 +178,7 @@ pub use theme::{
|
||||
pub use time_attack_plugin::{
|
||||
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
||||
};
|
||||
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
|
||||
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
||||
pub use ui_modal::{
|
||||
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
|
||||
|
||||
@@ -36,6 +36,7 @@ use crate::ui_theme::{
|
||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
};
|
||||
use crate::splash_plugin::SplashRoot;
|
||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -153,7 +154,7 @@ pub struct OnboardingPlugin;
|
||||
impl Plugin for OnboardingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<OnboardingSlideIndex>()
|
||||
.add_systems(PostStartup, spawn_if_first_run)
|
||||
.add_systems(Update, spawn_if_first_run)
|
||||
.add_systems(
|
||||
Update,
|
||||
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
|
||||
@@ -170,11 +171,30 @@ fn spawn_if_first_run(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut slide_index: ResMut<OnboardingSlideIndex>,
|
||||
splashes: Query<(), With<SplashRoot>>,
|
||||
existing: Query<(), With<OnboardingScreen>>,
|
||||
mut spawned: Local<bool>,
|
||||
) {
|
||||
let Some(s) = settings else { return };
|
||||
if s.0.first_run_complete {
|
||||
if *spawned {
|
||||
return;
|
||||
}
|
||||
// Wait until the launch splash has despawned so the two screens
|
||||
// never overlap. PostStartup would fire before the first Update
|
||||
// tick, guaranteeing overlap; checking here costs one frame of
|
||||
// latency after the splash clears, which is imperceptible.
|
||||
if !splashes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !existing.is_empty() {
|
||||
*spawned = true;
|
||||
return;
|
||||
}
|
||||
let Some(s) = settings else { return };
|
||||
if s.0.first_run_complete {
|
||||
*spawned = true;
|
||||
return;
|
||||
}
|
||||
*spawned = true;
|
||||
slide_index.0 = 0;
|
||||
spawn_slide(&mut commands, 0, font_res.as_deref());
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
@@ -101,7 +101,10 @@ struct HintTask {
|
||||
enum HintTaskOutput {
|
||||
/// Solver verdict was `Winnable`; here is the first move on the
|
||||
/// solution path.
|
||||
SolverMove { from: PileType, to: PileType },
|
||||
SolverMove {
|
||||
from: KlondikePile,
|
||||
to: KlondikePile,
|
||||
},
|
||||
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
|
||||
/// runs the legacy heuristic against the live `GameState` so the
|
||||
/// H key always produces feedback while any legal move exists.
|
||||
@@ -183,6 +186,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::events::HintVisualEvent;
|
||||
use crate::input_plugin::HintSolverConfig;
|
||||
use solitaire_core::{Foundation, Tableau};
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
@@ -214,22 +218,27 @@ mod tests {
|
||||
/// tableau columns 0..3, stock and waste empty.
|
||||
fn near_finished_state() -> GameState {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
game.set_test_stock_cards(Vec::new());
|
||||
game.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
game.set_test_foundation_cards(foundation, Vec::new());
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
game.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let ranks_below_king = [
|
||||
Rank::Ace,
|
||||
@@ -245,31 +254,44 @@ mod tests {
|
||||
Rank::Jack,
|
||||
Rank::Queen,
|
||||
];
|
||||
for (slot, suit) in suits.iter().enumerate() {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Foundation(slot as u8))
|
||||
.unwrap();
|
||||
for (foundation, suit) in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
]
|
||||
.into_iter()
|
||||
.zip(suits.iter())
|
||||
{
|
||||
let mut cards = Vec::new();
|
||||
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||
pile.cards.push(Card {
|
||||
id: (slot as u32) * 13 + i as u32,
|
||||
cards.push(Card {
|
||||
id: (foundation as u32) * 13 + i as u32,
|
||||
suit: *suit,
|
||||
rank: *rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
game.set_test_foundation_cards(foundation, cards);
|
||||
}
|
||||
for (col, suit) in suits.iter().enumerate() {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(col))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 100 + col as u32,
|
||||
for (tableau, suit) in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
]
|
||||
.into_iter()
|
||||
.zip(suits.iter())
|
||||
{
|
||||
game.set_test_tableau_cards(
|
||||
tableau,
|
||||
vec![Card {
|
||||
id: 100 + tableau as u32,
|
||||
suit: *suit,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}],
|
||||
);
|
||||
}
|
||||
game
|
||||
}
|
||||
@@ -309,7 +331,7 @@ mod tests {
|
||||
"exactly one HintVisualEvent must fire when the solver returns Winnable",
|
||||
);
|
||||
assert!(
|
||||
matches!(collected[0].dest_pile, PileType::Foundation(_)),
|
||||
matches!(collected[0].dest_pile, KlondikePile::Foundation(_)),
|
||||
"solver hint destination must be a foundation slot; got {:?}",
|
||||
collected[0].dest_pile,
|
||||
);
|
||||
|
||||
@@ -12,7 +12,11 @@ use solitaire_core::achievement::{ALL_ACHIEVEMENTS, achievement_by_id};
|
||||
use solitaire_data::SyncBackend;
|
||||
|
||||
use crate::achievement_plugin::AchievementsResource;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(bevy::prelude::Resource)]
|
||||
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
use crate::events::ToggleProfileRequestEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
|
||||
+180
-158
@@ -47,13 +47,12 @@ use bevy::input::touch::Touches;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||
use crate::events::MoveRequestEvent;
|
||||
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
|
||||
use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
@@ -108,7 +107,7 @@ pub enum RightClickRadialState {
|
||||
/// `hovered_index` (or none).
|
||||
Active {
|
||||
/// Pile the right-clicked card came from.
|
||||
source_pile: PileType,
|
||||
source_pile: KlondikePile,
|
||||
/// Number of cards that would be moved (always `1` — only the
|
||||
/// top face-up card is ever offered for a quick-drop, since the
|
||||
/// radial is built around single-card foundation/tableau
|
||||
@@ -123,7 +122,7 @@ pub enum RightClickRadialState {
|
||||
/// [`RADIAL_RADIUS_PX`] centred on the press position. A single
|
||||
/// destination is placed directly above the cursor; multiple
|
||||
/// destinations span an arc.
|
||||
legal_destinations: Vec<(PileType, Vec2)>,
|
||||
legal_destinations: Vec<(KlondikePile, Vec2)>,
|
||||
/// Cursor position (world space) the radial was opened at —
|
||||
/// used as the centre of the ring for cursor-hover hit testing.
|
||||
centre: Vec2,
|
||||
@@ -250,30 +249,20 @@ pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option<usize> {
|
||||
/// that legally accept the card. The source pile is excluded because
|
||||
/// dropping a card on its own pile is a no-op.
|
||||
pub fn legal_destinations_for_card(
|
||||
card: &Card,
|
||||
source_pile: &PileType,
|
||||
_card: &Card,
|
||||
source_pile: &KlondikePile,
|
||||
game: &GameState,
|
||||
) -> Vec<PileType> {
|
||||
) -> Vec<KlondikePile> {
|
||||
let mut out = Vec::new();
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if dest == *source_pile {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile)
|
||||
{
|
||||
for foundation in foundations() {
|
||||
let dest = KlondikePile::Foundation(foundation);
|
||||
if game.can_move_cards(source_pile, &dest, 1) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if dest == *source_pile {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, pile)
|
||||
{
|
||||
for tableau in tableaus() {
|
||||
let dest = KlondikePile::Tableau(tableau);
|
||||
if game.can_move_cards(source_pile, &dest, 1) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
@@ -292,36 +281,34 @@ pub fn find_top_face_up_card_at(
|
||||
cursor: Vec2,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
) -> Option<(PileType, Card)> {
|
||||
) -> Option<(KlondikePile, Card)> {
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
KlondikePile::Foundation(Foundation::Foundation2),
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
KlondikePile::Foundation(Foundation::Foundation4),
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
KlondikePile::Tableau(Tableau::Tableau3),
|
||||
KlondikePile::Tableau(Tableau::Tableau4),
|
||||
KlondikePile::Tableau(Tableau::Tableau5),
|
||||
KlondikePile::Tableau(Tableau::Tableau6),
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
];
|
||||
for pile in piles {
|
||||
let Some(pile_cards) = game.piles.get(&pile) else {
|
||||
continue;
|
||||
};
|
||||
if pile_cards.cards.is_empty() {
|
||||
let pile_cards = pile_cards(game, &pile);
|
||||
if pile_cards.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let is_tableau = matches!(pile, PileType::Tableau(_));
|
||||
for i in (0..pile_cards.cards.len()).rev() {
|
||||
let card = &pile_cards.cards[i];
|
||||
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
|
||||
for i in (0..pile_cards.len()).rev() {
|
||||
let card = &pile_cards[i];
|
||||
if !card.face_up {
|
||||
continue;
|
||||
}
|
||||
// Only the top card is draggable on non-tableau piles.
|
||||
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
||||
if !is_tableau && i != pile_cards.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
let pos = card_position(game, layout, &pile, i);
|
||||
@@ -342,19 +329,22 @@ pub fn find_top_face_up_card_at(
|
||||
/// Mirror of `input_plugin::card_position` — kept private to this
|
||||
/// module so the radial's hit-test geometry tracks renderer geometry
|
||||
/// without depending on `input_plugin` internals.
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
||||
fn card_position(
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
pile: &KlondikePile,
|
||||
stack_index: usize,
|
||||
) -> Vec2 {
|
||||
let base = layout.pile_positions[pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||
let mut y_offset = 0.0_f32;
|
||||
if let Some(pile_cards) = game.piles.get(pile) {
|
||||
for card in pile_cards.cards.iter().take(stack_index) {
|
||||
let step = if card.face_up {
|
||||
TABLEAU_FAN_FRAC
|
||||
} else {
|
||||
TABLEAU_FACEDOWN_FAN_FRAC
|
||||
};
|
||||
y_offset -= layout.card_size.y * step;
|
||||
}
|
||||
for card in pile_cards(game, pile).iter().take(stack_index) {
|
||||
let step = if card.face_up {
|
||||
TABLEAU_FAN_FRAC
|
||||
} else {
|
||||
TABLEAU_FACEDOWN_FAN_FRAC
|
||||
};
|
||||
y_offset -= layout.card_size.y * step;
|
||||
}
|
||||
Vec2::new(base.x, base.y + y_offset)
|
||||
} else {
|
||||
@@ -362,17 +352,56 @@ fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
const fn foundations() -> [Foundation; 4] {
|
||||
[
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
]
|
||||
}
|
||||
|
||||
const fn tableaus() -> [Tableau; 7] {
|
||||
[
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
]
|
||||
}
|
||||
|
||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||
fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileType, Vec2)> {
|
||||
///
|
||||
/// `half_extents` is the window half-size in world space — icons are clamped
|
||||
/// so that their edges stay within the viewport, preventing them from appearing
|
||||
/// off-screen on small or narrow devices.
|
||||
fn build_radial_destinations(
|
||||
centre: Vec2,
|
||||
dests: Vec<KlondikePile>,
|
||||
half_extents: Vec2,
|
||||
) -> Vec<(KlondikePile, Vec2)> {
|
||||
let count = dests.len();
|
||||
let margin = RADIAL_ICON_SIZE_PX / 2.0;
|
||||
dests
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
(
|
||||
d,
|
||||
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
|
||||
)
|
||||
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||
let clamped = Vec2::new(
|
||||
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||
);
|
||||
(d, clamped)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -407,9 +436,10 @@ fn cursor_world(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// On `MouseButton::Right` `just_pressed`, attempts to open the radial
|
||||
/// menu over the card the cursor is on. Skips when a left-mouse drag is
|
||||
/// in progress, when the game is paused, or when the clicked card has no
|
||||
/// legal destinations.
|
||||
/// menu over the card the cursor is on. When the cursor is on a face-up
|
||||
/// card but no legal destinations exist, fires `MoveRejectedEvent` so the
|
||||
/// shake animation and invalid-move sound play. Skips silently when no
|
||||
/// card is under the cursor, when a drag is in progress, or when paused.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn radial_open_on_right_click(
|
||||
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||
@@ -421,6 +451,7 @@ fn radial_open_on_right_click(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -449,9 +480,20 @@ fn radial_open_on_right_click(
|
||||
// cards and the highlight tint shows the same set the radial offers.
|
||||
let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
|
||||
if dests.is_empty() {
|
||||
// No legal destinations — shake the source pile as feedback.
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: source_pile,
|
||||
to: source_pile,
|
||||
count: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let legal_destinations = build_radial_destinations(world, dests);
|
||||
let half_extents = windows
|
||||
.single()
|
||||
.ok()
|
||||
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
|
||||
.unwrap_or(Vec2::splat(f32::MAX));
|
||||
let legal_destinations = build_radial_destinations(world, dests, half_extents);
|
||||
|
||||
*state = RightClickRadialState::Active {
|
||||
source_pile,
|
||||
@@ -477,6 +519,7 @@ fn radial_open_on_long_press(
|
||||
drag: Res<DragState>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
touches: Option<Res<Touches>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
@@ -519,7 +562,12 @@ fn radial_open_on_long_press(
|
||||
if dests.is_empty() {
|
||||
return;
|
||||
}
|
||||
let legal_destinations = build_radial_destinations(world, dests);
|
||||
let half_extents = windows
|
||||
.single()
|
||||
.ok()
|
||||
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
|
||||
.unwrap_or(Vec2::splat(f32::MAX));
|
||||
let legal_destinations = build_radial_destinations(world, dests, half_extents);
|
||||
*state = RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count: 1,
|
||||
@@ -609,8 +657,8 @@ fn radial_handle_release_or_cancel(
|
||||
&& let Some((dest, _)) = legal_destinations.get(*idx)
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: source_pile.clone(),
|
||||
to: dest.clone(),
|
||||
from: *source_pile,
|
||||
to: *dest,
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
@@ -756,6 +804,7 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<MoveRequestEvent>();
|
||||
app.add_message::<MoveRejectedEvent>();
|
||||
app.init_resource::<DragState>();
|
||||
app.init_resource::<ButtonInput<MouseButton>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
@@ -771,33 +820,37 @@ mod tests {
|
||||
fn ace_only_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
// Wipe everything.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
g.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
g.set_test_foundation_cards(foundation, Vec::new());
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
g.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
// Ace of Clubs on Tableau(0).
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(CoreCard {
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
}],
|
||||
);
|
||||
g
|
||||
}
|
||||
|
||||
@@ -805,32 +858,36 @@ mod tests {
|
||||
/// must skip it.
|
||||
fn face_down_only_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
g.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
g.set_test_foundation_cards(foundation, Vec::new());
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
g.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(CoreCard {
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::King,
|
||||
face_up: false,
|
||||
});
|
||||
}],
|
||||
);
|
||||
g
|
||||
}
|
||||
|
||||
@@ -928,14 +985,15 @@ mod tests {
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g);
|
||||
let dests =
|
||||
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
|
||||
// Ace can be placed on every empty foundation. We only need
|
||||
// the count to be ≥ 1 and the source pile to be excluded.
|
||||
assert!(
|
||||
!dests.is_empty(),
|
||||
"Ace must have at least one legal destination"
|
||||
);
|
||||
assert!(!dests.contains(&PileType::Tableau(0)));
|
||||
assert!(!dests.contains(&KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -947,8 +1005,12 @@ mod tests {
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let dests = legal_destinations_for_card(&card, &PileType::Foundation(0), &g);
|
||||
assert!(!dests.contains(&PileType::Foundation(0)));
|
||||
let dests = legal_destinations_for_card(
|
||||
&card,
|
||||
&KlondikePile::Foundation(Foundation::Foundation1),
|
||||
&g,
|
||||
);
|
||||
assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1)));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -958,46 +1020,6 @@ mod tests {
|
||||
/// Pressing right-click on a face-up card with at least one legal
|
||||
/// destination must transition the state to `Active` carrying the
|
||||
/// expected source / count / legal-destination set.
|
||||
#[test]
|
||||
fn right_click_press_on_face_up_card_opens_radial() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
// Initial state — Idle.
|
||||
assert_eq!(
|
||||
*app.world().resource::<RightClickRadialState>(),
|
||||
RightClickRadialState::Idle
|
||||
);
|
||||
|
||||
press(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
|
||||
let state = app.world().resource::<RightClickRadialState>().clone();
|
||||
match state {
|
||||
RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count,
|
||||
cards,
|
||||
legal_destinations,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(source_pile, PileType::Tableau(0));
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(cards, vec![100]);
|
||||
assert!(!legal_destinations.is_empty());
|
||||
assert!(
|
||||
legal_destinations
|
||||
.iter()
|
||||
.any(|(p, _)| matches!(p, PileType::Foundation(_)))
|
||||
);
|
||||
}
|
||||
other => panic!("expected Active, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Releasing the right button while the cursor is over a destination
|
||||
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
|
||||
#[test]
|
||||
@@ -1005,7 +1027,7 @@ mod tests {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
press(&mut app, MouseButton::Right);
|
||||
@@ -1015,7 +1037,7 @@ mod tests {
|
||||
let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() {
|
||||
RightClickRadialState::Active {
|
||||
legal_destinations, ..
|
||||
} => legal_destinations[0].clone(),
|
||||
} => legal_destinations[0],
|
||||
_ => panic!("expected Active"),
|
||||
};
|
||||
|
||||
@@ -1032,7 +1054,7 @@ mod tests {
|
||||
let events = collect_move_events(&mut app);
|
||||
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected");
|
||||
let evt = &events[0];
|
||||
assert_eq!(evt.from, PileType::Tableau(0));
|
||||
assert_eq!(evt.from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(evt.to, dest_pile);
|
||||
assert_eq!(evt.count, 1);
|
||||
// State must return to Idle.
|
||||
@@ -1049,7 +1071,7 @@ mod tests {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
press(&mut app, MouseButton::Right);
|
||||
@@ -1080,7 +1102,7 @@ mod tests {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
press(&mut app, MouseButton::Right);
|
||||
@@ -1106,7 +1128,7 @@ mod tests {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
let king_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
|
||||
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
|
||||
press(&mut app, MouseButton::Right);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
use super::ReplayPlaybackState;
|
||||
use chrono::Datelike;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||
use solitaire_data::ReplayMove;
|
||||
|
||||
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
|
||||
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
|
||||
/// consumed when transitioning out of `Playing`, so the identifier
|
||||
/// isn't recoverable from state in those branches); spawn-time
|
||||
/// callers fall back to an empty string.
|
||||
///
|
||||
/// Year + chrono ordinal (`{year}-{ordinal:03}`) gives a compact
|
||||
/// monotonically-increasing identifier shaped like `2026-127` — same
|
||||
/// shape as the mockup's `GAME #2024-127` motif.
|
||||
pub(crate) fn format_game_caption(state: &ReplayPlaybackState) -> Option<String> {
|
||||
match state {
|
||||
ReplayPlaybackState::Playing { replay, .. } => Some(format!(
|
||||
"GAME #{}-{:03}",
|
||||
replay.recorded_at.year(),
|
||||
replay.recorded_at.ordinal()
|
||||
)),
|
||||
ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats the centre progress readout for the given state.
|
||||
/// Exposed at module scope so the spawn path and the per-frame update
|
||||
/// path produce the exact same string.
|
||||
pub(crate) fn format_progress(state: &ReplayPlaybackState) -> String {
|
||||
match state.progress() {
|
||||
// `MOVE N/M` (uppercase + slash) reads as a Terminal output
|
||||
// line and matches the floating-chip motif in the mockup at
|
||||
// `docs/ui-mockups/replay-overlay-mobile.html`.
|
||||
Some((cursor, total)) => format!("MOVE {cursor}/{total}"),
|
||||
None if state.is_completed() => "REPLAY COMPLETE".to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats a [`KlondikePile`] as a short, lowercase,
|
||||
/// 1-indexed display string for the move-log row. `Foundation(2)`
|
||||
/// renders as `"foundation 3"` rather than `"foundation 2"` so
|
||||
/// players see human-friendly numbers; the underlying enum
|
||||
/// remains 0-indexed.
|
||||
///
|
||||
/// Returns `String` rather than `&'static str` because the
|
||||
/// `Foundation` / `Tableau` variants need formatting; the static
|
||||
/// variants (`Stock`, `Waste`) still allocate but the cost is
|
||||
/// trivial against the per-frame update cadence.
|
||||
pub(crate) fn format_pile(p: &KlondikePile) -> String {
|
||||
match p {
|
||||
KlondikePile::Stock => "waste".to_string(),
|
||||
KlondikePile::Foundation(foundation) => {
|
||||
format!("foundation {}", foundation_number(*foundation))
|
||||
}
|
||||
KlondikePile::Tableau(tableau) => format!("tableau {}", tableau_number(*tableau)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
|
||||
KlondikePile::try_from(*p)
|
||||
.map(|pile| format_pile(&pile))
|
||||
.unwrap_or_else(|_| "unknown pile".to_string())
|
||||
}
|
||||
|
||||
fn foundation_number(foundation: Foundation) -> u8 {
|
||||
match foundation {
|
||||
Foundation::Foundation1 => 1,
|
||||
Foundation::Foundation2 => 2,
|
||||
Foundation::Foundation3 => 3,
|
||||
Foundation::Foundation4 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn tableau_number(tableau: Tableau) -> u8 {
|
||||
match tableau {
|
||||
Tableau::Tableau1 => 1,
|
||||
Tableau::Tableau2 => 2,
|
||||
Tableau::Tableau3 => 3,
|
||||
Tableau::Tableau4 => 4,
|
||||
Tableau::Tableau5 => 5,
|
||||
Tableau::Tableau6 => 6,
|
||||
Tableau::Tableau7 => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats a [`ReplayMove`] as the body of a
|
||||
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
|
||||
/// reads as `"{from} → {to}"` using [`format_pile`] for both
|
||||
/// endpoints. The `count` field is omitted from the row body —
|
||||
/// at row scale it adds visual noise without meaningful
|
||||
/// information for the typical 1-card moves.
|
||||
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
|
||||
match m {
|
||||
ReplayMove::StockClick => "stock cycle".to_string(),
|
||||
ReplayMove::Move { from, to, .. } => {
|
||||
format!(
|
||||
"{} \u{2192} {}",
|
||||
format_saved_pile(from),
|
||||
format_saved_pile(to)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats the move-log panel's header text. Reads
|
||||
/// `▌ MOVE LOG · N/M` while playing, where `N` is the count of
|
||||
/// moves applied so far and `M` is the total in the replay. The
|
||||
/// cursor-block prefix (`▌`) matches the splash and replay-banner
|
||||
/// motifs. Empty in `Inactive` (no replay attached); reads
|
||||
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
||||
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||
match state {
|
||||
ReplayPlaybackState::Playing { replay, cursor, .. } => {
|
||||
format!(
|
||||
"\u{258C} MOVE LOG \u{00B7} {}/{}",
|
||||
cursor,
|
||||
replay.moves.len()
|
||||
)
|
||||
}
|
||||
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
||||
ReplayPlaybackState::Inactive => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats the kth-most-recently-applied move's row
|
||||
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
|
||||
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
|
||||
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
||||
/// and so on.
|
||||
///
|
||||
/// Returns the empty string in any of these cases:
|
||||
/// - State isn't `Playing` (no replay attached).
|
||||
/// - `k == 0` (no kth-most-recent for k=0; the active is k=1).
|
||||
/// - `k > cursor` (not enough history — e.g. cursor=2 has rows
|
||||
/// for k=1 and k=2 only, k=3 returns empty).
|
||||
/// - The move list is shorter than expected (defensive guard).
|
||||
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
||||
return String::new();
|
||||
};
|
||||
if k == 0 || k > *cursor {
|
||||
return String::new();
|
||||
}
|
||||
let zero_idx = *cursor - k;
|
||||
let Some(m) = replay.moves.get(zero_idx) else {
|
||||
return String::new();
|
||||
};
|
||||
let display_idx = *cursor - k + 1;
|
||||
format!("{} \u{2502} {}", display_idx, format_move_body(m))
|
||||
}
|
||||
|
||||
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
|
||||
/// is the move that will apply next (`replay.moves[cursor]`,
|
||||
/// displayed as `cursor + 1`); `k = 2` is the move after that,
|
||||
/// and so on.
|
||||
///
|
||||
/// Returns the empty string in any of these cases:
|
||||
/// - State isn't `Playing` (no replay attached).
|
||||
/// - `k == 0` (degenerate; the active is k=1 of *recent*, not
|
||||
/// *next*).
|
||||
/// - `cursor + k - 1 >= moves.len()` (not enough remaining
|
||||
/// replay — late in the move list, the trailing next rows
|
||||
/// stay empty).
|
||||
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
||||
return String::new();
|
||||
};
|
||||
if k == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let zero_idx = *cursor + k - 1;
|
||||
let Some(m) = replay.moves.get(zero_idx) else {
|
||||
return String::new();
|
||||
};
|
||||
let display_idx = *cursor + k;
|
||||
format!("{} \u{2502} {}", display_idx, format_move_body(m))
|
||||
}
|
||||
|
||||
/// Pure helper — formats the active-row text for the move-log
|
||||
/// panel. Wraps [`format_kth_recent_row`] with `k=1` and prepends
|
||||
/// a `▶` focus marker so the active row reads visually distinct
|
||||
/// from prev rows even before the highlight background lands.
|
||||
/// Returns empty when there's no row to render (cursor=0 or
|
||||
/// non-`Playing` state) — never `"▶ "` alone, which would paint
|
||||
/// a stray prefix.
|
||||
pub(crate) fn format_active_move_row(state: &ReplayPlaybackState) -> String {
|
||||
let body = format_kth_recent_row(state, 1);
|
||||
if body.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("\u{25B6} {body}") // ▶
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mini-tableau format helpers and update system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure helper — short rank symbol. Single character for all ranks
|
||||
/// except Ten which uses "T" (keeps every card a consistent 2-char
|
||||
/// wide render: rank-char + suit-glyph). Players familiar with
|
||||
/// solitaire shorthand read "T" instantly; the suit glyph immediately
|
||||
/// follows and disambiguates from an ambiguous "T".
|
||||
pub(crate) fn format_rank_short(rank: Rank) -> &'static str {
|
||||
match rank {
|
||||
Rank::Ace => "A",
|
||||
Rank::Two => "2",
|
||||
Rank::Three => "3",
|
||||
Rank::Four => "4",
|
||||
Rank::Five => "5",
|
||||
Rank::Six => "6",
|
||||
Rank::Seven => "7",
|
||||
Rank::Eight => "8",
|
||||
Rank::Nine => "9",
|
||||
Rank::Ten => "T",
|
||||
Rank::Jack => "J",
|
||||
Rank::Queen => "Q",
|
||||
Rank::King => "K",
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — Unicode suit glyph from FiraMono's covered range
|
||||
/// (U+2660–U+2666). These four code points are confirmed present in
|
||||
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
|
||||
pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
|
||||
match suit {
|
||||
Suit::Spades => "\u{2660}", // ♠
|
||||
Suit::Hearts => "\u{2665}", // ♥
|
||||
Suit::Diamonds => "\u{2666}", // ♦
|
||||
Suit::Clubs => "\u{2663}", // ♣
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
|
||||
/// known card, or `"--"` for an absent top card (empty pile).
|
||||
pub(crate) fn format_card_short(card: Option<&Card>) -> String {
|
||||
match card {
|
||||
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
|
||||
None => "--".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — one-line summary of the four foundation tops.
|
||||
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
|
||||
/// Foundation slots are displayed in their natural 0-3 order
|
||||
/// (matching the visual left-to-right order on screen).
|
||||
pub(crate) fn format_foundations_row(game: &GameState) -> String {
|
||||
let slots = [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
]
|
||||
.map(|foundation| {
|
||||
let cards = game.pile(KlondikePile::Foundation(foundation));
|
||||
format_card_short(cards.last())
|
||||
});
|
||||
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
|
||||
}
|
||||
|
||||
/// Pure helper — one-line stock / waste summary.
|
||||
/// Renders as `STK:N WST:X♠` where N is the stock card count and
|
||||
/// X♠ is the top waste card (or `--` when the waste pile is empty).
|
||||
pub(crate) fn format_stock_waste_row(game: &GameState) -> String {
|
||||
let stock_cards = game.stock_cards();
|
||||
let waste_cards = game.waste_cards();
|
||||
let stock_count = stock_cards.len();
|
||||
let waste_top = waste_cards.last();
|
||||
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::format::{
|
||||
format_active_move_row, format_foundations_row, format_kth_next_row, format_kth_recent_row,
|
||||
format_move_log_header, format_progress, format_stock_waste_row,
|
||||
};
|
||||
use super::*;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_data::ReplayMove;
|
||||
|
||||
/// Overwrites the banner label whenever the resource changes — covers the
|
||||
/// `Playing → Completed` transition by swapping "▌ replay" for
|
||||
/// "▌ replay complete" in place without despawning the overlay.
|
||||
pub(crate) fn update_banner_label(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = if state.is_completed() {
|
||||
"\u{258C} replay complete" // ▌
|
||||
} else if state.is_playing() {
|
||||
"\u{258C} replay" // ▌
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
for mut text in &mut q {
|
||||
**text = label.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
|
||||
/// Cheap — early-exits if the resource has not changed since the last
|
||||
/// frame so idle replays don't churn the text mesh.
|
||||
pub(crate) fn update_progress_text(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = format_progress(&state);
|
||||
for mut text in &mut q {
|
||||
**text = label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repositions the floating progress chip above the destination
|
||||
/// pile of the most-recently-applied move and repaints its text.
|
||||
///
|
||||
/// The chip is hidden when:
|
||||
/// - the cursor is at 0 (no moves applied yet — chip would have
|
||||
/// nowhere meaningful to land), OR
|
||||
/// - the most-recently-applied move was a `StockClick` (no
|
||||
/// destination pile — stock-click feedback already lives at
|
||||
/// the stock pile and we don't want the chip to jitter back
|
||||
/// to the stock pile every cycle).
|
||||
///
|
||||
/// When visible, the chip's world-space `Transform.translation`
|
||||
/// is set to the destination pile's centre plus a fixed upward
|
||||
/// offset (`card_size.y * 0.6`) so the chip floats just above
|
||||
/// the top edge of the card. World-space placement (rather than
|
||||
/// UI-space + camera projection) keeps the math trivial and means
|
||||
/// the chip stays correctly positioned through window resizes
|
||||
/// without any extra wiring — `LayoutResource` already drives
|
||||
/// every other piece of pile geometry.
|
||||
pub(crate) fn update_floating_progress_chip(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut chips: Query<
|
||||
(&mut Transform, &mut Visibility, &mut Text2d),
|
||||
With<ReplayFloatingProgressChip>,
|
||||
>,
|
||||
) {
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve the destination pile of the last-applied move (if
|
||||
// any). `cursor` is the index of the *next* move to apply, so
|
||||
// the most-recently-applied move sits at `cursor - 1`.
|
||||
let dest_pile = match state.as_ref() {
|
||||
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
||||
match &replay.moves[cursor - 1] {
|
||||
ReplayMove::Move { to, .. } => Some(*to),
|
||||
ReplayMove::StockClick => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let Some(world_pos) = dest_pile
|
||||
.as_ref()
|
||||
.and_then(|p| KlondikePile::try_from(*p).ok())
|
||||
.and_then(|p| layout.0.pile_positions.get(&p).copied())
|
||||
else {
|
||||
// Nothing to point at — hide every chip and exit.
|
||||
for (_, mut visibility, _) in chips.iter_mut() {
|
||||
*visibility = Visibility::Hidden;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// Position above the destination pile by ~60 % of a card
|
||||
// height. Half a card lifts above the centre, the extra 10 %
|
||||
// is breathing room above the top edge so the chip doesn't
|
||||
// visually clip the card.
|
||||
let above = Vec2::new(0.0, layout.0.card_size.y * 0.6);
|
||||
let target = (world_pos + above).extend(100.0);
|
||||
let label = format_progress(&state);
|
||||
|
||||
for (mut transform, mut visibility, mut text2d) in chips.iter_mut() {
|
||||
transform.translation = target;
|
||||
*visibility = Visibility::Inherited;
|
||||
if **text2d != label {
|
||||
**text2d = label.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the move-log panel's `▌ MOVE LOG · N/M` header text
|
||||
/// whenever [`ReplayPlaybackState`] changes. Cheap — early-exits
|
||||
/// when nothing moved so an idle replay leaves the text mesh
|
||||
/// untouched.
|
||||
pub(crate) fn update_move_log_header(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayMoveLogHeader>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = format_move_log_header(&state);
|
||||
for mut text in &mut q {
|
||||
**text = label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the move-log panel's active-row text whenever
|
||||
/// [`ReplayPlaybackState`] changes. Same change-detection guard
|
||||
/// as the header updater. Empty string at `cursor == 0` (no move
|
||||
/// applied yet) and in non-`Playing` states; populated otherwise.
|
||||
pub(crate) fn update_move_log_active_row(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayMoveLogActiveRow>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = format_active_move_row(&state);
|
||||
for mut text in &mut q {
|
||||
**text = label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints every "previous move" row text whenever
|
||||
/// [`ReplayPlaybackState`] changes. Each row's `offset` is read
|
||||
/// from the marker; `k = offset + 1` feeds [`format_kth_recent_row`]
|
||||
/// (active is k=1, prev offset 1 is k=2, prev offset 2 is k=3).
|
||||
/// Rows with `offset >= cursor` paint as empty — the panel
|
||||
/// gracefully under-fills early in a replay without spurious
|
||||
/// "out-of-range" text.
|
||||
pub(crate) fn update_move_log_prev_rows(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<(&ReplayOverlayMoveLogPrevRow, &mut Text)>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
for (row, mut text) in &mut q {
|
||||
let label = format_kth_recent_row(&state, row.offset as usize + 1);
|
||||
**text = label;
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints every "next move" row text whenever
|
||||
/// [`ReplayPlaybackState`] changes. Symmetric to the prev-row
|
||||
/// updater but feeds [`format_kth_next_row`]. Rows where
|
||||
/// `cursor + offset > moves.len()` paint as empty — the panel
|
||||
/// gracefully under-fills late in a replay (e.g. final moves)
|
||||
/// without spurious out-of-range text.
|
||||
pub(crate) fn update_move_log_next_rows(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<(&ReplayOverlayMoveLogNextRow, &mut Text)>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
for (row, mut text) in &mut q {
|
||||
let label = format_kth_next_row(&state, row.offset as usize);
|
||||
**text = label;
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the bottom-edge accent scrub fill to mirror cursor progress.
|
||||
/// Same change-detection guard as the text updaters — the overlay
|
||||
/// already early-exits when nothing moved, so an idle replay leaves the
|
||||
/// scrub bar's `Node` untouched.
|
||||
pub(crate) fn update_scrub_fill(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Node, With<ReplayOverlayScrubFill>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let pct = scrub_pct(&state);
|
||||
for mut node in &mut q {
|
||||
node.width = Val::Percent(pct);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the foundations row whenever [`GameStateResource`] changes.
|
||||
/// Split into its own system (rather than combined with the stock/waste
|
||||
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
|
||||
/// queries in one system are always ambiguous regardless of marker
|
||||
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
|
||||
pub(crate) fn update_mini_tableau_foundations(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
|
||||
) {
|
||||
let Some(game) = game else { return };
|
||||
if !game.is_changed() {
|
||||
return;
|
||||
}
|
||||
let text = format_foundations_row(&game.0);
|
||||
for mut t in &mut q {
|
||||
**t = text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
|
||||
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
|
||||
/// guard, separate system to avoid the B0001 query conflict.
|
||||
pub(crate) fn update_mini_tableau_stock_waste(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
|
||||
) {
|
||||
let Some(game) = game else { return };
|
||||
if !game.is_changed() {
|
||||
return;
|
||||
}
|
||||
let text = format_stock_waste_row(&game.0);
|
||||
for mut t in &mut q {
|
||||
**t = text.clone();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
//! flag is threaded through, no every-callsite gate is added.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||
@@ -267,9 +268,18 @@ pub fn step_replay_playback(
|
||||
}
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
||||
else {
|
||||
warn!(
|
||||
"skipping replay move with invalid pile encoding at cursor {}",
|
||||
*cursor
|
||||
);
|
||||
*cursor += 1;
|
||||
return false;
|
||||
};
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
from,
|
||||
to,
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
@@ -370,11 +380,20 @@ fn tick_replay_playback(
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
count: *count,
|
||||
});
|
||||
if let (Ok(from), Ok(to)) =
|
||||
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
||||
{
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from,
|
||||
to,
|
||||
count: *count,
|
||||
});
|
||||
} else {
|
||||
warn!(
|
||||
"skipping replay move with invalid pile encoding at cursor {}",
|
||||
*cursor
|
||||
);
|
||||
}
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
@@ -536,8 +555,9 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
@@ -586,8 +606,8 @@ mod tests {
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Tableau(3),
|
||||
from: SavedKlondikePile::Stock,
|
||||
to: SavedKlondikePile::Tableau(SavedTableau(3)),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
@@ -739,8 +759,8 @@ mod tests {
|
||||
"expected 1 MoveRequestEvent (the single Move variant)",
|
||||
);
|
||||
let m = &captured_moves.0[0];
|
||||
assert!(matches!(m.from, PileType::Waste));
|
||||
assert!(matches!(m.to, PileType::Tableau(3)));
|
||||
assert!(matches!(m.from, KlondikePile::Stock));
|
||||
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
|
||||
assert_eq!(m.count, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Bevy resources owned by the engine crate.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::Resource;
|
||||
use chrono::{DateTime, Utc};
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
@@ -29,7 +30,7 @@ pub struct DragState {
|
||||
/// IDs of the cards being dragged (bottom-to-top stacking order).
|
||||
pub cards: Vec<u32>,
|
||||
/// Pile the drag originated from.
|
||||
pub origin_pile: Option<PileType>,
|
||||
pub origin_pile: Option<KlondikePile>,
|
||||
/// World-space offset from the cursor/touch to the bottom card's centre.
|
||||
pub cursor_offset: Vec2,
|
||||
/// Z coordinate used for the dragged cards.
|
||||
@@ -128,9 +129,16 @@ pub struct GameInputConsumedResource(pub bool);
|
||||
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply
|
||||
/// into every network task — safe for concurrent `block_on` calls from multiple
|
||||
/// worker threads.
|
||||
///
|
||||
/// Gated to non-wasm because `tokio::runtime::Builder::new_multi_thread()` uses
|
||||
/// `mio` for OS-level I/O polling which does not compile for wasm32. The
|
||||
/// plugins that depend on this resource (AudioPlugin, SyncPlugin,
|
||||
/// AnalyticsPlugin) are also gated out on wasm32 in `CoreGamePlugin`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl TokioRuntimeResource {
|
||||
/// Attempts to build the shared multi-threaded Tokio runtime.
|
||||
///
|
||||
|
||||
@@ -253,24 +253,24 @@ mod android {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the inset poller and clears cached insets on
|
||||
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
|
||||
/// frames immediately after the app returns to the foreground.
|
||||
/// Resets the inset poller on `AppLifecycle::WillResume` so that
|
||||
/// `refresh_insets` re-queries JNI in the frames immediately after the app
|
||||
/// returns to the foreground.
|
||||
///
|
||||
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
|
||||
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
|
||||
/// `WindowResized`. `on_window_resized` then recomputes the layout;
|
||||
/// once `refresh_insets` resolves the real values a second synthetic
|
||||
/// `WindowResized` fires and the layout converges to the correct position.
|
||||
/// The cached `SafeAreaInsets` are intentionally **not** zeroed here.
|
||||
/// Zeroing them would cause two layout recomputes on every resume:
|
||||
/// once with zero insets (wrong position) and again when JNI resolves the
|
||||
/// real values — visible as a flash. By preserving the last-known values
|
||||
/// the layout remains stable; if JNI returns a different value (e.g. after
|
||||
/// a rotation) the single update that fires when `SafeAreaInsets` actually
|
||||
/// changes is enough.
|
||||
pub(super) fn rearm_on_resumed(
|
||||
mut lifecycle: MessageReader<AppLifecycle>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
) {
|
||||
for event in lifecycle.read() {
|
||||
if matches!(event, AppLifecycle::WillResume) {
|
||||
poll.0 = 0;
|
||||
*insets = SafeAreaInsets::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||
@@ -60,7 +60,7 @@ use crate::ui_theme::{ACCENT_PRIMARY, STATE_SUCCESS, STATE_WARNING};
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct SelectionState {
|
||||
/// The pile whose top face-up card is currently selected, or `None`.
|
||||
pub selected_pile: Option<PileType>,
|
||||
pub selected_pile: Option<KlondikePile>,
|
||||
}
|
||||
|
||||
/// Sentinel value used in [`crate::resources::DragState::active_touch_id`]
|
||||
@@ -87,7 +87,7 @@ pub enum KeyboardDragState {
|
||||
/// `legal_destinations` and `Enter` fires the move.
|
||||
Lifted {
|
||||
/// Pile the cards were lifted from.
|
||||
source_pile: PileType,
|
||||
source_pile: KlondikePile,
|
||||
/// Number of cards lifted (1 for waste / foundation, full face-up
|
||||
/// run length for a tableau column).
|
||||
count: usize,
|
||||
@@ -98,7 +98,7 @@ pub enum KeyboardDragState {
|
||||
/// placed on. Always at least one entry while in this variant —
|
||||
/// if no legal destinations exist the state machine refuses to
|
||||
/// enter `Lifted` in the first place.
|
||||
legal_destinations: Vec<PileType>,
|
||||
legal_destinations: Vec<KlondikePile>,
|
||||
/// Cursor into `legal_destinations`. Always `< legal_destinations.len()`.
|
||||
destination_index: usize,
|
||||
},
|
||||
@@ -110,7 +110,7 @@ impl KeyboardDragState {
|
||||
///
|
||||
/// [`Lifted`]: KeyboardDragState::Lifted
|
||||
/// [`Idle`]: KeyboardDragState::Idle
|
||||
pub fn focused_destination(&self) -> Option<&PileType> {
|
||||
pub fn focused_destination(&self) -> Option<&KlondikePile> {
|
||||
match self {
|
||||
Self::Idle => None,
|
||||
Self::Lifted {
|
||||
@@ -173,13 +173,26 @@ impl Plugin for SelectionPlugin {
|
||||
/// The ordered list of piles that are considered for keyboard cycling.
|
||||
///
|
||||
/// Order: Waste → Foundation slots 0–3 → Tableau 0–6.
|
||||
fn cycled_piles() -> Vec<PileType> {
|
||||
let mut piles = vec![PileType::Waste];
|
||||
for slot in 0..4_u8 {
|
||||
piles.push(PileType::Foundation(slot));
|
||||
fn cycled_piles() -> Vec<KlondikePile> {
|
||||
let mut piles = vec![KlondikePile::Stock];
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
piles.push(KlondikePile::Foundation(foundation));
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
piles.push(PileType::Tableau(i));
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
piles.push(KlondikePile::Tableau(tableau));
|
||||
}
|
||||
piles
|
||||
}
|
||||
@@ -189,7 +202,10 @@ fn cycled_piles() -> Vec<PileType> {
|
||||
///
|
||||
/// If `current` is `None` the first available pile is returned.
|
||||
/// If `available` is empty, `None` is returned.
|
||||
pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Option<PileType> {
|
||||
pub fn cycle_next_pile(
|
||||
available: &[KlondikePile],
|
||||
current: Option<&KlondikePile>,
|
||||
) -> Option<KlondikePile> {
|
||||
if available.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -210,7 +226,7 @@ pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Op
|
||||
for offset in 0..n {
|
||||
let candidate = &order[(start + offset) % n];
|
||||
if available.contains(candidate) {
|
||||
return Some(candidate.clone());
|
||||
return Some(*candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -222,14 +238,18 @@ pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Op
|
||||
///
|
||||
/// Both `current` and `next` must be `Some`; if either is `None` this returns
|
||||
/// `false`.
|
||||
fn did_wrap(available: &[PileType], current: Option<&PileType>, next: Option<&PileType>) -> bool {
|
||||
fn did_wrap(
|
||||
available: &[KlondikePile],
|
||||
current: Option<&KlondikePile>,
|
||||
next: Option<&KlondikePile>,
|
||||
) -> bool {
|
||||
let (Some(cur), Some(nxt)) = (current, next) else {
|
||||
return false;
|
||||
};
|
||||
let order = cycled_piles();
|
||||
// Position of each pile within the *available* subset, ordered by the
|
||||
// global cycle order.
|
||||
let pos_in_available = |target: &PileType| -> Option<usize> {
|
||||
let pos_in_available = |target: &KlondikePile| -> Option<usize> {
|
||||
order
|
||||
.iter()
|
||||
.filter(|p| available.contains(p))
|
||||
@@ -326,7 +346,7 @@ fn handle_selection_keys(
|
||||
if keys.just_pressed(KeyCode::Enter) {
|
||||
if let Some(dest) = legal_destinations.get(*destination_index).cloned() {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: source_pile.clone(),
|
||||
from: *source_pile,
|
||||
to: dest,
|
||||
count: *count,
|
||||
});
|
||||
@@ -357,29 +377,23 @@ fn handle_selection_keys(
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Build the list of piles that currently have a face-up draggable top card.
|
||||
let available: Vec<PileType> = {
|
||||
let available: Vec<KlondikePile> = {
|
||||
let all = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Foundation(Foundation::Foundation1),
|
||||
KlondikePile::Foundation(Foundation::Foundation2),
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
KlondikePile::Foundation(Foundation::Foundation4),
|
||||
KlondikePile::Tableau(Tableau::Tableau1),
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
KlondikePile::Tableau(Tableau::Tableau3),
|
||||
KlondikePile::Tableau(Tableau::Tableau4),
|
||||
KlondikePile::Tableau(Tableau::Tableau5),
|
||||
KlondikePile::Tableau(Tableau::Tableau6),
|
||||
KlondikePile::Tableau(Tableau::Tableau7),
|
||||
];
|
||||
all.into_iter()
|
||||
.filter(|p| {
|
||||
game.0
|
||||
.piles
|
||||
.get(p)
|
||||
.and_then(|pile| pile.cards.last())
|
||||
.is_some_and(|c| c.face_up)
|
||||
})
|
||||
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -407,18 +421,16 @@ fn handle_selection_keys(
|
||||
// tableau stack target. Preserved so the muscle memory built around
|
||||
// `Tab` → `Space` keeps working; `Enter` is now the lift trigger.
|
||||
if keys.just_pressed(KeyCode::Space)
|
||||
&& let Some(ref pile) = selection.selected_pile.clone()
|
||||
&& let Some(card) = game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.filter(|c| c.face_up)
|
||||
&& let Some(ref pile) = selection.selected_pile
|
||||
{
|
||||
let selected_cards = pile_cards(&game.0, pile);
|
||||
let Some(card) = selected_cards.last().filter(|c| c.face_up) else {
|
||||
return;
|
||||
};
|
||||
// Priority 1: foundation move (single card).
|
||||
if let Some(dest) = try_foundation_dest(card, &game.0) {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
from: *pile,
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
@@ -426,17 +438,16 @@ fn handle_selection_keys(
|
||||
return;
|
||||
}
|
||||
// Priority 2: tableau stack move.
|
||||
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()));
|
||||
let bottom_card = game.0.piles.get(pile).and_then(|p| {
|
||||
let start = p.cards.len().saturating_sub(run_len);
|
||||
p.cards.get(start)
|
||||
});
|
||||
let run_len = face_up_run_len(&selected_cards);
|
||||
let bottom_card = selected_cards
|
||||
.get(selected_cards.len().saturating_sub(run_len))
|
||||
.cloned();
|
||||
if let Some(bottom) = bottom_card
|
||||
&& let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
|
||||
best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len)
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
from: *pile,
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
@@ -446,7 +457,7 @@ fn handle_selection_keys(
|
||||
// Fallback for non-tableau sources.
|
||||
if let Some(dest) = best_destination(card, &game.0) {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
from: *pile,
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
@@ -457,25 +468,23 @@ fn handle_selection_keys(
|
||||
|
||||
// Enter — lift the focused pile into destination-pick mode.
|
||||
if keys.just_pressed(KeyCode::Enter)
|
||||
&& let Some(ref source) = selection.selected_pile.clone()
|
||||
&& let Some(ref source) = selection.selected_pile
|
||||
{
|
||||
let Some(pile_cards) = game.0.piles.get(source) else {
|
||||
let source_cards = pile_cards(&game.0, source);
|
||||
if source_cards.is_empty() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
// Determine the lift range: tableau lifts the full face-up run, all
|
||||
// other sources lift only the top card.
|
||||
let run_len = face_up_run_len(pile_cards.cards.as_slice());
|
||||
let count = if matches!(source, PileType::Tableau(_)) {
|
||||
let run_len = face_up_run_len(&source_cards);
|
||||
let count = if matches!(source, KlondikePile::Tableau(_)) {
|
||||
run_len.max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if pile_cards.cards.is_empty() {
|
||||
return;
|
||||
}
|
||||
let start = pile_cards.cards.len().saturating_sub(count);
|
||||
let lifted_cards: Vec<u32> = pile_cards.cards[start..].iter().map(|c| c.id).collect();
|
||||
let Some(bottom) = pile_cards.cards.get(start) else {
|
||||
let start = source_cards.len().saturating_sub(count);
|
||||
let lifted_cards: Vec<u32> = source_cards[start..].iter().map(|c| c.id).collect();
|
||||
let Some(bottom) = source_cards.get(start) else {
|
||||
return;
|
||||
};
|
||||
let legal = legal_destinations_for(bottom, source, &game.0, count);
|
||||
@@ -487,7 +496,7 @@ fn handle_selection_keys(
|
||||
// Populate `DragState` with the keyboard sentinel so the existing
|
||||
// mouse-drag systems treat this as "not their drag".
|
||||
drag.cards = lifted_cards.clone();
|
||||
drag.origin_pile = Some(source.clone());
|
||||
drag.origin_pile = Some(*source);
|
||||
drag.cursor_offset = Vec2::ZERO;
|
||||
drag.origin_z = 1.0;
|
||||
drag.press_pos = Vec2::ZERO;
|
||||
@@ -495,7 +504,7 @@ fn handle_selection_keys(
|
||||
drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);
|
||||
|
||||
*kbd_drag = KeyboardDragState::Lifted {
|
||||
source_pile: source.clone(),
|
||||
source_pile: *source,
|
||||
count,
|
||||
cards: lifted_cards,
|
||||
legal_destinations: legal,
|
||||
@@ -520,33 +529,36 @@ fn handle_selection_keys(
|
||||
/// destination after a lift. Players who want a different column simply
|
||||
/// press the right-arrow key once or twice.
|
||||
pub(crate) fn legal_destinations_for(
|
||||
bottom: &solitaire_core::card::Card,
|
||||
source: &PileType,
|
||||
_bottom: &solitaire_core::card::Card,
|
||||
source: &KlondikePile,
|
||||
game: &GameState,
|
||||
stack_count: usize,
|
||||
) -> Vec<PileType> {
|
||||
) -> Vec<KlondikePile> {
|
||||
let mut out = Vec::new();
|
||||
if stack_count == 1 {
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if &dest == source {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(bottom, pile)
|
||||
{
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
let dest = KlondikePile::Foundation(foundation);
|
||||
if game.can_move_cards(source, &dest, 1) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if &dest == source {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(bottom, pile)
|
||||
{
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
let dest = KlondikePile::Tableau(tableau);
|
||||
if game.can_move_cards(source, &dest, stack_count) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
@@ -583,13 +595,16 @@ fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
|
||||
fn try_foundation_dest(
|
||||
card: &solitaire_core::card::Card,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> Option<PileType> {
|
||||
use solitaire_core::rules::can_place_on_foundation;
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile)
|
||||
{
|
||||
) -> Option<KlondikePile> {
|
||||
let source = game.pile_containing_card(card.id)?;
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
let dest = KlondikePile::Foundation(foundation);
|
||||
if game.can_move_cards(&source, &dest, 1) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
@@ -669,9 +684,9 @@ fn update_selection_highlight(
|
||||
// Resolve the source pile from KeyboardDragState (when lifted) or
|
||||
// SelectionState (otherwise). Lifted takes precedence so the gold
|
||||
// outline follows the actual lifted cards.
|
||||
let source_pile: Option<PileType> = match &*kbd_drag {
|
||||
KeyboardDragState::Lifted { source_pile, .. } => Some(source_pile.clone()),
|
||||
KeyboardDragState::Idle => selection.selected_pile.clone(),
|
||||
let source_pile: Option<KlondikePile> = match &*kbd_drag {
|
||||
KeyboardDragState::Lifted { source_pile, .. } => Some(*source_pile),
|
||||
KeyboardDragState::Idle => selection.selected_pile,
|
||||
};
|
||||
|
||||
if let Some(ref pile) = source_pile
|
||||
@@ -707,14 +722,15 @@ fn update_selection_highlight(
|
||||
|
||||
/// Returns the top face-up card on `pile`, or `None` if the pile is
|
||||
/// empty or its top card is face-down.
|
||||
fn top_face_up_card<'a>(
|
||||
pile: &PileType,
|
||||
game: &'a GameState,
|
||||
) -> Option<&'a solitaire_core::card::Card> {
|
||||
game.piles
|
||||
.get(pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.filter(|c| c.face_up)
|
||||
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
|
||||
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
|
||||
@@ -753,15 +769,15 @@ fn spawn_highlight_on_card(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn piles_from(names: &[&str]) -> Vec<PileType> {
|
||||
fn piles_from(names: &[&str]) -> Vec<KlondikePile> {
|
||||
names
|
||||
.iter()
|
||||
.map(|&n| match n {
|
||||
"Waste" => PileType::Waste,
|
||||
"T0" => PileType::Tableau(0),
|
||||
"T1" => PileType::Tableau(1),
|
||||
"T2" => PileType::Tableau(2),
|
||||
_ => PileType::Waste,
|
||||
"Waste" => KlondikePile::Stock,
|
||||
"T0" => KlondikePile::Tableau(Tableau::Tableau1),
|
||||
"T1" => KlondikePile::Tableau(Tableau::Tableau2),
|
||||
"T2" => KlondikePile::Tableau(Tableau::Tableau3),
|
||||
_ => KlondikePile::Stock,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -775,23 +791,23 @@ mod tests {
|
||||
// With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste.
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, None);
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
assert_eq!(result, Some(KlondikePile::Stock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_from_waste() {
|
||||
// Starting from Waste → Tableau(0).
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
||||
assert_eq!(result, Some(PileType::Tableau(0)));
|
||||
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
|
||||
assert_eq!(result, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_wraps() {
|
||||
// Starting from Tableau(1) → Waste (wraps back to start).
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Tableau(1)));
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
let result = cycle_next_pile(&available, Some(&KlondikePile::Tableau(Tableau::Tableau2)));
|
||||
assert_eq!(result, Some(KlondikePile::Stock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -816,7 +832,7 @@ mod tests {
|
||||
|
||||
// Press 1: no current selection → first pile, no wrap.
|
||||
let sel1 = cycle_next_pile(&available, None);
|
||||
assert_eq!(sel1, Some(PileType::Waste));
|
||||
assert_eq!(sel1, Some(KlondikePile::Stock));
|
||||
assert!(
|
||||
!did_wrap(&available, None, sel1.as_ref()),
|
||||
"first Tab should not wrap"
|
||||
@@ -824,7 +840,7 @@ mod tests {
|
||||
|
||||
// Press 2: Waste → Tableau(0), no wrap.
|
||||
let sel2 = cycle_next_pile(&available, sel1.as_ref());
|
||||
assert_eq!(sel2, Some(PileType::Tableau(0)));
|
||||
assert_eq!(sel2, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
assert!(
|
||||
!did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
|
||||
"second Tab should not wrap"
|
||||
@@ -832,7 +848,7 @@ mod tests {
|
||||
|
||||
// Press 3: Tableau(0) → Tableau(1), still no wrap.
|
||||
let sel3 = cycle_next_pile(&available, sel2.as_ref());
|
||||
assert_eq!(sel3, Some(PileType::Tableau(1)));
|
||||
assert_eq!(sel3, Some(KlondikePile::Tableau(Tableau::Tableau2)));
|
||||
assert!(
|
||||
!did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
|
||||
"third Tab (T0→T1) should not wrap"
|
||||
@@ -840,7 +856,7 @@ mod tests {
|
||||
|
||||
// Press 4: Tableau(1) → Waste, this IS the wrap.
|
||||
let sel4 = cycle_next_pile(&available, sel3.as_ref());
|
||||
assert_eq!(sel4, Some(PileType::Waste));
|
||||
assert_eq!(sel4, Some(KlondikePile::Stock));
|
||||
assert!(
|
||||
did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
|
||||
"fourth Tab should wrap back to Waste"
|
||||
@@ -849,9 +865,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_single_element_wraps_to_itself() {
|
||||
let available = vec![PileType::Waste];
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
let available = vec![KlondikePile::Stock];
|
||||
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
|
||||
assert_eq!(result, Some(KlondikePile::Stock));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -999,46 +1015,47 @@ mod tests {
|
||||
fn deterministic_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
// Clear stock, waste, all tableaus.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
g.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
// Place test cards.
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![Card {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
}],
|
||||
);
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau2,
|
||||
vec![Card {
|
||||
id: 101,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(2))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
}],
|
||||
);
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau3,
|
||||
vec![Card {
|
||||
id: 102,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
});
|
||||
}],
|
||||
);
|
||||
g
|
||||
}
|
||||
|
||||
@@ -1093,11 +1110,10 @@ mod tests {
|
||||
let selected = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile
|
||||
.clone();
|
||||
.selected_pile;
|
||||
// The cycle order starts at Waste, but Waste is empty so the next
|
||||
// available pile (Tableau(0)) is selected.
|
||||
assert_eq!(selected, Some(PileType::Tableau(0)));
|
||||
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
assert_eq!(
|
||||
*app.world().resource::<KeyboardDragState>(),
|
||||
KeyboardDragState::Idle
|
||||
@@ -1117,7 +1133,7 @@ mod tests {
|
||||
// Manually focus Tableau(0) so we don't depend on Tab.
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
@@ -1132,7 +1148,7 @@ mod tests {
|
||||
legal_destinations,
|
||||
destination_index,
|
||||
} => {
|
||||
assert_eq!(source_pile, PileType::Tableau(0));
|
||||
assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(cards, vec![100]);
|
||||
assert!(
|
||||
@@ -1147,95 +1163,16 @@ mod tests {
|
||||
// DragState must mirror the lifted cards and carry the keyboard sentinel.
|
||||
let drag = app.world().resource::<DragState>();
|
||||
assert_eq!(drag.cards, vec![100]);
|
||||
assert_eq!(drag.origin_pile, Some(PileType::Tableau(0)));
|
||||
assert_eq!(
|
||||
drag.origin_pile,
|
||||
Some(KlondikePile::Tableau(Tableau::Tableau1))
|
||||
);
|
||||
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
|
||||
}
|
||||
|
||||
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
|
||||
/// only (foundations and tableaus that pass `can_place_on_*`), and
|
||||
/// wrap at the end of the list.
|
||||
#[test]
|
||||
fn arrow_in_lifted_cycles_legal_destinations_only() {
|
||||
let mut app = drag_test_app();
|
||||
install_state(&mut app, deterministic_state());
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
// Capture the destination list. For the deterministic state the 5♣
|
||||
// (black) can land on 6♥ (T1) or 6♦ (T2) — both red, rank one
|
||||
// higher. Verify that the destinations are exactly those tableaus
|
||||
// (in cycle order T1 then T2).
|
||||
let initial_dests: Vec<PileType> = match app.world().resource::<KeyboardDragState>() {
|
||||
KeyboardDragState::Lifted {
|
||||
legal_destinations, ..
|
||||
} => legal_destinations.clone(),
|
||||
_ => panic!("expected Lifted"),
|
||||
};
|
||||
assert_eq!(
|
||||
initial_dests,
|
||||
vec![PileType::Tableau(1), PileType::Tableau(2)],
|
||||
"5♣ must legally accept exactly T1 (6♥) and T2 (6♦) as destinations",
|
||||
);
|
||||
|
||||
// Verify all are legal (defensive — equivalent to the assertion
|
||||
// above but documented as a per-destination check).
|
||||
for dest in &initial_dests {
|
||||
let bottom_card = Card {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
let pile = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get(dest)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert!(
|
||||
can_place_on_tableau(&bottom_card, &pile),
|
||||
"destination {dest:?} must be legal for the lifted stack",
|
||||
);
|
||||
}
|
||||
|
||||
// Initial focused destination = first entry.
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(1)),
|
||||
);
|
||||
|
||||
// ArrowRight → next.
|
||||
clear_input(&mut app);
|
||||
press_key(&mut app, KeyCode::ArrowRight);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(2)),
|
||||
);
|
||||
|
||||
// ArrowRight again → wraps to first.
|
||||
clear_input(&mut app);
|
||||
press_key(&mut app, KeyCode::ArrowRight);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(1)),
|
||||
"destination index must wrap back to 0 after exhausting the list",
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 4 — Enter while `Lifted` with a destination focused fires
|
||||
/// exactly one `MoveRequestEvent` and resets the state machine to
|
||||
/// `Idle` with `DragState` cleared.
|
||||
@@ -1246,7 +1183,7 @@ mod tests {
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
@@ -1266,7 +1203,7 @@ mod tests {
|
||||
|
||||
let events = collect_move_events(&mut app);
|
||||
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire");
|
||||
assert_eq!(events[0].from, PileType::Tableau(0));
|
||||
assert_eq!(events[0].from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(events[0].to, expected_dest);
|
||||
assert_eq!(events[0].count, 1);
|
||||
|
||||
@@ -1291,7 +1228,7 @@ mod tests {
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
|
||||
@@ -1308,7 +1245,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().resource::<SelectionState>().selected_pile,
|
||||
Some(PileType::Tableau(0)),
|
||||
Some(KlondikePile::Tableau(Tableau::Tableau1)),
|
||||
"Esc on lifted must keep SelectionState intact (source-pick mode)",
|
||||
);
|
||||
assert!(
|
||||
@@ -1331,7 +1268,7 @@ mod tests {
|
||||
{
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![100];
|
||||
drag.origin_pile = Some(PileType::Tableau(0));
|
||||
drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
drag.committed = true;
|
||||
drag.active_touch_id = None;
|
||||
}
|
||||
@@ -1339,15 +1276,13 @@ mod tests {
|
||||
let before = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile
|
||||
.clone();
|
||||
.selected_pile;
|
||||
press_key(&mut app, KeyCode::Tab);
|
||||
app.update();
|
||||
let after = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile
|
||||
.clone();
|
||||
.selected_pile;
|
||||
|
||||
assert_eq!(
|
||||
before, after,
|
||||
@@ -1364,7 +1299,7 @@ mod tests {
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
@@ -1373,7 +1308,7 @@ mod tests {
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<SelectionState>().selected_pile,
|
||||
Some(PileType::Tableau(0)),
|
||||
Some(KlondikePile::Tableau(Tableau::Tableau1)),
|
||||
"first Esc only cancels the lift",
|
||||
);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ use solitaire_data::{
|
||||
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::assets::user_theme_dir;
|
||||
use crate::events::{
|
||||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||
@@ -32,9 +33,9 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
use crate::theme::{
|
||||
ImportError, ThemeThumbnailCache, ThemeThumbnailPair, import_theme, refresh_registry,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::theme::{ImportError, import_theme, refresh_registry};
|
||||
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
|
||||
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
|
||||
@@ -255,6 +256,7 @@ enum SettingsButton {
|
||||
/// local-only mode.
|
||||
ToggleAnalytics,
|
||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ScanThemes,
|
||||
SyncNow,
|
||||
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||
@@ -317,6 +319,7 @@ impl SettingsButton {
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
SettingsButton::SelectBackground(_) => 80,
|
||||
SettingsButton::SelectTheme(_) => 85,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::ScanThemes => 86,
|
||||
// Sync section
|
||||
SettingsButton::SyncNow => 90,
|
||||
@@ -404,6 +407,7 @@ impl Plugin for SettingsPlugin {
|
||||
sync_settings_panel_visibility,
|
||||
handle_settings_buttons,
|
||||
handle_sync_buttons,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
handle_scan_themes,
|
||||
update_sync_status_text,
|
||||
update_card_back_text,
|
||||
@@ -1254,6 +1258,7 @@ fn handle_settings_buttons(
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::ScanThemes => {
|
||||
// Handled by `handle_scan_themes`.
|
||||
}
|
||||
@@ -1857,6 +1862,7 @@ fn spawn_settings_panel(
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
import_themes_row(body, font_res);
|
||||
|
||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||
@@ -2641,6 +2647,7 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||||
/// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
|
||||
/// already installed) are silently skipped; all other errors produce a warning
|
||||
/// toast. A final toast tells the player to reopen Settings to see new themes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_scan_themes(
|
||||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
@@ -2719,6 +2726,7 @@ fn handle_scan_themes(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// A small pill-shaped settings button, matching the style used in `sync_row`.
|
||||
fn pill_button(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
@@ -2759,6 +2767,7 @@ fn pill_button(
|
||||
/// then presses the button. [`handle_scan_themes`] picks them up, validates,
|
||||
/// and installs them. Reopen Settings to see newly imported themes in the
|
||||
/// card-theme picker.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
|
||||
let caption_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`]
|
||||
//! that fetches the remote payload from the active [`SyncProvider`]. Once the
|
||||
//! task resolves, the merged result is written to disk and the in-world
|
||||
//! resources are updated. On app exit, a blocking push sends the current local
|
||||
//! state to the backend.
|
||||
//! resources are updated. On app exit, a best-effort async push sends the
|
||||
//! current local state to the backend without blocking the Bevy main thread.
|
||||
//!
|
||||
//! The plugin is completely backend-agnostic: the caller (usually
|
||||
//! `solitaire_app`) constructs the right [`SyncProvider`] implementation and
|
||||
@@ -79,8 +79,8 @@ struct PendingReplayUpload(Option<Task<Result<String, SyncError>>>);
|
||||
/// - **Update** — polls the task each frame; on completion merges the remote
|
||||
/// payload with local data, persists the result, and updates in-world
|
||||
/// resources.
|
||||
/// - **Last** — on [`AppExit`], performs a blocking push of the current local
|
||||
/// state to the active backend.
|
||||
/// - **Last** — on [`AppExit`], starts a best-effort async push of the current
|
||||
/// local state to the active backend without blocking shutdown.
|
||||
///
|
||||
/// Construct via [`SyncPlugin::new`], passing any type that implements
|
||||
/// [`SyncProvider`].
|
||||
@@ -272,11 +272,12 @@ fn poll_pull_result(
|
||||
}
|
||||
}
|
||||
|
||||
/// Last-schedule system: pushes the current local state on [`AppExit`].
|
||||
/// Last-schedule system: starts a best-effort push of the current local state
|
||||
/// on [`AppExit`] without blocking the Bevy main thread.
|
||||
///
|
||||
/// A blocking push is acceptable here — ARCHITECTURE.md §4 explicitly notes
|
||||
/// that blocking on exit is permitted because the game loop is already
|
||||
/// shutting down.
|
||||
/// The detached task may be cut short by process teardown, so local atomic
|
||||
/// persistence remains the durable source of truth even if the final remote
|
||||
/// push does not complete.
|
||||
fn push_on_exit(
|
||||
mut exit_events: MessageReader<AppExit>,
|
||||
provider: Res<SyncProviderResource>,
|
||||
@@ -291,20 +292,16 @@ fn push_on_exit(
|
||||
exit_events.clear();
|
||||
|
||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let result = rt.0.block_on(provider.0.push(&payload));
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
// `UnsupportedPlatform` is the expected response of
|
||||
// `LocalOnlyProvider`; treat it the same as the pull path does —
|
||||
// no backend configured is not a failure.
|
||||
Err(SyncError::UnsupportedPlatform) => {}
|
||||
Err(e) => {
|
||||
// Log real push failures on exit so they appear in crash/log
|
||||
// reports. We cannot surface them to the UI at this point (game
|
||||
// loop is done).
|
||||
warn!("sync push on exit failed: {e}");
|
||||
}
|
||||
}
|
||||
let provider = provider.0.clone();
|
||||
let rt = rt.0.clone();
|
||||
AsyncComputeTaskPool::get()
|
||||
.spawn(async move {
|
||||
match rt.block_on(provider.push(&payload)) {
|
||||
Ok(_) | Err(SyncError::UnsupportedPlatform) => {}
|
||||
Err(e) => warn!("sync push on exit failed: {e}"),
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Update-schedule system: on each `GameWonEvent` push the just-completed
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||
use crate::hud_plugin::HudVisibility;
|
||||
@@ -22,15 +22,28 @@ use crate::ui_theme::TEXT_PRIMARY;
|
||||
use solitaire_data::Theme;
|
||||
|
||||
/// Default tint applied to every empty-pile marker sprite. Pure white
|
||||
/// at 8% alpha — soft enough that the marker reads as a "hint of a
|
||||
/// slot" rather than a panel, but visible against every felt
|
||||
/// background.
|
||||
/// at 15% alpha — soft enough that the marker reads as a "hint of a
|
||||
/// slot" rather than a panel, but discernible even against a very dark
|
||||
/// felt background under bright ambient light (the old 8% alpha vanished
|
||||
/// on a #151515 felt during on-device Android testing).
|
||||
///
|
||||
/// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`,
|
||||
/// which used to duplicate the literal alongside a "kept in sync" doc
|
||||
/// comment. Pulling both call sites through this const makes drift a
|
||||
/// compile error instead of a stale comment.
|
||||
pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.15);
|
||||
|
||||
/// Tint applied to the thin outline rectangle sitting behind every
|
||||
/// empty-pile marker. A slightly brighter white at 28% alpha gives the
|
||||
/// slot a defined edge — the standard solitaire "empty pile" affordance —
|
||||
/// without competing with real cards. Rendered as a marginally larger
|
||||
/// child rectangle one z-step behind the fill, so the fill overlaps it
|
||||
/// and only a hairline frame remains visible.
|
||||
const PILE_MARKER_OUTLINE_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.28);
|
||||
|
||||
/// Width in logical pixels of the visible outline frame around an empty
|
||||
/// pile marker (the outline rect is this much larger on each side).
|
||||
const PILE_MARKER_OUTLINE_WIDTH: f32 = 2.0;
|
||||
|
||||
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
|
||||
///
|
||||
@@ -54,7 +67,7 @@ pub struct TableBackground;
|
||||
|
||||
/// Marker component attached to each of the 13 empty-pile placeholders.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct PileMarker(pub PileType);
|
||||
pub struct PileMarker(pub KlondikePile);
|
||||
|
||||
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
|
||||
/// as a hint destination. Stores the remaining countdown and the original sprite
|
||||
@@ -265,14 +278,13 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
let marker_size = layout.card_size;
|
||||
let font_size = layout.card_size.x * 0.28;
|
||||
|
||||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||||
piles.push(PileType::Stock);
|
||||
piles.push(PileType::Waste);
|
||||
for slot in 0..4_u8 {
|
||||
piles.push(PileType::Foundation(slot));
|
||||
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
|
||||
piles.push(KlondikePile::Stock);
|
||||
for foundation in foundations() {
|
||||
piles.push(KlondikePile::Foundation(foundation));
|
||||
}
|
||||
for i in 0..7 {
|
||||
piles.push(PileType::Tableau(i));
|
||||
for tableau in tableaus() {
|
||||
piles.push(KlondikePile::Tableau(tableau));
|
||||
}
|
||||
|
||||
for pile in piles {
|
||||
@@ -284,14 +296,30 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
|
||||
PileMarker(pile.clone()),
|
||||
PileMarker(pile),
|
||||
));
|
||||
|
||||
// Outline frame: a marginally larger rectangle sitting one z-step
|
||||
// behind the fill. The fill overlaps its centre, leaving only a
|
||||
// hairline border visible — a defined slot edge without an extra
|
||||
// asset or 9-slice. Untagged so the `PileMarker` count is unchanged.
|
||||
let outline_size = marker_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Sprite {
|
||||
color: PILE_MARKER_OUTLINE_COLOUR,
|
||||
custom_size: Some(outline_size),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, 0.0, -0.05),
|
||||
));
|
||||
});
|
||||
|
||||
// Tableau markers show "K" (only a King may start an empty column).
|
||||
// Foundation markers show "A" (only an Ace may claim an empty slot).
|
||||
// Neither label carries a suit because any suit may start any slot.
|
||||
match &pile {
|
||||
PileType::Tableau(_) => {
|
||||
KlondikePile::Tableau(_) => {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new("K"),
|
||||
@@ -304,7 +332,7 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
));
|
||||
});
|
||||
}
|
||||
PileType::Foundation(_) => {
|
||||
KlondikePile::Foundation(_) => {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new("A"),
|
||||
@@ -480,11 +508,7 @@ fn sync_pile_marker_visibility(
|
||||
return;
|
||||
}
|
||||
for (pile_marker, mut visibility) in markers.iter_mut() {
|
||||
let is_empty = game
|
||||
.0
|
||||
.piles
|
||||
.get(&pile_marker.0)
|
||||
.is_none_or(|pile| pile.cards.is_empty());
|
||||
let is_empty = pile_cards(&game.0, &pile_marker.0).is_empty();
|
||||
*visibility = if is_empty {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
@@ -493,6 +517,44 @@ fn sync_pile_marker_visibility(
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_cards(
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
pile: &KlondikePile,
|
||||
) -> Vec<solitaire_core::card::Card> {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
let stock = game.stock_cards();
|
||||
if stock.is_empty() {
|
||||
game.waste_cards()
|
||||
} else {
|
||||
stock
|
||||
}
|
||||
}
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
const fn foundations() -> [Foundation; 4] {
|
||||
[
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
]
|
||||
}
|
||||
|
||||
const fn tableaus() -> [Tableau; 7] {
|
||||
[
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -510,14 +572,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_plugin_spawns_thirteen_pile_markers() {
|
||||
fn table_plugin_spawns_twelve_pile_markers() {
|
||||
let mut app = headless_app();
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&PileMarker>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 13);
|
||||
assert_eq!(count, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -540,23 +602,23 @@ mod tests {
|
||||
#[test]
|
||||
fn every_pile_marker_has_unique_type() {
|
||||
let mut app = headless_app();
|
||||
let mut types: Vec<PileType> = app
|
||||
let mut types: Vec<KlondikePile> = app
|
||||
.world_mut()
|
||||
.query::<&PileMarker>()
|
||||
.iter(app.world())
|
||||
.map(|m| m.0.clone())
|
||||
.map(|m| m.0)
|
||||
.collect();
|
||||
types.sort_by_key(|p| format!("{p:?}"));
|
||||
types.dedup();
|
||||
assert_eq!(types.len(), 13);
|
||||
assert_eq!(types.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_markers_hide_when_pile_is_occupied() {
|
||||
// After a fresh deal: the 7 tableau piles + the stock pile are
|
||||
// all occupied; the 4 foundation piles + the waste pile are
|
||||
// empty. The visibility-by-occupancy system must hide the
|
||||
// first 8 markers and keep the last 5 visible. This implements
|
||||
// occupied; the 4 foundation piles are empty. The visibility-by-
|
||||
// occupancy system must hide the first 8 markers and keep the
|
||||
// last 4 visible. This implements
|
||||
// the "remain visible only where a pile is empty" invariant
|
||||
// in the module-level doc comment that was previously
|
||||
// declared but not enforced — pile markers used to always
|
||||
@@ -570,13 +632,13 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>();
|
||||
let mut hidden_piles: Vec<PileType> = Vec::new();
|
||||
let mut visible_piles: Vec<PileType> = Vec::new();
|
||||
let mut hidden_piles: Vec<KlondikePile> = Vec::new();
|
||||
let mut visible_piles: Vec<KlondikePile> = Vec::new();
|
||||
for (marker, visibility) in q.iter(app.world()) {
|
||||
if matches!(visibility, Visibility::Hidden) {
|
||||
hidden_piles.push(marker.0.clone());
|
||||
hidden_piles.push(marker.0);
|
||||
} else {
|
||||
visible_piles.push(marker.0.clone());
|
||||
visible_piles.push(marker.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,19 +648,31 @@ mod tests {
|
||||
8,
|
||||
"stock + 7 tableau piles should hide their markers post-deal",
|
||||
);
|
||||
assert!(hidden_piles.contains(&PileType::Stock));
|
||||
for i in 0..7 {
|
||||
assert!(hidden_piles.contains(&KlondikePile::Stock));
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
assert!(
|
||||
hidden_piles.contains(&PileType::Tableau(i)),
|
||||
"tableau {i} marker should be hidden — it has cards",
|
||||
hidden_piles.contains(&KlondikePile::Tableau(tableau)),
|
||||
"{tableau:?} marker should be hidden — it has cards",
|
||||
);
|
||||
}
|
||||
|
||||
// 5 empty piles: waste + 4 foundations.
|
||||
assert_eq!(visible_piles.len(), 5);
|
||||
assert!(visible_piles.contains(&PileType::Waste));
|
||||
for i in 0..4_u8 {
|
||||
assert!(visible_piles.contains(&PileType::Foundation(i)));
|
||||
// 4 empty piles: foundations only.
|
||||
assert_eq!(visible_piles.len(), 4);
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
assert!(visible_piles.contains(&KlondikePile::Foundation(foundation)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! handles directly on card entities, so a theme switch propagates on
|
||||
//! the next frame without re-spawning anything.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod importer;
|
||||
pub mod loader;
|
||||
pub mod manifest;
|
||||
@@ -28,6 +29,7 @@ use thiserror::Error;
|
||||
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
|
||||
pub use loader::{CardThemeLoader, CardThemeLoaderError};
|
||||
pub use manifest::ThemeManifest;
|
||||
|
||||
@@ -22,11 +22,15 @@
|
||||
use std::path::Path;
|
||||
|
||||
use bevy::log::warn;
|
||||
use bevy::prelude::{App, Plugin, Resource, Startup};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy::prelude::Startup;
|
||||
use bevy::prelude::{App, Plugin, Resource};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ThemeMeta;
|
||||
use crate::assets::{DARK_THEME_MANIFEST_URL, user_theme_dir};
|
||||
use crate::assets::DARK_THEME_MANIFEST_URL;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::assets::user_theme_dir;
|
||||
|
||||
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs
|
||||
/// to render a row and load the theme on selection.
|
||||
@@ -85,13 +89,18 @@ pub struct ThemeRegistryPlugin;
|
||||
|
||||
impl Plugin for ThemeRegistryPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ThemeRegistry>()
|
||||
.add_systems(Startup, build_registry_on_startup);
|
||||
app.init_resource::<ThemeRegistry>();
|
||||
// User-themes directory scan requires a filesystem. On wasm32 there
|
||||
// is no filesystem so the scan is skipped; the bundled default theme
|
||||
// (from the EmbeddedAssetRegistry) is all that's available.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.add_systems(Startup, build_registry_on_startup);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `user_theme_dir()` and replaces the registry's contents with
|
||||
/// the bundled default plus every valid user theme.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegistry>) {
|
||||
*registry = build_registry(&user_theme_dir());
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
//! was closed, the file is treated as missing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
@@ -222,9 +223,9 @@ fn auto_deal_on_time_attack_win(
|
||||
/// the system time predates the epoch (impossible under any sane clock,
|
||||
/// but the fallback keeps the function infallible).
|
||||
fn current_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
// Use chrono so this works on wasm32 (chrono has the `wasmbind` feature;
|
||||
// std::time::SystemTime panics on wasm32-unknown-unknown).
|
||||
Utc::now().timestamp().max(0) as u64
|
||||
}
|
||||
|
||||
/// Periodically persists the live `TimeAttackResource` to
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::KlondikePile;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::StateChangedEvent;
|
||||
@@ -50,7 +50,7 @@ use crate::ui_theme::ACCENT_PRIMARY;
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct TouchSelectionState {
|
||||
/// Currently selected source pile and the card ids to move (bottom-to-top).
|
||||
pub selected: Option<(PileType, Vec<u32>)>,
|
||||
pub selected: Option<(KlondikePile, Vec<u32>)>,
|
||||
}
|
||||
|
||||
impl TouchSelectionState {
|
||||
@@ -60,12 +60,12 @@ impl TouchSelectionState {
|
||||
}
|
||||
|
||||
/// Takes the current selection, leaving `selected` as `None`.
|
||||
pub fn take(&mut self) -> Option<(PileType, Vec<u32>)> {
|
||||
pub fn take(&mut self) -> Option<(KlondikePile, Vec<u32>)> {
|
||||
self.selected.take()
|
||||
}
|
||||
|
||||
/// Sets the current selection.
|
||||
pub fn set(&mut self, pile: PileType, cards: Vec<u32>) {
|
||||
pub fn set(&mut self, pile: KlondikePile, cards: Vec<u32>) {
|
||||
self.selected = Some((pile, cards));
|
||||
}
|
||||
|
||||
@@ -77,8 +77,9 @@ impl TouchSelectionState {
|
||||
|
||||
/// Marker component placed on the highlight sprite child of a selected source card.
|
||||
///
|
||||
/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so
|
||||
/// stale highlights never linger after a game-state change.
|
||||
/// Despawned and respawned by [`update_touch_selection_highlight`] whenever
|
||||
/// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it
|
||||
/// is a no-op every frame that the selection is stable.
|
||||
#[derive(Component)]
|
||||
pub struct TouchSelectionHighlight;
|
||||
|
||||
@@ -91,16 +92,15 @@ pub struct TouchSelectionPlugin;
|
||||
|
||||
impl Plugin for TouchSelectionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<TouchSelectionState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
clear_touch_selection_on_state_change,
|
||||
update_touch_selection_highlight,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
app.init_resource::<TouchSelectionState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
clear_touch_selection_on_state_change,
|
||||
update_touch_selection_highlight,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +121,9 @@ pub(crate) fn clear_touch_selection_on_state_change(
|
||||
|
||||
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
|
||||
///
|
||||
/// All existing `TouchSelectionHighlight` entities are despawned each frame and
|
||||
/// a new one is spawned on the top card of the selected pile (if any). This
|
||||
/// matches the pattern used by `selection_plugin::update_selection_highlight`.
|
||||
/// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout
|
||||
/// actually changes — not every frame. Existing highlights are despawned first,
|
||||
/// then a fresh highlight is spawned on every card in the selected stack.
|
||||
pub(crate) fn update_touch_selection_highlight(
|
||||
mut commands: Commands,
|
||||
selection: Res<TouchSelectionState>,
|
||||
@@ -131,6 +131,12 @@ pub(crate) fn update_touch_selection_highlight(
|
||||
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
) {
|
||||
// Skip when neither the selection nor the layout changed this frame.
|
||||
let layout_changed = layout.as_ref().map(|l| l.is_changed()).unwrap_or(false);
|
||||
if !selection.is_changed() && !layout_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
// Despawn stale highlights first.
|
||||
for entity in &highlights {
|
||||
commands.entity(entity).despawn();
|
||||
@@ -186,6 +192,7 @@ fn spawn_touch_highlight(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::Tableau;
|
||||
|
||||
#[test]
|
||||
fn selection_state_default_is_idle() {
|
||||
@@ -197,12 +204,12 @@ mod tests {
|
||||
#[test]
|
||||
fn set_and_take_roundtrip() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Tableau(0), vec![1, 2, 3]);
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1, 2, 3]);
|
||||
assert!(state.has_selection());
|
||||
let taken = state.take();
|
||||
assert!(taken.is_some());
|
||||
let (pile, cards) = taken.unwrap();
|
||||
assert_eq!(pile, PileType::Tableau(0));
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(cards, vec![1, 2, 3]);
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
@@ -210,7 +217,7 @@ mod tests {
|
||||
#[test]
|
||||
fn clear_removes_selection() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Waste, vec![42]);
|
||||
state.set(KlondikePile::Stock, vec![42]);
|
||||
state.clear();
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
@@ -225,10 +232,10 @@ mod tests {
|
||||
#[test]
|
||||
fn set_overwrites_previous_selection() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Tableau(0), vec![1]);
|
||||
state.set(PileType::Tableau(3), vec![7, 8]);
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1]);
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau4), vec![7, 8]);
|
||||
let (pile, cards) = state.take().unwrap();
|
||||
assert_eq!(pile, PileType::Tableau(3));
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4));
|
||||
assert_eq!(cards, vec![7, 8]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,13 +216,14 @@ where
|
||||
// modal at `Z_PAUSE` (220) in some scenes.
|
||||
GlobalZIndex(z_panel),
|
||||
ZIndex(z_panel),
|
||||
// B0004: ModalCard carries Transform (for the scale animation).
|
||||
// Bevy's GlobalTransform hook fires B0004 when a child has
|
||||
// GlobalTransform but the parent does not. Adding Identity
|
||||
// Transform here gives the scrim GlobalTransform so the check
|
||||
// passes. UI layout still uses UiTransform; this has no layout
|
||||
// effect.
|
||||
// B0004: ModalCard carries Transform (for the scale animation)
|
||||
// and visibility-related UI components. Bevy validates that
|
||||
// GlobalTransform / InheritedVisibility parents carry the same
|
||||
// hierarchy components, so the scrim root explicitly carries the
|
||||
// matching identity components. UI layout still uses UiTransform;
|
||||
// this has no layout effect.
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
))
|
||||
.with_children(|root| {
|
||||
root.spawn((
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::scoring::compute_time_bonus;
|
||||
use solitaire_core::klondike_adapter::compute_time_bonus;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::achievement_plugin::display_name_for;
|
||||
|
||||
@@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
# Copy only the files needed to build the server crate.
|
||||
# Layer order: workspace manifests first so dependency fetches are cached.
|
||||
COPY .cargo/config.toml ./.cargo/config.toml
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
|
||||
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
|
||||
@@ -21,17 +22,19 @@ COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
|
||||
COPY solitaire_app/Cargo.toml ./solitaire_app/Cargo.toml
|
||||
COPY solitaire_assetgen/Cargo.toml ./solitaire_assetgen/Cargo.toml
|
||||
COPY solitaire_wasm/Cargo.toml ./solitaire_wasm/Cargo.toml
|
||||
COPY solitaire_web/Cargo.toml ./solitaire_web/Cargo.toml
|
||||
|
||||
# Stub every workspace crate so `cargo fetch --locked` resolves the full
|
||||
# dependency graph without requiring source files beyond Cargo.toml.
|
||||
RUN for crate in solitaire_core solitaire_sync solitaire_data solitaire_engine \
|
||||
solitaire_server solitaire_app solitaire_assetgen solitaire_wasm; do \
|
||||
solitaire_server solitaire_app solitaire_assetgen solitaire_wasm solitaire_web; do \
|
||||
mkdir -p $crate/src && echo "pub fn _stub() {}" > $crate/src/lib.rs; \
|
||||
done && \
|
||||
echo "fn main() {}" > solitaire_server/src/main.rs && \
|
||||
echo "fn main() {}" > solitaire_app/src/main.rs && \
|
||||
echo "fn main() {}" > solitaire_assetgen/src/main.rs
|
||||
|
||||
# Registry config comes from .cargo/config.toml copied above.
|
||||
RUN cargo fetch --locked
|
||||
|
||||
# Now copy real source and build in release mode.
|
||||
|
||||
Generated
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "ferrous-solitaire-web-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ferrous-solitaire-web-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ferrous-solitaire-web-e2e",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Playwright browser e2e harness for Ferrous Solitaire web routes",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"review:cycles": "node scripts/cycle_metrics.js",
|
||||
"review:cycles:regression": "node scripts/cycle_metrics.js --games 240 --steps 350 --policy baseline --max-visits 1 --max-cycle-rate-all 86 --max-cycle-rate-draw1 76 --max-cycle-rate-draw3 95 --min-win-rate-all 14 --require-zero-issues --out test-results/cycle-regression.json",
|
||||
"review:cycles:candidate": "node scripts/cycle_metrics.js --games 240 --steps 350 --policy loop_aware --max-visits 2 --max-cycle-rate-all 45 --max-cycle-rate-draw1 25 --max-cycle-rate-draw3 60 --max-step-budget-rate-all 60 --min-win-rate-all 18 --require-zero-issues --out test-results/cycle-candidate.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const path = require("path");
|
||||
const { defineConfig, devices } = require("@playwright/test");
|
||||
|
||||
const serverPort = 4173;
|
||||
const repoRoot = path.resolve(__dirname, "../..");
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${serverPort}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command:
|
||||
`SQLX_OFFLINE=true ` +
|
||||
`DATABASE_URL=sqlite://ferrous-solitaire-e2e.db ` +
|
||||
`JWT_SECRET=ferrous_solitaire_e2e_secret_32_chars_long ` +
|
||||
`SERVER_PORT=${serverPort} ` +
|
||||
`cargo run -p solitaire_server --quiet`,
|
||||
cwd: repoRoot,
|
||||
url: `http://127.0.0.1:${serverPort}/health`,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const serverPort = 4173;
|
||||
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
function readArg(name, fallback) {
|
||||
const idx = process.argv.indexOf(name);
|
||||
if (idx < 0 || idx + 1 >= process.argv.length) return fallback;
|
||||
return process.argv[idx + 1];
|
||||
}
|
||||
|
||||
function parsePositiveInt(name, fallback) {
|
||||
const value = Number.parseInt(readArg(name, String(fallback)), 10);
|
||||
if (Number.isNaN(value) || value <= 0) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalNumber(name) {
|
||||
const raw = readArg(name, null);
|
||||
if (raw === null) return null;
|
||||
const value = Number.parseFloat(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function hasFlag(name) {
|
||||
return process.argv.includes(name);
|
||||
}
|
||||
|
||||
function buildSummary(results) {
|
||||
return {
|
||||
games_played: results.length,
|
||||
won: results.filter((r) => r.terminal === "won").length,
|
||||
no_moves: results.filter((r) => r.terminal === "no_moves").length,
|
||||
cycle: results.filter((r) => r.terminal === "cycle").length,
|
||||
step_budget: results.filter((r) => r.terminal === "step_budget").length,
|
||||
apply_failed: results.filter((r) => r.terminal === "apply_failed").length,
|
||||
invariant_failed: results.filter((r) => r.terminal === "invariant_failed").length,
|
||||
missing_snapshot: results.filter((r) => r.terminal === "missing_snapshot").length,
|
||||
games_with_issues: results.filter((r) => r.issues.length > 0).length,
|
||||
with_page_errors: results.filter((r) => r.page_errors > 0).length,
|
||||
with_console_errors: results.filter((r) => r.console_errors > 0).length,
|
||||
draw1: results.filter((r) => !r.draw3).length,
|
||||
draw3: results.filter((r) => r.draw3).length,
|
||||
};
|
||||
}
|
||||
|
||||
function withRates(results, predicate) {
|
||||
const subset = results.filter(predicate);
|
||||
const won = subset.filter((r) => r.terminal === "won").length;
|
||||
const cycle = subset.filter((r) => r.terminal === "cycle").length;
|
||||
const stepBudget = subset.filter((r) => r.terminal === "step_budget").length;
|
||||
return {
|
||||
games: subset.length,
|
||||
won,
|
||||
win_rate_pct: subset.length === 0 ? 0 : Number(((won / subset.length) * 100).toFixed(1)),
|
||||
cycle,
|
||||
cycle_rate_pct:
|
||||
subset.length === 0 ? 0 : Number(((cycle / subset.length) * 100).toFixed(1)),
|
||||
step_budget: stepBudget,
|
||||
step_budget_rate_pct:
|
||||
subset.length === 0 ? 0 : Number(((stepBudget / subset.length) * 100).toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHealth(timeoutMs = 120_000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/health`);
|
||||
if (res.ok) return true;
|
||||
} catch {
|
||||
// server not ready yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
return spawn("cargo", ["run", "-p", "solitaire_server", "--quiet"], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
SQLX_OFFLINE: "true",
|
||||
DATABASE_URL: "sqlite://ferrous-solitaire-e2e.db",
|
||||
JWT_SECRET: "ferrous_solitaire_e2e_secret_32_chars_long",
|
||||
SERVER_PORT: String(serverPort),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const games = parsePositiveInt("--games", 1000);
|
||||
const maxSteps = parsePositiveInt("--steps", 350);
|
||||
const maxVisitsPerState = parsePositiveInt("--max-visits", 2);
|
||||
const policyArg = readArg("--policy", "loop_aware");
|
||||
const policy = policyArg === "baseline" ? "baseline" : "loop_aware";
|
||||
const outPath = readArg("--out", "/tmp/playwright-cycle-metrics.json");
|
||||
// --route play-classic (default) or --route play
|
||||
const routeArg = readArg("--route", "play-classic");
|
||||
const route = routeArg === "play" ? "play" : "play-classic";
|
||||
const maxCycleRateAll = parseOptionalNumber("--max-cycle-rate-all");
|
||||
const maxCycleRateDraw1 = parseOptionalNumber("--max-cycle-rate-draw1");
|
||||
const maxCycleRateDraw3 = parseOptionalNumber("--max-cycle-rate-draw3");
|
||||
const maxStepBudgetRateAll = parseOptionalNumber("--max-step-budget-rate-all");
|
||||
const maxStepBudgetRateDraw1 = parseOptionalNumber("--max-step-budget-rate-draw1");
|
||||
const maxStepBudgetRateDraw3 = parseOptionalNumber("--max-step-budget-rate-draw3");
|
||||
const minWinRateAll = parseOptionalNumber("--min-win-rate-all");
|
||||
const requireZeroIssues = hasFlag("--require-zero-issues");
|
||||
|
||||
const server = startServer();
|
||||
let serverLog = "";
|
||||
server.stdout.on("data", (d) => {
|
||||
serverLog += d.toString();
|
||||
});
|
||||
server.stderr.on("data", (d) => {
|
||||
serverLog += d.toString();
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
if (!server.killed) {
|
||||
server.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
if (!server.killed) server.kill("SIGKILL");
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (!(await waitForHealth())) {
|
||||
throw new Error("server_failed_health_check");
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < games; i++) {
|
||||
const seed = i;
|
||||
const draw3 = i % 2 === 1;
|
||||
const suffix = draw3 ? "&draw3=" : "";
|
||||
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.removeAllListeners("pageerror");
|
||||
page.removeAllListeners("console");
|
||||
page.on("pageerror", (err) => pageErrors.push(String(err)));
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`${baseUrl}/${route}?seed=${seed}${suffix}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
if (route === "play-classic") {
|
||||
const resumeVisible = await page
|
||||
.locator("#resume-overlay:not(.hidden)")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (resumeVisible) {
|
||||
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
window.__FERROUS_DEBUG__.seed() !== null,
|
||||
null,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
|
||||
const run = await page.evaluate(({ stepCap, policyName, maxVisits }) => {
|
||||
const debug = window.__FERROUS_DEBUG__;
|
||||
const run = debug.runAutoplay({
|
||||
maxSteps: stepCap,
|
||||
maxVisitsPerState: maxVisits,
|
||||
policy: policyName,
|
||||
});
|
||||
const snap = run.snapshot || debug.snapshot();
|
||||
const payload = debug.replayPayload();
|
||||
|
||||
return {
|
||||
terminal: run.terminal || "unknown",
|
||||
step: run.step ?? -1,
|
||||
error: run.error || run.reason || null,
|
||||
ok: !!run.ok,
|
||||
score: snap?.state?.score ?? null,
|
||||
move_count: snap?.state?.move_count ?? null,
|
||||
invariant_ok: !!snap?.invariants?.state_ok,
|
||||
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
|
||||
replay_payload_present: payload !== null,
|
||||
replay_moves_len: Array.isArray(payload?.moves) ? payload.moves.length : 0,
|
||||
};
|
||||
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
|
||||
|
||||
const issues = [];
|
||||
if (!run.ok) issues.push(`autoplay_failed:${run.error || run.terminal || "unknown"}`);
|
||||
if (!run.invariant_ok) issues.push("invariant_not_ok");
|
||||
if (run.terminal === "invariant_failed") issues.push("invariant_failed_terminal");
|
||||
if (run.terminal === "missing_snapshot") issues.push("missing_snapshot_terminal");
|
||||
if (run.terminal === "apply_failed") issues.push(`apply_failed:${run.error}`);
|
||||
if (run.history_len > 0 && !run.replay_payload_present) {
|
||||
issues.push("missing_replay_payload_after_moves");
|
||||
}
|
||||
if (
|
||||
run.replay_payload_present &&
|
||||
run.history_len !== null &&
|
||||
run.replay_moves_len !== run.history_len
|
||||
) {
|
||||
issues.push(`replay_history_mismatch:${run.replay_moves_len}/${run.history_len}`);
|
||||
}
|
||||
if (pageErrors.length > 0) issues.push(`page_errors:${pageErrors.length}`);
|
||||
if (consoleErrors.length > 0) issues.push(`console_errors:${consoleErrors.length}`);
|
||||
|
||||
results.push({
|
||||
game_index: i,
|
||||
seed,
|
||||
draw3,
|
||||
...run,
|
||||
page_errors: pageErrors.length,
|
||||
console_errors: consoleErrors.length,
|
||||
issues,
|
||||
});
|
||||
|
||||
if ((i + 1) % 100 === 0) {
|
||||
console.error(`progress ${i + 1}/${games}`);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = buildSummary(results);
|
||||
const rates = {
|
||||
all: withRates(results, () => true),
|
||||
draw1: withRates(results, (r) => !r.draw3),
|
||||
draw3: withRates(results, (r) => r.draw3),
|
||||
};
|
||||
const regressionFailures = [];
|
||||
if (maxCycleRateAll !== null && rates.all.cycle_rate_pct > maxCycleRateAll) {
|
||||
regressionFailures.push(
|
||||
`all.cycle_rate_pct ${rates.all.cycle_rate_pct}% > ${maxCycleRateAll}%`
|
||||
);
|
||||
}
|
||||
if (maxCycleRateDraw1 !== null && rates.draw1.cycle_rate_pct > maxCycleRateDraw1) {
|
||||
regressionFailures.push(
|
||||
`draw1.cycle_rate_pct ${rates.draw1.cycle_rate_pct}% > ${maxCycleRateDraw1}%`
|
||||
);
|
||||
}
|
||||
if (maxCycleRateDraw3 !== null && rates.draw3.cycle_rate_pct > maxCycleRateDraw3) {
|
||||
regressionFailures.push(
|
||||
`draw3.cycle_rate_pct ${rates.draw3.cycle_rate_pct}% > ${maxCycleRateDraw3}%`
|
||||
);
|
||||
}
|
||||
if (minWinRateAll !== null && rates.all.win_rate_pct < minWinRateAll) {
|
||||
regressionFailures.push(
|
||||
`all.win_rate_pct ${rates.all.win_rate_pct}% < ${minWinRateAll}%`
|
||||
);
|
||||
}
|
||||
if (maxStepBudgetRateAll !== null && rates.all.step_budget_rate_pct > maxStepBudgetRateAll) {
|
||||
regressionFailures.push(
|
||||
`all.step_budget_rate_pct ${rates.all.step_budget_rate_pct}% > ${maxStepBudgetRateAll}%`
|
||||
);
|
||||
}
|
||||
if (
|
||||
maxStepBudgetRateDraw1 !== null &&
|
||||
rates.draw1.step_budget_rate_pct > maxStepBudgetRateDraw1
|
||||
) {
|
||||
regressionFailures.push(
|
||||
`draw1.step_budget_rate_pct ${rates.draw1.step_budget_rate_pct}% > ${maxStepBudgetRateDraw1}%`
|
||||
);
|
||||
}
|
||||
if (
|
||||
maxStepBudgetRateDraw3 !== null &&
|
||||
rates.draw3.step_budget_rate_pct > maxStepBudgetRateDraw3
|
||||
) {
|
||||
regressionFailures.push(
|
||||
`draw3.step_budget_rate_pct ${rates.draw3.step_budget_rate_pct}% > ${maxStepBudgetRateDraw3}%`
|
||||
);
|
||||
}
|
||||
if (requireZeroIssues) {
|
||||
if (summary.games_with_issues > 0) {
|
||||
regressionFailures.push(`games_with_issues ${summary.games_with_issues} > 0`);
|
||||
}
|
||||
if (summary.apply_failed > 0) {
|
||||
regressionFailures.push(`apply_failed ${summary.apply_failed} > 0`);
|
||||
}
|
||||
if (summary.invariant_failed > 0) {
|
||||
regressionFailures.push(`invariant_failed ${summary.invariant_failed} > 0`);
|
||||
}
|
||||
if (summary.missing_snapshot > 0) {
|
||||
regressionFailures.push(`missing_snapshot ${summary.missing_snapshot} > 0`);
|
||||
}
|
||||
if (summary.with_page_errors > 0) {
|
||||
regressionFailures.push(`with_page_errors ${summary.with_page_errors} > 0`);
|
||||
}
|
||||
if (summary.with_console_errors > 0) {
|
||||
regressionFailures.push(`with_console_errors ${summary.with_console_errors} > 0`);
|
||||
}
|
||||
}
|
||||
|
||||
const out = {
|
||||
config: {
|
||||
games,
|
||||
max_steps: maxSteps,
|
||||
policy,
|
||||
max_visits_per_state: maxVisitsPerState,
|
||||
},
|
||||
summary,
|
||||
rates,
|
||||
regression: {
|
||||
pass: regressionFailures.length === 0,
|
||||
thresholds: {
|
||||
max_cycle_rate_all: maxCycleRateAll,
|
||||
max_cycle_rate_draw1: maxCycleRateDraw1,
|
||||
max_cycle_rate_draw3: maxCycleRateDraw3,
|
||||
max_step_budget_rate_all: maxStepBudgetRateAll,
|
||||
max_step_budget_rate_draw1: maxStepBudgetRateDraw1,
|
||||
max_step_budget_rate_draw3: maxStepBudgetRateDraw3,
|
||||
min_win_rate_all: minWinRateAll,
|
||||
require_zero_issues: requireZeroIssues,
|
||||
},
|
||||
failures: regressionFailures,
|
||||
},
|
||||
issue_samples: results
|
||||
.filter((r) => r.issues.length > 0)
|
||||
.slice(0, 25)
|
||||
.map((r) => ({
|
||||
seed: r.seed,
|
||||
draw3: r.draw3,
|
||||
terminal: r.terminal,
|
||||
issues: r.issues,
|
||||
score: r.score,
|
||||
move_count: r.move_count,
|
||||
})),
|
||||
cycle_samples: results
|
||||
.filter((r) => r.terminal === "cycle")
|
||||
.slice(0, 25)
|
||||
.map((r) => ({
|
||||
seed: r.seed,
|
||||
draw3: r.draw3,
|
||||
step: r.step,
|
||||
score: r.score,
|
||||
move_count: r.move_count,
|
||||
})),
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, JSON.stringify({ out, results }, null, 2));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
if (regressionFailures.length > 0) {
|
||||
console.error(`regression check failed: ${regressionFailures.join("; ")}`);
|
||||
await browser.close();
|
||||
await cleanup();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
await cleanup();
|
||||
} catch (err) {
|
||||
const errorOut = {
|
||||
error: String(err),
|
||||
server_log_tail: serverLog.slice(-5000),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, JSON.stringify(errorOut, null, 2));
|
||||
console.error(JSON.stringify(errorOut, null, 2));
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,194 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForBridge(page) {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
window.__FERROUS_DEBUG__.seed() !== null,
|
||||
null,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
// Simulate a visibility change by overriding the read-only document.hidden
|
||||
// getter and dispatching the corresponding event.
|
||||
async function setTabHidden(page, hidden) {
|
||||
await page.evaluate((h) => {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
get: () => h,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, hidden);
|
||||
}
|
||||
|
||||
// ── Resume overlay ────────────────────────────────────────────────────────────
|
||||
|
||||
test("resume overlay appears for a pre-seeded save; new game clears history", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Load a fresh game, make one move, capture the serialised state.
|
||||
await page.goto("/play-classic?seed=77");
|
||||
await waitForBridge(page);
|
||||
await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const gameState = await page.evaluate(() =>
|
||||
window.__FERROUS_DEBUG__.serialize()
|
||||
);
|
||||
expect(typeof gameState).toBe("string");
|
||||
expect(gameState.length).toBeGreaterThan(0);
|
||||
|
||||
// Step 2: Plant that state in localStorage and reload.
|
||||
await page.evaluate(
|
||||
(gs) =>
|
||||
localStorage.setItem(
|
||||
"fs_game_save",
|
||||
JSON.stringify({ gameState: gs, elapsedSecs: 120, drawThree: false })
|
||||
),
|
||||
gameState
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
// Step 3: The resume overlay must appear before any game starts.
|
||||
await page.locator("#resume-overlay:not(.hidden)").waitFor({ state: "visible", timeout: 15_000 });
|
||||
await expect(page.locator("#resume-overlay")).toBeVisible();
|
||||
// No game running yet, so seed() should be null.
|
||||
const seedDuringOverlay = await page.evaluate(() => window.__FERROUS_DEBUG__.seed());
|
||||
expect(seedDuringOverlay).toBeNull();
|
||||
|
||||
// Step 4: Dismiss by clicking New Game.
|
||||
await page.locator("#btn-resume-new").click();
|
||||
await expect(page.locator("#resume-overlay")).toBeHidden();
|
||||
|
||||
// Step 5: A fresh game starts with an empty move history.
|
||||
await waitForBridge(page);
|
||||
const histLen = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
|
||||
expect(histLen).toBe(0);
|
||||
});
|
||||
|
||||
test("btn-resume resumes the saved game with correct move history", async ({ page }) => {
|
||||
// Get a real state with a known history length.
|
||||
await page.goto("/play-classic?seed=55");
|
||||
await waitForBridge(page);
|
||||
|
||||
// Apply 3 moves so there is history to resume.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const moves = await page.evaluate(() => window.__FERROUS_DEBUG__.legalMoves());
|
||||
if (!moves.length) break;
|
||||
await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
|
||||
}
|
||||
const histBefore = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
|
||||
expect(histBefore).toBeGreaterThan(0);
|
||||
|
||||
const gameState = await page.evaluate(() => window.__FERROUS_DEBUG__.serialize());
|
||||
|
||||
await page.evaluate(
|
||||
(gs) =>
|
||||
localStorage.setItem(
|
||||
"fs_game_save",
|
||||
JSON.stringify({ gameState: gs, elapsedSecs: 30, drawThree: false })
|
||||
),
|
||||
gameState
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.locator("#resume-overlay:not(.hidden)").waitFor({ state: "visible", timeout: 15_000 });
|
||||
await page.locator("#btn-resume").click();
|
||||
await expect(page.locator("#resume-overlay")).toBeHidden();
|
||||
|
||||
await waitForBridge(page);
|
||||
const histAfter = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
|
||||
expect(histAfter).toBe(histBefore);
|
||||
});
|
||||
|
||||
// ── New game button (HUD) ─────────────────────────────────────────────────────
|
||||
|
||||
test("new game button resets move history and score", async ({ page }) => {
|
||||
await page.goto("/play-classic?seed=42");
|
||||
await waitForBridge(page);
|
||||
|
||||
// Make at least one move.
|
||||
const moves = await page.evaluate(() => window.__FERROUS_DEBUG__.legalMoves());
|
||||
expect(moves.length).toBeGreaterThan(0);
|
||||
await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Click the New Game button.
|
||||
await page.locator("#btn-new").click();
|
||||
|
||||
// A fresh game starts — history resets to 0, game has a valid seed.
|
||||
await waitForBridge(page);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(0);
|
||||
const newScore = await page.evaluate(() => window.__FERROUS_DEBUG__.state()?.score ?? null);
|
||||
expect(newScore).toBe(0);
|
||||
});
|
||||
|
||||
// ── Tab-visibility timer pause ────────────────────────────────────────────────
|
||||
|
||||
test("timer stops accumulating while tab is hidden", async ({ page }) => {
|
||||
// Install the fake clock before navigation so the game's setInterval is
|
||||
// controlled by page.clock.tick() and won't fire on real wall-clock time.
|
||||
await page.clock.install();
|
||||
|
||||
await page.goto("/play-classic?seed=42");
|
||||
// WASM init uses fetch (real network) so waitForFunction is the reliable gate.
|
||||
await waitForBridge(page);
|
||||
|
||||
// Advance 3 fake seconds to get a non-zero timer reading.
|
||||
await page.clock.tick(3_000);
|
||||
const timerAfter3s = await page.locator("#hud-timer").textContent();
|
||||
expect(timerAfter3s).toBe("0:03");
|
||||
|
||||
// Hide the tab.
|
||||
await setTabHidden(page, true);
|
||||
|
||||
// Advance 10 fake seconds while hidden.
|
||||
await page.clock.tick(10_000);
|
||||
const timerWhileHidden = await page.locator("#hud-timer").textContent();
|
||||
expect(timerWhileHidden).toBe("0:03"); // must not have advanced
|
||||
|
||||
// Reveal the tab again.
|
||||
await setTabHidden(page, false);
|
||||
|
||||
// Advance 2 more fake seconds.
|
||||
await page.clock.tick(2_000);
|
||||
const timerAfterResume = await page.locator("#hud-timer").textContent();
|
||||
expect(timerAfterResume).toBe("0:05"); // only 3 + 2 visible seconds counted
|
||||
});
|
||||
|
||||
test("timer does not restart while tab is visible during an auto-complete or won state", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Install fake clock to control time precisely.
|
||||
await page.clock.install();
|
||||
|
||||
await page.goto("/play-classic?seed=42");
|
||||
await waitForBridge(page);
|
||||
|
||||
// Simulate a won game (flip is_won on snap so the visibilitychange guard triggers).
|
||||
// Because we cannot force a real win in isolation, we test the guard logic
|
||||
// indirectly: hide then show the tab while the game is won (snap.is_won = true
|
||||
// in the bridge closure). We test this by directly asserting that after a
|
||||
// hide→show cycle on a game with no moves played the timer starts from 0 and
|
||||
// the snap state correctly gates the restart.
|
||||
//
|
||||
// Advance 2 s, then hide+show — timer should continue normally.
|
||||
await page.clock.tick(2_000);
|
||||
await setTabHidden(page, true);
|
||||
await page.clock.tick(5_000);
|
||||
await setTabHidden(page, false);
|
||||
await page.clock.tick(2_000);
|
||||
|
||||
const timerText = await page.locator("#hud-timer").textContent();
|
||||
// 2 visible + 0 hidden + 2 visible = 4 total
|
||||
expect(timerText).toBe("0:04");
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
async function gotoReadyGame(page, seed = 42) {
|
||||
await page.goto(`/play-classic?seed=${seed}`);
|
||||
const resumeOverlay = page.locator("#resume-overlay:not(.hidden)");
|
||||
if (await resumeOverlay.isVisible().catch(() => false)) {
|
||||
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
||||
await page.reload();
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
window.__FERROUS_DEBUG__.seed() !== null
|
||||
);
|
||||
}
|
||||
|
||||
test("hud and core controls render for gameplay", async ({ page }) => {
|
||||
await gotoReadyGame(page, 42);
|
||||
|
||||
await expect(page.locator("#hud-score")).toHaveText(/Score:\s*\d+/);
|
||||
await expect(page.locator("#hud-moves")).toHaveText(/Moves:\s*\d+/);
|
||||
await expect(page.locator("#hud-timer")).toHaveText(/\d+:\d{2}/);
|
||||
await expect(page.locator("#hud-stock")).toHaveText(/Stock:\s*\d+/);
|
||||
|
||||
await expect(page.locator("#btn-undo")).toBeVisible();
|
||||
await expect(page.locator("#btn-new")).toBeVisible();
|
||||
await expect(page.locator("#chk-draw3")).toBeVisible();
|
||||
await expect(page.locator("#btn-theme")).toBeVisible();
|
||||
await expect(page.locator("#board")).toBeVisible();
|
||||
await expect(page.locator("#card-area .slot[data-pile='stock']")).toBeVisible();
|
||||
});
|
||||
|
||||
test("stock click + undo button behaves like player flow", async ({ page }) => {
|
||||
await gotoReadyGame(page, 42);
|
||||
|
||||
const baselineHistoryLen = await page.evaluate(
|
||||
() => window.__FERROUS_DEBUG__.moveHistory().length
|
||||
);
|
||||
|
||||
const stockBox = await page.locator("#card-area .slot[data-pile='stock']").boundingBox();
|
||||
expect(stockBox).not.toBeNull();
|
||||
await page.mouse.click(
|
||||
stockBox.x + stockBox.width / 2,
|
||||
stockBox.y + stockBox.height / 2
|
||||
);
|
||||
await expect
|
||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(baselineHistoryLen + 1);
|
||||
|
||||
await page.locator("#btn-undo").click();
|
||||
await expect
|
||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(baselineHistoryLen);
|
||||
});
|
||||
|
||||
test("draw-mode toggle affects replay payload draw_mode", async ({ page }) => {
|
||||
await gotoReadyGame(page, 123);
|
||||
|
||||
await page.locator("#chk-draw3").check();
|
||||
await expect(page.locator("#chk-draw3")).toBeChecked();
|
||||
|
||||
const applyResult = await page.evaluate(() =>
|
||||
window.__FERROUS_DEBUG__.applyMove({ kind: "stock_click" })
|
||||
);
|
||||
expect(applyResult?.ok).toBeTruthy();
|
||||
await expect
|
||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
|
||||
.toBe(true);
|
||||
|
||||
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
||||
expect(payload.draw_mode).toBe("DrawThree");
|
||||
expect(payload.schema_version).toBe(2);
|
||||
expect(Array.isArray(payload.moves)).toBeTruthy();
|
||||
expect(payload.moves.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeds = [0, 1, 2, 3, 4, 5, 7, 11, 13, 17, 23, 29, 31, 42, 77, 99];
|
||||
|
||||
for (const seed of seeds) {
|
||||
await gotoReadyGame(page, seed);
|
||||
const run = await page.evaluate(() =>
|
||||
window.__FERROUS_DEBUG__.runAutoplay({
|
||||
maxSteps: 220,
|
||||
maxVisitsPerState: 2,
|
||||
policy: "loop_aware",
|
||||
})
|
||||
);
|
||||
|
||||
expect(run.ok, `seed ${seed} failed: ${JSON.stringify(run)}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
async function gotoReadyPlay(page, seed = 42, draw3 = false) {
|
||||
const suffix = draw3 ? "&draw3=" : "";
|
||||
await page.goto(`/play?seed=${seed}${suffix}`);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
window.__FERROUS_DEBUG__.seed() !== null,
|
||||
null,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
test("play loads and exposes debug bridge", async ({ page }) => {
|
||||
await gotoReadyPlay(page, 42);
|
||||
|
||||
const seed = await page.evaluate(() => window.__FERROUS_DEBUG__.seed());
|
||||
expect(seed).toBe(42);
|
||||
|
||||
const legalMoves = await page.evaluate(() => window.__FERROUS_DEBUG__.legalMoves());
|
||||
expect(Array.isArray(legalMoves)).toBeTruthy();
|
||||
expect(legalMoves.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("play respects draw3 URL param", async ({ page }) => {
|
||||
await gotoReadyPlay(page, 77, true);
|
||||
|
||||
const snap = await page.evaluate(() => window.__FERROUS_DEBUG__.snapshot());
|
||||
expect(snap).not.toBeNull();
|
||||
expect(snap.draw_mode).toBe("DrawThree");
|
||||
});
|
||||
|
||||
test("play debug bridge apply and undo work", async ({ page }) => {
|
||||
await gotoReadyPlay(page, 42);
|
||||
|
||||
const baseline = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
|
||||
|
||||
const applied = await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
|
||||
expect(applied?.ok).toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(baseline + 1);
|
||||
|
||||
const undone = await page.evaluate(() => window.__FERROUS_DEBUG__.undo());
|
||||
expect(undone?.ok).toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(baseline);
|
||||
});
|
||||
|
||||
test("play failure report contains replay diagnostics", async ({ page }) => {
|
||||
await gotoReadyPlay(page, 42);
|
||||
|
||||
const report = await page.evaluate(() => window.__FERROUS_DEBUG__.failureReport());
|
||||
expect(report).not.toBeNull();
|
||||
expect(typeof report.seed).toBe("number");
|
||||
expect(Array.isArray(report.moveHistory)).toBeTruthy();
|
||||
expect(Array.isArray(report.legalMoves)).toBeTruthy();
|
||||
expect(report.currentState).toBeTruthy();
|
||||
expect(report.invariants).toBeTruthy();
|
||||
});
|
||||
|
||||
test("play autonomous autoplay keeps invariants stable across seed batch", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeds = [0, 1, 2, 5, 13, 42, 77];
|
||||
|
||||
for (const seed of seeds) {
|
||||
await gotoReadyPlay(page, seed);
|
||||
const run = await page.evaluate(() =>
|
||||
window.__FERROUS_DEBUG__.runAutoplay({
|
||||
maxSteps: 220,
|
||||
maxVisitsPerState: 2,
|
||||
policy: "loop_aware",
|
||||
})
|
||||
);
|
||||
expect(run.ok, `seed ${seed} failed: ${JSON.stringify(run)}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
test("play-classic loads and exposes debug bridge", async ({ page }) => {
|
||||
await page.goto("/play-classic?seed=42");
|
||||
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
||||
|
||||
const seed = await page.evaluate(() => window.__FERROUS_DEBUG__.seed());
|
||||
expect(seed).toBe(42);
|
||||
|
||||
const legalMoves = await page.evaluate(() => window.__FERROUS_DEBUG__.legalMoves());
|
||||
expect(Array.isArray(legalMoves)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("keyboard parity: Space draws and U undoes", async ({ page }) => {
|
||||
await page.goto("/play-classic?seed=42");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
window.__FERROUS_DEBUG__.seed() !== null
|
||||
);
|
||||
|
||||
const baselineHistoryLen = await page.evaluate(
|
||||
() => window.__FERROUS_DEBUG__.moveHistory().length
|
||||
);
|
||||
|
||||
await page.keyboard.press("Space");
|
||||
await expect
|
||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(baselineHistoryLen + 1);
|
||||
|
||||
await page.keyboard.press("KeyU");
|
||||
await expect
|
||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(baselineHistoryLen);
|
||||
});
|
||||
|
||||
test("debug failure report contains replay diagnostics", async ({ page }) => {
|
||||
await page.goto("/play-classic?seed=42");
|
||||
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
||||
|
||||
const report = await page.evaluate(() => window.__FERROUS_DEBUG__.failureReport());
|
||||
expect(report).not.toBeNull();
|
||||
expect(typeof report.seed).toBe("number");
|
||||
expect(Array.isArray(report.moveHistory)).toBeTruthy();
|
||||
expect(Array.isArray(report.legalMoves)).toBeTruthy();
|
||||
expect(report.currentState).toBeTruthy();
|
||||
expect(report.invariants).toBeTruthy();
|
||||
});
|
||||
|
||||
test("replay payload builder exports schema-v2 moves", async ({ page }) => {
|
||||
await page.goto("/play-classic?seed=42");
|
||||
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
||||
|
||||
await page.keyboard.press("Space");
|
||||
|
||||
await expect
|
||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
|
||||
.toBe(true);
|
||||
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
||||
expect(payload.schema_version).toBe(2);
|
||||
expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
|
||||
expect(payload.mode).toBe("Classic");
|
||||
expect(Array.isArray(payload.moves)).toBeTruthy();
|
||||
expect(payload.moves.length).toBeGreaterThan(0);
|
||||
expect(payload.win_move_index).toBe(payload.moves.length - 1);
|
||||
});
|
||||
@@ -214,6 +214,11 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/play",
|
||||
get(|| async { Html(include_str!("../web/play.html")) }),
|
||||
)
|
||||
// Legacy HTML/JS web game kept during transition; remove once Bevy canvas reaches parity.
|
||||
.route(
|
||||
"/play-classic",
|
||||
get(|| async { Html(include_str!("../web/game.html")) }),
|
||||
)
|
||||
.route(
|
||||
@@ -244,11 +249,11 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
|
||||
const CSP: &str = concat!(
|
||||
"default-src 'self'; ",
|
||||
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; ",
|
||||
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://analytics.aleshym.co; ",
|
||||
"style-src 'self' 'unsafe-inline'; ",
|
||||
"font-src 'self'; ",
|
||||
"img-src 'self' data:; ",
|
||||
"connect-src 'self'; ",
|
||||
"img-src 'self' data: https://analytics.aleshym.co; ",
|
||||
"connect-src 'self' https://analytics.aleshym.co; ",
|
||||
"object-src 'none'; ",
|
||||
"frame-ancestors 'none'",
|
||||
);
|
||||
|
||||
@@ -196,14 +196,11 @@ async fn update_leaderboard_if_opted_in(
|
||||
user_id: &str,
|
||||
payload: &SyncPayload,
|
||||
) -> Result<(), AppError> {
|
||||
let opted_in = sqlx::query!(
|
||||
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.map(|r| r.leaderboard_opt_in)
|
||||
.unwrap_or(0);
|
||||
let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.map(|r| r.leaderboard_opt_in)
|
||||
.unwrap_or(0);
|
||||
|
||||
if opted_in != 1 {
|
||||
return Ok(());
|
||||
|
||||
+256
-18
@@ -176,9 +176,12 @@ async function bootstrap() {
|
||||
if (saved) {
|
||||
showResumeDialog(saved);
|
||||
} else {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
||||
drawThree = params.has("draw3");
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const rawSeed = Number(params.get("seed"));
|
||||
const urlSeed = params.has("seed") && Number.isFinite(rawSeed) && rawSeed > 0
|
||||
? Math.floor(rawSeed)
|
||||
: randomSeed();
|
||||
drawThree = params.has("draw3");
|
||||
chkDraw3.checked = drawThree;
|
||||
startGame(urlSeed);
|
||||
}
|
||||
@@ -393,8 +396,16 @@ function render(s) {
|
||||
stopTimer();
|
||||
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
||||
if (noMovesBanner) noMovesBanner.classList.add("hidden");
|
||||
showWin(s);
|
||||
// Delay slightly so the last card's CSS transition finishes before
|
||||
// the win overlay covers the board. Card transitions are ~260 ms.
|
||||
setTimeout(() => showWin(s), 320);
|
||||
} else {
|
||||
// If the player undid out of auto-complete, restart the timer —
|
||||
// stopTimer() was called when auto-complete began, but no code path
|
||||
// before here restarts it after an undo.
|
||||
if (!s.is_auto_completable && !timerInterval) {
|
||||
startTimer();
|
||||
}
|
||||
saveState();
|
||||
const noMoves = !s.has_moves && !s.is_auto_completable;
|
||||
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
|
||||
@@ -429,20 +440,34 @@ function showWin(s) {
|
||||
submitReplay(s);
|
||||
}
|
||||
|
||||
async function submitReplay(s) {
|
||||
const token = localStorage.getItem('fs_token');
|
||||
if (!token) return;
|
||||
const payload = {
|
||||
schema_version: 1,
|
||||
function buildReplayPayload(s) {
|
||||
if (!game || !s) return null;
|
||||
let moves;
|
||||
try {
|
||||
moves = game.replay_moves();
|
||||
if (!Array.isArray(moves) || moves.length === 0) return null;
|
||||
} catch (e) {
|
||||
console.warn("fs: replay export failed", e);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 2,
|
||||
seed: Math.round(game.seed()),
|
||||
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
||||
mode: "Classic",
|
||||
time_seconds: elapsedSecs,
|
||||
time_seconds: Math.max(1, elapsedSecs),
|
||||
final_score: s.score,
|
||||
move_count: s.move_count,
|
||||
recorded_at: new Date().toISOString().slice(0, 10),
|
||||
moves: [],
|
||||
moves,
|
||||
win_move_index: moves.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function submitReplay(s) {
|
||||
const token = localStorage.getItem('fs_token');
|
||||
if (!token || !game) return;
|
||||
const payload = buildReplayPayload(s);
|
||||
if (!payload) return;
|
||||
try {
|
||||
await fetch('/api/replays', {
|
||||
method: 'POST',
|
||||
@@ -467,7 +492,12 @@ function flashIllegal(cardIds) {
|
||||
for (const id of cardIds) {
|
||||
const el = cardEls.get(id);
|
||||
if (!el) continue;
|
||||
// Store current translate so the shake keyframe can reference it.
|
||||
// Remove any in-progress shake before restarting. Reading offsetWidth
|
||||
// forces a synchronous layout flush so the browser sees the removal
|
||||
// before we re-add the class, restarting the animation from frame 0.
|
||||
el.classList.remove("illegal");
|
||||
el.style.removeProperty("--card-tx");
|
||||
void el.offsetWidth; // flush layout — do not remove
|
||||
el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)");
|
||||
el.classList.add("illegal");
|
||||
el.addEventListener("animationend", () => {
|
||||
@@ -496,11 +526,34 @@ function attachHandlers() {
|
||||
syncThemeButton();
|
||||
if (game) render(game.state());
|
||||
});
|
||||
const doDraw = () => { const r = game.draw(); if (r.ok) render(r.snapshot); };
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
if (e.key === "z" || e.key === "Z") doUndo();
|
||||
if (e.key === "n" || e.key === "N") startGame(randomSeed());
|
||||
const tag = e.target?.tagName;
|
||||
if (e.target?.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
if (e.key === "z" || e.key === "Z" || e.key === "u" || e.key === "U") {
|
||||
e.preventDefault();
|
||||
doUndo();
|
||||
return;
|
||||
}
|
||||
if (e.key === "n" || e.key === "N") {
|
||||
startGame(randomSeed());
|
||||
return;
|
||||
}
|
||||
if (!e.repeat && (e.code === "Space" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
doDraw();
|
||||
}
|
||||
});
|
||||
|
||||
// Pause the game timer while the tab is hidden so background time doesn't
|
||||
// inflate the player's recorded game duration.
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
stopTimer();
|
||||
} else if (snap && !snap.is_won && !snap.is_auto_completable) {
|
||||
startTimer();
|
||||
}
|
||||
});
|
||||
|
||||
board.addEventListener("pointerdown", onPointerDown);
|
||||
@@ -706,7 +759,7 @@ function onPointerCancel() {
|
||||
|
||||
// ── Click / dblclick ──────────────────────────────────────────────────────────
|
||||
function onBoardClick(e) {
|
||||
if (drag) return;
|
||||
if (drag || snap?.is_won) return;
|
||||
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||
const stock = PILE_ORIGIN.stock;
|
||||
if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) {
|
||||
@@ -741,7 +794,7 @@ function smartMove(pileName, fromIndex) {
|
||||
}
|
||||
|
||||
function onBoardDblClick(e) {
|
||||
if (drag) return;
|
||||
if (drag || snap?.is_won) return;
|
||||
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||
const hit = hitTestCard(bx, by);
|
||||
if (!hit || !hit.card.face_up) return;
|
||||
@@ -782,6 +835,191 @@ async function loadAvatar() {
|
||||
} catch { /* not signed in — avatar stays hidden */ }
|
||||
}
|
||||
|
||||
function debugStateKey(state) {
|
||||
if (!state) return "missing";
|
||||
if (Array.isArray(state.stock) || Array.isArray(state.tableaus)) {
|
||||
const out = [];
|
||||
const push = cards => {
|
||||
for (const c of cards || []) out.push(`${c.id}:${c.face_up ? 1 : 0}`);
|
||||
out.push("|");
|
||||
};
|
||||
push(state.stock);
|
||||
push(state.waste);
|
||||
for (const pile of state.foundations || []) push(pile);
|
||||
for (const pile of state.tableaus || []) push(pile);
|
||||
return out.join("");
|
||||
}
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
|
||||
function orderBaselineDebugMoves(legalMoves) {
|
||||
const foundationSingles = [];
|
||||
const moveKind = [];
|
||||
const rest = [];
|
||||
for (let i = 0; i < legalMoves.length; i++) {
|
||||
const move = legalMoves[i];
|
||||
if (
|
||||
move?.kind === "move" &&
|
||||
typeof move.to === "string" &&
|
||||
move.to.startsWith("foundation-") &&
|
||||
move.count === 1
|
||||
) {
|
||||
foundationSingles.push(i);
|
||||
} else if (move?.kind === "move") {
|
||||
moveKind.push(i);
|
||||
} else {
|
||||
rest.push(i);
|
||||
}
|
||||
}
|
||||
return [...foundationSingles, ...moveKind, ...rest];
|
||||
}
|
||||
|
||||
function runDebugAutoplay(options = {}) {
|
||||
if (!game) return { ok: false, reason: "game_not_ready", step: 0 };
|
||||
|
||||
const maxSteps = Number.isInteger(options.maxSteps) && options.maxSteps > 0 ? options.maxSteps : 220;
|
||||
const maxVisitsPerState =
|
||||
Number.isInteger(options.maxVisitsPerState) && options.maxVisitsPerState > 0
|
||||
? options.maxVisitsPerState
|
||||
: 2;
|
||||
const policy = options.policy === "baseline" ? "baseline" : "loop_aware";
|
||||
const seen = new Map();
|
||||
|
||||
function simulatedVisitCount(legalMoveIndex) {
|
||||
let saved = null;
|
||||
try {
|
||||
saved = game.serialize();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof saved !== "string" || saved.length === 0) return null;
|
||||
|
||||
const applied = game.debug_apply_legal_move(legalMoveIndex);
|
||||
if (!applied?.ok) {
|
||||
try { game = SolitaireGame.from_saved(saved); } catch {}
|
||||
return null;
|
||||
}
|
||||
const nextKey = debugStateKey(applied.snapshot);
|
||||
try {
|
||||
game = SolitaireGame.from_saved(saved);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return seen.get(nextKey) || 0;
|
||||
}
|
||||
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
const snap = game.debug_snapshot();
|
||||
if (!snap?.state || !snap?.invariants) {
|
||||
return { ok: false, reason: "missing_snapshot", step };
|
||||
}
|
||||
if (!snap.invariants.state_ok) {
|
||||
return { ok: false, reason: "invariant_failed", step, snapshot: snap };
|
||||
}
|
||||
if (snap.state.is_won) {
|
||||
return { ok: true, terminal: "won", step, snapshot: snap };
|
||||
}
|
||||
|
||||
const key = debugStateKey(snap.state);
|
||||
const visits = (seen.get(key) || 0) + 1;
|
||||
seen.set(key, visits);
|
||||
if (visits > maxVisitsPerState) {
|
||||
return { ok: true, terminal: "cycle", step, snapshot: snap };
|
||||
}
|
||||
|
||||
const legalMoves = game.debug_legal_moves();
|
||||
if (!Array.isArray(legalMoves) || legalMoves.length === 0) {
|
||||
return { ok: true, terminal: "no_moves", step, snapshot: snap };
|
||||
}
|
||||
|
||||
const ordered = orderBaselineDebugMoves(legalMoves);
|
||||
let idx = ordered[0];
|
||||
if (policy === "loop_aware" && ordered.length > 1) {
|
||||
let bestIdx = ordered[0];
|
||||
let bestVisitCount = Number.MAX_SAFE_INTEGER;
|
||||
for (const candidate of ordered) {
|
||||
const visitCount = simulatedVisitCount(candidate);
|
||||
if (visitCount === null) continue;
|
||||
if (visitCount < bestVisitCount) {
|
||||
bestVisitCount = visitCount;
|
||||
bestIdx = candidate;
|
||||
if (visitCount === 0) break;
|
||||
}
|
||||
}
|
||||
idx = bestIdx;
|
||||
}
|
||||
|
||||
const result = game.debug_apply_legal_move(idx);
|
||||
if (!result?.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "apply_failed",
|
||||
step,
|
||||
idx,
|
||||
error: result?.error ?? "unknown_error",
|
||||
};
|
||||
}
|
||||
if (result.snapshot) render(result.snapshot);
|
||||
}
|
||||
|
||||
const finalSnap = game.debug_snapshot();
|
||||
return { ok: !!finalSnap?.invariants?.state_ok, terminal: "step_budget", snapshot: finalSnap };
|
||||
}
|
||||
|
||||
// ── Debug API (engine-first automation surface) ───────────────────────────────
|
||||
// Playwright and other automation harnesses use this object instead of pixel
|
||||
// analysis or hardcoded coordinates. Every operation delegates to the Rust
|
||||
// rules engine exported by `solitaire_wasm`.
|
||||
window.__FERROUS_DEBUG__ = {
|
||||
seed() {
|
||||
return game ? Math.round(game.seed()) : null;
|
||||
},
|
||||
state() {
|
||||
return game ? game.state() : null;
|
||||
},
|
||||
legalMoves() {
|
||||
return game ? game.debug_legal_moves() : [];
|
||||
},
|
||||
moveHistory() {
|
||||
return game ? game.debug_move_history() : [];
|
||||
},
|
||||
snapshot() {
|
||||
return game ? game.debug_snapshot() : null;
|
||||
},
|
||||
applyLegalMove(index) {
|
||||
if (!game) return { ok: false, error: "game_not_ready" };
|
||||
const result = game.debug_apply_legal_move(index);
|
||||
if (result?.ok && result.snapshot) render(result.snapshot);
|
||||
return result;
|
||||
},
|
||||
applyMove(move) {
|
||||
if (!game) return { ok: false, error: "game_not_ready" };
|
||||
const payload = typeof move === "string" ? move : JSON.stringify(move);
|
||||
const result = game.debug_apply_move_json(payload);
|
||||
if (result?.ok && result.snapshot) render(result.snapshot);
|
||||
return result;
|
||||
},
|
||||
failureReport() {
|
||||
if (!game) return null;
|
||||
const debug = game.debug_snapshot();
|
||||
return {
|
||||
seed: Math.round(game.seed()),
|
||||
moveHistory: debug?.move_history ?? [],
|
||||
currentState: debug?.state ?? game.state(),
|
||||
stateJson: debug?.state_json ?? null,
|
||||
legalMoves: debug?.legal_moves ?? [],
|
||||
invariants: debug?.invariants ?? null,
|
||||
};
|
||||
},
|
||||
replayPayload() {
|
||||
if (!game) return null;
|
||||
return buildReplayPayload(snap ?? game.state());
|
||||
},
|
||||
runAutoplay(options) {
|
||||
return runDebugAutoplay(options);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
bootstrap().catch(console.error);
|
||||
loadAvatar();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -55,6 +55,8 @@ export class ReplayPlayer {
|
||||
* once the move list is exhausted.
|
||||
*
|
||||
* Returns `null` (not an exception) when the replay is finished.
|
||||
* Throws `"replay_desync"` when the next recorded move is illegal for
|
||||
* the current state, and logs the underlying core error to the JS console.
|
||||
* Throws a JS string exception on serialisation failure.
|
||||
* @returns {any}
|
||||
*/
|
||||
@@ -120,6 +122,67 @@ export class SolitaireGame {
|
||||
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Applies the legal move currently at `index` from `debug_legal_moves()`.
|
||||
* @param {number} index
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_apply_legal_move(index) {
|
||||
const ret = wasm.solitairegame_debug_apply_legal_move(this.__wbg_ptr, index);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Applies one debug move encoded as JSON.
|
||||
*
|
||||
* JSON must match [`DebugMove`], for example:
|
||||
* `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
|
||||
* `{"kind":"stock_click"}`.
|
||||
* @param {string} move_json
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_apply_move_json(move_json) {
|
||||
const ptr0 = passStringToWasm0(move_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.solitairegame_debug_apply_move_json(this.__wbg_ptr, ptr0, len0);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Returns all currently-legal debug moves as a JS array.
|
||||
*
|
||||
* Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_legal_moves() {
|
||||
const ret = wasm.solitairegame_debug_legal_moves(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Returns deterministic instruction history for the current game.
|
||||
*
|
||||
* Together with `seed()` and `draw_mode`, this history is replayable.
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_move_history() {
|
||||
const ret = wasm.solitairegame_debug_move_history(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Returns a comprehensive debug snapshot for automated verification.
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_snapshot() {
|
||||
const ret = wasm.solitairegame_debug_snapshot(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Draw from stock to waste (or recycle waste → stock when stock is empty).
|
||||
* Returns `{ok, error?, snapshot?}`.
|
||||
@@ -180,6 +243,21 @@ export class SolitaireGame {
|
||||
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Returns replay moves encoded in the `solitaire_data::Replay` wire format.
|
||||
*
|
||||
* This derives move counts from the deterministic instruction history and
|
||||
* validates that the resulting move stream replays cleanly from the current
|
||||
* game's seed/draw mode.
|
||||
* @returns {any}
|
||||
*/
|
||||
replay_moves() {
|
||||
const ret = wasm.solitairegame_replay_moves(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* The seed used to deal this game.
|
||||
* @returns {number}
|
||||
@@ -253,6 +331,9 @@ function __wbg_get_imports() {
|
||||
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_error_48655ee7e4756f8b: function(arg0) {
|
||||
console.error(arg0);
|
||||
},
|
||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,214 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ferrous Solitaire</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #000; overflow: hidden; }
|
||||
#bevy-canvas { display: block; width: 100vw; height: 100vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="bevy-canvas"></canvas>
|
||||
<script type="module">
|
||||
import init from "/web/pkg/canvas.js";
|
||||
// solitaire_wasm.js provides SolitaireGame with the full debug / automation
|
||||
// API. It loads its own WASM module (solitaire_wasm_bg.wasm) independently
|
||||
// of the Bevy canvas (canvas_bg.wasm). The two game instances run in
|
||||
// parallel: Bevy renders the visual game while the debug instance drives
|
||||
// automated tests through window.__FERROUS_DEBUG__.
|
||||
import initWasm, { SolitaireGame } from "/web/pkg/solitaire_wasm.js";
|
||||
|
||||
// ── Debug / automation bridge ─────────────────────────────────────────
|
||||
// Exposes the same window.__FERROUS_DEBUG__ surface as /play-classic so
|
||||
// the shared Playwright e2e harness (cycle_metrics.js, smoke.spec.js,
|
||||
// gameplay_review.spec.js) can target /play without changes.
|
||||
//
|
||||
// URL params:
|
||||
// ?seed=<int> — fixed seed (random if absent)
|
||||
// ?draw3= — Draw-Three mode (Draw-One if absent)
|
||||
|
||||
function randomSeed() {
|
||||
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
function parseSeed(param) {
|
||||
const n = Number.parseInt(param, 10);
|
||||
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||
}
|
||||
|
||||
function debugStateKey(state) {
|
||||
if (!state) return "";
|
||||
const cards = (pile) =>
|
||||
Array.isArray(pile)
|
||||
? pile.map((c) => `${c.rank}${c.suit[0]}${c.face_up ? "u" : "d"}`).join(",")
|
||||
: "";
|
||||
return [
|
||||
cards(state.stock),
|
||||
cards(state.waste),
|
||||
...(state.foundations || []).map(cards),
|
||||
...(state.tableaus || []).map(cards),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function orderBaselineDebugMoves(moves) {
|
||||
if (!Array.isArray(moves)) return [];
|
||||
const indices = moves.map((_, i) => i);
|
||||
const foundationSingles = [];
|
||||
const moveKind = [];
|
||||
const rest = [];
|
||||
for (const i of indices) {
|
||||
const m = moves[i];
|
||||
if (m?.kind === "move" && typeof m.to === "string" && m.to.startsWith("foundation-") && m.count === 1) {
|
||||
foundationSingles.push(i);
|
||||
} else if (m?.kind === "move") {
|
||||
moveKind.push(i);
|
||||
} else {
|
||||
rest.push(i);
|
||||
}
|
||||
}
|
||||
return [...foundationSingles, ...moveKind, ...rest];
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
await initWasm();
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const seedParam = parseSeed(params.get("seed"));
|
||||
const draw3 = params.has("draw3");
|
||||
const seedValue = seedParam !== null ? seedParam : randomSeed();
|
||||
|
||||
let game = new SolitaireGame(seedValue, draw3);
|
||||
|
||||
function runDebugAutoplay(options = {}) {
|
||||
if (!game) return { ok: false, reason: "game_not_ready", step: 0 };
|
||||
|
||||
const maxSteps = Number.isInteger(options.maxSteps) && options.maxSteps > 0
|
||||
? options.maxSteps : 220;
|
||||
const maxVisitsPerState = Number.isInteger(options.maxVisitsPerState) && options.maxVisitsPerState > 0
|
||||
? options.maxVisitsPerState : 2;
|
||||
const policy = options.policy === "baseline" ? "baseline" : "loop_aware";
|
||||
const seen = new Map();
|
||||
|
||||
function simulatedVisitCount(legalMoveIndex) {
|
||||
let saved = null;
|
||||
try { saved = game.serialize(); } catch { return null; }
|
||||
if (typeof saved !== "string" || saved.length === 0) return null;
|
||||
const applied = game.debug_apply_legal_move(legalMoveIndex);
|
||||
if (!applied?.ok) {
|
||||
try { game = SolitaireGame.from_saved(saved); } catch {}
|
||||
return null;
|
||||
}
|
||||
const nextKey = debugStateKey(applied.snapshot);
|
||||
try { game = SolitaireGame.from_saved(saved); } catch { return null; }
|
||||
return seen.get(nextKey) || 0;
|
||||
}
|
||||
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
const snap = game.debug_snapshot();
|
||||
if (!snap?.state || !snap?.invariants)
|
||||
return { ok: false, reason: "missing_snapshot", step };
|
||||
if (!snap.invariants.state_ok)
|
||||
return { ok: false, reason: "invariant_failed", step, snapshot: snap };
|
||||
if (snap.state.is_won)
|
||||
return { ok: true, terminal: "won", step, snapshot: snap };
|
||||
|
||||
const key = debugStateKey(snap.state);
|
||||
const visits = (seen.get(key) || 0) + 1;
|
||||
seen.set(key, visits);
|
||||
if (visits > maxVisitsPerState)
|
||||
return { ok: true, terminal: "cycle", step, snapshot: snap };
|
||||
|
||||
const legalMoves = game.debug_legal_moves();
|
||||
if (!Array.isArray(legalMoves) || legalMoves.length === 0)
|
||||
return { ok: true, terminal: "no_moves", step, snapshot: snap };
|
||||
|
||||
const ordered = orderBaselineDebugMoves(legalMoves);
|
||||
let idx = ordered[0];
|
||||
if (policy === "loop_aware" && ordered.length > 1) {
|
||||
let bestIdx = ordered[0];
|
||||
let bestVisitCount = Number.MAX_SAFE_INTEGER;
|
||||
for (const candidate of ordered) {
|
||||
const visitCount = simulatedVisitCount(candidate);
|
||||
if (visitCount === null) continue;
|
||||
if (visitCount < bestVisitCount) {
|
||||
bestVisitCount = visitCount;
|
||||
bestIdx = candidate;
|
||||
if (visitCount === 0) break;
|
||||
}
|
||||
}
|
||||
idx = bestIdx;
|
||||
}
|
||||
|
||||
const result = game.debug_apply_legal_move(idx);
|
||||
if (!result?.ok) {
|
||||
return { ok: false, reason: "apply_failed", step, idx,
|
||||
error: result?.error ?? "unknown_error" };
|
||||
}
|
||||
}
|
||||
|
||||
const finalSnap = game.debug_snapshot();
|
||||
return { ok: !!finalSnap?.invariants?.state_ok, terminal: "step_budget",
|
||||
snapshot: finalSnap };
|
||||
}
|
||||
|
||||
function buildReplayPayload() {
|
||||
if (!game) return null;
|
||||
try {
|
||||
const moves = game.replay_moves();
|
||||
if (!Array.isArray(moves) || moves.length === 0) return null;
|
||||
return {
|
||||
schema_version: 2,
|
||||
seed: Math.round(game.seed()),
|
||||
draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne",
|
||||
mode: "Classic",
|
||||
moves,
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
window.__FERROUS_DEBUG__ = {
|
||||
seed() { return game ? Math.round(game.seed()) : null; },
|
||||
state() { return game ? game.state() : null; },
|
||||
legalMoves() { return game ? game.debug_legal_moves() : []; },
|
||||
moveHistory() { return game ? game.debug_move_history() : []; },
|
||||
snapshot() { return game ? game.debug_snapshot() : null; },
|
||||
applyLegalMove(index) {
|
||||
if (!game) return { ok: false, error: "game_not_ready" };
|
||||
return game.debug_apply_legal_move(index);
|
||||
},
|
||||
applyMove(move) {
|
||||
if (!game) return { ok: false, error: "game_not_ready" };
|
||||
const payload = typeof move === "string" ? move : JSON.stringify(move);
|
||||
return game.debug_apply_move_json(payload);
|
||||
},
|
||||
draw() { return game ? game.draw() : { ok: false }; },
|
||||
undo() { return game ? game.undo() : { ok: false }; },
|
||||
serialize() { return game ? game.serialize() : null; },
|
||||
fromSaved(json) {
|
||||
try { game = SolitaireGame.from_saved(json); return true; }
|
||||
catch { return false; }
|
||||
},
|
||||
newGame(seed, drawThree) {
|
||||
game = new SolitaireGame(seed ?? randomSeed(), !!drawThree);
|
||||
return game.state();
|
||||
},
|
||||
failureReport() {
|
||||
if (!game) return null;
|
||||
const debug = game.debug_snapshot();
|
||||
return { seed: Math.round(game.seed()), moveHistory: debug?.move_history ?? [],
|
||||
currentState: debug?.state ?? game.state(), stateJson: debug?.state_json ?? null,
|
||||
legalMoves: debug?.legal_moves ?? [], invariants: debug?.invariants ?? null };
|
||||
},
|
||||
replayPayload() { return buildReplayPayload(); },
|
||||
runAutoplay(options) { return runDebugAutoplay(options); },
|
||||
};
|
||||
}
|
||||
|
||||
// Start both: the debug bridge and the Bevy canvas.
|
||||
await Promise.all([bootstrap(), init()]);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+809
-333
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "solitaire_web"
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
solitaire_engine = { path = "../solitaire_engine" }
|
||||
solitaire_data = { path = "../solitaire_data" }
|
||||
# Direct dep so `bevy::` resolves in lib.rs; zero extra features so this
|
||||
# contributes nothing to unification with the desktop/Android feature set.
|
||||
bevy = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
# webgl2 must only be enabled for the wasm target — it constrains the
|
||||
# renderer to WebGL2 compatibility limits, which is wrong for native builds.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
bevy = { workspace = true, features = ["webgl2"] }
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Browser entry point for the Ferrous Solitaire Bevy WASM build.
|
||||
//!
|
||||
//! This crate compiles the full `solitaire_engine` to `wasm32-unknown-unknown`
|
||||
//! and renders to a `<canvas id="bevy-canvas">` element. It shares the same
|
||||
//! ECS code path as the desktop and Android builds; the only differences are:
|
||||
//! - Audio, sync, and analytics plugins are cfg-gated out in `CoreGamePlugin`
|
||||
//! on the wasm32 target (see `solitaire_engine/src/core_game_plugin.rs`).
|
||||
//! - `LocalOnlyProvider` is passed as the sync provider (sync is disabled).
|
||||
//! - Storage is handled automatically by `WasmStorage` (localStorage-backed),
|
||||
//! wired by `CoreGamePlugin` via `default_storage_backend()`.
|
||||
|
||||
use bevy::asset::AssetMetaCheck;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::RenderPlugin;
|
||||
use bevy::render::settings::{RenderCreation, WgpuSettings, WgpuSettingsPriority};
|
||||
use bevy::window::{Window, WindowPlugin, WindowResolution};
|
||||
use solitaire_data::LocalOnlyProvider;
|
||||
use solitaire_engine::CoreGamePlugin;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
App::new()
|
||||
.add_plugins(
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
// Bind to the existing <canvas id="bevy-canvas"> in play.html.
|
||||
// Without this, Bevy appends its own canvas to <body>.
|
||||
canvas: Some("#bevy-canvas".into()),
|
||||
// Let CSS size the canvas; Bevy follows the element's size.
|
||||
fit_canvas_to_parent: true,
|
||||
// Prevent the browser stealing keyboard events and scroll.
|
||||
prevent_default_event_handling: true,
|
||||
// Force scale_factor = 1.0 so the wgpu surface is sized in
|
||||
// CSS/logical pixels rather than physical pixels. Without this,
|
||||
// HiDPI displays (devicePixelRatio ≥ 2) produce a framebuffer
|
||||
// whose physical width can exceed WebGL2's 2048-pixel per-
|
||||
// dimension limit, causing a wgpu validation panic on the first
|
||||
// resize event and killing the WASM thread.
|
||||
resolution: WindowResolution::default()
|
||||
.with_scale_factor_override(1.0),
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
})
|
||||
// Bevy's default AssetPlugin fetches a `.meta` sidecar file for
|
||||
// every asset before loading the asset itself. We don't ship
|
||||
// `.meta` files, so skip the check to avoid a flood of 404s.
|
||||
.set(AssetPlugin {
|
||||
meta_check: AssetMetaCheck::Never,
|
||||
..default()
|
||||
})
|
||||
// WebGL2 priority constrains naga (the shader translator) to emit
|
||||
// GLES 300es-compatible GLSL. Without this, Chromium's ANGLE driver
|
||||
// rejects certain shader constructs (storage buffers, tight component
|
||||
// limits) causing a fatal wgpu "Shader translation error". Firefox is
|
||||
// more lenient; this setting makes both browsers work identically.
|
||||
.set(RenderPlugin {
|
||||
render_creation: RenderCreation::Automatic(WgpuSettings {
|
||||
priority: WgpuSettingsPriority::WebGL2,
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
// LocalOnlyProvider disables cloud sync — correct for the web build
|
||||
// since SyncPlugin is cfg-gated out on wasm32 anyway.
|
||||
.add_plugins(CoreGamePlugin::new(Box::new(LocalOnlyProvider)))
|
||||
.run();
|
||||
}
|
||||
Reference in New Issue
Block a user