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
71 changed files with 11075 additions and 11522 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"']
-4
View File
@@ -8,10 +8,8 @@ on:
- 'solitaire_server/**'
- 'solitaire_sync/**'
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'solitaire_server/Dockerfile'
- '.gitea/workflows/docker-build.yml'
env:
@@ -57,8 +55,6 @@ jobs:
${{ env.IMAGE }}:latest
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
secrets: |
cargo_token=${{ secrets.CI_TOKEN }}
- name: Install kustomize
run: |
Generated
+13 -352
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 = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "d206df6d87340019a0f5b621976cf98bc75c659a7f93ef348aaab2a9336098a9"
dependencies = [
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
]
[[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"
@@ -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 = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "347d55e6cf7c90b3d038262071eb2fdb0b75a713fe66c452a3400ff08fb716bc"
dependencies = [
"card_game",
"rand 0.10.1",
]
[[package]]
name = "kurbo"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
dependencies = [
"arrayvec 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",
@@ -6228,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"
@@ -6276,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"
@@ -7277,8 +6980,7 @@ dependencies = [
name = "solitaire_core"
version = "0.1.0"
dependencies = [
"card_game",
"klondike",
"rand 0.9.4",
"serde",
"thiserror 2.0.18",
]
@@ -7295,7 +6997,6 @@ dependencies = [
"jni 0.21.1",
"jsonwebtoken",
"keyring-core",
"klondike",
"reqwest",
"serde",
"serde_json",
@@ -7322,7 +7023,6 @@ dependencies = [
"image",
"jni 0.21.1",
"kira",
"klondike",
"reqwest",
"resvg",
"ron",
@@ -7382,7 +7082,6 @@ dependencies = [
"chrono",
"console_error_panic_hook",
"getrandom 0.3.4",
"klondike",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -7391,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"
@@ -7815,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",
@@ -7914,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",
@@ -8183,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",
@@ -8197,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",
@@ -9357,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",
@@ -9371,8 +9057,6 @@ dependencies = [
"raw-window-handle",
"smallvec",
"static_assertions",
"wasm-bindgen",
"web-sys",
"wgpu-core",
"wgpu-hal",
"wgpu-types",
@@ -9384,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",
@@ -9404,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",
@@ -9419,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"
@@ -9444,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",
@@ -9453,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",
@@ -9479,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",
@@ -10363,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 = { version = "0.3.0", registry = "Quaternions" }
card_game = { version = "0.4.0", registry = "Quaternions" }
# Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
+7 -44
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,39 +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..."
wasm-opt -Oz \
-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 -8
View File
@@ -172,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)),
});
+1 -2
View File
@@ -7,5 +7,4 @@ edition.workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
rand = { workspace = true }
+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
-503
View File
@@ -1,503 +0,0 @@
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
//!
//! # Current scope (integration steps 14)
//!
//! [`KlondikeAdapter`] owns the authoritative [`KlondikeConfig`] and exposes
//! scoring helpers backed by [`ScoringConfig::DEFAULT`] (Windows XP Standard
//! values). [`GameState`] delegates scoring here so that klondike remains the
//! single source of truth for scoring constants.
//!
//! # Not yet implemented
//!
//! - Live [`klondike::Klondike`] shadow state (requires pile-mapping, step 2).
//! - Move validation via klondike's rule engine (step 2).
//! - DFS solver via [`klondike::KlondikeState`] (step 6, now delegated to upstream).
use card_game::{Card as KlCard, Rank as KlRank, Suit as KlSuit};
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
TableauStack,
};
use serde::{Deserialize, Serialize};
use crate::game_state::{DrawMode, GameMode};
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
///
/// Holds a [`KlondikeConfig`] reflecting the current game settings and exposes
/// scoring helpers that read from [`ScoringConfig::DEFAULT`] (WXP values).
/// [`GameState`] uses this instead of calling `scoring.rs` functions directly.
#[derive(Clone, Debug)]
pub struct KlondikeAdapter {
config: KlondikeConfig,
}
impl PartialEq for KlondikeAdapter {
fn eq(&self, other: &Self) -> bool {
self.config.draw_stock == other.config.draw_stock
&& self.config.move_from_foundation == other.config.move_from_foundation
}
}
impl Eq for KlondikeAdapter {}
impl Default for KlondikeAdapter {
/// Returns an adapter with Draw-1 and `take_from_foundation = true`,
/// matching `GameState`'s own defaults. Used by `#[serde(skip)]`
/// field initialisation on deserialisation.
fn default() -> Self {
Self::new(DrawMode::DrawOne, true)
}
}
impl KlondikeAdapter {
/// Create an adapter from the game's draw mode and foundation house-rule setting.
///
/// `take_from_foundation = true` maps to [`MoveFromFoundationConfig::Allowed`];
/// `false` maps to [`MoveFromFoundationConfig::Disallowed`].
pub fn new(draw_mode: DrawMode, take_from_foundation: bool) -> Self {
let config = 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,
};
Self { config }
}
/// Returns a reference to the underlying [`KlondikeConfig`].
///
/// Used by the solver and pile-mapping code added in later integration steps.
pub fn klondike_config(&self) -> &KlondikeConfig {
&self.config
}
/// Update the foundation house-rule flag, keeping [`KlondikeConfig`] in sync.
pub fn set_take_from_foundation(&mut self, allowed: bool) {
self.config.move_from_foundation = if allowed {
MoveFromFoundationConfig::Allowed
} else {
MoveFromFoundationConfig::Disallowed
};
}
// ── 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(&self, from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = &self.config.scoring;
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(&self) -> i32 {
self.config.scoring.flip_up_bonus
}
/// Score delta for undo: 15.
///
/// [`card_game::Session`] handles this via `SessionConfig::undo_penalty`
/// (default 15). We mirror the constant here so `GameState` can apply it
/// in its snapshot-based undo path without owning a `Session`.
pub const 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 |
///
/// `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(
&self,
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(&self, 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)
}
}
}
// ── Type-conversion utilities ─────────────────────────────────────────────
/// Convert [`card_game::Suit`] back to our [`crate::card::Suit`].
pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
match suit {
KlSuit::Clubs => crate::card::Suit::Clubs,
KlSuit::Diamonds => crate::card::Suit::Diamonds,
KlSuit::Hearts => crate::card::Suit::Hearts,
KlSuit::Spades => crate::card::Suit::Spades,
}
}
/// Convert [`card_game::Rank`] back to our [`crate::card::Rank`].
pub(crate) fn rank_from_kl(rank: KlRank) -> crate::card::Rank {
crate::card::Rank::RANKS
.into_iter()
.find(|r| r.value() == rank as u8)
.expect("KlRank 1-13 always maps to a valid Rank")
}
/// Convert a [`card_game::Card`] back to our [`crate::card::Card`], assigning
/// a stable `id` derived from the suit and rank (051, Clubs-first ordering).
///
/// The id is consistent for the same logical card across all reconstructions.
pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
let suit = suit_from_kl(card.suit());
let rank = rank_from_kl(card.rank());
let suit_index = crate::card::Suit::SUITS
.iter()
.position(|s| *s == suit)
.expect("suit always in SUITS") as u32;
let id = suit_index * 13 + (rank.value() as u32 - 1);
crate::card::Card { id, suit, rank, face_up: false }
}
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
//
// `klondike::KlondikeInstruction` (and its sub-types) do not derive
// `Serialize` / `Deserialize`. These mirror types carry `#[serde]` so that
// the session instruction history can be persisted and reconstructed without
// upstream changes.
//
// Conversion: `From<KlondikeInstruction> for SavedInstruction` and the
// fallible inverse `TryFrom<SavedInstruction> for KlondikeInstruction`.
// Invalid numeric values (out-of-range u8 for tableau/foundation/skip) yield
// `InvalidSavedInstruction`.
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableau(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedFoundation(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedSkipCards(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePile {
Tableau(SavedTableau),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableauStack {
pub tableau: SavedTableau,
pub skip_cards: SavedSkipCards,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePileStack {
Tableau(SavedTableauStack),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstFoundation {
pub src: SavedKlondikePile,
pub foundation: SavedFoundation,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstTableau {
pub src: SavedKlondikePileStack,
pub tableau: SavedTableau,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
///
/// Convert to/from the upstream type with:
/// ```ignore
/// let saved = SavedInstruction::from(instruction);
/// let instruction = KlondikeInstruction::try_from(saved)?;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedInstruction {
DstFoundation(SavedDstFoundation),
DstTableau(SavedDstTableau),
RotateStock,
}
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSavedInstruction {
#[error("invalid tableau index {0} (expected 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> {
match s.0 {
0 => Ok(Tableau::Tableau1),
1 => Ok(Tableau::Tableau2),
2 => Ok(Tableau::Tableau3),
3 => Ok(Tableau::Tableau4),
4 => Ok(Tableau::Tableau5),
5 => Ok(Tableau::Tableau6),
6 => Ok(Tableau::Tableau7),
n => Err(InvalidSavedInstruction::Tableau(n)),
}
}
}
impl TryFrom<SavedFoundation> for Foundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(Foundation::Foundation1),
1 => Ok(Foundation::Foundation2),
2 => Ok(Foundation::Foundation3),
3 => Ok(Foundation::Foundation4),
n => Err(InvalidSavedInstruction::Foundation(n)),
}
}
}
impl TryFrom<SavedSkipCards> for SkipCards {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(SkipCards::Skip0),
1 => Ok(SkipCards::Skip1),
2 => Ok(SkipCards::Skip2),
3 => Ok(SkipCards::Skip3),
4 => Ok(SkipCards::Skip4),
5 => Ok(SkipCards::Skip5),
6 => Ok(SkipCards::Skip6),
7 => Ok(SkipCards::Skip7),
8 => Ok(SkipCards::Skip8),
9 => Ok(SkipCards::Skip9),
10 => Ok(SkipCards::Skip10),
11 => Ok(SkipCards::Skip11),
12 => Ok(SkipCards::Skip12),
n => Err(InvalidSavedInstruction::SkipCards(n)),
}
}
}
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 -1
View File
@@ -1,7 +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;
+37 -12
View File
@@ -1,18 +1,33 @@
use crate::card::{Card, Suit};
use klondike::KlondikePile;
use serde::{Deserialize, Serialize};
/// 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)]
#[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.
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(),
@@ -29,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,
}
}
@@ -42,13 +57,13 @@ 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);
let mut pile = Pile::new(PileType::Waste);
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
@@ -66,19 +81,29 @@ mod tests {
#[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));
let mut pile = Pile::new(PileType::Tableau(0));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
@@ -90,7 +115,7 @@ mod tests {
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
let mut pile = Pile::new(PileType::Foundation(2));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
+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);
}
}
+1287 -249
View File
File diff suppressed because it is too large Load Diff
+2 -9
View File
@@ -12,17 +12,10 @@ serde_json = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
uuid = { workspace = true }
klondike = { 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
@@ -31,7 +24,7 @@ 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]
+1 -7
View File
@@ -146,17 +146,13 @@ pub use settings::{
#[cfg(target_os = "android")]
mod android_keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
};
pub mod sync_client;
pub use sync_client::LocalOnlyProvider;
#[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::{
@@ -167,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 -3
View File
@@ -6,7 +6,7 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::Utc;
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};
+3 -19
View File
@@ -12,14 +12,10 @@
//! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait;
use solitaire_sync::{SyncPayload, SyncResponse};
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
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,12 +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 }
klondike = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -20,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]
+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 klondike::{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()."
)
})
}
+3 -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;
@@ -375,7 +376,8 @@ fn play_on_draw(
// 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
.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();
+31 -70
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,9 +39,7 @@ pub struct AutoCompletePlugin;
impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>()
.add_message::<RequestRedraw>()
.add_systems(
app.init_resource::<AutoCompleteState>().add_systems(
Update,
(
detect_auto_complete,
@@ -82,7 +71,7 @@ fn detect_auto_complete(
}
if game.0.is_auto_completable && !state.active {
state.active = true;
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
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
@@ -99,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>,
@@ -120,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>,
@@ -160,9 +142,9 @@ mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use klondike::{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();
@@ -175,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]
@@ -225,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();
@@ -237,16 +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>>();
@@ -254,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>()
+129 -139
View File
@@ -18,8 +18,9 @@ use bevy::sprite::Anchor;
use bevy::window::WindowResized;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use klondike::{Foundation, KlondikePile, Tableau};
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;
@@ -733,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)
};
@@ -789,7 +790,7 @@ fn sync_cards(
update_card_entity(
&mut commands,
entity,
&card,
card,
position,
z,
layout,
@@ -807,7 +808,7 @@ fn sync_cards(
}
None => spawn_card_entity(
&mut commands,
&card,
card,
position,
z,
layout,
@@ -829,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
@@ -854,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.
@@ -895,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,
@@ -924,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
@@ -938,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,
@@ -1542,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 {
@@ -1714,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
@@ -1762,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) {
@@ -1806,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;
}
@@ -1925,7 +1904,9 @@ 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,
@@ -1936,7 +1917,7 @@ fn stock_badge_translation(layout: &Layout) -> Vec3 {
// 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;
@@ -2346,23 +2327,13 @@ 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| {
let max_depth = (0..7_usize)
.filter_map(|i| {
game.0
.pile(klondike::KlondikePile::Tableau(tableau))
.into_iter()
.filter(|c| c.face_up)
.count()
.piles
.get(&solitaire_core::pile::PileType::Tableau(i))
})
.map(|pile| pile.cards.iter().filter(|c| c.face_up).count())
.max()
.unwrap_or(0);
@@ -2531,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);
@@ -2554,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"
@@ -2569,14 +2543,13 @@ 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"
);
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> = waste_pile.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);
@@ -2622,14 +2595,13 @@ 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();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> = waste_pile.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);
@@ -2666,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
@@ -2696,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)
@@ -3083,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)
@@ -3493,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!(
@@ -3511,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");
@@ -3524,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);
}
@@ -3818,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()
@@ -3866,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,
);
}
}
@@ -3898,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);
}
}
+214 -65
View File
@@ -34,8 +34,9 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use klondike::{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,10 +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;
@@ -355,7 +412,7 @@ fn drop_overlay_rect(pile: &KlondikePile, layout: &Layout, game: &GameState) ->
/// the appropriate world position for `pile`.
fn spawn_drop_target_overlay(
commands: &mut Commands,
pile: &KlondikePile,
pile: &PileType,
layout: &Layout,
game: &GameState,
) {
@@ -373,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.
@@ -422,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,
@@ -432,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)
@@ -442,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
@@ -600,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
@@ -611,15 +664,60 @@ 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]
#[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)
// — same colour family, illegal. Tableau(2) must NOT be
@@ -647,16 +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.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 -16
View File
@@ -13,11 +13,9 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
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 bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
#[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`
+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 klondike::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 klondike::{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)
// ---------------------------------------------------------------------------
@@ -850,7 +832,6 @@ mod tests {
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use klondike::Tableau;
use solitaire_data::Settings;
let mut app = App::new();
@@ -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
+12 -39
View File
@@ -8,19 +8,12 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use klondike::{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::{
@@ -2323,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")
};
@@ -2387,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;
}
@@ -2404,11 +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 {
@@ -2419,28 +2413,7 @@ fn foundation_selection_label(slot: Foundation, game: &solitaire_core::game_stat
};
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),
}
}
File diff suppressed because it is too large Load Diff
+52 -98
View File
@@ -7,7 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet};
use klondike::{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,35 +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
@@ -315,35 +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]
@@ -402,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);
}
}
@@ -421,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);
}
@@ -434,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!(
@@ -446,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,
);
}
}
@@ -516,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;
@@ -535,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;
@@ -566,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.
@@ -625,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): {} → {}",
@@ -648,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,
@@ -674,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;
@@ -707,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!(
@@ -726,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",
@@ -741,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,
@@ -758,7 +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()
-12
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};
@@ -165,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,
+32 -51
View File
@@ -27,7 +27,7 @@
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use klondike::KlondikePile;
use solitaire_core::pile::PileType;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity;
@@ -101,7 +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.
@@ -183,7 +183,6 @@ mod tests {
use super::*;
use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig;
use klondike::{Foundation, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
@@ -215,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,
@@ -251,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
}
@@ -328,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;
+152 -142
View File
@@ -47,12 +47,13 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use klondike::{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,17 +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 {
@@ -347,36 +362,8 @@ fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_i
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
}
const fn tableaus() -> [Tableau; 7] {
[
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
}
/// Builds the `(destination, anchor)` list for a fresh radial open.
fn build_radial_destinations(centre: Vec2, dests: Vec<KlondikePile>) -> Vec<(KlondikePile, Vec2)> {
fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileType, Vec2)> {
let count = dests.len();
dests
.into_iter()
@@ -420,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>>>,
@@ -435,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;
@@ -464,12 +449,6 @@ fn radial_open_on_right_click(
// cards and the highlight tint shows the same set the radial offers.
let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
if dests.is_empty() {
// No legal destinations — shake the source pile as feedback.
rejected.write(MoveRejectedEvent {
from: source_pile,
to: source_pile,
count: 1,
});
return;
}
let legal_destinations = build_radial_destinations(world, dests);
@@ -630,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,
});
}
@@ -777,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>>();
@@ -793,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
}
@@ -831,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
}
@@ -958,14 +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]
@@ -977,8 +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)));
}
// -----------------------------------------------------------------------
@@ -988,14 +958,54 @@ 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.
/// Releasing the right button while the cursor is over a destination
#[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]
fn right_click_release_over_destination_fires_move_request() {
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);
@@ -1022,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.
@@ -1039,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);
@@ -1070,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);
@@ -1096,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,269 +0,0 @@
use super::ReplayPlaybackState;
use chrono::Datelike;
use klondike::{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::*;
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 crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use klondike::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 klondike::KlondikePile;
use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
@@ -268,17 +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,
});
}
@@ -379,21 +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 klondike::{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::game_state::GameState;
use klondike::KlondikePile;
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.
///
+239 -172
View File
@@ -37,9 +37,9 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use klondike::{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,7 +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;
}
@@ -223,7 +210,7 @@ pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile
for offset in 0..n {
let candidate = &order[(start + offset) % n];
if available.contains(candidate) {
return Some(*candidate);
return Some(candidate.clone());
}
}
None
@@ -235,14 +222,14 @@ pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile
///
/// 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))
@@ -339,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,
});
@@ -370,24 +357,28 @@ 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)
game.0
.piles
.get(p)
.and_then(|pile| pile.cards.last())
.is_some_and(|c| c.face_up)
})
.collect()
};
@@ -416,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,
});
@@ -433,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,
});
@@ -452,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,
});
@@ -463,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);
@@ -491,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;
@@ -499,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,
@@ -524,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);
}
}
@@ -590,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);
}
}
@@ -679,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
@@ -717,18 +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
@@ -767,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()
}
@@ -789,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]
@@ -830,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"
@@ -838,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"
@@ -846,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"
@@ -854,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"
@@ -863,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));
}
// -----------------------------------------------------------------------
@@ -1013,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
}
@@ -1112,7 +1097,7 @@ mod tests {
.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
@@ -1132,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();
@@ -1147,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!(
@@ -1162,14 +1147,96 @@ 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 4 — Enter while `Lifted` with a destination focused fires
#[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.
#[test]
@@ -1179,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();
@@ -1199,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);
@@ -1224,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());
@@ -1241,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!(
@@ -1264,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;
}
@@ -1297,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();
@@ -1306,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};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
#[cfg(not(target_arch = "wasm32"))]
use crate::theme::{ImportError, import_theme, refresh_registry};
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(),
+35 -80
View File
@@ -6,8 +6,8 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
@@ -54,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
@@ -265,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 {
@@ -283,14 +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()),
));
// 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"),
@@ -303,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"),
@@ -479,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 {
@@ -488,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::*;
@@ -543,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]
@@ -573,7 +540,7 @@ 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())
@@ -581,15 +548,15 @@ mod tests {
.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
@@ -603,8 +570,8 @@ 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.clone());
@@ -619,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;
use bevy::prelude::{App, Plugin, Resource};
#[cfg(not(target_arch = "wasm32"))]
use bevy::prelude::Startup;
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
+10 -11
View File
@@ -28,7 +28,7 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use klondike::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));
}
@@ -186,7 +186,6 @@ fn spawn_touch_highlight(
#[cfg(test)]
mod tests {
use super::*;
use klondike::Tableau;
#[test]
fn selection_state_default_is_idle() {
@@ -198,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());
}
@@ -211,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());
}
@@ -226,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]);
}
}
+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;
+3 -11
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,23 +21,18 @@ 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
# The Quaternions registry requires authentication. CI passes CI_TOKEN as a
# build secret so it never appears in image layers or docker history.
RUN --mount=type=secret,id=cargo_token,required=true \
CARGO_REGISTRIES_QUATERNIONS_TOKEN="Bearer $(cat /run/secrets/cargo_token)" \
cargo fetch --locked
RUN cargo fetch --locked
# Now copy real source and build in release mode.
COPY solitaire_core/src ./solitaire_core/src
@@ -51,9 +45,7 @@ COPY solitaire_server/migrations ./solitaire_server/migrations
COPY .sqlx ./.sqlx
ENV SQLX_OFFLINE=true
RUN --mount=type=secret,id=cargo_token,required=true \
CARGO_REGISTRIES_QUATERNIONS_TOKEN="Bearer $(cat /run/secrets/cargo_token)" \
cargo build --release --locked -p solitaire_server --bin solitaire_server
RUN cargo build --release --locked -p solitaire_server --bin solitaire_server
# --- Runtime stage ---
FROM debian:bookworm-slim
-5
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(
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}
*/
@@ -255,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.
-20
View File
@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ferrous Solitaire</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #000; overflow: hidden; }
#bevy-canvas { display: block; width: 100vw; height: 100vh; }
</style>
</head>
<body>
<canvas id="bevy-canvas"></canvas>
<script type="module">
import init from "/web/pkg/canvas.js";
await init();
</script>
</body>
</html>
-1
View File
@@ -12,7 +12,6 @@ solitaire_core = { path = "../solitaire_core" }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
klondike = { workspace = true }
wasm-bindgen = "0.2"
serde-wasm-bindgen = "0.6"
console_error_panic_hook = { version = "0.1", optional = true }
+359 -62
View File
@@ -19,12 +19,11 @@
//! is the contract.
use chrono::NaiveDate;
use klondike::{Foundation, KlondikePile, Tableau};
use serde::{Deserialize, Serialize};
use solitaire_core::card::Suit;
use solitaire_core::error::MoveError;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_core::pile::PileType;
use wasm_bindgen::prelude::*;
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
@@ -33,8 +32,8 @@ use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
Move {
from: SavedKlondikePile,
to: SavedKlondikePile,
from: PileType,
to: PileType,
count: usize,
},
StockClick,
@@ -143,13 +142,7 @@ impl ReplayPlayer {
}
let mv = self.moves[self.step_idx].clone();
match mv {
ReplayMove::Move { from, to, count } => self.game.move_cards(
from.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
to.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
count,
)?,
ReplayMove::Move { from, to, count } => self.game.move_cards(from, to, count)?,
ReplayMove::StockClick => self.game.draw()?,
}
self.step_idx += 1;
@@ -157,22 +150,27 @@ impl ReplayPlayer {
}
fn snapshot(&self) -> StateSnapshot {
let pile_cards =
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
let pile_cards = |t: PileType| -> Vec<CardSnapshot> {
self.game
.piles
.get(&t)
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default()
};
let foundations: [Vec<CardSnapshot>; 4] = [
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation3)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation4)),
pile_cards(PileType::Foundation(0)),
pile_cards(PileType::Foundation(1)),
pile_cards(PileType::Foundation(2)),
pile_cards(PileType::Foundation(3)),
];
let tableaus: [Vec<CardSnapshot>; 7] = [
pile_cards(KlondikePile::Tableau(Tableau::Tableau1)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau2)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau3)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau4)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau5)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau6)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau7)),
pile_cards(PileType::Tableau(0)),
pile_cards(PileType::Tableau(1)),
pile_cards(PileType::Tableau(2)),
pile_cards(PileType::Tableau(3)),
pile_cards(PileType::Tableau(4)),
pile_cards(PileType::Tableau(5)),
pile_cards(PileType::Tableau(6)),
];
StateSnapshot {
step_idx: self.step_idx,
@@ -180,8 +178,8 @@ impl ReplayPlayer {
score: self.game.score,
move_count: self.game.move_count,
is_won: self.game.is_won,
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
stock: pile_cards(PileType::Stock),
waste: pile_cards(PileType::Waste),
foundations,
tableaus,
}
@@ -291,11 +289,24 @@ pub struct SolitaireGame {
impl SolitaireGame {
fn snap(&self) -> GameSnapshot {
let cards =
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
let cards = |t: PileType| -> Vec<CardSnapshot> {
self.game
.piles
.get(&t)
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default()
};
let has_moves = {
let stock_empty = self.game.stock_cards().is_empty();
let waste_empty = self.game.waste_cards().is_empty();
let stock_empty = self
.game
.piles
.get(&PileType::Stock)
.is_none_or(|p| p.cards.is_empty());
let waste_empty = self
.game
.piles
.get(&PileType::Waste)
.is_none_or(|p| p.cards.is_empty());
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
};
GameSnapshot {
@@ -306,29 +317,30 @@ impl SolitaireGame {
has_moves,
undo_count: self.game.undo_count,
undo_stack_len: self.game.undo_stack_len(),
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
stock: cards(PileType::Stock),
waste: cards(PileType::Waste),
foundations: [
cards(KlondikePile::Foundation(Foundation::Foundation1)),
cards(KlondikePile::Foundation(Foundation::Foundation2)),
cards(KlondikePile::Foundation(Foundation::Foundation3)),
cards(KlondikePile::Foundation(Foundation::Foundation4)),
cards(PileType::Foundation(0)),
cards(PileType::Foundation(1)),
cards(PileType::Foundation(2)),
cards(PileType::Foundation(3)),
],
tableaus: [
cards(KlondikePile::Tableau(Tableau::Tableau1)),
cards(KlondikePile::Tableau(Tableau::Tableau2)),
cards(KlondikePile::Tableau(Tableau::Tableau3)),
cards(KlondikePile::Tableau(Tableau::Tableau4)),
cards(KlondikePile::Tableau(Tableau::Tableau5)),
cards(KlondikePile::Tableau(Tableau::Tableau6)),
cards(KlondikePile::Tableau(Tableau::Tableau7)),
cards(PileType::Tableau(0)),
cards(PileType::Tableau(1)),
cards(PileType::Tableau(2)),
cards(PileType::Tableau(3)),
cards(PileType::Tableau(4)),
cards(PileType::Tableau(5)),
cards(PileType::Tableau(6)),
],
}
}
fn pile_from_str(s: &str) -> Result<KlondikePile, String> {
fn pile_from_str(s: &str) -> Result<PileType, String> {
match s {
"stock" | "waste" => Ok(KlondikePile::Stock),
"stock" => Ok(PileType::Stock),
"waste" => Ok(PileType::Waste),
_ if s.starts_with("foundation-") => {
let slot: u8 = s["foundation-".len()..]
.parse()
@@ -336,13 +348,7 @@ impl SolitaireGame {
if slot >= 4 {
return Err(format!("foundation slot out of range: {slot}"));
}
Ok(KlondikePile::Foundation(match slot {
0 => Foundation::Foundation1,
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
3 => Foundation::Foundation4,
_ => return Err(format!("foundation slot out of range: {slot}")),
}))
Ok(PileType::Foundation(slot))
}
_ if s.starts_with("tableau-") => {
let col: usize = s["tableau-".len()..]
@@ -351,16 +357,7 @@ impl SolitaireGame {
if col >= 7 {
return Err(format!("tableau col out of range: {col}"));
}
Ok(KlondikePile::Tableau(match col {
0 => Tableau::Tableau1,
1 => Tableau::Tableau2,
2 => Tableau::Tableau3,
3 => Tableau::Tableau4,
4 => Tableau::Tableau5,
5 => Tableau::Tableau6,
6 => Tableau::Tableau7,
_ => return Err(format!("tableau col out of range: {col}")),
}))
Ok(PileType::Tableau(col))
}
_ => Err(format!("unknown pile: {s}")),
}
@@ -498,3 +495,303 @@ impl SolitaireGame {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_replay_json() -> String {
// Minimal v2 replay: seed 42, two stock clicks. Real winning
// replays will have many more moves; for the test we just
// verify deserialization + step() advances correctly.
r#"{
"schema_version": 2,
"seed": 42,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": ["StockClick", "StockClick"]
}"#
.to_string()
}
/// Constructing from a valid v2 replay JSON must succeed and
/// initialise step_idx to 0.
#[test]
fn new_initialises_step_idx_zero() {
let player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
assert_eq!(player.step_idx, 0);
assert_eq!(player.moves.len(), 2);
}
/// Each step advances the index; once exhausted, step_native returns None.
#[test]
fn steps_advance_then_terminate() {
let mut player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
assert!(
player
.step_native()
.expect("first move should apply")
.is_some()
);
assert_eq!(player.step_idx, 1);
assert!(
player
.step_native()
.expect("second move should apply")
.is_some()
);
assert_eq!(player.step_idx, 2);
assert!(
player
.step_native()
.expect("replay should be exhausted")
.is_none(),
"no further steps"
);
}
/// Malformed JSON returns an error rather than panicking.
#[test]
fn invalid_json_returns_error() {
let result = ReplayPlayer::from_json("not valid json");
assert!(result.is_err());
}
#[test]
fn invalid_replay_move_returns_error_without_advancing() {
let replay = Replay {
schema_version: 2,
seed: 42,
draw_mode: DrawMode::DrawOne,
mode: GameMode::Classic,
time_seconds: 60,
final_score: 100,
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
moves: vec![ReplayMove::Move {
from: PileType::Waste,
to: PileType::Foundation(0),
count: 1,
}],
};
let json = serde_json::to_string(&replay).expect("replay serialises");
let mut player = ReplayPlayer::from_json(&json).expect("valid JSON");
let err = player
.step_native()
.expect_err("illegal replay move must surface an error");
assert_eq!(err, MoveError::EmptySource);
assert_eq!(
player.step_idx, 0,
"desync must not advance the replay cursor"
);
}
// -------------------------------------------------------------------------
// Winning-sequence step-through
// -------------------------------------------------------------------------
/// Greedy Klondike solver for DrawOne Classic.
///
/// Returns a `ReplayMove` list that wins the game from `seed`, or `None`
/// when the greedy heuristic gets stuck within the move budget.
///
/// Priority order (highest first):
/// 1. Waste → Foundation
/// 2. Tableau top → Foundation
/// 3. Tableau stack → Tableau, only if the move uncovers a face-down card
/// 4. Waste → Tableau
/// 5. Draw from stock (recycle is automatic inside `GameState::draw`)
fn greedy_solve(seed: u64) -> Option<Vec<ReplayMove>> {
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
let mut game = GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic);
let mut moves: Vec<ReplayMove> = Vec::new();
const MAX_MOVES: usize = 10_000;
'outer: loop {
if game.is_won {
return Some(moves);
}
if moves.len() >= MAX_MOVES {
return None;
}
// Auto-complete: drive to win without further player input.
if game.is_auto_completable {
while let Some((from, to)) = game.next_auto_complete_move() {
if game.move_cards(from.clone(), to.clone(), 1).is_err() {
return None;
}
moves.push(ReplayMove::Move { from, to, count: 1 });
}
return if game.is_won { Some(moves) } else { None };
}
// P1: Waste → Foundation.
for slot in 0..4_u8 {
if game
.move_cards(PileType::Waste, PileType::Foundation(slot), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Foundation(slot),
count: 1,
});
continue 'outer;
}
}
// P2: Tableau top → Foundation.
for i in 0..7_usize {
for slot in 0..4_u8 {
if game
.move_cards(PileType::Tableau(i), PileType::Foundation(slot), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Tableau(i),
to: PileType::Foundation(slot),
count: 1,
});
continue 'outer;
}
}
}
// P3: Tableau stack → Tableau only when it uncovers a face-down card.
let mut made_move = false;
'p3: for i in 0..7_usize {
let pile_len = game.piles[&PileType::Tableau(i)].cards.len();
for count in 1..=pile_len {
let start = pile_len - count;
// Only worth moving if a face-down card sits just below.
let would_uncover =
start > 0 && !game.piles[&PileType::Tableau(i)].cards[start - 1].face_up;
if !would_uncover {
continue;
}
for j in 0..7_usize {
if i == j {
continue;
}
if game
.move_cards(PileType::Tableau(i), PileType::Tableau(j), count)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Tableau(i),
to: PileType::Tableau(j),
count,
});
made_move = true;
break 'p3;
}
}
}
}
if made_move {
continue 'outer;
}
// P4: Waste → Tableau.
for j in 0..7_usize {
if game
.move_cards(PileType::Waste, PileType::Tableau(j), 1)
.is_ok()
{
moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(j),
count: 1,
});
continue 'outer;
}
}
// P5: Draw from stock (handles recycle automatically).
if game.draw().is_ok() {
moves.push(ReplayMove::StockClick);
continue 'outer;
}
// No moves available — greedy solver is stuck on this seed.
return None;
}
}
/// Full end-to-end winning-sequence regression test.
///
/// 1. Runs the greedy solver on seeds 1200 to find the first
/// deterministically winnable game.
/// 2. Serialises the winning move list as a `Replay` JSON string.
/// 3. Feeds the JSON to `ReplayPlayer::from_json`.
/// 4. Steps through every move via `step_native` and asserts `is_won`
/// on the final snapshot.
///
/// Regression target: a `GameState` or `ReplayMove` change that breaks
/// an historically valid move sequence will cause `is_won` to be `false`
/// at the end of the replay, failing this test before any release.
#[test]
fn replay_player_completes_full_winning_sequence() {
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
let (seed, winning_moves) = (1_u64..=200)
.find_map(|s| greedy_solve(s).map(|m| (s, m)))
.expect("at least one seed in 1..=200 must be solvable by the greedy strategy");
let replay = Replay {
schema_version: 2,
seed,
draw_mode: DrawMode::DrawOne,
mode: GameMode::Classic,
time_seconds: 300,
final_score: 0,
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 12).expect("2026-05-12 is a valid date"),
moves: winning_moves.clone(),
};
let json = serde_json::to_string(&replay).expect("replay serialises to JSON cleanly");
let mut player =
ReplayPlayer::from_json(&json).expect("solver-generated replay JSON must be valid");
assert_eq!(player.step_idx, 0, "player must start at step 0");
assert_eq!(
player.moves.len(),
winning_moves.len(),
"player must hold the complete move list"
);
let mut last_snap: Option<StateSnapshot> = None;
while let Some(snap) = player
.step_native()
.expect("solver-generated replay must stay in sync")
{
last_snap = Some(snap);
}
let snap = last_snap.expect("winning sequence must contain at least one move");
assert!(
snap.is_won,
"seed {seed}: final snapshot after full replay must have is_won = true \
({} moves applied)",
winning_moves.len()
);
assert_eq!(
snap.step_idx,
winning_moves.len(),
"step_idx after the last move must equal the total move count"
);
assert!(
player
.step_native()
.expect("winning replay should still be exhausted")
.is_none(),
"step_native must return None once all moves are exhausted"
);
}
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "solitaire_web"
version.workspace = true
license.workspace = true
edition.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
solitaire_engine = { path = "../solitaire_engine" }
solitaire_data = { path = "../solitaire_data" }
# Direct dep so `bevy::` resolves in lib.rs; zero extra features so this
# contributes nothing to unification with the desktop/Android feature set.
bevy = { workspace = true }
wasm-bindgen = "0.2"
getrandom = { version = "0.3", features = ["wasm_js"] }
console_error_panic_hook = "0.1"
# webgl2 must only be enabled for the wasm target — it constrains the
# renderer to WebGL2 compatibility limits, which is wrong for native builds.
[target.'cfg(target_arch = "wasm32")'.dependencies]
bevy = { workspace = true, features = ["webgl2"] }
-65
View File
@@ -1,65 +0,0 @@
//! Browser entry point for the Ferrous Solitaire Bevy WASM build.
//!
//! This crate compiles the full `solitaire_engine` to `wasm32-unknown-unknown`
//! and renders to a `<canvas id="bevy-canvas">` element. It shares the same
//! ECS code path as the desktop and Android builds; the only differences are:
//! - Audio, sync, and analytics plugins are cfg-gated out in `CoreGamePlugin`
//! on the wasm32 target (see `solitaire_engine/src/core_game_plugin.rs`).
//! - `LocalOnlyProvider` is passed as the sync provider (sync is disabled).
//! - Storage is handled automatically by `WasmStorage` (localStorage-backed),
//! wired by `CoreGamePlugin` via `default_storage_backend()`.
use bevy::asset::AssetMetaCheck;
use bevy::prelude::*;
use bevy::render::RenderPlugin;
use bevy::render::settings::{RenderCreation, WgpuSettings, WgpuSettingsPriority};
use bevy::window::{Window, WindowPlugin};
use solitaire_data::LocalOnlyProvider;
use solitaire_engine::CoreGamePlugin;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn start() {
console_error_panic_hook::set_once();
App::new()
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
// Bind to the existing <canvas id="bevy-canvas"> in play.html.
// Without this, Bevy appends its own canvas to <body>.
canvas: Some("#bevy-canvas".into()),
// Let CSS size the canvas; Bevy follows the element's size.
fit_canvas_to_parent: true,
// Prevent the browser stealing keyboard events and scroll.
prevent_default_event_handling: true,
..default()
}),
..default()
})
// Bevy's default AssetPlugin fetches a `.meta` sidecar file for
// every asset before loading the asset itself. We don't ship
// `.meta` files, so skip the check to avoid a flood of 404s.
.set(AssetPlugin {
meta_check: AssetMetaCheck::Never,
..default()
})
// WebGL2 priority constrains naga (the shader translator) to emit
// GLES 300es-compatible GLSL. Without this, Chromium's ANGLE driver
// rejects certain shader constructs (storage buffers, tight component
// limits) causing a fatal wgpu "Shader translation error". Firefox is
// more lenient; this setting makes both browsers work identically.
.set(RenderPlugin {
render_creation: RenderCreation::Automatic(WgpuSettings {
priority: WgpuSettingsPriority::WebGL2,
..default()
}),
..default()
}),
)
// LocalOnlyProvider disables cloud sync — correct for the web build
// since SyncPlugin is cfg-gated out on wasm32 anyway.
.add_plugins(CoreGamePlugin::new(Box::new(LocalOnlyProvider)))
.run();
}