Compare commits

..

5 Commits

Author SHA1 Message Date
funman300 aebb401c44 docs: update for card_game v0.4.0 / klondike v0.3.0 — undo scoring + solver
Correct Gap 1 undo penalty: SessionState::score() already includes
undos × undo_penalty via SessionStats — undo IS tracked upstream,
just in SessionStats not KlondikeStats. Mark as  upstream.

Add Gap 3 upstream-merged note: Session::solve() in card_game v0.4.0
is a budget-bounded DFS that replaces our 767-line solver. Document
SolveError mapping (both variants → Inconclusive).

Update 'Already has' table for v0.4.0: Session now derives Clone,
uses snapshot-based O(1) undo (StateSnapshot stores pre-move state +
instruction), and carries SessionConfig with solve budgets.

Mark Gap 8 (undo O(1)) resolved: card_game v0.4.0 uses snapshots,
same approach as our existing undo_stack.

Update integration path: steps 1/3/4/5 marked ; steps 2/6/7 remain.
Update references with new release commits and solver PR #14.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:08:32 -07:00
funman300 a550a0cdf9 docs: update integration doc to reflect klondike v0.2.0 / card_game v0.3.0
Both upstream issues are now merged:
- PR #13 (closes #10): ScoringConfig with 5 configurable deltas lands
  in KlondikeConfig; KlondikeStats gains flip_up_bonus_count and
  move_from_foundation_count; score() takes &ScoringConfig
- PR #12 (closes #11): MoveFromFoundationConfig (Allowed/Disallowed)
  lands in KlondikeConfig; is_instruction_valid enforces it

Doc changes:
- "Already has" table updated with ScoringConfig, MoveFromFoundationConfig,
  richer KlondikeStats counters, and version numbers (v0.3.0 / v0.2.0)
- Gap 1 scoring table gains a "Handled by" column showing which deltas
  upstream now owns vs. which remain in our adapter (undo penalty,
  recycle-with-free-allowance, score floor, time bonus)
- Gap 1 adds note that ScoringConfig::recycle is a flat delta and cannot
  express the "N free recycles then penalty" WXP rule
- Gap 4 marked as upstream merged; notes that upstream default is
  MoveFromFoundationConfig::Allowed — we must explicitly set Disallowed
- Integration path: steps renumbered (8→7), step 3 now configures
  MoveFromFoundationConfig, step 4 splits upstream-handled vs.
  adapter-owned scoring; dependency versions pinned
- References updated with PR links and release commit hashes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:07:34 -07:00
funman300 8f5193035b docs: update card-game-integration with PR discussion outcomes
- Approach: note Quaternions is addressing gaps 1 and 4 upstream
  (card_game issues #10 and #11)
- Gap 1: replace comparison table with exact WXP scoring table from
  solitaire_core/src/scoring.rs; add solitaireparadise.com reference;
  note time bonus stays in adapter (not wasm-portable)
- Gap 2: expand mode table with full Scoring + Undo columns; add
  descriptions for Zen (relaxed, score = 0) and Challenge (timed
  daily puzzle, undo disabled)
- Gap 4: clarify the flag *enables* an optional move (off by default),
  not disables; link upstream issue #11
- Gap 5: note Quaternions confirmed newtypes approach, no upstream
  changes needed
- Gap 6: document that MoveError is generated at instruction-
  construction boundary in solitaire_core, not by wrapping
  is_instruction_valid's bool
- Gap 8: mark resolved; 0.02 ms worst case at 1M moves/s; drop
  snapshot ring-buffer plan
- Integration path: updated steps to reflect resolved gaps and
  upstream issue dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:44:36 -07:00
funman300 c21c0ebf99 docs: revise integration plan — all gaps closed in Ferrous Solitaire wrapper
Reframe the integration approach: klondike is a read-only dependency;
all 8 gaps (scoring, game modes, solver, take_from_foundation, serde,
MoveError, waste pile, undo stack) are closed in solitaire_core via a
KlondikeAdapter wrapper layer. No upstream changes to card_game or
klondike are required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 16:23:05 -07:00
funman300 ccccdd2b40 docs: add card-game integration gap analysis
Documents what Quaternions/card_game already provides, what
solitaire_core requires that is currently missing, and the
suggested step-by-step integration path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:28:44 -07:00
104 changed files with 11628 additions and 16780 deletions
-5
View File
@@ -1,5 +0,0 @@
[registries.Quaternions]
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
-46
View File
@@ -6,14 +6,10 @@ 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:
@@ -36,48 +32,6 @@ 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:
-49
View File
@@ -1,49 +0,0 @@
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
-5
View File
@@ -15,11 +15,6 @@ 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
+3 -5
View File
@@ -430,11 +430,9 @@ explicitly replacing the current one (despawn first, then spawn).
## 14.3 Safe area
Every `ModalScrim` automatically receives `padding.top` equal to the logical
status-bar height and `padding.bottom` equal to the logical gesture-bar height
via `apply_safe_area_to_modal_scrims` in `SafeAreaInsetsPlugin`. This centres
the modal card within the usable area between both system bars. Do not manually
add top or bottom padding to scrim nodes.
Every `ModalScrim` automatically receives `padding.bottom` equal to the
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
## 14.4 Z-ordering
Generated
+15 -412
View File
@@ -364,12 +364,6 @@ 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"
@@ -723,28 +717,6 @@ 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"
@@ -906,35 +878,6 @@ 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"
@@ -958,7 +901,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bevy_ecs_macros",
"bevy_platform",
"bevy_ptr",
@@ -1002,36 +945,6 @@ 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"
@@ -1154,17 +1067,14 @@ 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",
@@ -1172,7 +1082,6 @@ dependencies = [
"bevy_log",
"bevy_math",
"bevy_mesh",
"bevy_pbr",
"bevy_platform",
"bevy_ptr",
"bevy_reflect",
@@ -1192,27 +1101,6 @@ 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"
@@ -1250,7 +1138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84"
dependencies = [
"approx",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bevy_reflect",
"derive_more",
"glam 0.30.10",
@@ -1273,9 +1161,7 @@ dependencies = [
"bevy_asset",
"bevy_derive",
"bevy_ecs",
"bevy_image",
"bevy_math",
"bevy_mikktspace",
"bevy_platform",
"bevy_reflect",
"bevy_transform",
@@ -1288,71 +1174,6 @@ 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"
@@ -1679,7 +1500,6 @@ dependencies = [
"bevy_input",
"bevy_input_focus",
"bevy_math",
"bevy_picking",
"bevy_platform",
"bevy_reflect",
"bevy_sprite",
@@ -1692,7 +1512,6 @@ dependencies = [
"taffy",
"thiserror 2.0.18",
"tracing",
"uuid",
]
[[package]]
@@ -1726,26 +1545,6 @@ 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"
@@ -1873,7 +1672,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"bytemuck",
"serde_core",
]
@@ -1905,7 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
@@ -2081,15 +1879,6 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "card_game"
version = "0.4.0"
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
dependencies = [
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
"serde",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -2150,17 +1939,6 @@ 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"
@@ -3679,17 +3457,6 @@ 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"
@@ -3718,27 +3485,6 @@ 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"
@@ -4305,7 +4051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error 2.0.1",
"quick-error",
]
[[package]]
@@ -4563,23 +4309,6 @@ 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"
@@ -4597,23 +4326,13 @@ dependencies = [
"triple_buffer",
]
[[package]]
name = "klondike"
version = "0.3.0"
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
dependencies = [
"card_game",
"rand 0.10.1",
"serde",
]
[[package]]
name = "kurbo"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"euclid",
"smallvec",
]
@@ -5021,7 +4740,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bit-set",
"bitflags 2.11.1",
"cfg-if",
@@ -6059,25 +5778,6 @@ 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"
@@ -6122,12 +5822,6 @@ 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"
@@ -6253,16 +5947,6 @@ 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"
@@ -6301,12 +5985,6 @@ 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"
@@ -6326,15 +6004,6 @@ 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"
@@ -6824,18 +6493,6 @@ 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"
@@ -7323,9 +6980,7 @@ dependencies = [
name = "solitaire_core"
version = "0.1.0"
dependencies = [
"card_game",
"klondike",
"proptest",
"rand 0.9.4",
"serde",
"thiserror 2.0.18",
]
@@ -7336,6 +6991,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"bevy",
"chrono",
"dirs",
"jni 0.21.1",
@@ -7434,18 +7090,6 @@ 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"
@@ -7858,7 +7502,7 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
@@ -7957,7 +7601,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"grid",
"serde",
"slotmap",
@@ -8226,7 +7870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
@@ -8240,7 +7884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
@@ -8889,12 +8533,6 @@ 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"
@@ -9101,15 +8739,6 @@ 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"
@@ -9415,13 +9044,12 @@ version = "27.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"js-sys",
"log",
"naga",
"portable-atomic",
@@ -9429,8 +9057,6 @@ dependencies = [
"raw-window-handle",
"smallvec",
"static_assertions",
"wasm-bindgen",
"web-sys",
"wgpu-core",
"wgpu-hal",
"wgpu-types",
@@ -9442,7 +9068,7 @@ version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
dependencies = [
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"bit-set",
"bit-vec",
"bitflags 2.11.1",
@@ -9462,7 +9088,6 @@ dependencies = [
"smallvec",
"thiserror 2.0.18",
"wgpu-core-deps-apple",
"wgpu-core-deps-wasm",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-types",
@@ -9477,15 +9102,6 @@ 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"
@@ -9502,7 +9118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
dependencies = [
"android_system_properties",
"arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)",
"arrayvec",
"ash",
"bit-set",
"bitflags 2.11.1",
@@ -9511,20 +9127,15 @@ 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",
@@ -9537,8 +9148,6 @@ dependencies = [
"renderdoc-sys",
"smallvec",
"thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
"wgpu-types",
"windows 0.58.0",
"windows-core 0.58.0",
@@ -10421,12 +10030,6 @@ 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"
+1 -4
View File
@@ -8,7 +8,6 @@ members = [
"solitaire_app",
"solitaire_assetgen",
"solitaire_wasm",
"solitaire_web",
]
resolver = "2"
@@ -22,7 +21,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", "wasmbind"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
rand = "0.9"
async-trait = "0.1"
@@ -38,8 +37,6 @@ solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" }
klondike = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
# Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
-20
View File
@@ -118,28 +118,8 @@ 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
+6 -49
View File
@@ -1,45 +1,16 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-02 — Web e2e test suite complete; `/play` canvas bridge added and tested. All commits on origin/master.
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
---
## Current state
- **HEAD:** `play_canvas.spec.js` added (Playwright tests for `/play` Bevy canvas route)
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
- **Latest tag:** `v0.35.1`
- **Working tree:** clean
- **Build:** `cargo clippy --workspace -- -D warnings` clean
- **Tests:** 1243 Rust tests passing; Playwright suite in `solitaire_server/e2e/`
---
## 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).
- **Tests:** 1277 passing / 0 failing across the workspace
---
@@ -112,8 +83,9 @@ Three bugs fixed:
### 1. CHANGELOG documentation debt
CHANGELOG.md currently ends at v0.33.0. All post-v0.33.0 work is in git log. Low
priority git log is authoritative.
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.
### 2. Android APK launch verification (Option A)
@@ -156,18 +128,3 @@ 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`.
+7 -48
View File
@@ -1,21 +1,18 @@
#!/usr/bin/env bash
# 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)
# Rebuild the solitaire_wasm crate and install the output into
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
#
# Prerequisites:
# cargo install wasm-pack wasm-bindgen-cli
# cargo install wasm-pack
# 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 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/.
# 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/.
set -euo pipefail
@@ -39,43 +36,5 @@ 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"
+5 -34
View File
@@ -94,22 +94,16 @@ Our 767-line `solitaire_core::solver` reimplements the full game rules to run th
### 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.
**Default behaviour:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire **also defaults to Allowed** (`take_from_foundation: true` in `GameState`, `Settings`). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (`take_from_foundation = false`), which maps to `Disallowed`.
**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:** `KlondikeAdapter::config_for(draw_mode, take_from_foundation)` constructs `KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }`. No custom intercept needed — `klondike` enforces the rule automatically.
**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.
`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.
**Upstream serde status (rev 99b49e62):** At this revision, `klondike` and `card_game` both enable a `serde` feature. All nine instruction/pile types (`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`, `TableauStack`, `Foundation`, `Tableau`, `SkipCards`) derive `serde::Serialize` + `serde::Deserialize` under that feature. The workspace `Cargo.toml` enables `features = ["serde"]`.
**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.
**Schema v4 (current):** `saved_moves` serialises as `Vec<KlondikeInstruction>` using upstream named-variant serde. Example: `{"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}`.
**Schema v3 (legacy, auto-migrated):** `saved_moves` used local `SavedInstruction` mirror types with u8 indices. Example: `{"DstFoundation": {"src": "Stock", "foundation": 0}}`. On load, an `AnyInstruction` untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The `SavedInstruction` bridge types are retained in `solitaire_core::klondike_adapter` for this migration path and for backward-compatible `solitaire_data::ReplayMove` / WASM replay formats.
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
**In our wrapper:** `GameState::Serialize` emits schema v4 (upstream instruction types). `GameState::Deserialize` accepts v3 (auto-migrates) and v4 (direct). Schema version field lives on our wrapper.
**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.):
@@ -153,29 +147,6 @@ Steps in dependency order. Upstream issues #10, #11, and the solver are all merg
---
## 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`.
-408
View File
@@ -1,408 +0,0 @@
# In-Place card_game / klondike Rewrite Plan
**Date:** 2026-06-08
**Upstream rev:** `99b49e62`
**Status:** All phases complete (03). recycle_count drift and score compound error on undo fixed in `56e3b62`.
---
## 1. What Is Already Integrated
The integration is substantially complete. `solitaire_core` already delegates all
authoritative Klondike logic to the upstream crates.
| Area | Status | Location |
|---|---|---|
| `Session<Klondike>` ownership | ✅ complete | `GameState.session` |
| `draw()``session.process_instruction(RotateStock)` | ✅ complete | `game_state.rs` |
| `move_cards()``session.process_instruction(KlondikeInstruction)` | ✅ complete | `game_state.rs` |
| `undo()``session.undo()` | ✅ complete | `game_state.rs` |
| `possible_instructions()``session.state().state().get_sorted_moves()` | ✅ complete | `game_state.rs` |
| `can_move_cards()``session.state().state().is_instruction_valid()` | ✅ complete | `game_state.rs` |
| `solver.rs``session.solve()` | ✅ complete | `solver.rs` |
| `Suit`, `Rank` → re-export from `card_game` | ✅ complete | `card.rs` |
| `Foundation`, `Klondike`, `KlondikePile`, `Session`, `Tableau``solitaire_core::lib` | ✅ complete | `lib.rs` |
| Move legality enforcement | ✅ upstream (`is_instruction_valid`) | `klondike/src/lib.rs` |
| Foundation placement rules (Ace start, suit match) | ✅ upstream | `klondike/src/lib.rs` |
| Tableau placement rules (alternating colour, King on empty) | ✅ upstream | `klondike/src/lib.rs` |
| Multi-card stack moves via `SkipCards` | ✅ upstream | `klondike/src/lib.rs` |
| Session history / snapshot undo | ✅ upstream | `card_game/src/lib.rs` |
| DFS solver with budget limits | ✅ upstream | `card_game/src/lib.rs` |
| Instruction history → `SavedInstruction` serde mirrors | ✅ in adapter | `klondike_adapter.rs` |
| Schema v3 save/load (instruction replay) | ✅ complete | `game_state.rs`, `storage.rs` |
| `take_from_foundation` house rule → `MoveFromFoundationConfig` | ✅ complete | `klondike_adapter.rs` |
---
## 2. Duplicated / Replaceable Logic
These are local implementations that either replicate upstream or could be removed.
### 2a. `SavedInstruction` mirror types (~300 lines, `klondike_adapter.rs`)
**What:** A full hand-written serde mirror for every upstream klondike instruction type
(`SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
`SavedSkipCards`, `InvalidSavedInstruction`) plus ~20 `From`/`TryFrom` conversion impls.
**Why written:** At the time, upstream klondike had no serde feature.
**Current upstream status:** At rev `99b49e62`, the `serde` feature is present and active.
`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`,
`TableauStack`, `Tableau`, `Foundation`, `SkipCards` all derive
`#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]`.
**Blocker — JSON format incompatibility:**
| Field | Local `SavedInstruction` JSON | Upstream `KlondikeInstruction` JSON |
|---|---|---|
| Tableau index | `{ "Tableau": 0 }` (u8) | `{ "Tableau": "Tableau1" }` (named) |
| Foundation slot | `{ "Foundation": 0 }` (u8) | `{ "Foundation": "Foundation1" }` (named) |
| Skip count | `{ "skip_cards": 0 }` (u8) | `{ "skip_cards": "Skip0" }` (named) |
Switching to direct upstream serde **changes the `saved_moves` JSON shape** stored in
`game_state.json`. Any existing v3 save file would fail to deserialize after the switch.
This requires either:
- A schema bump to v4 **with a migration** (deserialize v3 manually then re-save as v4), or
- A schema bump to v4 **with graceful fallback** (v3 files rejected → fresh game).
**Recommendation:** Schema v4 with graceful fallback (v3 saves start fresh). Migration
is feasible but adds ~100 lines of throwaway code; the in-progress game loss is modest
since schema v3 was never shipped to users (it landed in the current dev branch, not a
release).
### 2b. `GameState::check_win()` (~15 lines)
**What:** Iterates all four foundation slots checking 13-card A→K sequences.
**Upstream equivalent:** `session.state().state().is_win()` on `Klondike`.
**Status:** Local check is correct but redundant. Trivially replaceable with no format change.
**Risk:** None — only affects `is_won` flag update path.
### 2c. `GameState::check_auto_complete()` (~15 lines)
**What:** Checks stock empty, waste empty, all tableau cards face-up.
**Upstream equivalent:** `session.state().state().is_win_trivial()` on `Klondike`.
**Semantic difference:** Upstream `is_win_trivial` checks `stock.is_empty()` (both faces)
and all `tableau.face_down().is_empty()`. Ferrous additionally checks `waste.is_empty()`.
These are logically equivalent for a valid game state (waste = stock face-up half).
**Risk:** Low — validated by existing auto-complete engine tests.
### 2c. `recycle_count` drift on undo (existing bug, not new)
**What:** `GameState.recycle_count` is incremented in `draw()` when stock is empty.
`undo()` does not decrement it. After undoing a recycle, `recycle_count` is stale and
may cause incorrect future penalty application.
**Upstream:** `KlondikeStats.recycle_count()` has the same problem — it is cumulative
and not restored on undo (stats are not part of the session snapshot, only game state is).
**Fix approach:** After each undo, recompute `recycle_count` by scanning
`session.history()` for `RotateStock` instructions that caused recycling.
**Priority:** Medium — affects scoring correctness in rare paths. File as a separate bug.
---
## 3. What Must Remain Ferrous-Specific
These responsibilities are product-layer, not Klondike-rules-layer, and must stay in `solitaire_core`.
| Responsibility | Why upstream cannot own it |
|---|---|
| WXP recycle penalties (free allowance + -100/-20) | `ScoringConfig::recycle` is a flat delta; no free-allowance concept exists upstream |
| Score floor (`score.max(0)`) | Not modelled upstream |
| Time bonus (`700_000 / elapsed_seconds`) | Not modelled upstream |
| `DrawMode` / `GameMode` enums | Product concept; not in upstream |
| Challenge mode undo block | Product rule |
| Zen mode scoring suppression | Product rule |
| `MoveError` variants for UI feedback | Upstream returns `bool`; Ferrous needs typed errors |
| `card::Card` projection (adds `id`, `face_up`) | Renderer requires stable `id` and face orientation |
| `Pile` DTO for engine sync | Renderer-facing snapshot type |
| `stock_cards()` / `waste_cards()` distinction | Engine models waste as a separate pile; upstream uses stock face-up half |
| `recycle_count` tracking | Needed for free-allowance penalty calculation |
| Persistence format + schema versioning | Product concern |
| `SavedInstruction` (currently) or upstream serde (after migration) | Either way, Ferrous owns the save contract |
---
## 4. Key Audit Findings
### Finding 1 — Upstream serde claim in docs is stale
`docs/card-game-integration.md` (last section "JSON Serialisation") states:
> Current verification (2026-06-01): klondike v0.3.0 and card_game v0.4.0 crate manifests
> expose no serde dependency/feature.
**This is wrong at rev 99b49e62.** The `serde` feature is present and active. All nine
instruction/pile types have `#[cfg_attr(feature = "serde", derive(...))]`. The doc must
be updated.
### Finding 2 — `take_from_foundation` default: docs vs code
`docs/card-game-integration.md` says:
> Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the
> default, with the house rule as an opt-in.
**The code and settings say the opposite:** `Settings::take_from_foundation` defaults to
`true` (Allowed); `GameState.take_from_foundation` also initializes to `true`. Multiple
tests assert this is the intended behavior. The upstream default is also `Allowed`.
**Resolution:** The docs are wrong. Default = Allowed (house rule on by default for
beginner-friendliness) is intentional. Update the docs; do not change the code.
### Finding 3 — `KlondikeStats` cumulative vs session-history-aware counts
`KlondikeStats.moves()` and `KlondikeStats.recycle_count()` accumulate monotonically.
They are NOT restored when `Session::undo()` is called (only `Klondike` game state is
restored from the snapshot, not the stats). Ferrous correctly uses
`session.history().len()` for `move_count` (history-aware). But `recycle_count` is
stored separately in `GameState` and also not decremented on undo — making them
equivalent in this one bug.
### Finding 4 — `SkipCards as usize` cast is correct
Upstream `SkipCards` has no explicit discriminants, so `Skip0 = 0 .. Skip12 = 12`.
`skip_cards as usize` in `solver.rs` and `game_state.rs` is correct.
---
## 5. Staged Migration
### Phase 0 — Doc fixes only (no code change)
Files: `docs/card-game-integration.md`
- Correct the serde claim (upstream has serde at rev 99b49e62).
- Correct the `take_from_foundation` default description.
- Update integration status table.
### Phase 1 — Delegate `is_win` / `is_win_trivial` (safe, no format change)
Files: `solitaire_core/src/game_state.rs`
Replace local `check_win()` and `check_auto_complete()` with upstream delegation:
```rust
// before
pub fn check_win(&self) -> bool { ... 40 lines ... }
// after
pub fn check_win(&self) -> bool {
self.session.state().state().is_win()
}
```
```rust
// before
pub fn check_auto_complete(&self) -> bool { ... 15 lines ... }
// after
pub fn check_auto_complete(&self) -> bool {
self.session.state().state().is_win_trivial()
}
```
**Risk:** Very low. Both methods are tested by existing integration tests. The semantic
difference in `check_auto_complete` (upstream vs Ferrous definition) is equivalent for
valid game states.
### Phase 2 — Replace `SavedInstruction` with upstream serde (schema v4)
Files:
- `solitaire_core/src/klondike_adapter.rs` (remove ~300 lines)
- `solitaire_core/src/game_state.rs` (update `Serialize`/`Deserialize` impls)
- `solitaire_core/src/proptest_tests.rs` (remove now-redundant SavedInstruction tests)
- `solitaire_data/src/storage.rs` (add schema v4 rejection test)
- `solitaire_data/src/replay.rs` (no change — uses `SavedKlondikePile` independently)
- `solitaire_wasm/src/lib.rs` (uses `SavedKlondikePileStack` in its own mirror — evaluate)
**Steps:**
1. In `game_state.rs`, change `PersistedGameState.saved_moves` from
`Vec<SavedInstruction>` to `Vec<KlondikeInstruction>` (upstream serde now works).
2. Update `GameState::Serialize` to emit `KlondikeInstruction` directly.
3. Update `GameState::Deserialize` to parse `KlondikeInstruction` directly.
4. Increment `GAME_STATE_SCHEMA_VERSION` to 4.
5. In `GameState::Deserialize`, reject schema != 4 with graceful fallback (already
handled by `load_game_state_from` returning `None` on serde error or wrong version).
6. Delete `SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
`SavedSkipCards`, `InvalidSavedInstruction` from `klondike_adapter.rs`.
7. Delete the 20 `From`/`TryFrom` impls.
8. Remove `SavedInstruction` proptest and boundary tests (no longer needed).
9. Add schema v4 round-trip test and v3 rejection test.
**Note on `solitaire_data::replay.rs`:**
`replay.rs` uses `SavedKlondikePile` independently (for `ReplayMove`). This is a
separate type from the game-state save format and is NOT changed by this phase.
`ReplayMove` has its own schema (`REPLAY_SCHEMA_VERSION`) and can keep using the local
mirror types.
**Note on `solitaire_wasm/src/lib.rs`:**
Uses `SavedKlondikePileStack` in its own `ReplayMove` mirror. Same as above — separate
type, not affected.
### Pre-Phase 3 — Undo Field Audit (completed 2026-06-08)
Full audit of every Ferrous-owned field in `GameState` for undo correctness.
| Field | Correctly updated by `undo()`? | Notes |
|---|---|---|
| `score` | ✅ By design | 15 WXP undo penalty applied; Zen: stays 0 |
| `move_count` | ✅ Correct | Recomputed from `session.history().len()` |
| `is_won` | ✅ Correct | Recomputed; undo blocked on won game |
| `is_auto_completable` | ✅ Correct | Recomputed |
| `undo_count` | ✅ By design | Total undos ever, intentionally non-reversible |
| `elapsed_seconds` | ✅ Intentional | Timer is independent of moves |
| `seed` / `draw_mode` / `mode` / `take_from_foundation` | ✅ Immutable | |
| **`recycle_count`** | ❌ **Bug** | Not decremented — see below |
**`recycle_count` drift bug:**
`draw()` increments `recycle_count` when `stock.face_down().is_empty()` (the rotation
is a recycle, not just a draw). `undo()` calls `session.undo()` which restores the
`Klondike` card state, but does NOT decrement `recycle_count`.
Consequence: if the player recycles, undoes it, then recycles again, `recycle_count`
is `2` instead of `1` — the free-recycle allowance is consumed even though the first
recycle was undone. On Draw-1, the 2nd recycle costs 100; after the undo-and-replay
bug the player pays 100 for what should be their still-free recycle.
**Score compound effect:** When `undo()` is applied to a recycle that incurred a
penalty, the penalty amount (`score_after_recycle - 100`) is already in `self.score`.
`apply_undo_score` then adds `15` on top. The recycle penalty is never reversed.
**Fix approach for Phase 3:**
- After `session.undo()`, recompute `recycle_count` by scanning the new
`session.history()` for `RotateStock` snapshots where
`snapshot.state().state().stock().face_down().is_empty()` (indicating the rotation
was a recycle, not a draw from a populated stock).
- Restore `score` to `snapshot_score` **before** the undone move, then apply only
the 15 undo penalty. This requires reading the score stored in `StateSnapshot`
or keeping a pre-move score stack alongside the session history.
**Simpler alternative:** Store `(score_before, recycle_count_before)` in `GameState`
alongside each `session.process_instruction` call, mirroring the snapshot stack.
Undo pops this alongside the session undo.
### Phase 3 — Fix `recycle_count` drift on undo (optional, post-approval)
Files: `solitaire_core/src/game_state.rs`
After `session.undo()`, recompute `recycle_count` by scanning `session.history()` for
`RotateStock` snapshots where the pre-instruction stock face-down was empty (indicating
a recycle). Also correct the score: restore to the pre-undone-move score and apply only
the 15 undo penalty.
**Tests to add:**
- `recycle_count_decrements_when_recycle_is_undone`
- `score_recycle_penalty_is_reversed_on_undo`
**Risk:** Medium — changes observable scoring behavior. The fix is strictly more
correct, but any golden-file or regression test that recorded the old (buggy) score
after undo-of-recycle will need updating.
---
## 6. Files Likely to Change Per Phase
| Phase | Files |
|---|---|
| Phase 0 | `docs/card-game-integration.md` |
| Phase 1 | `solitaire_core/src/game_state.rs` |
| Phase 2 | `solitaire_core/src/klondike_adapter.rs`, `solitaire_core/src/game_state.rs`, `solitaire_core/src/proptest_tests.rs`, `solitaire_data/src/storage.rs` |
| Phase 3 | `solitaire_core/src/game_state.rs`, new test module |
---
## 7. Risks
### R1 — Save file format break (Phase 2, HIGH)
Users with v3 saves lose their in-progress game. Mitigated by the fact that v3 is
not in any shipped release (dev branch only). Graceful fallback (start fresh) is
acceptable; a migration shim is possible but not required.
### R2 — `solitaire_wasm` / `solitaire_data::replay` breakage (Phase 2, MEDIUM)
`SavedKlondikePile` and `SavedKlondikePileStack` are also used in `replay.rs` and
`wasm/src/lib.rs`. These are separate from the game-state save format and must be
left in place. Plan is to keep them in `klondike_adapter.rs` (or relocate to
`replay.rs`) after the game-state mirror types are deleted.
### R3 — `check_auto_complete` semantic drift (Phase 1, LOW)
Upstream `is_win_trivial` checks `stock.is_empty()` (no cards at all in stock)
whereas Ferrous also checks waste. These are equivalent for a valid game state but
could differ under test-support pile overrides. Existing auto-complete tests will
catch any regression.
### R4 — `SkipCards as usize` cast correctness
Already verified: enums have implicit 0..12 discriminants. No risk.
### R5 — Upstream changes after rev pin
The workspace is pinned to `rev = "99b49e62"`. No upstream drift risk until explicitly
re-pinned.
---
## 8. Test Plan
### Phase 1 tests (all currently pass)
- `game_state::tests::take_from_foundation_allows_legal_return_move`
- `game_state::tests::take_from_foundation_disabled_blocks_return_move_everywhere`
- `proptest_tests::*` (card conservation, deal determinism, undo invariant, legal moves)
### Phase 2 tests to add
- `storage::tests::game_state_v4_mid_game_round_trip` — verify upstream serde round-trip
after migrating to `KlondikeInstruction` directly
- `storage::tests::save_format_v3_is_rejected` — v3 files must return `None`
- Update `game_state::tests::*` — all existing tests must continue to pass
### Phase 2 tests to remove
- `proptest_tests::saved_instruction_round_trip` — no longer needed (no mirror types)
- `proptest_tests::saved_instruction_boundary_tests::*` — no longer needed
### Phase 3 tests to add
- `game_state::tests::recycle_count_decrements_on_undo` — after recycling and undoing,
`recycle_count` must reflect the correct post-undo count
---
## 9. Validation Commands
Run after each phase:
```bash
# Targeted (fast)
cargo test -p solitaire_core
cargo clippy -p solitaire_core -- -D warnings
# Broader
cargo test -p solitaire_wasm
cargo test -p solitaire_data
# Full workspace (run before declaring phase complete)
cargo test --workspace
cargo clippy --workspace -- -D warnings
```
---
## Summary: What Would Be Removed vs Kept
### Removed after all phases complete
| Code | Lines est. | Reason |
|---|---|---|
| `SavedInstruction` + 8 mirror types | ~150 | Upstream serde now available |
| 20 `From`/`TryFrom` impls | ~150 | Upstream serde now available |
| `InvalidSavedInstruction` error type | ~10 | Upstream serde now available |
| `check_win()` local impl | ~20 | Replaced by `is_win()` delegation |
| `check_auto_complete()` local impl | ~15 | Replaced by `is_win_trivial()` delegation |
| `SavedInstruction` proptest + boundary tests | ~60 | Mirror types removed |
**Total: ~400 lines removed from `solitaire_core`**
### Remains Ferrous-specific
- `KlondikeAdapter` scoring helpers (recycle penalties, score floor, time bonus, Zen/mode suppression)
- `DrawMode`, `GameMode`, `DifficultyLevel`
- `MoveError` and all boundary-checking logic
- `card::Card` (id + face_up projection)
- `Pile` DTO
- `stock_cards()` / `waste_cards()` projections
- Persistence format (`GameState` serde, schema version, `PersistedGameState`)
- `solitaire_data::replay` types (`ReplayMove`, `SavedKlondikePile` mirror — unchanged)
- `solitaire_wasm` replay mirror types (unchanged)
-115
View File
@@ -1,115 +0,0 @@
# 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.
-228
View File
@@ -1,228 +0,0 @@
# Android testing
This directory contains lightweight Android test helpers for Ferrous Solitaire.
They are intended to run against either a physical Android device or an emulator
connected through `adb`. When no device is connected the smoke script can
automatically launch an AVD for you.
## Prerequisites
- Android SDK and NDK installed.
- `adb` available on `PATH`.
- One device/emulator visible in `adb devices`, **or** at least one AVD created
(the script will launch one automatically if `LAUNCH_AVD=1`, which is the default).
- If multiple devices are connected, set `ADB_SERIAL` to the target device serial.
- Environment variables required by `scripts/build_android_apk.sh` when building:
```sh
export ANDROID_HOME=/path/to/android-sdk
export ANDROID_NDK_HOME=/path/to/android-ndk
export BUILD_TOOLS_VERSION=34.0.0
export PLATFORM=android-34
```
## Smoke test
From the workspace root (`Rusty_Solitaire/`):
```sh
scripts/android_smoke.sh
```
The smoke test first checks whether `adb` can see a ready device. If no device
is connected and `LAUNCH_AVD=1` (default), it:
1. locates the `emulator` binary under `ANDROID_HOME` or `PATH`,
2. picks the first available AVD (or uses `AVD_NAME`),
3. launches the emulator in the foreground (or headless with `AVD_HEADLESS=1`),
4. waits for `sys.boot_completed=1` before proceeding,
5. dismisses the lock screen so the screenshot shows the app.
Once a device is ready (auto-launched or pre-existing) the script:
1. builds the APK using `scripts/build_android_apk.sh`,
2. installs it with `adb install -r -d` so debug smoke builds can replace newer local builds,
3. force-stops the package by default for a clean launch,
4. clears `logcat`,
5. launches `com.ferrousapp.solitaire/android.app.NativeActivity`,
6. waits for the app to settle,
7. verifies the process is still running,
8. captures a screenshot and `logcat`, and
9. fails on fatal log patterns such as native crashes, JNI fatal errors, real ANRs,
and Rust panics.
On exit the script kills any emulator it launched (`SHUTDOWN_AVD_ON_EXIT=1` by
default). Set `SHUTDOWN_AVD_ON_EXIT=0` to keep the emulator open for inspection.
Artifacts are written to `target/android-smoke/<timestamp>/` by default. A successful run includes:
- `device.txt` — selected device and display metadata,
- `df-data-before.txt` / `df-data-after.txt` — emulator/device storage snapshots,
- `emulator.log` — stdout/stderr from the emulator process (AVD runs only),
- `emulator.pid` — PID of the emulator process (AVD runs only),
- `launch.png` — screenshot after the wait period,
- `logcat.txt` — full captured log,
- `log-summary.txt` — grep summary for warnings, errors, JNI, safe-area, and crash terms, and
- `pid.txt` — running app process id.
## Creating an AVD
If no AVDs exist, create one before running the smoke test:
```sh
# Install a system image
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
'system-images;android-34;google_apis;x86_64'
# Create the AVD
"$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd \
-n Pixel_7_API_34 \
-k 'system-images;android-34;google_apis;x86_64' \
--device 'pixel_7'
```
Then run the smoke test — it will pick `Pixel_7_API_34` automatically:
```sh
scripts/android_smoke.sh
```
## Faster iteration
If you already built the APK and only want to reinstall/relaunch:
```sh
BUILD_APK=0 scripts/android_smoke.sh
```
If the APK is already installed and you only want to relaunch/capture logs:
```sh
BUILD_APK=0 INSTALL_APK=0 scripts/android_smoke.sh
```
By default the script force-stops the package before launch so logcat and screenshots represent a clean app start. To test warm-launch behavior instead:
```sh
BUILD_APK=0 INSTALL_APK=0 FORCE_STOP=0 scripts/android_smoke.sh
```
This is also useful when an already-installed build is good enough for launch/log checks. On install failure, the script writes `adb-install.txt`, storage snapshots, and installed-package diagnostics to the output directory.
If install fails with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, the smoke script uninstalls the package and retries once by default (`RESET_ON_SIGNATURE_MISMATCH=1`). This resets app data on the device/emulator. Disable it with:
```sh
RESET_ON_SIGNATURE_MISMATCH=0 scripts/android_smoke.sh
```
To write artifacts to a stable path:
```sh
OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
```
When reusing an output directory, previous files are removed by default so stale artifacts do not contaminate the latest result. To keep existing files:
```sh
CLEAN_OUT_DIR=0 OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh
```
To target a specific device when more than one is attached:
```sh
ADB_SERIAL=emulator-5554 scripts/android_smoke.sh
```
To wait longer for safe-area inset polling or slow devices:
```sh
WAIT_SECS=8 scripts/android_smoke.sh
```
## AVD options
To pick a specific AVD by name instead of auto-selecting the first one:
```sh
AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
```
To run headless (no emulator window) — useful in CI or on a display-less machine:
```sh
AVD_HEADLESS=1 scripts/android_smoke.sh
```
To give a slow machine more time to boot the emulator (default is 120 s):
```sh
AVD_BOOT_TIMEOUT=180 scripts/android_smoke.sh
```
To keep the emulator running after the test (useful for manual inspection):
```sh
SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh
```
To pass extra flags to the emulator (e.g. disable snapshot for a completely
cold boot, or change GPU mode):
```sh
AVD_EXTRA_ARGS="-gpu swiftshader_indirect" scripts/android_smoke.sh
```
To disable AVD auto-launch entirely and fail immediately if no device is
connected:
```sh
LAUNCH_AVD=0 scripts/android_smoke.sh
```
For build-only validation without requiring a connected device, use the lower-level APK builder directly:
```sh
scripts/build_android_apk.sh
```
For smoke testing, `scripts/android_smoke.sh` defaults to the connected device's primary ABI when `BUILD_APK=1`, which keeps emulator APKs much smaller than the full multi-ABI default. You can still override it explicitly:
```sh
ABIS=x86_64 scripts/android_smoke.sh
```
For build-only validation, `scripts/build_android_apk.sh` still defaults to all configured ABIs unless you set `ABIS` yourself:
```sh
ABIS=x86_64 scripts/build_android_apk.sh
```
The APK builder signs debug builds with a persistent keystore at `target/android/debug.keystore` by default. This avoids signature churn across smoke-test runs.
The APK builder also strips native debug symbols by default before packaging (`STRIP_NATIVE_LIBS=1`). This keeps debug APKs installable on emulators with limited `/data` storage. To preserve native debug symbols for low-level debugging:
```sh
STRIP_NATIVE_LIBS=0 ABIS=x86_64 scripts/build_android_apk.sh
```
## Device checklist
The script is only a smoke test. Before shipping Android builds, also verify:
- safe-area insets arrive and shift the HUD after a few seconds,
- HUD does not overlap the top status bar,
- modal Done buttons are above the gesture/navigation bar,
- stock tap works,
- drag-and-drop works on tableau, waste, and foundation piles,
- Settings/Help/Profile modals open and close,
- login tokens persist after app restart, and
- `target/android-smoke/.../logcat.txt` contains no fatal JNI/native crash output.
## Notes
- `adb shell input tap X Y` uses physical pixels, not Bevy logical pixels.
- The projects common test device mapping is physical `1080×2400`, Bevy logical
`900×2000`, scale factor `1.20`; multiply logical coordinates by `1.20` for
scripted `adb shell input` commands on that device.
- Keep generated screenshots/logs under `target/android-smoke/` so they stay out
of source control.
-362
View File
@@ -1,362 +0,0 @@
#!/usr/bin/env bash
# Android smoke test for Ferrous Solitaire.
#
# Builds (optional), installs, launches, captures logcat + screenshot, and
# fails on fatal Android log patterns. Designed as a lightweight device/emulator
# sanity check rather than a full UI automation suite.
#
# Required:
# adb on PATH
# Android SDK/NDK env required by scripts/build_android_apk.sh when BUILD_APK=1
#
# Optional environment:
# BUILD_APK=1|0 Build APK before install (default: 1)
# INSTALL_APK=1|0 Install APK before launch (default: 1)
# RESET_ON_SIGNATURE_MISMATCH=1|0
# Uninstall/retry if debug signatures differ (default: 1)
# LAUNCH_APP=1|0 Launch app before checks (default: 1)
# FORCE_STOP=1|0 Force-stop package before launch for clean logs (default: 1)
# CAPTURE_SCREENSHOT=1|0 Capture screenshot (default: 1)
# ADB_SERIAL=... Device serial to use when multiple devices are connected
# APK_PATH=... APK to install (default: target/debug/apk/ferrous-solitaire.apk)
# PACKAGE=... Android package (default: com.ferrousapp.solitaire)
# ACTIVITY=... Activity class (default: android.app.NativeActivity)
# OUT_DIR=... Artifact directory (default: target/android-smoke/<timestamp>)
# CLEAN_OUT_DIR=1|0 Remove prior artifacts from OUT_DIR first (default: 1)
# WAIT_SECS=... Seconds to wait after launch (default: 5)
# ABIS=... Passed to build script. If unset and BUILD_APK=1,
# defaults to the connected device's primary ABI.
#
# AVD auto-launch (used when no device/emulator is already connected):
# LAUNCH_AVD=1|0 Auto-launch an AVD when no device is ready (default: 1)
# AVD_NAME=... AVD name to launch (default: first from `emulator -list-avds`)
# AVD_BOOT_TIMEOUT=... Seconds to wait for the emulator to finish booting (default: 120)
# AVD_HEADLESS=1|0 Run with -no-window -no-audio for CI/no-display environments (default: 0)
# AVD_EXTRA_ARGS=... Extra arguments appended verbatim to the emulator command line
# SHUTDOWN_AVD_ON_EXIT=1|0
# Kill the AVD this script launched on exit (default: 1).
# Set to 0 to leave the emulator running after the test.
#
# Examples:
# scripts/android_smoke.sh
# BUILD_APK=0 scripts/android_smoke.sh
# LAUNCH_AVD=0 scripts/android_smoke.sh # error out if no device, never auto-launch
# AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh
# AVD_HEADLESS=1 scripts/android_smoke.sh # CI / no-display
# SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh # keep emulator open after test
# OUT_DIR=target/android-smoke/latest WAIT_SECS=8 scripts/android_smoke.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
BUILD_APK="${BUILD_APK:-1}"
INSTALL_APK="${INSTALL_APK:-1}"
RESET_ON_SIGNATURE_MISMATCH="${RESET_ON_SIGNATURE_MISMATCH:-1}"
LAUNCH_APP="${LAUNCH_APP:-1}"
FORCE_STOP="${FORCE_STOP:-1}"
CAPTURE_SCREENSHOT="${CAPTURE_SCREENSHOT:-1}"
APK_PATH="${APK_PATH:-target/debug/apk/ferrous-solitaire.apk}"
PACKAGE="${PACKAGE:-com.ferrousapp.solitaire}"
ACTIVITY="${ACTIVITY:-android.app.NativeActivity}"
WAIT_SECS="${WAIT_SECS:-5}"
OUT_DIR="${OUT_DIR:-target/android-smoke/$(date +%Y%m%d-%H%M%S)}"
CLEAN_OUT_DIR="${CLEAN_OUT_DIR:-1}"
REMOTE_SCREENSHOT="/sdcard/ferrous-solitaire-smoke.png"
LAUNCH_AVD="${LAUNCH_AVD:-1}"
AVD_NAME="${AVD_NAME:-}"
AVD_BOOT_TIMEOUT="${AVD_BOOT_TIMEOUT:-120}"
AVD_HEADLESS="${AVD_HEADLESS:-0}"
AVD_EXTRA_ARGS="${AVD_EXTRA_ARGS:-}"
SHUTDOWN_AVD_ON_EXIT="${SHUTDOWN_AVD_ON_EXIT:-1}"
ADB=(adb)
if [ -n "${ADB_SERIAL:-}" ]; then
ADB+=( -s "$ADB_SERIAL" )
fi
# PID of any emulator we start so the EXIT trap can clean it up.
_LAUNCHED_EMULATOR_PID=""
_cleanup_emulator() {
if [ -n "$_LAUNCHED_EMULATOR_PID" ] && [ "$SHUTDOWN_AVD_ON_EXIT" = "1" ]; then
echo ">>> shutdown emulator (PID $_LAUNCHED_EMULATOR_PID)"
kill "$_LAUNCHED_EMULATOR_PID" 2>/dev/null || true
fi
}
trap _cleanup_emulator EXIT
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 1
}
}
mkdir -p "$OUT_DIR"
if [ "$CLEAN_OUT_DIR" = "1" ]; then
find "$OUT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
fi
require_cmd adb
# ---------------------------------------------------------------------------
# Device / emulator availability
# ---------------------------------------------------------------------------
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$DEVICE_STATE" != "device" ]; then
if [ "$LAUNCH_AVD" != "1" ]; then
adb devices > "$OUT_DIR/adb-devices.txt" 2>&1 || true
if [ -n "${ADB_SERIAL:-}" ]; then
echo "Android device '$ADB_SERIAL' is not connected/ready (state: ${DEVICE_STATE:-unknown})." >&2
else
echo "No Android device/emulator is connected and ready." >&2
fi
echo "Run 'adb devices' or start an emulator, then retry." >&2
echo "Device list saved to $OUT_DIR/adb-devices.txt" >&2
exit 1
fi
# --- locate emulator binary -----------------------------------------------
# Priority: ANDROID_HOME env → PATH → common SDK install locations.
_find_sdk_root() {
for candidate in \
"$HOME/Android/Sdk" \
"$HOME/Library/Android/sdk" \
"/opt/android-sdk" \
"/usr/lib/android-sdk"; do
[ -d "$candidate" ] && echo "$candidate" && return
done
}
EMULATOR_BIN=""
if [ -n "${ANDROID_HOME:-}" ] && [ -x "$ANDROID_HOME/emulator/emulator" ]; then
EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
elif command -v emulator >/dev/null 2>&1; then
EMULATOR_BIN="$(command -v emulator)"
else
_SDK_ROOT="$(_find_sdk_root)"
if [ -n "$_SDK_ROOT" ] && [ -x "$_SDK_ROOT/emulator/emulator" ]; then
EMULATOR_BIN="$_SDK_ROOT/emulator/emulator"
fi
fi
if [ -z "$EMULATOR_BIN" ]; then
echo "No Android device found and 'emulator' binary is not available." >&2
echo " • Install the Android SDK emulator component, or" >&2
echo " • Set ANDROID_HOME to your SDK root, or" >&2
echo " • Start a device/emulator manually then retry with LAUNCH_AVD=0." >&2
exit 1
fi
echo ">>> emulator binary: $EMULATOR_BIN"
# --- select AVD -----------------------------------------------------------
if [ -z "$AVD_NAME" ]; then
AVD_NAME="$("$EMULATOR_BIN" -list-avds 2>/dev/null | head -n 1 | tr -d '\r')"
if [ -z "$AVD_NAME" ]; then
echo "No AVDs found. Create one first, for example:" >&2
echo " sdkmanager 'system-images;android-34;google_apis;x86_64'" >&2
echo " avdmanager create avd -n Pixel_7_API_34 \\" >&2
echo " -k 'system-images;android-34;google_apis;x86_64' --device 'pixel_7'" >&2
exit 1
fi
echo ">>> auto-selected AVD: $AVD_NAME"
fi
# --- launch emulator -------------------------------------------------------
EMULATOR_ARGS=( -avd "$AVD_NAME" -no-snapshot-load )
[ "$AVD_HEADLESS" = "1" ] && EMULATOR_ARGS+=( -no-window -no-audio )
# Split AVD_EXTRA_ARGS on whitespace only (disable glob expansion).
set -f
# shellcheck disable=SC2206
[ -n "$AVD_EXTRA_ARGS" ] && EMULATOR_ARGS+=( $AVD_EXTRA_ARGS )
set +f
echo ">>> launch emulator: $AVD_NAME"
"$EMULATOR_BIN" "${EMULATOR_ARGS[@]}" > "$OUT_DIR/emulator.log" 2>&1 &
_LAUNCHED_EMULATOR_PID=$!
echo "$_LAUNCHED_EMULATOR_PID" > "$OUT_DIR/emulator.pid"
echo " emulator PID: $_LAUNCHED_EMULATOR_PID"
echo " emulator log: $OUT_DIR/emulator.log"
# --- wait for adb transport -----------------------------------------------
# Poll adb get-state (≠ wait-for-device which blocks indefinitely) so we can
# honour AVD_BOOT_TIMEOUT for the whole boot sequence.
echo ">>> waiting for device to appear in adb (timeout: ${AVD_BOOT_TIMEOUT}s)"
_ELAPSED=0
while true; do
_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$_STATE" = "device" ] || [ "$_STATE" = "offline" ]; then
break
fi
if [ "$_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
echo "Device did not appear in adb within ${AVD_BOOT_TIMEOUT}s" >&2
echo "emulator log:" >&2
tail -20 "$OUT_DIR/emulator.log" >&2 || true
exit 1
fi
sleep 3
_ELAPSED=$(( _ELAPSED + 3 ))
echo " ... ${_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s"
done
# Capture emulator serial (emulator-5554 etc.) so all subsequent adb calls
# target the right device when ADB_SERIAL was not set by the caller.
if [ -z "${ADB_SERIAL:-}" ]; then
_EMU_SERIAL="$(adb devices 2>/dev/null | awk '/^emulator-/{print $1; exit}' | tr -d '\r')"
if [ -n "$_EMU_SERIAL" ]; then
ADB_SERIAL="$_EMU_SERIAL"
ADB=(adb -s "$ADB_SERIAL")
echo ">>> detected emulator serial: $ADB_SERIAL"
fi
fi
# --- wait for full Android boot -------------------------------------------
# adb get-state returning "device" means the transport is up, but the
# Android framework may still be initialising. Poll sys.boot_completed.
echo ">>> waiting for boot_completed (timeout: ${AVD_BOOT_TIMEOUT}s)"
_BOOT_ELAPSED=0
_BOOT_INTERVAL=5
while true; do
_BOOT="$("${ADB[@]}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
if [ "$_BOOT" = "1" ]; then
echo ">>> emulator boot complete"
break
fi
if [ "$_BOOT_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then
echo "Emulator did not finish booting within ${AVD_BOOT_TIMEOUT}s" >&2
echo "emulator log:" >&2
tail -20 "$OUT_DIR/emulator.log" >&2 || true
exit 1
fi
sleep "$_BOOT_INTERVAL"
_BOOT_ELAPSED=$(( _BOOT_ELAPSED + _BOOT_INTERVAL ))
echo " ... ${_BOOT_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s (boot_completed='${_BOOT}')"
done
# Dismiss the lock screen so later screencap shows the app, not the keyguard.
"${ADB[@]}" shell input keyevent 82 2>/dev/null || true
# Final sanity check — device must be fully ready before we proceed.
DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)"
if [ "$DEVICE_STATE" != "device" ]; then
echo "Emulator is running but adb state is '${DEVICE_STATE:-unknown}'." >&2
exit 1
fi
fi
# ---------------------------------------------------------------------------
# Device metadata
# ---------------------------------------------------------------------------
{
echo "adb_serial=${ADB_SERIAL:-default}"
echo "package=$PACKAGE"
echo "activity=$ACTIVITY"
echo "device_state=$DEVICE_STATE"
"${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' | sed 's/^/product_model=/'
"${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' | sed 's/^/android_release=/'
"${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' | sed 's/^/android_sdk=/'
"${ADB[@]}" shell wm size 2>/dev/null | tr -d '\r' | sed 's/^/wm_size=/'
"${ADB[@]}" shell wm density 2>/dev/null | tr -d '\r' | sed 's/^/wm_density=/'
} > "$OUT_DIR/device.txt"
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-before.txt" 2>&1 || true
if [ "$BUILD_APK" = "1" ]; then
if [ -z "${ABIS:-}" ]; then
DEVICE_ABI="$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')"
case "$DEVICE_ABI" in
x86_64|arm64-v8a|armeabi-v7a)
export ABIS="$DEVICE_ABI"
;;
armeabi*)
export ABIS="armeabi-v7a"
;;
*)
echo "Could not map device ABI '$DEVICE_ABI'; using build script default ABIS." >&2
;;
esac
fi
echo ">>> build Android APK${ABIS:+ (ABIS=$ABIS)}"
scripts/build_android_apk.sh
fi
if [ "$INSTALL_APK" = "1" ]; then
[ -f "$APK_PATH" ] || {
echo "APK not found: $APK_PATH" >&2
echo "Set APK_PATH or run with BUILD_APK=1." >&2
exit 1
}
ls -lh "$APK_PATH" > "$OUT_DIR/apk.txt"
echo ">>> install $APK_PATH"
if ! "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install.txt" 2>&1; then
if [ "$RESET_ON_SIGNATURE_MISMATCH" = "1" ] && grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE" "$OUT_DIR/adb-install.txt"; then
echo ">>> signature mismatch; uninstalling $PACKAGE and retrying install"
"${ADB[@]}" uninstall "$PACKAGE" > "$OUT_DIR/adb-uninstall-before-retry.txt" 2>&1 || true
if "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install-retry.txt" 2>&1; then
cat "$OUT_DIR/adb-install-retry.txt" >> "$OUT_DIR/adb-install.txt"
else
cat "$OUT_DIR/adb-install.txt" >&2
cat "$OUT_DIR/adb-install-retry.txt" >&2
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
echo "APK install retry failed. Diagnostics saved in $OUT_DIR" >&2
exit 1
fi
else
cat "$OUT_DIR/adb-install.txt" >&2
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true
"${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true
echo "APK install failed. Diagnostics saved in $OUT_DIR" >&2
echo "If the package is already installed and you only need launch/log checks, retry with INSTALL_APK=0." >&2
exit 1
fi
fi
fi
if [ "$FORCE_STOP" = "1" ]; then
echo ">>> force-stop $PACKAGE"
"${ADB[@]}" shell am force-stop "$PACKAGE" || true
fi
echo ">>> clear logcat"
"${ADB[@]}" logcat -c
if [ "$LAUNCH_APP" = "1" ]; then
echo ">>> launch $PACKAGE/$ACTIVITY"
"${ADB[@]}" shell am start -n "$PACKAGE/$ACTIVITY" > "$OUT_DIR/am-start.txt"
fi
echo ">>> wait ${WAIT_SECS}s"
sleep "$WAIT_SECS"
PID="$("${ADB[@]}" shell pidof "$PACKAGE" | tr -d '\r' || true)"
if [ -z "$PID" ]; then
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt" || true
echo "app process is not running after launch: $PACKAGE" >&2
echo "logcat saved to $OUT_DIR/logcat.txt" >&2
exit 1
fi
echo "$PID" > "$OUT_DIR/pid.txt"
if [ "$CAPTURE_SCREENSHOT" = "1" ]; then
echo ">>> capture screenshot"
"${ADB[@]}" shell screencap -p "$REMOTE_SCREENSHOT"
"${ADB[@]}" pull "$REMOTE_SCREENSHOT" "$OUT_DIR/launch.png" >/dev/null
"${ADB[@]}" shell rm -f "$REMOTE_SCREENSHOT" >/dev/null 2>&1 || true
fi
echo ">>> capture logcat"
"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt"
grep -iE "panic|fatal|jni|native crash|\bANR\b|exception|error|warn|keystore|safe_area" "$OUT_DIR/logcat.txt" > "$OUT_DIR/log-summary.txt" || true
"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after.txt" 2>&1 || true
# Fatal patterns only. Avoid matching generic "error" because Android logs are
# noisy and many non-fatal framework lines contain that word.
if grep -iE "fatal exception|jni detected error|native crash|signal [0-9]+|ANR in|Application Not Responding|Input dispatching timed out|thread exiting with uncaught exception|panicked at" "$OUT_DIR/logcat.txt"; then
echo "Android smoke test found fatal log output" >&2
echo "Artifacts saved in $OUT_DIR" >&2
exit 1
fi
echo ">>> Android smoke test passed"
echo "Artifacts saved in $OUT_DIR"
+15 -87
View File
@@ -6,15 +6,11 @@
# ndk-build crate that we couldn't isolate; running each Android toolchain
# step explicitly gives us a debuggable pipeline.
#
# Environment:
# ANDROID_HOME Path to Android SDK root. If unset, common SDK
# locations such as ~/Android/Sdk are tried.
# ANDROID_NDK_HOME Path to the specific NDK version. If unset, the
# newest $ANDROID_HOME/ndk/* directory is used.
# BUILD_TOOLS_VERSION e.g. "34.0.0". If unset, newest installed build-tools
# version is used.
# PLATFORM e.g. "android-34". If unset, newest installed
# $ANDROID_HOME/platforms/android-* platform is used.
# Required environment:
# ANDROID_HOME Path to Android SDK root
# ANDROID_NDK_HOME Path to the specific NDK version
# BUILD_TOOLS_VERSION e.g. "34.0.0"
# PLATFORM e.g. "android-34"
#
# Optional environment:
# PROFILE "debug" (default) | "release"
@@ -23,8 +19,7 @@
# fit the runner's disk budget — a full three-ABI
# debug build can exceed 25 GB of target/ output.
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
# STRIP_NATIVE_LIBS 1 to strip .so files before packaging (default: 1)
# KEYSTORE Path to keystore for signing (default: target/android/debug.keystore)
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
# KEY_ALIAS Key alias (default: "androiddebugkey")
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
@@ -33,63 +28,18 @@
# $APK_OUT Signed, zipaligned APK
set -euo pipefail
infer_latest_dir_name() {
local pattern="$1"
local latest=""
shopt -s nullglob
local dirs=( $pattern )
shopt -u nullglob
if [ ${#dirs[@]} -gt 0 ]; then
latest="$(printf '%s\n' "${dirs[@]}" | sort -V | tail -n 1)"
basename "$latest"
fi
}
if [ -z "${ANDROID_HOME:-}" ]; then
for candidate in "$HOME/Android/Sdk" "$HOME/Library/Android/sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do
if [ -d "$candidate" ]; then
ANDROID_HOME="$candidate"
export ANDROID_HOME
break
fi
done
fi
: "${ANDROID_HOME:?ANDROID_HOME must be set or discoverable under a common SDK path}"
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
NDK_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/ndk/*")"
if [ -n "$NDK_VERSION" ]; then
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
export ANDROID_NDK_HOME
fi
fi
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set or discoverable under ANDROID_HOME/ndk}"
if [ -z "${BUILD_TOOLS_VERSION:-}" ]; then
BUILD_TOOLS_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/build-tools/*")"
export BUILD_TOOLS_VERSION
fi
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set or discoverable under ANDROID_HOME/build-tools}"
if [ -z "${PLATFORM:-}" ]; then
PLATFORM="$(infer_latest_dir_name "$ANDROID_HOME/platforms/android-*")"
export PLATFORM
fi
: "${PLATFORM:?PLATFORM must be set or discoverable under ANDROID_HOME/platforms}"
: "${ANDROID_HOME:?ANDROID_HOME must be set}"
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
PROFILE="${PROFILE:-debug}"
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
STRIP_NATIVE_LIBS="${STRIP_NATIVE_LIBS:-1}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
echo ">>> Android SDK: $ANDROID_HOME"
echo ">>> Android NDK: $ANDROID_NDK_HOME"
echo ">>> Build tools: $BUILD_TOOLS_VERSION"
echo ">>> Platform: $PLATFORM"
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
MANIFEST="solitaire_app/android/AndroidManifest.xml"
@@ -119,24 +69,6 @@ fi
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
cargo ndk "${CARGO_NDK_ARGS[@]}"
if [ "$STRIP_NATIVE_LIBS" = "1" ]; then
LLVM_STRIP=""
shopt -s nullglob
STRIP_CANDIDATES=( "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/llvm-strip )
shopt -u nullglob
if [ ${#STRIP_CANDIDATES[@]} -gt 0 ]; then
LLVM_STRIP="${STRIP_CANDIDATES[0]}"
fi
if [ -z "$LLVM_STRIP" ]; then
echo "llvm-strip not found under ANDROID_NDK_HOME; native libraries will remain unstripped" >&2
else
echo ">>> strip native libraries with $LLVM_STRIP"
find "$STAGING/lib" -name '*.so' -print0 | while IFS= read -r -d '' so; do
"$LLVM_STRIP" --strip-debug "$so"
done
fi
fi
# --- 2. compile + link resources and manifest ------------------------------
if [ -d "$RES_DIR" ]; then
echo ">>> aapt2 compile resources"
@@ -188,15 +120,11 @@ rm -f "$STAGING/app-unsigned.apk"
# --- 5. sign ---------------------------------------------------------------
if [ -z "${KEYSTORE:-}" ]; then
KEYSTORE="target/android/debug.keystore"
fi
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
if [ ! -f "$KEYSTORE" ]; then
mkdir -p "$(dirname "$KEYSTORE")"
# Generate a deterministic debug keystore on the fly.
KEYSTORE="$STAGING/debug.keystore"
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
echo ">>> generating debug keystore at $KEYSTORE"
keytool -genkeypair -v \
-keystore "$KEYSTORE" \
-51
View File
@@ -1,51 +0,0 @@
#!/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"
+10 -25
View File
@@ -25,10 +25,7 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use bevy::winit::WinitWindows;
#[cfg(target_os = "android")]
use bevy::winit::{UpdateMode, WinitSettings};
use solitaire_data::{
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
settings_file_path,
};
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
fn load_settings() -> Settings {
@@ -52,12 +49,6 @@ 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.
@@ -65,9 +56,10 @@ pub fn run() {
// operations will fail gracefully with TokenError::KeychainUnavailable.
//
// Android: `keyring` isn't compiled in (its `rpassword` transitive
// pulls a libc symbol Android's bionic doesn't expose). The Android
// auth-token path uses Android Keystore via JNI; `android_main` passes
// the process JavaVM pointer into `solitaire_data` before `run()`.
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
// ships an Android stub that returns KeychainUnavailable for every
// call — the runtime behaviour is "session login required each launch"
// until we wire Android Keystore via JNI in the Phase-Android round.
#[cfg(not(target_os = "android"))]
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
@@ -180,16 +172,13 @@ 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.
//
// 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.
// 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.
#[cfg(target_os = "android")]
app.insert_resource(WinitSettings {
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
focused_mode: UpdateMode::Continuous,
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
});
@@ -365,10 +354,6 @@ fn set_window_icon(
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let vm_ptr = android_app.vm_as_ptr().cast();
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) {
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
}
let _ = bevy::android::ANDROID_APP.set(android_app);
run();
}
+1 -9
View File
@@ -4,15 +4,7 @@ version.workspace = true
license.workspace = true
edition.workspace = true
[features]
default = []
test-support = []
[dev-dependencies]
proptest = "1"
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
rand = { workspace = true }
+97 -38
View File
@@ -1,6 +1,102 @@
use serde::{Deserialize, Serialize};
pub use card_game::{Rank, Suit};
/// 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,
}
}
}
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -15,28 +111,6 @@ 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::*;
@@ -92,19 +166,4 @@ 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);
}
}
+193
View File
@@ -0,0 +1,193 @@
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 051).
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<_>>());
}
}
File diff suppressed because it is too large Load Diff
-492
View File
@@ -1,492 +0,0 @@
//! 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 (051, Clubs-first ordering).
///
/// The id is consistent for the same logical card across all reconstructions.
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
let suit = kl_card.suit();
let rank = kl_card.rank();
let suit_index = match suit {
card::Suit::Clubs => 0,
card::Suit::Diamonds => 1,
card::Suit::Hearts => 2,
card::Suit::Spades => 3,
};
let id = suit_index * 13 + (rank.value() as u32 - 1);
card::Card {
id,
suit,
rank,
face_up: false,
}
}
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
//
// These types were introduced when upstream `klondike` had no serde feature.
// At rev 99b49e62, upstream provides full serde support, and `GameState`
// serialises `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
//
// The mirror types are retained for three reasons:
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
// files with u8 indices and replay them.
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
// type; changing it would break the on-disk replay format (schema v2).
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
// replay JSON is cross-compatible between the desktop and browser builds.
//
// These types should not be used for new serialisation concerns. If the
// ReplayMove format is ever bumped to a new schema, migrate those callers to
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableau(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedFoundation(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedSkipCards(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePile {
Tableau(SavedTableau),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableauStack {
pub tableau: SavedTableau,
pub skip_cards: SavedSkipCards,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePileStack {
Tableau(SavedTableauStack),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstFoundation {
pub src: SavedKlondikePile,
pub foundation: SavedFoundation,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstTableau {
pub src: SavedKlondikePileStack,
pub tableau: SavedTableau,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
///
/// Convert to/from the upstream type with:
/// ```ignore
/// let saved = SavedInstruction::from(instruction);
/// let instruction = KlondikeInstruction::try_from(saved)?;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedInstruction {
DstFoundation(SavedDstFoundation),
DstTableau(SavedDstTableau),
RotateStock,
}
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSavedInstruction {
#[error("invalid tableau index {0} (expected 06)")]
Tableau(u8),
#[error("invalid foundation index {0} (expected 03)")]
Foundation(u8),
#[error("invalid skip_cards value {0} (expected 012)")]
SkipCards(u8),
}
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
impl From<Tableau> for SavedTableau {
fn from(t: Tableau) -> Self {
Self(t as u8)
}
}
impl From<Foundation> for SavedFoundation {
fn from(f: Foundation) -> Self {
Self(f as u8)
}
}
impl From<SkipCards> for SavedSkipCards {
fn from(s: SkipCards) -> Self {
Self(s as u8)
}
}
impl From<KlondikePile> for SavedKlondikePile {
fn from(p: KlondikePile) -> Self {
match p {
KlondikePile::Tableau(t) => Self::Tableau(t.into()),
KlondikePile::Stock => Self::Stock,
KlondikePile::Foundation(f) => Self::Foundation(f.into()),
}
}
}
impl From<TableauStack> for SavedTableauStack {
fn from(ts: TableauStack) -> Self {
Self {
tableau: ts.tableau.into(),
skip_cards: ts.skip_cards.into(),
}
}
}
impl From<KlondikePileStack> for SavedKlondikePileStack {
fn from(ps: KlondikePileStack) -> Self {
match ps {
KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()),
KlondikePileStack::Stock => Self::Stock,
KlondikePileStack::Foundation(f) => Self::Foundation(f.into()),
}
}
}
impl From<DstFoundation> for SavedDstFoundation {
fn from(df: DstFoundation) -> Self {
Self {
src: df.src.into(),
foundation: df.foundation.into(),
}
}
}
impl From<DstTableau> for SavedDstTableau {
fn from(dt: DstTableau) -> Self {
Self {
src: dt.src.into(),
tableau: dt.tableau.into(),
}
}
}
impl From<KlondikeInstruction> for SavedInstruction {
fn from(i: KlondikeInstruction) -> Self {
match i {
KlondikeInstruction::RotateStock => Self::RotateStock,
KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()),
KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()),
}
}
}
// ── TryFrom impls: Saved* → KlondikeInstruction ──────────────────────────────
impl TryFrom<SavedTableau> for Tableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
}
}
impl TryFrom<SavedFoundation> for Foundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
}
}
impl TryFrom<SavedSkipCards> for SkipCards {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
}
}
impl TryFrom<SavedKlondikePile> for KlondikePile {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
Ok(match s {
SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?),
SavedKlondikePile::Stock => KlondikePile::Stock,
SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?),
})
}
}
impl TryFrom<SavedTableauStack> for TableauStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
Ok(TableauStack {
tableau: s.tableau.try_into()?,
skip_cards: s.skip_cards.try_into()?,
})
}
}
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
Ok(match s {
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
})
}
}
impl TryFrom<SavedDstFoundation> for DstFoundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
Ok(DstFoundation {
src: s.src.try_into()?,
foundation: s.foundation.try_into()?,
})
}
}
impl TryFrom<SavedDstTableau> for DstTableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
Ok(DstTableau {
src: s.src.try_into()?,
tableau: s.tableau.try_into()?,
})
}
}
impl TryFrom<SavedInstruction> for KlondikeInstruction {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
Ok(match s {
SavedInstruction::RotateStock => KlondikeInstruction::RotateStock,
SavedInstruction::DstFoundation(df) => {
KlondikeInstruction::DstFoundation(df.try_into()?)
}
SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?),
})
}
}
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
if elapsed_seconds == 0 {
return 0;
}
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
}
+3 -14
View File
@@ -1,20 +1,9 @@
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 the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct
// `klondike` / `card_game` dep.
//
// `KlondikePileStack`, `SkipCards`, and `TableauStack` are intentionally NOT
// re-exported — they are only used internally in `klondike_adapter.rs` and do
// not appear in any public method signature.
pub use card_game::Session;
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
#[cfg(test)]
mod proptest_tests;
+68 -25
View File
@@ -1,25 +1,33 @@
use crate::card::{Card, Suit};
use klondike::KlondikePile;
use serde::{Deserialize, Serialize};
/// 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)]
/// 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 (06).
Tableau(usize),
}
/// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile {
/// Which logical Klondike pile this is.
pub pile_type: KlondikePile,
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
/// Populated by the sync system; do not mutate from game-logic code.
pub cards: Vec<Card>,
}
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: KlondikePile) -> Self {
pub fn new(pile_type: PileType) -> Self {
Self {
pile_type,
cards: Vec::new(),
@@ -36,7 +44,7 @@ impl Pile {
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
KlondikePile::Foundation(_) => self.cards.first().map(|c| c.suit),
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
@@ -49,42 +57,77 @@ mod tests {
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(KlondikePile::Stock);
let pile = Pile::new(PileType::Stock);
assert!(pile.cards.is_empty());
}
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(KlondikePile::Stock);
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
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,
});
assert_eq!(pile.top().unwrap().id, 1);
}
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(KlondikePile::Stock);
let pile = Pile::new(PileType::Waste);
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(KlondikePile::Foundation(klondike::Foundation::Foundation1));
let pile = Pile::new(PileType::Foundation(0));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_is_none_for_non_foundation() {
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
let mut pile = Pile::new(PileType::Tableau(0));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
});
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
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,
});
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
}
-389
View File
@@ -1,389 +0,0 @@
use card_game::Game;
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
use proptest::prelude::*;
use crate::game_state::{DrawMode, GameState};
use crate::klondike_adapter::{
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
SavedTableauStack,
};
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Collect all card IDs across every pile in a fixed traversal order:
/// stock → waste → foundations 14 → tableaux 17.
///
/// The order is deterministic for a given game state, so two calls on
/// equivalent states produce identical Vec outputs — the right fingerprint
/// for undo-reversibility checks.
fn all_card_ids(game: &GameState) -> Vec<u32> {
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! {
/// `check_auto_complete()` and `is_win_trivial()` must agree on every
/// reachable game state.
///
/// The upstream `Klondike::is_win_trivial()` checks that the stock pile
/// (both face-down and face-up halves) is completely empty AND that all
/// tableau columns have no face-down cards. Ferrous `check_auto_complete()`
/// checks the same three conditions individually (stock empty, waste empty,
/// all tableau cards face-up). This property guards against any semantic
/// drift between the two implementations so that delegating to upstream is
/// safe.
///
/// If this property ever fails, `check_auto_complete()` must NOT be fully
/// replaced — the Ferrous conditions must be preserved and `is_win_trivial()`
/// used only as a supplementary guard.
#[test]
fn check_auto_complete_agrees_with_is_win_trivial(
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);
prop_assert_eq!(
game.check_auto_complete(),
game.session().state().state().is_win_trivial(),
"check_auto_complete() disagreed with is_win_trivial() after {:?} actions",
actions.len(),
);
}
/// `check_win()` and `is_win()` must agree on every reachable game state.
#[test]
fn check_win_agrees_with_is_win(
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);
prop_assert_eq!(
game.check_win(),
game.session().state().state().is_win(),
"check_win() disagreed with is_win()",
);
}
/// 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:?}",
);
}
}
// -------------------------------------------------------------------------
// SavedInstruction ↔ KlondikeInstruction round-trip
// -------------------------------------------------------------------------
/// Every valid `SavedInstruction` survives a round-trip through
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
///
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
/// and all legal sub-field ranges:
/// - `SavedTableau`: 06
/// - `SavedFoundation`: 03
/// - `SavedSkipCards`: 012
#[test]
fn saved_instruction_round_trip(
instruction in saved_instruction_strategy(),
) {
let klondike = KlondikeInstruction::try_from(instruction);
prop_assert!(
klondike.is_ok(),
"TryFrom failed for valid SavedInstruction {instruction:?}: {:?}",
klondike.err(),
);
let saved_again = SavedInstruction::from(klondike.expect("checked above"));
prop_assert_eq!(
saved_again,
instruction,
"round-trip produced a different SavedInstruction",
);
}
}
// ---------------------------------------------------------------------------
// Proptest strategies for SavedInstruction and its sub-types
// ---------------------------------------------------------------------------
fn saved_tableau_strategy() -> impl Strategy<Value = SavedTableau> {
(0u8..=6).prop_map(SavedTableau)
}
fn saved_foundation_strategy() -> impl Strategy<Value = SavedFoundation> {
(0u8..=3).prop_map(SavedFoundation)
}
fn saved_skip_cards_strategy() -> impl Strategy<Value = SavedSkipCards> {
(0u8..=12).prop_map(SavedSkipCards)
}
fn saved_klondike_pile_strategy() -> impl Strategy<Value = SavedKlondikePile> {
prop_oneof![
saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau),
Just(SavedKlondikePile::Stock),
saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation),
]
}
fn saved_klondike_pile_stack_strategy() -> impl Strategy<Value = SavedKlondikePileStack> {
prop_oneof![
(saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| {
SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards })
}),
Just(SavedKlondikePileStack::Stock),
saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation),
]
}
fn saved_instruction_strategy() -> impl Strategy<Value = SavedInstruction> {
prop_oneof![
Just(SavedInstruction::RotateStock),
(saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map(
|(src, foundation)| {
SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation })
}
),
(saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map(
|(src, tableau)| {
SavedInstruction::DstTableau(SavedDstTableau { src, tableau })
}
),
]
}
// ---------------------------------------------------------------------------
// Boundary error unit tests (exact out-of-range values)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod saved_instruction_boundary_tests {
use super::*;
#[test]
fn saved_tableau_7_is_invalid() {
let result = Tableau::try_from(SavedTableau(7));
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7)));
}
#[test]
fn saved_tableau_255_is_invalid() {
let result = Tableau::try_from(SavedTableau(255));
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255)));
}
#[test]
fn saved_foundation_4_is_invalid() {
let result = Foundation::try_from(SavedFoundation(4));
assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4)));
}
#[test]
fn saved_skip_cards_13_is_invalid() {
let result = SkipCards::try_from(SavedSkipCards(13));
assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13)));
}
}
+228
View File
@@ -0,0 +1,228 @@
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),
]));
}
}
+152
View File
@@ -0,0 +1,152 @@
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);
}
}
+1283 -182
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -12,16 +12,10 @@ 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
@@ -30,11 +24,15 @@ tokio = { 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(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
[target.'cfg(not(target_os = "android"))'.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" }
+6 -29
View File
@@ -19,14 +19,11 @@ use jni::{
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ffi::c_void;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
#[derive(Serialize, Deserialize)]
struct TokenBlob {
@@ -39,37 +36,17 @@ struct TokenBlob {
// JVM helper
// ---------------------------------------------------------------------------
/// Initialise Android Keystore access with the process-wide `JavaVM*`.
///
/// This is called by `solitaire_app` from Android startup code. Keeping the
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or
/// engine layer just to reach platform startup state.
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
if vm_ptr.is_null() {
return Err(TokenError::KeychainUnavailable(
"JavaVM pointer is null".into(),
));
}
if ANDROID_JVM.get().is_some() {
return Ok(());
}
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
// the process lifetime.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
let _ = ANDROID_JVM.set(vm);
Ok(())
}
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
where
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
{
let vm = ANDROID_JVM
let app = bevy::android::ANDROID_APP
.get()
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?;
.ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?;
// SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
let mut env = vm
.attach_current_thread_permanently()
+6 -4
View File
@@ -14,13 +14,15 @@
//! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`].
//!
//! # Android
//! # Android stub
//!
//! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android this module delegates to an Android Keystore
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm`
//! from Android startup before token operations can succeed.
//! doesn't expose). On Android every function in this module returns
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
//! the same way they handle a Linux box without Secret Service. The
//! real Android backend will arrive in the Phase-Android round when we
//! wire Android Keystore via JNI.
//!
//! # Note: no unit tests — requires live OS keychain.
+184 -184
View File
@@ -26,227 +26,227 @@ use solitaire_core::game_state::DifficultyLevel;
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
pub const EASY_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-06-04)
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
0xD1FF_0000_0000_0001,
0xD1FF_0000_0000_0002,
0xD1FF_0000_0000_0007,
0xD1FF_0000_0000_0008,
0xD1FF_0000_0000_0009,
0xD1FF_0000_0000_0087,
0xD1FF_0000_0000_00EB,
0xD1FF_0000_0000_017F,
0xD1FF_0000_0000_01CE,
0xD1FF_0000_0000_020F,
0xD1FF_0000_0000_0251,
0xD1FF_0000_0000_0275,
0xD1FF_0000_0000_029C,
0xD1FF_0000_0000_02BD,
0xD1FF_0000_0000_02ED,
0xD1FF_0000_0000_038F,
0xD1FF_0000_0000_03C9,
0xD1FF_0000_0000_0415,
0xD1FF_0000_0000_045F,
0xD1FF_0000_0000_04C4,
0xD1FF_0000_0000_04CC,
0xD1FF_0000_0000_04EE,
0xD1FF_0000_0000_0631,
0xD1FF_0000_0000_0651,
0xD1FF_0000_0000_0689,
0xD1FF_0000_0000_0735,
0xD1FF_0000_0000_0748,
0xD1FF_0000_0000_0801,
0xD1FF_0000_0000_0820,
0xD1FF_0000_0000_08F9,
0xD1FF_0000_0000_091C,
0xD1FF_0000_0000_0937,
0xD1FF_0000_0000_09A6,
0xD1FF_0000_0000_09C3,
0xD1FF_0000_0000_09DD,
0xD1FF_0000_0000_0BD9,
0xD1FF_0000_0000_0BEC,
0xD1FF_0000_0000_0BF2,
0xD1FF_0000_0000_0C1B,
0xD1FF_0000_0000_0C26,
0xD1FF_0000_0000_0C36,
0xD1FF_0000_0000_0C4B,
0xD1FF_0000_0000_0C78,
0xD1FF_0000_0000_0CBC,
0xD1FF_0000_0000_000E,
0xD1FF_0000_0000_0013,
0xD1FF_0000_0000_0015,
0xD1FF_0000_0000_0018,
0xD1FF_0000_0000_001D,
0xD1FF_0000_0000_0021,
0xD1FF_0000_0000_0022,
0xD1FF_0000_0000_0026,
0xD1FF_0000_0000_002C,
0xD1FF_0000_0000_002E,
0xD1FF_0000_0000_002F,
0xD1FF_0000_0000_0035,
0xD1FF_0000_0000_0036,
0xD1FF_0000_0000_003C,
0xD1FF_0000_0000_0045,
0xD1FF_0000_0000_0046,
0xD1FF_0000_0000_0048,
0xD1FF_0000_0000_0049,
0xD1FF_0000_0000_004D,
0xD1FF_0000_0000_004F,
0xD1FF_0000_0000_0050,
0xD1FF_0000_0000_0051,
0xD1FF_0000_0000_0053,
0xD1FF_0000_0000_0054,
0xD1FF_0000_0000_0057,
0xD1FF_0000_0000_0058,
0xD1FF_0000_0000_005A,
0xD1FF_0000_0000_005B,
0xD1FF_0000_0000_005C,
0xD1FF_0000_0000_005D,
0xD1FF_0000_0000_005F,
0xD1FF_0000_0000_0061,
0xD1FF_0000_0000_0062,
0xD1FF_0000_0000_0063,
0xD1FF_0000_0000_0069,
];
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
pub const MEDIUM_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-06-04)
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
0xD1FF_0000_0000_0000,
0xD1FF_0000_0000_0012,
0xD1FF_0000_0000_002C,
0xD1FF_0000_0000_004B,
0xD1FF_0000_0000_0052,
0xD1FF_0000_0000_0058,
0xD1FF_0000_0000_005E,
0xD1FF_0000_0000_0063,
0xD1FF_0000_0000_0016,
0xD1FF_0000_0000_001B,
0xD1FF_0000_0000_001C,
0xD1FF_0000_0000_0020,
0xD1FF_0000_0000_002A,
0xD1FF_0000_0000_0034,
0xD1FF_0000_0000_003A,
0xD1FF_0000_0000_0041,
0xD1FF_0000_0000_0043,
0xD1FF_0000_0000_0060,
0xD1FF_0000_0000_006A,
0xD1FF_0000_0000_006C,
0xD1FF_0000_0000_006E,
0xD1FF_0000_0000_006F,
0xD1FF_0000_0000_0071,
0xD1FF_0000_0000_0072,
0xD1FF_0000_0000_0075,
0xD1FF_0000_0000_0076,
0xD1FF_0000_0000_007B,
0xD1FF_0000_0000_007E,
0xD1FF_0000_0000_0081,
0xD1FF_0000_0000_0083,
0xD1FF_0000_0000_0084,
0xD1FF_0000_0000_0087,
0xD1FF_0000_0000_0090,
0xD1FF_0000_0000_0092,
0xD1FF_0000_0000_0093,
0xD1FF_0000_0000_0098,
0xD1FF_0000_0000_0099,
0xD1FF_0000_0000_00A9,
0xD1FF_0000_0000_009A,
0xD1FF_0000_0000_009E,
0xD1FF_0000_0000_00A5,
0xD1FF_0000_0000_00A8,
0xD1FF_0000_0000_00AA,
0xD1FF_0000_0000_00AB,
0xD1FF_0000_0000_00AE,
0xD1FF_0000_0000_00AF,
0xD1FF_0000_0000_00BB,
0xD1FF_0000_0000_00D1,
0xD1FF_0000_0000_00E3,
0xD1FF_0000_0000_0108,
0xD1FF_0000_0000_010D,
0xD1FF_0000_0000_0110,
0xD1FF_0000_0000_012F,
0xD1FF_0000_0000_0139,
0xD1FF_0000_0000_013C,
0xD1FF_0000_0000_0148,
0xD1FF_0000_0000_015E,
0xD1FF_0000_0000_016A,
0xD1FF_0000_0000_016F,
0xD1FF_0000_0000_0179,
0xD1FF_0000_0000_019E,
0xD1FF_0000_0000_01A8,
0xD1FF_0000_0000_01AB,
0xD1FF_0000_0000_01B5,
0xD1FF_0000_0000_01B8,
0xD1FF_0000_0000_01D3,
0xD1FF_0000_0000_01EE,
0xD1FF_0000_0000_01F3,
0xD1FF_0000_0000_0202,
0xD1FF_0000_0000_0203,
0xD1FF_0000_0000_021E,
0xD1FF_0000_0000_022C,
0xD1FF_0000_0000_022D,
0xD1FF_0000_0000_0233,
0xD1FF_0000_0000_0245,
0xD1FF_0000_0000_024E,
0xD1FF_0000_0000_00B0,
];
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
pub const HARD_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-06-04)
0xD1FF_0000_0000_0006,
0xD1FF_0000_0000_0008,
0xD1FF_0000_0000_000F,
0xD1FF_0000_0000_0011,
0xD1FF_0000_0000_0022,
0xD1FF_0000_0000_0023,
0xD1FF_0000_0000_002A,
0xD1FF_0000_0000_002D,
0xD1FF_0000_0000_0040,
0xD1FF_0000_0000_0042,
0xD1FF_0000_0000_0050,
0xD1FF_0000_0000_005B,
0xD1FF_0000_0000_005D,
0xD1FF_0000_0000_0067,
0xD1FF_0000_0000_0069,
0xD1FF_0000_0000_006E,
0xD1FF_0000_0000_0072,
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
0xD1FF_0000_0000_001F,
0xD1FF_0000_0000_0024,
0xD1FF_0000_0000_0025,
0xD1FF_0000_0000_0031,
0xD1FF_0000_0000_0032,
0xD1FF_0000_0000_003E,
0xD1FF_0000_0000_004A,
0xD1FF_0000_0000_006D,
0xD1FF_0000_0000_0079,
0xD1FF_0000_0000_007C,
0xD1FF_0000_0000_0080,
0xD1FF_0000_0000_0081,
0xD1FF_0000_0000_0083,
0xD1FF_0000_0000_0091,
0xD1FF_0000_0000_009B,
0xD1FF_0000_0000_00A1,
0xD1FF_0000_0000_008A,
0xD1FF_0000_0000_0097,
0xD1FF_0000_0000_00B1,
0xD1FF_0000_0000_00B2,
0xD1FF_0000_0000_00B3,
0xD1FF_0000_0000_00B5,
0xD1FF_0000_0000_00B7,
0xD1FF_0000_0000_00B8,
0xD1FF_0000_0000_00B9,
0xD1FF_0000_0000_00BA,
0xD1FF_0000_0000_00BB,
0xD1FF_0000_0000_00BC,
0xD1FF_0000_0000_00BD,
0xD1FF_0000_0000_00C2,
0xD1FF_0000_0000_00C3,
0xD1FF_0000_0000_00C5,
0xD1FF_0000_0000_00CC,
0xD1FF_0000_0000_00CE,
0xD1FF_0000_0000_00D1,
0xD1FF_0000_0000_00D2,
0xD1FF_0000_0000_00D6,
0xD1FF_0000_0000_00DD,
0xD1FF_0000_0000_00E8,
0xD1FF_0000_0000_00F2,
0xD1FF_0000_0000_0101,
0xD1FF_0000_0000_010F,
0xD1FF_0000_0000_0113,
0xD1FF_0000_0000_0118,
0xD1FF_0000_0000_0119,
0xD1FF_0000_0000_012D,
0xD1FF_0000_0000_0133,
0xD1FF_0000_0000_0144,
0xD1FF_0000_0000_0147,
0xD1FF_0000_0000_00D7,
0xD1FF_0000_0000_00DC,
0xD1FF_0000_0000_00DF,
0xD1FF_0000_0000_00E0,
0xD1FF_0000_0000_00E1,
0xD1FF_0000_0000_00E4,
0xD1FF_0000_0000_00E6,
0xD1FF_0000_0000_00E7,
];
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
pub const EXPERT_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-06-04)
0xD1FF_0000_0000_0000,
0xD1FF_0000_0000_0002,
0xD1FF_0000_0000_000A,
0xD1FF_0000_0000_0013,
0xD1FF_0000_0000_0017,
0xD1FF_0000_0000_001C,
0xD1FF_0000_0000_001F,
0xD1FF_0000_0000_0021,
0xD1FF_0000_0000_0024,
0xD1FF_0000_0000_0029,
0xD1FF_0000_0000_002E,
0xD1FF_0000_0000_0035,
0xD1FF_0000_0000_0045,
0xD1FF_0000_0000_0048,
0xD1FF_0000_0000_0049,
0xD1FF_0000_0000_004F,
0xD1FF_0000_0000_0062,
0xD1FF_0000_0000_006D,
0xD1FF_0000_0000_0074,
0xD1FF_0000_0000_0076,
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
0xD1FF_0000_0000_0006,
0xD1FF_0000_0000_000B,
0xD1FF_0000_0000_0019,
0xD1FF_0000_0000_0082,
0xD1FF_0000_0000_008F,
0xD1FF_0000_0000_0090,
0xD1FF_0000_0000_0097,
0xD1FF_0000_0000_009A,
0xD1FF_0000_0000_009F,
0xD1FF_0000_0000_00A5,
0xD1FF_0000_0000_00A8,
0xD1FF_0000_0000_00AD,
0xD1FF_0000_0000_00AE,
0xD1FF_0000_0000_00B8,
0xD1FF_0000_0000_00B9,
0xD1FF_0000_0000_00BC,
0xD1FF_0000_0000_00C5,
0xD1FF_0000_0000_00CA,
0xD1FF_0000_0000_00CE,
0xD1FF_0000_0000_00DE,
0xD1FF_0000_0000_00CB,
0xD1FF_0000_0000_00D5,
0xD1FF_0000_0000_00D8,
0xD1FF_0000_0000_00E8,
0xD1FF_0000_0000_00EA,
0xD1FF_0000_0000_00EB,
0xD1FF_0000_0000_00EC,
0xD1FF_0000_0000_00ED,
0xD1FF_0000_0000_00EE,
0xD1FF_0000_0000_00EF,
0xD1FF_0000_0000_00F2,
0xD1FF_0000_0000_00F3,
0xD1FF_0000_0000_00F4,
0xD1FF_0000_0000_00FE,
0xD1FF_0000_0000_00FF,
0xD1FF_0000_0000_0102,
0xD1FF_0000_0000_0103,
0xD1FF_0000_0000_0104,
0xD1FF_0000_0000_0105,
0xD1FF_0000_0000_0106,
0xD1FF_0000_0000_0109,
0xD1FF_0000_0000_010B,
0xD1FF_0000_0000_010C,
0xD1FF_0000_0000_0110,
0xD1FF_0000_0000_0113,
0xD1FF_0000_0000_0114,
0xD1FF_0000_0000_011B,
0xD1FF_0000_0000_011C,
0xD1FF_0000_0000_011E,
0xD1FF_0000_0000_0120,
0xD1FF_0000_0000_0121,
0xD1FF_0000_0000_0122,
0xD1FF_0000_0000_0123,
0xD1FF_0000_0000_0124,
0xD1FF_0000_0000_0126,
0xD1FF_0000_0000_012B,
0xD1FF_0000_0000_012C,
0xD1FF_0000_0000_012E,
];
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
pub const GRANDMASTER_SEEDS: &[u64] = &[
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-06-04)
0xD1FF_0000_0000_003C,
0xD1FF_0000_0000_0047,
0xD1FF_0000_0000_005A,
0xD1FF_0000_0000_009C,
0xD1FF_0000_0000_00D2,
0xD1FF_0000_0000_00F4,
0xD1FF_0000_0000_00F6,
0xD1FF_0000_0000_0104,
0xD1FF_0000_0000_0106,
0xD1FF_0000_0000_0111,
0xD1FF_0000_0000_0112,
0xD1FF_0000_0000_0116,
0xD1FF_0000_0000_0117,
0xD1FF_0000_0000_011A,
0xD1FF_0000_0000_0123,
0xD1FF_0000_0000_012B,
0xD1FF_0000_0000_012E,
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
0xD1FF_0000_0000_0027,
0xD1FF_0000_0000_00A0,
0xD1FF_0000_0000_00C4,
0xD1FF_0000_0000_00D4,
0xD1FF_0000_0000_00DE,
0xD1FF_0000_0000_00F9,
0xD1FF_0000_0000_0107,
0xD1FF_0000_0000_0108,
0xD1FF_0000_0000_0130,
0xD1FF_0000_0000_0132,
0xD1FF_0000_0000_0133,
0xD1FF_0000_0000_0134,
0xD1FF_0000_0000_0135,
0xD1FF_0000_0000_0137,
0xD1FF_0000_0000_0139,
0xD1FF_0000_0000_013A,
0xD1FF_0000_0000_013B,
0xD1FF_0000_0000_013D,
0xD1FF_0000_0000_013F,
0xD1FF_0000_0000_0140,
0xD1FF_0000_0000_0141,
0xD1FF_0000_0000_0142,
0xD1FF_0000_0000_0143,
0xD1FF_0000_0000_0145,
0xD1FF_0000_0000_0146,
0xD1FF_0000_0000_014A,
0xD1FF_0000_0000_014B,
0xD1FF_0000_0000_014E,
0xD1FF_0000_0000_014C,
0xD1FF_0000_0000_014D,
0xD1FF_0000_0000_014F,
0xD1FF_0000_0000_0150,
0xD1FF_0000_0000_0155,
0xD1FF_0000_0000_0151,
0xD1FF_0000_0000_0152,
0xD1FF_0000_0000_0153,
0xD1FF_0000_0000_0157,
0xD1FF_0000_0000_0158,
0xD1FF_0000_0000_0159,
0xD1FF_0000_0000_015A,
0xD1FF_0000_0000_015B,
0xD1FF_0000_0000_015C,
0xD1FF_0000_0000_015D,
0xD1FF_0000_0000_015F,
0xD1FF_0000_0000_0166,
0xD1FF_0000_0000_0173,
0xD1FF_0000_0000_0174,
0xD1FF_0000_0000_0178,
0xD1FF_0000_0000_017D,
0xD1FF_0000_0000_0182,
0xD1FF_0000_0000_0187,
0xD1FF_0000_0000_015E,
0xD1FF_0000_0000_0162,
0xD1FF_0000_0000_0164,
];
// ---------------------------------------------------------------------------
+1 -9
View File
@@ -145,20 +145,14 @@ pub use settings::{
#[cfg(target_os = "android")]
mod android_keystore;
#[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm;
#[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;
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend};
pub mod replay;
pub use replay::{
@@ -169,9 +163,7 @@ 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;
+1 -9
View File
@@ -55,15 +55,7 @@ pub fn data_dir() -> Option<PathBuf> {
{
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
}
#[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")))]
#[cfg(not(target_os = "android"))]
{
dirs::data_dir()
}
+7 -8
View File
@@ -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::klondike_adapter::SavedKlondikePile;
use solitaire_core::pile::PileType;
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: SavedKlondikePile,
from: PileType,
/// Destination pile.
to: SavedKlondikePile,
to: PileType,
/// Number of cards moved.
count: usize,
},
@@ -442,7 +442,6 @@ 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 {
@@ -461,14 +460,14 @@ mod tests {
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
from: PileType::Waste,
to: PileType::Tableau(3),
count: 1,
},
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Tableau(SavedTableau(3)),
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
from: PileType::Tableau(3),
to: PileType::Foundation(0),
count: 1,
},
],
+24 -151
View File
@@ -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,7 +234,9 @@ 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 = Utc::now().timestamp().max(0) as u64;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
load_time_attack_session_from_at(path, now)
}
@@ -252,7 +254,9 @@ 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 = Utc::now().timestamp().max(0) as u64;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
TimeAttackSession {
remaining_secs,
wins,
@@ -422,6 +426,23 @@ 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};
@@ -495,154 +516,6 @@ mod tests {
assert_eq!(loaded, StatsSnapshot::default());
}
/// Schema v4 serialises the instruction history using upstream
/// `KlondikeInstruction` serde (named enum variants). The deserialiser
/// replays all `saved_moves` to reconstruct every pile.
///
/// A fresh-game test (zero moves) never exercises that replay path, so this
/// test plays several real moves — including an undo — before saving, then
/// asserts the full pile layout round-trips exactly.
///
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
/// `recycle_count`. Any breakage in the upstream serde or replay path
/// will cause at least one pile to disagree.
#[test]
fn game_state_v4_mid_game_round_trip() {
use solitaire_core::KlondikePile;
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
let path = gs_path("v4_mid_game");
let _ = fs::remove_file(&path);
let mut gs = GameState::new(42, DrawMode::DrawOne);
// Draw several times to populate the instruction history with
// RotateStock entries and expose waste cards for further moves.
for _ in 0..6 {
if gs.draw().is_err() {
break;
}
}
// Execute the first available DstTableau or DstFoundation move so the
// instruction history contains a type other than RotateStock.
let moves = gs.possible_instructions();
if let Some((from, to, count)) = moves.iter().copied().find(|(_, to, _)| {
matches!(to, KlondikePile::Tableau(_) | KlondikePile::Foundation(_))
}) {
let _ = gs.move_cards(from, to, count);
}
// Undo once: verifies that `undo_count` is persisted and that the
// truncated history (post-undo) replays back to the correct state.
if gs.undo_stack_len() > 0 {
let _ = gs.undo();
}
assert!(
gs.undo_stack_len() > 0,
"instruction history must be non-empty (seed 42 always produces draws)",
);
save_game_state_to(&path, &gs).expect("save");
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
let json = fs::read_to_string(&path).expect("read json");
assert!(
json.contains("schema_version") && json.contains('4') && !json.contains(": 3"),
"saved file must use schema version 4",
);
let loaded = load_game_state_from(&path)
.expect("a valid in-progress game must load without error");
assert_eq!(loaded.schema_version, GAME_STATE_SCHEMA_VERSION);
assert_eq!(
loaded, gs,
"all pile layouts and counters must be identical after schema-v4 round-trip",
);
}
/// A schema v3 save (instruction history using u8 indices) must load
/// successfully and be transparently migrated to schema v4.
///
/// This verifies the `AnyInstruction` untagged deserialization migration
/// path. v3 files with `RotateStock` (unit variant, format-identical in
/// v3 and v4) load correctly and report `schema_version == 4` after load.
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
#[test]
fn game_state_v3_migrates_to_v4() {
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
let path = gs_path("v3_migrate");
let _ = fs::remove_file(&path);
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
// so this exercises the schema version acceptance code path.
let v3_json = r#"{
"draw_mode": "DrawOne",
"mode": "Classic",
"score": 0,
"elapsed_seconds": 0,
"seed": 42,
"undo_count": 0,
"recycle_count": 0,
"take_from_foundation": true,
"schema_version": 3,
"saved_moves": ["RotateStock"]
}"#;
fs::write(&path, v3_json).expect("write v3 fixture");
let loaded = load_game_state_from(&path)
.expect("schema v3 must be accepted and migrated to v4");
// After migration, the in-memory schema version must be current.
assert_eq!(
loaded.schema_version, GAME_STATE_SCHEMA_VERSION,
"migrated game must report current schema version",
);
// The loaded game should match a fresh game that had one draw applied.
let mut expected = GameState::new(42, DrawMode::DrawOne);
expected.draw().expect("draw must succeed on a fresh game");
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
}
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
/// history). Any file claiming `schema_version: 2` must be rejected so
/// players upgrading from an older build start with a fresh game rather
/// than a half-reconstructed state.
#[test]
fn save_format_v2_is_rejected() {
let path = gs_path("schema_v2");
let _ = fs::remove_file(&path);
// Structurally valid JSON for `PersistedGameState` but with
// `schema_version: 2`. The schema-version gate in
// `GameState::deserialize` must reject this before replay starts.
let v2_json = r#"{
"draw_mode": "DrawOne",
"mode": "Classic",
"score": 0,
"elapsed_seconds": 0,
"seed": 42,
"undo_count": 0,
"recycle_count": 0,
"take_from_foundation": true,
"schema_version": 2,
"saved_moves": []
}"#;
fs::write(&path, v2_json).expect("write v2 fixture");
assert!(
load_game_state_from(&path).is_none(),
"schema v2 game_state.json must be rejected — player must start a fresh game",
);
}
// -----------------------------------------------------------------------
// Time Attack session persistence
//
+3 -19
View File
@@ -12,14 +12,10 @@
//! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait;
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
use solitaire_sync::{SyncPayload, SyncResponse};
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, 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,
@@ -58,17 +54,12 @@ 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.
@@ -79,7 +70,6 @@ pub struct SolitaireServerClient {
client: reqwest::Client,
}
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient {
/// Construct a new client for the given server URL and username.
///
@@ -211,7 +201,6 @@ impl SolitaireServerClient {
}
}
#[cfg(not(target_arch = "wasm32"))]
#[async_trait]
impl SyncProvider for SolitaireServerClient {
/// Fetch the latest sync payload from the server.
@@ -497,7 +486,6 @@ 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.
@@ -593,10 +581,9 @@ impl SolitaireServerClient {
}
// ---------------------------------------------------------------------------
// Response extraction helpers (native-only, use reqwest::Response)
// Response extraction helpers
// ---------------------------------------------------------------------------
#[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`].
///
@@ -620,7 +607,6 @@ 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,
@@ -635,7 +621,6 @@ 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`].
///
@@ -667,7 +652,6 @@ 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),
+11 -16
View File
@@ -7,11 +7,14 @@ 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 }
@@ -19,24 +22,17 @@ 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` 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` 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 = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
@@ -51,4 +47,3 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
[dev-dependencies]
async-trait = { workspace = true }
tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] }
+3 -5
View File
@@ -13,7 +13,6 @@
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_data::{AnimSpeed, Settings};
use crate::achievement_plugin::display_name_for;
@@ -181,7 +180,6 @@ 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>()
@@ -1078,7 +1076,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::{KlondikePile, Tableau};
use solitaire_core::pile::PileType;
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
@@ -1090,8 +1088,8 @@ mod tests {
.count();
app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
+5 -14
View File
@@ -47,16 +47,12 @@
//! 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
@@ -239,16 +235,11 @@ 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 {
// 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()))),
);
}
let root = user_theme_dir();
app.register_asset_source(
USER_THEMES,
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
);
app
}
+7 -17
View File
@@ -82,23 +82,13 @@ 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(|| {
// 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()."
)
}
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()."
)
})
}
+5 -1
View File
@@ -34,6 +34,7 @@ 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;
@@ -373,7 +374,10 @@ 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().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
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
if is_recycle(stock_len) {
let mut data = lib.flip.clone();
+47 -91
View File
@@ -9,9 +9,7 @@
//! 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;
@@ -22,18 +20,11 @@ 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 {
@@ -48,18 +39,16 @@ pub struct AutoCompletePlugin;
impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>()
.add_message::<RequestRedraw>()
.add_systems(
Update,
(
detect_auto_complete,
on_auto_complete_start,
drive_auto_complete,
)
.chain()
.after(GameMutation),
);
app.init_resource::<AutoCompleteState>().add_systems(
Update,
(
detect_auto_complete,
on_auto_complete_start,
drive_auto_complete,
)
.chain()
.after(GameMutation),
);
}
}
@@ -82,22 +71,15 @@ fn detect_auto_complete(
}
if game.0.is_auto_completable && !state.active {
state.active = true;
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
} else if !game.0.is_auto_completable && state.active {
// `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;
state.cooldown = 0.0; // fire first move immediately
}
// 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.
@@ -106,7 +88,6 @@ 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>,
@@ -127,12 +108,6 @@ 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>,
@@ -167,9 +142,9 @@ mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Rank, Suit};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
fn headless_app() -> App {
let mut app = App::new();
@@ -182,45 +157,31 @@ mod tests {
app
}
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());
/// 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();
}
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,
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(Card {
id: 99,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
);
});
g.is_auto_completable = true;
let expected = (
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Foundation(Foundation::Foundation1),
);
assert_eq!(g.next_auto_complete_move(), Some(expected));
(g, expected)
g
}
#[test]
@@ -232,9 +193,8 @@ mod tests {
#[test]
fn detect_activates_when_auto_completable() {
let mut app = headless_app();
let mut g = GameState::new(42, DrawMode::DrawOne);
g.is_auto_completable = true;
app.world_mut().resource_mut::<GameStateResource>().0 = g;
// Install a nearly-won state and fire StateChangedEvent.
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().write_message(StateChangedEvent);
app.update();
@@ -244,14 +204,9 @@ mod tests {
#[test]
fn drive_fires_move_request_when_active() {
let mut app = headless_app();
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
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>>();
@@ -259,15 +214,16 @@ 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, expected_from);
assert_eq!(fired[0].to, expected_to);
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));
}
#[test]
fn drive_deactivates_on_win() {
let mut app = headless_app();
// Inject a won game state — active should not be set.
let (mut gs, _) = seeded_state_with_auto_move();
let mut gs = nearly_won_state();
gs.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().write_message(StateChangedEvent);
@@ -92,7 +92,6 @@ 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};
@@ -126,7 +125,6 @@ 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: 6.0,
drag_threshold_px: 4.0,
drag_scale: 1.08,
hover_scale: 1.04,
hover_lerp_speed: 14.0,
+149 -166
View File
@@ -16,9 +16,11 @@ 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;
@@ -230,7 +232,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 bottom-right corner of the stock pile and is hidden
/// The chip sits in the top-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.
@@ -301,18 +303,13 @@ pub struct CardShadow;
#[derive(Component, Debug)]
pub struct CardBackFrame;
/// 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);
/// 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);
/// Extra width/height (in world units) added to each side of the card to form
/// 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;
/// the visible border. 3 world units ≈ 3 dp on a 1× screen.
const CARD_BACK_FRAME_PADDING: f32 = 3.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
@@ -737,10 +734,10 @@ fn sync_cards(
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
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())
game.piles
.get(&PileType::Waste)
.filter(|w| w.cards.len() > visible)
.and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1)))
.map(|c| c.id)
};
@@ -793,7 +790,7 @@ fn sync_cards(
update_card_entity(
&mut commands,
entity,
&card,
card,
position,
z,
layout,
@@ -811,7 +808,7 @@ fn sync_cards(
}
None => spawn_card_entity(
&mut commands,
&card,
card,
position,
z,
layout,
@@ -833,22 +830,22 @@ fn sync_cards(
}
/// Returns an ordered vec of (card, position, z) for every card in the game.
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
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);
let piles = [
(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),
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),
];
// Compute the Draw-Three waste fan step proportional to the column spacing
@@ -858,39 +855,29 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
// the top fanned card's centre within the waste column's own horizontal
// footprint instead of spilling into the adjacent gap.
let tableau_col_step = {
let t1 = layout
let waste_fan_step = {
let s = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.get(&PileType::Stock)
.copied()
.unwrap_or_default();
let t2 = layout
let w = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.get(&PileType::Waste)
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
(w.x - s.x).abs() * 0.224
};
let waste_fan_step = tableau_col_step * 0.224;
for (pile_type, is_stock_area) in piles {
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
for pile_type in piles {
let Some(base) = layout.pile_positions.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 Some(pile) = game.piles.get(&pile_type) else {
continue;
};
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.
@@ -899,6 +886,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
// 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,
@@ -928,7 +916,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
};
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.clone(), pos, z));
out.push((card, pos, z));
if is_tableau {
let step = if card.face_up {
layout.tableau_fan_frac
@@ -942,32 +930,6 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
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,
@@ -1546,8 +1508,11 @@ fn tick_hint_highlight(
sprite.color = if use_images {
Color::WHITE
} else {
let is_face_up = all_cards(&game.0)
.iter()
let is_face_up = game
.0
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == card_entity.card_id)
.is_some_and(|c| c.face_up);
if is_face_up {
@@ -1718,13 +1683,17 @@ 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 legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
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,
};
if legal {
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
commands
@@ -1766,9 +1735,12 @@ fn find_top_card_at(
{
continue;
}
let card = all_cards(game)
.into_iter()
.find(|c| c.id == card_entity.card_id && c.face_up);
let card = game
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == card_entity.card_id && c.face_up)
.cloned();
if let Some(card) = card {
let z = transform.translation.z;
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
@@ -1810,10 +1782,13 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
layout: &Layout,
font: Handle<Font>,
) {
let stock_empty = game.stock_cards().is_empty();
let stock_empty = game
.piles
.get(&PileType::Stock)
.is_none_or(|p| p.cards.is_empty());
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
if pile_marker.0 != KlondikePile::Stock {
if pile_marker.0 != PileType::Stock {
continue;
}
@@ -1906,22 +1881,19 @@ fn update_stock_empty_indicator(
// ---------------------------------------------------------------------------
// Stock-pile remaining-count badge
//
// Shows a small "N" chip pinned to the bottom-right corner of the stock pile so
// Shows a small "N" chip pinned to the top-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 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);
/// 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);
/// 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.
@@ -1932,25 +1904,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.stock_cards().len()
game.piles
.get(&PileType::Stock)
.map_or(0, |p| p.cards.len())
}
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
/// 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.
/// given the current `Layout`. The badge sits at the top-right corner of
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`].
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(&KlondikePile::Stock)
.get(&PileType::Stock)
.copied()
.unwrap_or(Vec2::ZERO);
let half = layout.card_size * 0.5;
// 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;
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)
}
@@ -2355,25 +2327,15 @@ fn update_tableau_fan_frac(
return;
};
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 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 card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
@@ -2540,8 +2502,11 @@ mod tests {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::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);
@@ -2563,7 +2528,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.waste_cards().last().unwrap().id;
let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id;
assert!(
waste_rendered.iter().any(|(c, _, _)| c.id == top_id),
"top waste card must be rendered"
@@ -2578,7 +2543,7 @@ mod tests {
for _ in 0..5 {
let _ = g.draw();
}
let waste_pile = g.waste_cards();
let waste_pile = &g.piles[&PileType::Waste].cards;
assert!(
waste_pile.len() >= 3,
"need at least 3 waste cards for this test"
@@ -2630,7 +2595,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.waste_cards();
let waste_pile = &g.piles[&PileType::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();
@@ -2673,8 +2638,11 @@ mod tests {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::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
@@ -2703,7 +2671,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[&KlondikePile::Tableau(Tableau::Tableau7)];
let tableau_6_base = layout.pile_positions[&PileType::Tableau(6)];
let mut ys: Vec<f32> = positions
.iter()
.filter(|(_, pos, _)| (pos.x - tableau_6_base.x).abs() < 1e-3)
@@ -3090,7 +3058,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[&KlondikePile::Tableau(Tableau::Tableau7)];
let col6_base = layout.pile_positions[&PileType::Tableau(6)];
let mut col6_ys: Vec<f32> = positions
.iter()
.filter(|(_, pos, _)| (pos.x - col6_base.x).abs() < 1e-3)
@@ -3500,7 +3468,9 @@ mod tests {
let mut app = app();
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
game.0.set_test_stock_cards(Vec::new());
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
stock.cards.clear();
}
}
app.update();
assert!(matches!(
@@ -3518,9 +3488,9 @@ mod tests {
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock = game.0.stock_cards();
let _ = stock.pop();
game.0.set_test_stock_cards(stock);
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
let _ = stock.cards.pop();
}
}
app.update();
assert_eq!(stock_badge_text(&mut app), "23");
@@ -3531,11 +3501,15 @@ mod tests {
}
#[test]
fn stock_card_count_helper_reads_zero_for_empty_stock() {
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.
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let mut g_empty_stock = g.clone();
g_empty_stock.set_test_stock_cards(Vec::new());
assert_eq!(stock_card_count(&g_empty_stock), 0);
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.
assert_eq!(stock_card_count(&g), 24);
}
@@ -3825,8 +3799,11 @@ 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.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let mut waste_zs: Vec<f32> = positions
.iter()
@@ -3873,24 +3850,27 @@ 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[&KlondikePile::Stock].x;
let stock_x = layout.pile_positions[&PileType::Stock].x;
let stock_right_edge = stock_x + layout.card_size.x / 2.0;
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.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) {
let positions = card_positions(&g, &layout);
for (card, pos, _) in positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
{
let left_edge = pos.x - layout.card_size.x / 2.0;
assert!(
pos.x >= stock_x - 1e-3,
"waste card {} x {:.2} drifted left of stock origin {:.2} on portrait window",
left_edge >= stock_right_edge - 1e-3,
"waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window",
card.id,
pos.x,
stock_x,
left_edge,
stock_right_edge,
);
}
}
@@ -3905,8 +3885,11 @@ 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.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let mut waste_zs: Vec<f32> = positions
.iter()
+16 -24
View File
@@ -13,18 +13,16 @@ use crate::platform::{
default_storage_backend,
};
use crate::{
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,
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,
};
/// Groups all Ferrous Solitaire gameplay plugins.
@@ -47,7 +45,6 @@ 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");
@@ -107,26 +104,21 @@ 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);
}
}
+215 -69
View File
@@ -34,8 +34,9 @@
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};
@@ -65,10 +66,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
/// `KlondikePile` identifies which pile this overlay highlights, so test
/// `PileType` 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 KlondikePile);
pub struct DropTargetOverlay(pub PileType);
/// 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;
@@ -162,34 +163,33 @@ 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 = [
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),
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),
];
for pile in piles {
let pile_cards = pile_cards(game, &pile);
if pile_cards.is_empty() {
let Some(pile_cards) = game.piles.get(&pile) else {
continue;
}
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
};
let is_tableau = matches!(pile, PileType::Tableau(_));
let base = layout.pile_positions[&pile];
for (i, card) in pile_cards.iter().enumerate().rev() {
for (i, card) in pile_cards.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.len() - 1 {
if !is_tableau && i != pile_cards.cards.len() - 1 {
continue;
}
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
@@ -226,14 +226,38 @@ fn update_drop_highlights(
let Some(game) = game else { return };
let drag_count = drag.cards.len();
let Some(origin) = drag.origin_pile.as_ref() else {
// 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();
for (marker, mut sprite, _rch) in &mut markers {
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
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,
};
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
}
}
@@ -273,7 +297,20 @@ fn update_drop_target_overlays(
return;
};
let Some(origin) = drag.origin_pile.as_ref() else {
// 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 {
return;
};
let drag_count = drag.cards.len();
@@ -281,24 +318,44 @@ 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 = [
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),
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),
];
// Compute the new set of valid piles for this frame.
let mut valid: Vec<KlondikePile> = Vec::new();
let mut valid: Vec<PileType> = Vec::new();
for pile in &candidates {
if game.0.can_move_cards(origin, pile, drag_count) {
valid.push(*pile);
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());
}
}
@@ -310,9 +367,9 @@ fn update_drop_target_overlays(
}
// Spawn overlays for piles that are now valid but don't yet have one.
let already_overlaid: Vec<KlondikePile> = overlays
let already_overlaid: Vec<PileType> = overlays
.iter()
.map(|(_, m)| m.0)
.map(|(_, m)| m.0.clone())
.filter(|p| valid.contains(p))
.collect();
@@ -331,14 +388,10 @@ 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: &KlondikePile,
layout: &Layout,
game: &GameState,
) -> Option<(Vec2, Vec2)> {
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
let centre = layout.pile_positions.get(pile).copied()?;
if matches!(pile, KlondikePile::Tableau(_)) {
let card_count = game.pile(*pile).len();
if matches!(pile, PileType::Tableau(_)) {
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.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;
@@ -359,7 +412,7 @@ fn drop_overlay_rect(
/// the appropriate world position for `pile`.
fn spawn_drop_target_overlay(
commands: &mut Commands,
pile: &KlondikePile,
pile: &PileType,
layout: &Layout,
game: &GameState,
) {
@@ -377,7 +430,7 @@ fn spawn_drop_target_overlay(
..default()
},
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
DropTargetOverlay(*pile),
DropTargetOverlay(pile.clone()),
))
.with_children(|parent| {
// Top edge.
@@ -426,7 +479,7 @@ fn spawn_drop_target_overlay(
fn tableau_or_stack_pos(
game: &GameState,
layout: &Layout,
pile: &KlondikePile,
pile: &PileType,
index: usize,
base: Vec2,
is_tableau: bool,
@@ -436,8 +489,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, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree {
let pile_len = game.waste_cards().len();
} 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());
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)
@@ -446,14 +499,6 @@ 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
@@ -604,8 +649,12 @@ 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 tableau = GameState::tableau_from_index(idx).expect("tableau pile exists");
game.set_test_tableau_cards(tableau, vec![card]);
let pile = game
.piles
.get_mut(&PileType::Tableau(idx))
.expect("tableau pile exists");
pile.cards.clear();
pile.cards.push(card);
}
/// Inserts a single face-up dragged card into the waste pile and
@@ -615,14 +664,59 @@ mod tests {
// Place the dragged card on the waste pile (origin).
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
game.0.set_test_waste_cards(vec![dragged.clone()]);
let waste = game
.0
.piles
.get_mut(&PileType::Waste)
.expect("waste pile exists");
waste.cards.clear();
waste.cards.push(dragged.clone());
}
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![dragged.id];
drag.origin_pile = Some(KlondikePile::Stock);
drag.origin_pile = Some(PileType::Waste);
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)
@@ -651,15 +745,67 @@ mod tests {
app.update();
let overlays: Vec<KlondikePile> = app
let overlays: Vec<PileType> = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.map(|o| o.0)
.map(|o| o.0.clone())
.collect();
assert!(
!overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)),
!overlays.contains(&PileType::Tableau(2)),
"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"
);
}
}
+3 -15
View File
@@ -13,11 +13,9 @@
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::{
@@ -27,7 +25,6 @@ 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.
@@ -80,13 +77,8 @@ 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.
///
@@ -124,21 +116,17 @@ 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
@@ -154,7 +142,6 @@ 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`
@@ -354,6 +341,7 @@ fn check_date_rollover(
}
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
+5 -4
View File
@@ -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 chrono::Utc;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, GameMode};
@@ -104,9 +104,10 @@ fn handle_difficulty_request(
}
fn seed_from_system_time() -> u64 {
// 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
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
}
// ---------------------------------------------------------------------------
+6 -6
View File
@@ -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: KlondikePile,
pub to: KlondikePile,
pub from: PileType,
pub to: PileType,
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: KlondikePile,
pub to: KlondikePile,
pub from: PileType,
pub to: PileType,
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: KlondikePile,
pub dest_pile: solitaire_core::pile::PileType,
}
+25 -43
View File
@@ -42,8 +42,7 @@ use std::f32::consts::PI;
use std::hash::{Hash, Hasher};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_core::{Foundation, KlondikePile};
use solitaire_core::pile::PileType;
use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim;
@@ -205,7 +204,6 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<RequestRedraw>()
.add_systems(
Update,
(
@@ -246,8 +244,10 @@ fn start_shake_anim(
}
let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile.
let dest_cards = pile_cards(&game.0, dest_pile);
let dest_card_ids: Vec<u32> = dest_cards.iter().map(|c| c.id).collect();
let Some(pile) = game.0.piles.get(dest_pile) else {
continue;
};
let dest_card_ids: Vec<u32> = pile.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() {
let pile = pile_cards(&game.0, &ev.to);
if !pile.is_empty() {
if let Some(pile) = game.0.piles.get(&ev.to) {
// The moved cards land on top — take the last `count` ids.
let n = ev.count.min(pile.len());
let n = ev.count.min(pile.cards.len());
if n > 0 {
let start = pile.len() - n;
bounce_ids.extend(pile[start..].iter().map(|c| c.id));
let start = pile.cards.len() - n;
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
}
}
}
if draws.read().next().is_some()
&& let Some(top) = game.0.waste_cards().last()
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
&& let Some(top) = pile.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(&KlondikePile::Stock) else {
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
return;
};
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
@@ -518,13 +518,15 @@ fn start_foundation_flourish(
if reduce_motion {
continue;
}
let Some(foundation) = foundation_from_slot(ev.slot) else {
continue;
};
let pile_type = KlondikePile::Foundation(foundation);
let pile_type = PileType::Foundation(ev.slot);
// Top card of the completed foundation is the King.
let cards = game.0.pile(pile_type);
let Some(king_id) = cards.last().map(|c| c.id) else {
let Some(king_id) = game
.0
.piles
.get(&pile_type)
.and_then(|p| p.cards.last())
.map(|c| c.id)
else {
continue;
};
@@ -630,26 +632,6 @@ 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)
// ---------------------------------------------------------------------------
@@ -849,7 +831,6 @@ 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;
@@ -864,13 +845,14 @@ mod tests {
app.update();
// Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
let dest_pile = PileType::Tableau(0);
let card_id = app
.world()
.resource::<GameStateResource>()
.0
.pile(dest_pile)
.last()
.piles
.get(&dest_pile)
.and_then(|p| p.cards.last())
.map(|c| c.id)
.expect("Tableau(0) should have at least one card in a fresh game");
@@ -882,7 +864,7 @@ mod tests {
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()
.write(MoveRejectedEvent {
from: KlondikePile::Stock,
from: PileType::Stock,
to: dest_pile,
count: 1,
});
File diff suppressed because it is too large Load Diff
+25 -83
View File
@@ -8,19 +8,12 @@
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::{
@@ -315,17 +308,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 = "Hint";
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
#[cfg(target_os = "android")]
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu",
"Undo",
"Pause",
"Help",
"\u{2261}",
"\u{2190}",
"||",
"?",
ANDROID_HINT_LABEL,
"Mode",
"New",
"M",
"+",
];
#[cfg(not(target_os = "android"))]
const ACTION_BAR_LABELS: [&str; 7] = [
@@ -830,8 +823,6 @@ 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((
@@ -852,15 +843,6 @@ 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()),
@@ -1662,13 +1644,11 @@ 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
@@ -1676,7 +1656,6 @@ 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;
@@ -1701,7 +1680,6 @@ 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>,
@@ -2338,7 +2316,7 @@ fn update_hud(
// Hide when not in Draw-Three or after the game is won.
String::new()
} else {
let stock_len = g.stock_cards().len();
let stock_len = g.piles[&solitaire_core::pile::PileType::Stock].cards.len();
let next_draw = stock_len.min(3);
format!("Cycle: {next_draw}/3")
};
@@ -2402,14 +2380,15 @@ 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(KlondikePile::Stock) => "▶ Waste".to_string(),
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
Some(PileType::Waste) => "▶ Waste".to_string(),
Some(PileType::Stock) => "▶ Stock".to_string(),
Some(PileType::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 {}", foundation_number(*slot)),
None => format!("▶ Foundation {}", slot + 1),
},
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
};
**t = label;
}
@@ -2419,14 +2398,11 @@ 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: Foundation,
game: &solitaire_core::game_state::GameState,
) -> String {
fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String {
let claimed = game
.pile(KlondikePile::Foundation(slot))
.first()
.map(|c| c.suit);
.piles
.get(&PileType::Foundation(slot))
.and_then(|p| p.claimed_suit());
match claimed {
Some(suit) => {
let s = match suit {
@@ -2437,28 +2413,7 @@ fn foundation_selection_label(
};
format!("{s} Foundation")
}
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,
None => format!("▶ Foundation {}", slot + 1),
}
}
@@ -2582,18 +2537,10 @@ 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 {
// 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)
// ~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)
} else {
TYPE_BODY
}
@@ -2601,14 +2548,9 @@ 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(3.0), Val::Px(4.0)),
Val::Px(44.0),
UiRect::axes(Val::Px(4.0), Val::Px(4.0)),
Val::Px(52.0),
Val::Px(44.0),
)
} else {
File diff suppressed because it is too large Load Diff
+52 -107
View File
@@ -7,7 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::pile::PileType;
/// 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 `KlondikePile` (Stock, Waste, four Foundations, seven Tableaux) has an
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
/// entry. The map always contains exactly 13 entries after `compute_layout`.
pub pile_positions: HashMap<KlondikePile, Vec2>,
pub pile_positions: HashMap<PileType, 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,38 +241,21 @@ 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<KlondikePile, Vec2> = HashMap::with_capacity(13);
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
pile_positions.insert(KlondikePile::Stock, Vec2::new(col_x(1), top_y));
pile_positions.insert(PileType::Stock, Vec2::new(col_x(0), top_y));
pile_positions.insert(PileType::Waste, 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(
KlondikePile::Foundation(foundation),
PileType::Foundation(slot),
Vec2::new(col_x(3 + slot as usize), top_y),
);
}
for i in 0..7 {
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),
);
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
@@ -318,37 +301,23 @@ mod tests {
use super::*;
fn assert_all_piles_present(layout: &Layout) {
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::Stock));
assert!(layout.pile_positions.contains_key(&PileType::Waste));
for slot in 0..4_u8 {
assert!(
layout
.pile_positions
.contains_key(&KlondikePile::Foundation(foundation)),
"missing foundation slot {foundation:?}",
.contains_key(&PileType::Foundation(slot)),
"missing foundation slot {slot}",
);
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
for i in 0..7 {
assert!(
layout
.pile_positions
.contains_key(&KlondikePile::Tableau(tableau)),
"missing tableau {tableau:?}"
layout.pile_positions.contains_key(&PileType::Tableau(i)),
"missing tableau {i}"
);
}
assert_eq!(layout.pile_positions.len(), 12);
assert_eq!(layout.pile_positions.len(), 13);
}
#[test]
@@ -407,18 +376,9 @@ 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);
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;
for i in 0..6 {
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1);
}
}
@@ -426,8 +386,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[&KlondikePile::Stock].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
let stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
assert!(stock_y > tableau_y);
}
@@ -439,7 +399,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[&KlondikePile::Stock].y;
let stock_y = layout.pile_positions[&PileType::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
assert!(
@@ -451,35 +411,24 @@ 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[&KlondikePile::Stock].x;
let t1_x = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau2)].x;
assert!((stock_x - t1_x).abs() < 1e-5);
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);
}
#[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);
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;
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;
assert!(
(f_x - t_x).abs() < 1e-5,
"foundation slot {idx} should align with tableau {}",
3 + idx,
"foundation slot {slot} should align with tableau {}",
3 + slot as usize,
);
}
}
@@ -521,7 +470,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[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].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;
@@ -540,7 +489,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[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].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;
@@ -571,7 +520,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[&KlondikePile::Tableau(Tableau::Tableau1)].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].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.
@@ -630,8 +579,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[&KlondikePile::Stock].y;
let stock_with_inset = with_inset.pile_positions[&KlondikePile::Stock].y;
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
assert!(
stock_with_inset < stock_no_inset,
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
@@ -653,10 +602,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 [
KlondikePile::Stock,
KlondikePile::Stock,
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau7),
PileType::Stock,
PileType::Waste,
PileType::Tableau(0),
PileType::Tableau(6),
] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
@@ -679,7 +628,7 @@ mod tests {
with_inset.tableau_fan_frac,
);
let card_h = with_inset.card_size.y;
let tableau_y = with_inset.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].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;
@@ -712,8 +661,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[&KlondikePile::Stock].y;
let wrong_stock_y = wrong.pile_positions[&KlondikePile::Stock].y;
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
let wrong_stock_y = wrong.pile_positions[&PileType::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!(
@@ -731,14 +680,14 @@ mod tests {
"card size must be preserved after resume",
);
assert!(
(corrected.pile_positions[&KlondikePile::Stock].y - fresh_stock_y).abs() < 1e-3,
(corrected.pile_positions[&PileType::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[&KlondikePile::Stock].y,
corrected.pile_positions[&PileType::Stock].y,
);
assert!(
(corrected.pile_positions[&KlondikePile::Stock].x
- fresh.pile_positions[&KlondikePile::Stock].x)
(corrected.pile_positions[&PileType::Stock].x
- fresh.pile_positions[&PileType::Stock].x)
.abs()
< 1e-3,
"stock x must be unchanged after resume",
@@ -746,7 +695,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[&KlondikePile::Stock].y + layout.card_size.y / 2.0
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
};
assert!(
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
@@ -763,11 +712,7 @@ 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 [
KlondikePile::Stock,
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau7),
] {
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
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,7 +191,6 @@ 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>>,
@@ -209,11 +208,6 @@ 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
View File
@@ -1,16 +1,13 @@
//! 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;
@@ -29,7 +26,6 @@ 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;
@@ -47,9 +43,7 @@ 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;
@@ -63,17 +57,14 @@ 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,
@@ -126,7 +117,6 @@ 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};
@@ -153,6 +143,7 @@ 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,
@@ -164,9 +155,7 @@ 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,
@@ -178,7 +167,6 @@ 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,
+1 -21
View File
@@ -36,7 +36,6 @@ 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};
// ---------------------------------------------------------------------------
@@ -154,7 +153,7 @@ pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<OnboardingSlideIndex>()
.add_systems(Update, spawn_if_first_run)
.add_systems(PostStartup, spawn_if_first_run)
.add_systems(
Update,
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
@@ -171,30 +170,11 @@ 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>,
) {
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());
}
+32 -54
View File
@@ -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,10 +101,7 @@ struct HintTask {
enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the
/// solution path.
SolverMove {
from: KlondikePile,
to: KlondikePile,
},
SolverMove { from: PileType, to: PileType },
/// 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.
@@ -186,7 +183,6 @@ 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};
@@ -218,27 +214,22 @@ mod tests {
/// tableau columns 0..3, stock and waste empty.
fn near_finished_state() -> GameState {
let mut game = GameState::new(1, DrawMode::DrawOne);
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 slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.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());
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
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,
@@ -254,44 +245,31 @@ mod tests {
Rank::Jack,
Rank::Queen,
];
for (foundation, suit) in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
.into_iter()
.zip(suits.iter())
{
let mut cards = Vec::new();
for (slot, suit) in suits.iter().enumerate() {
let pile = game
.piles
.get_mut(&PileType::Foundation(slot as u8))
.unwrap();
for (i, rank) in ranks_below_king.iter().enumerate() {
cards.push(Card {
id: (foundation as u32) * 13 + i as u32,
pile.cards.push(Card {
id: (slot as u32) * 13 + i as u32,
suit: *suit,
rank: *rank,
face_up: true,
});
}
game.set_test_foundation_cards(foundation, cards);
}
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,
for (col, suit) in suits.iter().enumerate() {
game.piles
.get_mut(&PileType::Tableau(col))
.unwrap()
.cards
.push(Card {
id: 100 + col as u32,
suit: *suit,
rank: Rank::King,
face_up: true,
}],
);
});
}
game
}
@@ -331,7 +309,7 @@ mod tests {
"exactly one HintVisualEvent must fire when the solver returns Winnable",
);
assert!(
matches!(collected[0].dest_pile, KlondikePile::Foundation(_)),
matches!(collected[0].dest_pile, PileType::Foundation(_)),
"solver hint destination must be a foundation slot; got {:?}",
collected[0].dest_pile,
);
-4
View File
@@ -12,11 +12,7 @@ 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;
+158 -180
View File
@@ -47,12 +47,13 @@ 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::{MoveRejectedEvent, MoveRequestEvent};
use crate::events::MoveRequestEvent;
use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC};
use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
@@ -107,7 +108,7 @@ pub enum RightClickRadialState {
/// `hovered_index` (or none).
Active {
/// Pile the right-clicked card came from.
source_pile: KlondikePile,
source_pile: PileType,
/// 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
@@ -122,7 +123,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<(KlondikePile, Vec2)>,
legal_destinations: Vec<(PileType, Vec2)>,
/// Cursor position (world space) the radial was opened at —
/// used as the centre of the ring for cursor-hover hit testing.
centre: Vec2,
@@ -249,20 +250,30 @@ 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: &KlondikePile,
card: &Card,
source_pile: &PileType,
game: &GameState,
) -> Vec<KlondikePile> {
) -> Vec<PileType> {
let mut out = Vec::new();
for foundation in foundations() {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(source_pile, &dest, 1) {
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)
{
out.push(dest);
}
}
for tableau in tableaus() {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source_pile, &dest, 1) {
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)
{
out.push(dest);
}
}
@@ -281,34 +292,36 @@ pub fn find_top_face_up_card_at(
cursor: Vec2,
game: &GameState,
layout: &Layout,
) -> Option<(KlondikePile, Card)> {
) -> Option<(PileType, Card)> {
let piles = [
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),
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),
];
for pile in piles {
let pile_cards = pile_cards(game, &pile);
if pile_cards.is_empty() {
let Some(pile_cards) = game.piles.get(&pile) else {
continue;
};
if pile_cards.cards.is_empty() {
continue;
}
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
for i in (0..pile_cards.len()).rev() {
let card = &pile_cards[i];
let is_tableau = matches!(pile, PileType::Tableau(_));
for i in (0..pile_cards.cards.len()).rev() {
let card = &pile_cards.cards[i];
if !card.face_up {
continue;
}
// Only the top card is draggable on non-tableau piles.
if !is_tableau && i != pile_cards.len() - 1 {
if !is_tableau && i != pile_cards.cards.len() - 1 {
continue;
}
let pos = card_position(game, layout, &pile, i);
@@ -329,22 +342,19 @@ 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: &KlondikePile,
stack_index: usize,
) -> Vec2 {
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
let base = layout.pile_positions[pile];
if matches!(pile, KlondikePile::Tableau(_)) {
if matches!(pile, PileType::Tableau(_)) {
let mut y_offset = 0.0_f32;
for card in pile_cards(game, pile).iter().take(stack_index) {
let step = if card.face_up {
TABLEAU_FAN_FRAC
} else {
TABLEAU_FACEDOWN_FAN_FRAC
};
y_offset -= layout.card_size.y * step;
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;
}
}
Vec2::new(base.x, base.y + y_offset)
} else {
@@ -352,56 +362,17 @@ fn card_position(
}
}
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.
///
/// `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)> {
fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileType, Vec2)> {
let count = dests.len();
let margin = RADIAL_ICON_SIZE_PX / 2.0;
dests
.into_iter()
.enumerate()
.map(|(i, d)| {
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)
(
d,
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
)
})
.collect()
}
@@ -436,10 +407,9 @@ fn cursor_world(
// ---------------------------------------------------------------------------
/// On `MouseButton::Right` `just_pressed`, attempts to open the radial
/// 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.
/// 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.
#[allow(clippy::too_many_arguments)]
fn radial_open_on_right_click(
buttons: Option<Res<ButtonInput<MouseButton>>>,
@@ -451,7 +421,6 @@ 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;
@@ -480,20 +449,9 @@ 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 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);
let legal_destinations = build_radial_destinations(world, dests);
*state = RightClickRadialState::Active {
source_pile,
@@ -519,7 +477,6 @@ 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>>,
@@ -562,12 +519,7 @@ fn radial_open_on_long_press(
if dests.is_empty() {
return;
}
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);
let legal_destinations = build_radial_destinations(world, dests);
*state = RightClickRadialState::Active {
source_pile,
count: 1,
@@ -657,8 +609,8 @@ fn radial_handle_release_or_cancel(
&& let Some((dest, _)) = legal_destinations.get(*idx)
{
moves.write(MoveRequestEvent {
from: *source_pile,
to: *dest,
from: source_pile.clone(),
to: dest.clone(),
count: *count,
});
}
@@ -804,7 +756,6 @@ 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>>();
@@ -820,37 +771,33 @@ mod tests {
fn ace_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
// Wipe everything.
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::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();
}
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());
for i in 0..7_usize {
g.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
// Ace of Clubs on Tableau(0).
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![CoreCard {
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
);
});
g
}
@@ -858,36 +805,32 @@ mod tests {
/// must skip it.
fn face_down_only_state() -> GameState {
let mut g = GameState::new(0, 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::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();
}
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());
for i in 0..7_usize {
g.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![CoreCard {
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(CoreCard {
id: 100,
suit: Suit::Spades,
rank: Rank::King,
face_up: false,
}],
);
});
g
}
@@ -985,15 +928,14 @@ mod tests {
rank: Rank::Ace,
face_up: true,
};
let dests =
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &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(&KlondikePile::Tableau(Tableau::Tableau1)));
assert!(!dests.contains(&PileType::Tableau(0)));
}
#[test]
@@ -1005,12 +947,8 @@ mod tests {
rank: Rank::Ace,
face_up: true,
};
let dests = legal_destinations_for_card(
&card,
&KlondikePile::Foundation(Foundation::Foundation1),
&g,
);
assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1)));
let dests = legal_destinations_for_card(&card, &PileType::Foundation(0), &g);
assert!(!dests.contains(&PileType::Foundation(0)));
}
// -----------------------------------------------------------------------
@@ -1020,6 +958,46 @@ 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]
@@ -1027,7 +1005,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[&KlondikePile::Tableau(Tableau::Tableau1)];
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right);
@@ -1037,7 +1015,7 @@ mod tests {
let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() {
RightClickRadialState::Active {
legal_destinations, ..
} => legal_destinations[0],
} => legal_destinations[0].clone(),
_ => panic!("expected Active"),
};
@@ -1054,7 +1032,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, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(evt.from, PileType::Tableau(0));
assert_eq!(evt.to, dest_pile);
assert_eq!(evt.count, 1);
// State must return to Idle.
@@ -1071,7 +1049,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[&KlondikePile::Tableau(Tableau::Tableau1)];
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right);
@@ -1102,7 +1080,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[&KlondikePile::Tableau(Tableau::Tableau1)];
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right);
@@ -1128,7 +1106,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[&KlondikePile::Tableau(Tableau::Tableau1)];
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
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
@@ -1,273 +0,0 @@
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+2660U+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
@@ -1,249 +0,0 @@
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();
}
}
+12 -32
View File
@@ -40,7 +40,6 @@
//! 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};
@@ -268,18 +267,9 @@ 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,
to,
from: from.clone(),
to: to.clone(),
count: *count,
});
}
@@ -380,20 +370,11 @@ fn tick_replay_playback(
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
if let (Ok(from), Ok(to)) =
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
{
moves_writer.write(MoveRequestEvent {
from,
to,
count: *count,
});
} else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
}
moves_writer.write(MoveRequestEvent {
from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
@@ -555,9 +536,8 @@ mod tests {
use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use solitaire_core::pile::PileType;
use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -606,8 +586,8 @@ mod tests {
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
from: PileType::Waste,
to: PileType::Tableau(3),
count: 1,
},
ReplayMove::StockClick,
@@ -759,8 +739,8 @@ mod tests {
"expected 1 MoveRequestEvent (the single Move variant)",
);
let m = &captured_moves.0[0];
assert!(matches!(m.from, KlondikePile::Stock));
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
assert!(matches!(m.from, PileType::Waste));
assert!(matches!(m.to, PileType::Tableau(3)));
assert_eq!(m.count, 1);
}
+2 -10
View File
@@ -1,13 +1,12 @@
//! 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)]
@@ -30,7 +29,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<KlondikePile>,
pub origin_pile: Option<PileType>,
/// World-space offset from the cursor/touch to the bottom card's centre.
pub cursor_offset: Vec2,
/// Z coordinate used for the dragged cards.
@@ -129,16 +128,9 @@ 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.
///
+12 -27
View File
@@ -147,13 +147,8 @@ fn apply_safe_area_bottom_anchors(
}
}
/// Pads both edges of every [`ModalScrim`] by the logical system-bar insets so
/// modal cards are centred within the usable area (between the status bar at
/// the top and the gesture-navigation bar at the bottom).
///
/// `padding.top` = status-bar inset; `padding.bottom` = gesture-bar inset.
/// With `align_items: Center` / `justify_content: Center` on the scrim the
/// `ModalCard` lands at the visual midpoint of the visible content area.
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
/// modal cards don't extend into the Android gesture-navigation zone.
///
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
/// arriving a few frames after app start) AND when a new `ModalScrim` is
@@ -170,18 +165,8 @@ fn apply_safe_area_to_modal_scrims(
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
// Clamp each inset to 25% of screen height so an unexpectedly large OS
// value can't push the modal card off the visible area entirely.
let top_logical = (insets.top / scale).min(window_height * 0.25);
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
for mut node in &mut scrims {
// Set both edges so the scrim's content box equals the usable area
// between the status bar and the gesture/navigation bar. With
// `align_items: Center` / `justify_content: Center` on the scrim,
// the modal card is centred within that usable region rather than
// the full viewport, correcting the slight upward shift seen when
// only the bottom inset was applied.
node.padding.top = Val::Px(top_logical);
node.padding.bottom = Val::Px(bottom_logical);
}
}
@@ -268,24 +253,24 @@ mod android {
}
}
/// 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.
/// 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.
///
/// 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.
/// 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.
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();
}
}
}
+246 -181
View File
@@ -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<KlondikePile>,
pub selected_pile: Option<PileType>,
}
/// 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: KlondikePile,
source_pile: PileType,
/// 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<KlondikePile>,
legal_destinations: Vec<PileType>,
/// 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<&KlondikePile> {
pub fn focused_destination(&self) -> Option<&PileType> {
match self {
Self::Idle => None,
Self::Lifted {
@@ -173,26 +173,13 @@ impl Plugin for SelectionPlugin {
/// The ordered list of piles that are considered for keyboard cycling.
///
/// Order: Waste → Foundation slots 03 → Tableau 06.
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));
fn cycled_piles() -> Vec<PileType> {
let mut piles = vec![PileType::Waste];
for slot in 0..4_u8 {
piles.push(PileType::Foundation(slot));
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
piles.push(KlondikePile::Tableau(tableau));
for i in 0..7_usize {
piles.push(PileType::Tableau(i));
}
piles
}
@@ -202,10 +189,7 @@ fn cycled_piles() -> Vec<KlondikePile> {
///
/// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned.
pub fn cycle_next_pile(
available: &[KlondikePile],
current: Option<&KlondikePile>,
) -> Option<KlondikePile> {
pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Option<PileType> {
if available.is_empty() {
return None;
}
@@ -226,7 +210,7 @@ pub fn cycle_next_pile(
for offset in 0..n {
let candidate = &order[(start + offset) % n];
if available.contains(candidate) {
return Some(*candidate);
return Some(candidate.clone());
}
}
None
@@ -238,18 +222,14 @@ pub fn cycle_next_pile(
///
/// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`.
fn did_wrap(
available: &[KlondikePile],
current: Option<&KlondikePile>,
next: Option<&KlondikePile>,
) -> bool {
fn did_wrap(available: &[PileType], current: Option<&PileType>, next: Option<&PileType>) -> 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: &KlondikePile| -> Option<usize> {
let pos_in_available = |target: &PileType| -> Option<usize> {
order
.iter()
.filter(|p| available.contains(p))
@@ -346,7 +326,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,
from: source_pile.clone(),
to: dest,
count: *count,
});
@@ -377,23 +357,29 @@ fn handle_selection_keys(
// ---------------------------------------------------------------------
// Build the list of piles that currently have a face-up draggable top card.
let available: Vec<KlondikePile> = {
let available: Vec<PileType> = {
let all = [
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),
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),
];
all.into_iter()
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
.filter(|p| {
game.0
.piles
.get(p)
.and_then(|pile| pile.cards.last())
.is_some_and(|c| c.face_up)
})
.collect()
};
@@ -421,16 +407,18 @@ 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
&& 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 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,
from: pile.clone(),
to: dest,
count: 1,
});
@@ -438,16 +426,17 @@ fn handle_selection_keys(
return;
}
// Priority 2: tableau stack move.
let run_len = face_up_run_len(&selected_cards);
let bottom_card = selected_cards
.get(selected_cards.len().saturating_sub(run_len))
.cloned();
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)
});
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,
from: pile.clone(),
to: dest,
count,
});
@@ -457,7 +446,7 @@ fn handle_selection_keys(
// Fallback for non-tableau sources.
if let Some(dest) = best_destination(card, &game.0) {
moves.write(MoveRequestEvent {
from: *pile,
from: pile.clone(),
to: dest,
count: 1,
});
@@ -468,23 +457,25 @@ 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
&& let Some(ref source) = selection.selected_pile.clone()
{
let source_cards = pile_cards(&game.0, source);
if source_cards.is_empty() {
let Some(pile_cards) = game.0.piles.get(source) else {
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(&source_cards);
let count = if matches!(source, KlondikePile::Tableau(_)) {
let run_len = face_up_run_len(pile_cards.cards.as_slice());
let count = if matches!(source, PileType::Tableau(_)) {
run_len.max(1)
} else {
1
};
let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> = source_cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = source_cards.get(start) else {
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 {
return;
};
let legal = legal_destinations_for(bottom, source, &game.0, count);
@@ -496,7 +487,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);
drag.origin_pile = Some(source.clone());
drag.cursor_offset = Vec2::ZERO;
drag.origin_z = 1.0;
drag.press_pos = Vec2::ZERO;
@@ -504,7 +495,7 @@ fn handle_selection_keys(
drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);
*kbd_drag = KeyboardDragState::Lifted {
source_pile: *source,
source_pile: source.clone(),
count,
cards: lifted_cards,
legal_destinations: legal,
@@ -529,36 +520,33 @@ 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: &KlondikePile,
bottom: &solitaire_core::card::Card,
source: &PileType,
game: &GameState,
stack_count: usize,
) -> Vec<KlondikePile> {
) -> Vec<PileType> {
let mut out = Vec::new();
if stack_count == 1 {
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(source, &dest, 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)
{
out.push(dest);
}
}
}
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) {
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)
{
out.push(dest);
}
}
@@ -595,16 +583,13 @@ 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<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) {
) -> 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)
{
return Some(dest);
}
}
@@ -684,9 +669,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<KlondikePile> = match &*kbd_drag {
KeyboardDragState::Lifted { source_pile, .. } => Some(*source_pile),
KeyboardDragState::Idle => selection.selected_pile,
let source_pile: Option<PileType> = match &*kbd_drag {
KeyboardDragState::Lifted { source_pile, .. } => Some(source_pile.clone()),
KeyboardDragState::Idle => selection.selected_pile.clone(),
};
if let Some(ref pile) = source_pile
@@ -722,15 +707,14 @@ fn update_selection_highlight(
/// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down.
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
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)
}
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
@@ -769,15 +753,15 @@ fn spawn_highlight_on_card(
mod tests {
use super::*;
fn piles_from(names: &[&str]) -> Vec<KlondikePile> {
fn piles_from(names: &[&str]) -> Vec<PileType> {
names
.iter()
.map(|&n| match n {
"Waste" => KlondikePile::Stock,
"T0" => KlondikePile::Tableau(Tableau::Tableau1),
"T1" => KlondikePile::Tableau(Tableau::Tableau2),
"T2" => KlondikePile::Tableau(Tableau::Tableau3),
_ => KlondikePile::Stock,
"Waste" => PileType::Waste,
"T0" => PileType::Tableau(0),
"T1" => PileType::Tableau(1),
"T2" => PileType::Tableau(2),
_ => PileType::Waste,
})
.collect()
}
@@ -791,23 +775,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(KlondikePile::Stock));
assert_eq!(result, Some(PileType::Waste));
}
#[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(&KlondikePile::Stock));
assert_eq!(result, Some(KlondikePile::Tableau(Tableau::Tableau1)));
let result = cycle_next_pile(&available, Some(&PileType::Waste));
assert_eq!(result, Some(PileType::Tableau(0)));
}
#[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(&KlondikePile::Tableau(Tableau::Tableau2)));
assert_eq!(result, Some(KlondikePile::Stock));
let result = cycle_next_pile(&available, Some(&PileType::Tableau(1)));
assert_eq!(result, Some(PileType::Waste));
}
#[test]
@@ -832,7 +816,7 @@ mod tests {
// Press 1: no current selection → first pile, no wrap.
let sel1 = cycle_next_pile(&available, None);
assert_eq!(sel1, Some(KlondikePile::Stock));
assert_eq!(sel1, Some(PileType::Waste));
assert!(
!did_wrap(&available, None, sel1.as_ref()),
"first Tab should not wrap"
@@ -840,7 +824,7 @@ mod tests {
// Press 2: Waste → Tableau(0), no wrap.
let sel2 = cycle_next_pile(&available, sel1.as_ref());
assert_eq!(sel2, Some(KlondikePile::Tableau(Tableau::Tableau1)));
assert_eq!(sel2, Some(PileType::Tableau(0)));
assert!(
!did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
"second Tab should not wrap"
@@ -848,7 +832,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(KlondikePile::Tableau(Tableau::Tableau2)));
assert_eq!(sel3, Some(PileType::Tableau(1)));
assert!(
!did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
"third Tab (T0→T1) should not wrap"
@@ -856,7 +840,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(KlondikePile::Stock));
assert_eq!(sel4, Some(PileType::Waste));
assert!(
did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
"fourth Tab should wrap back to Waste"
@@ -865,9 +849,9 @@ mod tests {
#[test]
fn cycle_next_pile_single_element_wraps_to_itself() {
let available = vec![KlondikePile::Stock];
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
assert_eq!(result, Some(KlondikePile::Stock));
let available = vec![PileType::Waste];
let result = cycle_next_pile(&available, Some(&PileType::Waste));
assert_eq!(result, Some(PileType::Waste));
}
// -----------------------------------------------------------------------
@@ -1015,47 +999,46 @@ mod tests {
fn deterministic_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
// Clear stock, waste, all tableaus.
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());
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();
}
// Place test cards.
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
}],
);
g.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
});
g.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
}],
);
g.set_test_tableau_cards(
Tableau::Tableau3,
vec![Card {
});
g.piles
.get_mut(&PileType::Tableau(2))
.unwrap()
.cards
.push(Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
}],
);
});
g
}
@@ -1110,10 +1093,11 @@ mod tests {
let selected = app
.world()
.resource::<SelectionState>()
.selected_pile;
.selected_pile
.clone();
// The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
assert_eq!(selected, Some(PileType::Tableau(0)));
assert_eq!(
*app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle
@@ -1133,7 +1117,7 @@ mod tests {
// Manually focus Tableau(0) so we don't depend on Tab.
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1148,7 +1132,7 @@ mod tests {
legal_destinations,
destination_index,
} => {
assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(source_pile, PileType::Tableau(0));
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert!(
@@ -1163,16 +1147,95 @@ 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(KlondikePile::Tableau(Tableau::Tableau1))
);
assert_eq!(drag.origin_pile, Some(PileType::Tableau(0)));
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.
@@ -1183,7 +1246,7 @@ mod tests {
app.update();
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1203,7 +1266,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, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(events[0].from, PileType::Tableau(0));
assert_eq!(events[0].to, expected_dest);
assert_eq!(events[0].count, 1);
@@ -1228,7 +1291,7 @@ mod tests {
app.update();
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
@@ -1245,7 +1308,7 @@ mod tests {
);
assert_eq!(
app.world().resource::<SelectionState>().selected_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1)),
Some(PileType::Tableau(0)),
"Esc on lifted must keep SelectionState intact (source-pick mode)",
);
assert!(
@@ -1268,7 +1331,7 @@ mod tests {
{
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![100];
drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
drag.origin_pile = Some(PileType::Tableau(0));
drag.committed = true;
drag.active_touch_id = None;
}
@@ -1276,13 +1339,15 @@ mod tests {
let before = app
.world()
.resource::<SelectionState>()
.selected_pile;
.selected_pile
.clone();
press_key(&mut app, KeyCode::Tab);
app.update();
let after = app
.world()
.resource::<SelectionState>()
.selected_pile;
.selected_pile
.clone();
assert_eq!(
before, after,
@@ -1299,7 +1364,7 @@ mod tests {
app.update();
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1308,7 +1373,7 @@ mod tests {
app.update();
assert_eq!(
app.world().resource::<SelectionState>().selected_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1)),
Some(PileType::Tableau(0)),
"first Esc only cancels the lift",
);
+3 -12
View File
@@ -24,7 +24,6 @@ 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,
@@ -33,9 +32,9 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
#[cfg(not(target_arch = "wasm32"))]
use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
use crate::theme::{
ImportError, ThemeThumbnailCache, ThemeThumbnailPair, import_theme, refresh_registry,
};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
@@ -256,7 +255,6 @@ 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).
@@ -319,7 +317,6 @@ impl SettingsButton {
SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80,
SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86,
// Sync section
SettingsButton::SyncNow => 90,
@@ -407,7 +404,6 @@ 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,
@@ -1258,7 +1254,6 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => {
// Handled by `handle_scan_themes`.
}
@@ -1862,7 +1857,6 @@ 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) ---
@@ -2647,7 +2641,6 @@ 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>,
@@ -2726,7 +2719,6 @@ 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,
@@ -2767,7 +2759,6 @@ 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(),
+22 -19
View File
@@ -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 best-effort async push sends the
//! current local state to the backend without blocking the Bevy main thread.
//! resources are updated. On app exit, a blocking push sends the current local
//! state to the backend.
//!
//! 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`], starts a best-effort async push of the current
/// local state to the active backend without blocking shutdown.
/// - **Last** — on [`AppExit`], performs a blocking push of the current local
/// state to the active backend.
///
/// Construct via [`SyncPlugin::new`], passing any type that implements
/// [`SyncProvider`].
@@ -272,12 +272,11 @@ fn poll_pull_result(
}
}
/// Last-schedule system: starts a best-effort push of the current local state
/// on [`AppExit`] without blocking the Bevy main thread.
/// Last-schedule system: pushes the current local state on [`AppExit`].
///
/// 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.
/// 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.
fn push_on_exit(
mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>,
@@ -292,16 +291,20 @@ fn push_on_exit(
exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
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();
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}");
}
}
}
/// Update-schedule system: on each `GameWonEvent` push the just-completed
+42 -116
View File
@@ -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,28 +22,15 @@ use crate::ui_theme::TEXT_PRIMARY;
use solitaire_data::Theme;
/// Default tint applied to every empty-pile marker sprite. Pure white
/// 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).
/// 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.
///
/// 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.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;
pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
///
@@ -67,7 +54,7 @@ pub struct TableBackground;
/// Marker component attached to each of the 13 empty-pile placeholders.
#[derive(Component, Debug, Clone)]
pub struct PileMarker(pub KlondikePile);
pub struct PileMarker(pub PileType);
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
/// as a hint destination. Stores the remaining countdown and the original sprite
@@ -278,13 +265,14 @@ 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<KlondikePile> = Vec::with_capacity(12);
piles.push(KlondikePile::Stock);
for foundation in foundations() {
piles.push(KlondikePile::Foundation(foundation));
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));
}
for tableau in tableaus() {
piles.push(KlondikePile::Tableau(tableau));
for i in 0..7 {
piles.push(PileType::Tableau(i));
}
for pile in piles {
@@ -296,30 +284,14 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
..default()
},
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
PileMarker(pile),
PileMarker(pile.clone()),
));
// 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 {
KlondikePile::Tableau(_) => {
PileType::Tableau(_) => {
entity.with_children(|b| {
b.spawn((
Text2d::new("K"),
@@ -332,7 +304,7 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
));
});
}
KlondikePile::Foundation(_) => {
PileType::Foundation(_) => {
entity.with_children(|b| {
b.spawn((
Text2d::new("A"),
@@ -508,7 +480,11 @@ fn sync_pile_marker_visibility(
return;
}
for (pile_marker, mut visibility) in markers.iter_mut() {
let is_empty = pile_cards(&game.0, &pile_marker.0).is_empty();
let is_empty = game
.0
.piles
.get(&pile_marker.0)
.is_none_or(|pile| pile.cards.is_empty());
*visibility = if is_empty {
Visibility::Inherited
} else {
@@ -517,44 +493,6 @@ 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::*;
@@ -572,14 +510,14 @@ mod tests {
}
#[test]
fn table_plugin_spawns_twelve_pile_markers() {
fn table_plugin_spawns_thirteen_pile_markers() {
let mut app = headless_app();
let count = app
.world_mut()
.query::<&PileMarker>()
.iter(app.world())
.count();
assert_eq!(count, 12);
assert_eq!(count, 13);
}
#[test]
@@ -602,23 +540,23 @@ mod tests {
#[test]
fn every_pile_marker_has_unique_type() {
let mut app = headless_app();
let mut types: Vec<KlondikePile> = app
let mut types: Vec<PileType> = app
.world_mut()
.query::<&PileMarker>()
.iter(app.world())
.map(|m| m.0)
.map(|m| m.0.clone())
.collect();
types.sort_by_key(|p| format!("{p:?}"));
types.dedup();
assert_eq!(types.len(), 12);
assert_eq!(types.len(), 13);
}
#[test]
fn pile_markers_hide_when_pile_is_occupied() {
// After a fresh deal: the 7 tableau piles + the stock pile are
// 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
// 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
// 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
@@ -632,13 +570,13 @@ mod tests {
app.update();
let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>();
let mut hidden_piles: Vec<KlondikePile> = Vec::new();
let mut visible_piles: Vec<KlondikePile> = Vec::new();
let mut hidden_piles: Vec<PileType> = Vec::new();
let mut visible_piles: Vec<PileType> = Vec::new();
for (marker, visibility) in q.iter(app.world()) {
if matches!(visibility, Visibility::Hidden) {
hidden_piles.push(marker.0);
hidden_piles.push(marker.0.clone());
} else {
visible_piles.push(marker.0);
visible_piles.push(marker.0.clone());
}
}
@@ -648,31 +586,19 @@ mod tests {
8,
"stock + 7 tableau piles should hide their markers post-deal",
);
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::Stock));
for i in 0..7 {
assert!(
hidden_piles.contains(&KlondikePile::Tableau(tableau)),
"{tableau:?} marker should be hidden — it has cards",
hidden_piles.contains(&PileType::Tableau(i)),
"tableau {i} marker should be hidden — it has cards",
);
}
// 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)));
// 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)));
}
}
-2
View File
@@ -12,7 +12,6 @@
//! 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;
@@ -29,7 +28,6 @@ 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;
+4 -13
View File
@@ -22,15 +22,11 @@
use std::path::Path;
use bevy::log::warn;
#[cfg(not(target_arch = "wasm32"))]
use bevy::prelude::Startup;
use bevy::prelude::{App, Plugin, Resource};
use bevy::prelude::{App, Plugin, Resource, Startup};
use serde::Deserialize;
use super::ThemeMeta;
use crate::assets::DARK_THEME_MANIFEST_URL;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_theme_dir;
use crate::assets::{DARK_THEME_MANIFEST_URL, user_theme_dir};
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs
/// to render a row and load the theme on selection.
@@ -89,18 +85,13 @@ pub struct ThemeRegistryPlugin;
impl Plugin for ThemeRegistryPlugin {
fn build(&self, app: &mut App) {
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);
app.init_resource::<ThemeRegistry>()
.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());
}
+4 -5
View File
@@ -22,8 +22,7 @@
//! was closed, the file is treated as missing.
use std::path::PathBuf;
use chrono::Utc;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
@@ -223,9 +222,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 {
// 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
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
/// Periodically persists the live `TimeAttackResource` to
+25 -32
View File
@@ -28,7 +28,7 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::pile::PileType;
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<(KlondikePile, Vec<u32>)>,
pub selected: Option<(PileType, Vec<u32>)>,
}
impl TouchSelectionState {
@@ -60,12 +60,12 @@ impl TouchSelectionState {
}
/// Takes the current selection, leaving `selected` as `None`.
pub fn take(&mut self) -> Option<(KlondikePile, Vec<u32>)> {
pub fn take(&mut self) -> Option<(PileType, Vec<u32>)> {
self.selected.take()
}
/// Sets the current selection.
pub fn set(&mut self, pile: KlondikePile, cards: Vec<u32>) {
pub fn set(&mut self, pile: PileType, cards: Vec<u32>) {
self.selected = Some((pile, cards));
}
@@ -77,9 +77,8 @@ impl TouchSelectionState {
/// Marker component placed on the highlight sprite child of a selected source card.
///
/// 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.
/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so
/// stale highlights never linger after a game-state change.
#[derive(Component)]
pub struct TouchSelectionHighlight;
@@ -92,15 +91,16 @@ 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.
///
/// 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.
/// 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`.
pub(crate) fn update_touch_selection_highlight(
mut commands: Commands,
selection: Res<TouchSelectionState>,
@@ -131,12 +131,6 @@ 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();
@@ -192,7 +186,6 @@ fn spawn_touch_highlight(
#[cfg(test)]
mod tests {
use super::*;
use solitaire_core::Tableau;
#[test]
fn selection_state_default_is_idle() {
@@ -204,12 +197,12 @@ mod tests {
#[test]
fn set_and_take_roundtrip() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1, 2, 3]);
state.set(PileType::Tableau(0), vec![1, 2, 3]);
assert!(state.has_selection());
let taken = state.take();
assert!(taken.is_some());
let (pile, cards) = taken.unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(pile, PileType::Tableau(0));
assert_eq!(cards, vec![1, 2, 3]);
assert!(!state.has_selection());
}
@@ -217,7 +210,7 @@ mod tests {
#[test]
fn clear_removes_selection() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Stock, vec![42]);
state.set(PileType::Waste, vec![42]);
state.clear();
assert!(!state.has_selection());
}
@@ -232,10 +225,10 @@ mod tests {
#[test]
fn set_overwrites_previous_selection() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1]);
state.set(KlondikePile::Tableau(Tableau::Tableau4), vec![7, 8]);
state.set(PileType::Tableau(0), vec![1]);
state.set(PileType::Tableau(3), vec![7, 8]);
let (pile, cards) = state.take().unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4));
assert_eq!(pile, PileType::Tableau(3));
assert_eq!(cards, vec![7, 8]);
}
}
+6 -7
View File
@@ -216,14 +216,13 @@ where
// modal at `Z_PAUSE` (220) in some scenes.
GlobalZIndex(z_panel),
ZIndex(z_panel),
// 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.
// 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.
Transform::default(),
Visibility::default(),
))
.with_children(|root| {
root.spawn((
+1 -1
View File
@@ -12,7 +12,7 @@
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use solitaire_core::klondike_adapter::compute_time_bonus;
use solitaire_core::scoring::compute_time_bonus;
use solitaire_data::AnimSpeed;
use crate::achievement_plugin::display_name_for;
+1 -4
View File
@@ -12,7 +12,6 @@ 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
@@ -22,19 +21,17 @@ 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 solitaire_web; do \
solitaire_server solitaire_app solitaire_assetgen solitaire_wasm; 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.
-78
View File
@@ -1,78 +0,0 @@
{
"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"
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"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"
}
}
-41
View File
@@ -1,41 +0,0 @@
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,
},
});
@@ -1,382 +0,0 @@
#!/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();
@@ -1,194 +0,0 @@
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");
});
@@ -1,93 +0,0 @@
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();
}
});
@@ -1,81 +0,0 @@
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();
}
});
-66
View File
@@ -1,66 +0,0 @@
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);
});
+9 -28
View File
@@ -74,20 +74,6 @@ struct UserRow {
/// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
pub const BCRYPT_COST: u32 = 12;
async fn hash_password(password: String) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST))
.await
.map_err(|e| AppError::Internal(format!("password hash task failed: {e}")))?
.map_err(AppError::from)
}
async fn verify_password(password: String, password_hash: String) -> Result<bool, AppError> {
tokio::task::spawn_blocking(move || verify(password, &password_hash))
.await
.map_err(|e| AppError::Internal(format!("password verify task failed: {e}")))?
.map_err(AppError::from)
}
// ---------------------------------------------------------------------------
// Token generation helpers
// ---------------------------------------------------------------------------
@@ -205,7 +191,7 @@ pub async fn register(
}
let user_id = Uuid::new_v4().to_string();
let password_hash = hash_password(body.password).await?;
let password_hash = hash(&body.password, BCRYPT_COST)?;
let now = Utc::now().to_rfc3339();
sqlx::query!(
@@ -250,7 +236,7 @@ pub async fn login(
.password_hash
.ok_or_else(|| AppError::Internal("password hash missing".into()))?;
let valid = verify_password(body.password, row_hash).await?;
let valid = verify(&body.password, &row_hash)?;
if !valid {
tracing::warn!(username = %username, "login: invalid password");
return Err(AppError::InvalidCredentials);
@@ -392,28 +378,23 @@ pub async fn upload_avatar(
}
// Write to avatars/ directory, replacing any previous file for this user.
tokio::fs::create_dir_all("avatars")
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
let filename = format!("{}.{}", user.user_id, ext);
let path = std::path::Path::new("avatars").join(&filename);
let tmp_path = std::path::Path::new("avatars").join(format!("{}.{}.tmp", user.user_id, ext));
// Write to a temp file then atomically rename so concurrent readers never
// see a partially-written avatar.
tokio::fs::write(&tmp_path, &body)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
if let Err(e) = tokio::fs::rename(&tmp_path, &path).await {
let _ = tokio::fs::remove_file(&tmp_path).await;
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
if let Err(e) = std::fs::rename(&tmp_path, &path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(AppError::Internal(e.to_string()));
}
// Remove stale files with other extensions after the atomic rename.
for old_ext in &["jpg", "png", "webp", "gif"] {
if *old_ext != ext {
let _ = tokio::fs::remove_file(
let _ = std::fs::remove_file(
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
)
.await;
);
}
}
@@ -473,7 +454,7 @@ pub async fn reset_password(
let user_id =
user_id.ok_or_else(|| AppError::NotFound(format!("user '{username}' not found")))?;
let new_hash = hash_password(new_password.to_string()).await?;
let new_hash = hash(new_password, BCRYPT_COST)?;
sqlx::query!(
"UPDATE users SET password_hash = ? WHERE id = ?",
+3 -8
View File
@@ -214,11 +214,6 @@ 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(
@@ -249,11 +244,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' https://analytics.aleshym.co; ",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; ",
"style-src 'self' 'unsafe-inline'; ",
"font-src 'self'; ",
"img-src 'self' data: https://analytics.aleshym.co; ",
"connect-src 'self' https://analytics.aleshym.co; ",
"img-src 'self' data:; ",
"connect-src 'self'; ",
"object-src 'none'; ",
"frame-ancestors 'none'",
);
+1 -45
View File
@@ -39,18 +39,6 @@ use std::{
str::FromStr,
};
const JWT_SECRET_MIN_BYTES: usize = 32;
fn validate_jwt_secret(secret: &str) -> Result<(), String> {
if secret.len() < JWT_SECRET_MIN_BYTES {
Err(format!(
"JWT_SECRET must be at least {JWT_SECRET_MIN_BYTES} bytes; generate a high-entropy production secret"
))
} else {
Ok(())
}
}
#[tokio::main]
async fn main() {
// Load .env file if present (silently ignored when absent).
@@ -115,13 +103,8 @@ async fn run_reset_password(username: &str) {
/// optionally `SERVER_PORT`) in the environment.
async fn run_server() {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
// Load JWT_SECRET once at startup — a missing or weak secret is a fatal
// configuration error rather than a per-request failure.
// Load JWT_SECRET once at startup — a missing secret is a fatal configuration error.
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
if let Err(msg) = validate_jwt_secret(&jwt_secret) {
eprintln!("{msg}");
std::process::exit(1);
}
let port: u16 = std::env::var("SERVER_PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
@@ -154,30 +137,3 @@ async fn run_server() {
axum::serve(listener, app).await.expect("server error");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jwt_secret_rejects_short_secret() {
let secret = "x".repeat(JWT_SECRET_MIN_BYTES - 1);
let err = validate_jwt_secret(&secret).expect_err("short secret must be rejected");
assert!(
err.contains("JWT_SECRET must be at least"),
"error should explain the minimum length, got: {err}"
);
}
#[test]
fn jwt_secret_accepts_exact_minimum_length() {
let secret = "x".repeat(JWT_SECRET_MIN_BYTES);
assert!(validate_jwt_secret(&secret).is_ok());
}
#[test]
fn jwt_secret_accepts_longer_secret() {
let secret = "x".repeat(JWT_SECRET_MIN_BYTES + 16);
assert!(validate_jwt_secret(&secret).is_ok());
}
}
+8 -5
View File
@@ -196,11 +196,14 @@ 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(());
+18 -256
View File
@@ -176,12 +176,9 @@ async function bootstrap() {
if (saved) {
showResumeDialog(saved);
} else {
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");
const params = new URLSearchParams(window.location.search);
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
drawThree = params.has("draw3");
chkDraw3.checked = drawThree;
startGame(urlSeed);
}
@@ -396,16 +393,8 @@ function render(s) {
stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; }
if (noMovesBanner) noMovesBanner.classList.add("hidden");
// 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);
showWin(s);
} 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);
@@ -440,34 +429,20 @@ function showWin(s) {
submitReplay(s);
}
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,
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token) return;
const payload = {
schema_version: 1,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "DrawThree" : "DrawOne",
mode: "Classic",
time_seconds: Math.max(1, elapsedSecs),
time_seconds: elapsedSecs,
final_score: s.score,
move_count: s.move_count,
recorded_at: new Date().toISOString().slice(0, 10),
moves,
win_move_index: moves.length - 1,
moves: [],
};
}
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',
@@ -492,12 +467,7 @@ function flashIllegal(cardIds) {
for (const id of cardIds) {
const el = cardEls.get(id);
if (!el) continue;
// 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
// Store current translate so the shake keyframe can reference it.
el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)");
el.classList.add("illegal");
el.addEventListener("animationend", () => {
@@ -526,34 +496,11 @@ function attachHandlers() {
syncThemeButton();
if (game) render(game.state());
});
const doDraw = () => { const r = game.draw(); if (r.ok) render(r.snapshot); };
document.addEventListener("keydown", (e) => {
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();
}
if (e.target.tagName === "INPUT") return;
if (e.key === "z" || e.key === "Z") doUndo();
if (e.key === "n" || e.key === "N") startGame(randomSeed());
});
board.addEventListener("pointerdown", onPointerDown);
@@ -759,7 +706,7 @@ function onPointerCancel() {
// ── Click / dblclick ──────────────────────────────────────────────────────────
function onBoardClick(e) {
if (drag || snap?.is_won) return;
if (drag) 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) {
@@ -794,7 +741,7 @@ function smartMove(pileName, fromIndex) {
}
function onBoardDblClick(e) {
if (drag || snap?.is_won) return;
if (drag) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const hit = hitTestCard(bx, by);
if (!hit || !hit.card.face_up) return;
@@ -835,191 +782,6 @@ 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,8 +55,6 @@ 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}
*/
@@ -122,67 +120,6 @@ 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?}`.
@@ -243,21 +180,6 @@ 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}
@@ -331,9 +253,6 @@ 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.

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