Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2a021712 | |||
| 6037596cc0 | |||
| d7ffb16df5 | |||
| b57db017d3 | |||
| 0b3140ad6d | |||
| e41def8c89 | |||
| aad8bb9c83 | |||
| 55c235b55f | |||
| 21ec03b157 | |||
| 17e3112502 | |||
| de4751115f | |||
| 9ff48ace5b | |||
| 91b7605b9f | |||
| 42d90b199c | |||
| 3e11e9e79a |
+123
@@ -8,6 +8,129 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
_Nothing yet._
|
||||
|
||||
## [0.19.0] — 2026-05-06
|
||||
|
||||
Closes the v0.18.0 punch list (items B and D — async hint and
|
||||
persistent replay share URLs), expands desktop platform fit
|
||||
(Wayland session support + monitor-aware default window size for
|
||||
HiDPI / 4K displays), polishes the win-celebration and
|
||||
double-click animation paths, and clears two test-flake
|
||||
contributors. A short-lived "Rusty Pixel" pixel-art card theme
|
||||
was prototyped and reverted in the same window — the engine
|
||||
plumbing it touched (`pixel_art` field on `ThemeMeta`, PNG
|
||||
manifest face support, second `embedded://` theme channel) was
|
||||
fully reverted and is not part of this release.
|
||||
|
||||
### Changed
|
||||
|
||||
- **H-key hint runs on `AsyncComputeTaskPool`** (`3e11e9e`). The
|
||||
synchronous `try_solve_from_state` call on every H press is gone;
|
||||
`handle_keyboard_hint` now spawns a task whose result the new
|
||||
`pending_hint::poll_pending_hint_task` system surfaces one frame
|
||||
later. New `PendingHintTask` resource carries the in-flight handle
|
||||
plus `move_count_at_spawn` for staleness detection;
|
||||
`drop_pending_hint_on_state_change` cancels the task whenever the
|
||||
game state shifts; `PendingHintTask::spawn` implements
|
||||
cancel-on-replace so two quick H presses keep at most one task in
|
||||
flight. Mirrors the v0.18.0 `PendingNewGameSeed` template.
|
||||
`emit_hint_visuals` and `find_heuristic_hint` are extracted as
|
||||
`pub` helpers so the polling system can call them.
|
||||
- **Persistent replay share URLs** (`42d90b1`). v0.18.0's
|
||||
`LastSharedReplayUrl` was an in-memory resource wiped on quit —
|
||||
the player had to share within the session of the win.
|
||||
`solitaire_data::Replay` now carries a `share_url: Option<String>`
|
||||
field with `#[serde(default)]` (no `REPLAY_SCHEMA_VERSION` bump
|
||||
needed; older `replays.json` files load unchanged with `share_url
|
||||
== None` on every entry). `poll_replay_upload_result` writes the
|
||||
resolved URL into `replays[0].share_url` and persists the updated
|
||||
history via `save_replay_history_to`. The Stats overlay's
|
||||
"Copy share link" button reads from
|
||||
`history.0.replays[selected.0].share_url`, so the Prev/Next
|
||||
selector's currently-displayed replay drives the clipboard
|
||||
contents — each historical win keeps its own URL.
|
||||
`LastSharedReplayUrl` removed (its role is now subsumed by the
|
||||
`share_url` field on the replay record).
|
||||
|
||||
### Added
|
||||
|
||||
- **Wayland session support** (`b57db01`). The workspace
|
||||
`Cargo.toml` Bevy feature list now enables `wayland` alongside
|
||||
`x11`. winit prefers Wayland when `WAYLAND_DISPLAY` is set on the
|
||||
session, falling back to X11 when it isn't. Pre-fix, a Wayland
|
||||
desktop environment fell through to XWayland, rendering the
|
||||
game inside an X11 frame stitched into the Wayland compositor.
|
||||
Post-fix, the game opens as a native Wayland surface. Costs a
|
||||
few hundred KB of binary for the libwayland-client bindings;
|
||||
cross-distro friendly because winit dlopen-probes the libraries
|
||||
rather than hard-linking them.
|
||||
- **Monitor-relative default window size** (`b57db01`). On launches
|
||||
with no saved geometry, the new
|
||||
`apply_smart_default_window_size` Update system queries
|
||||
`Monitor` (with the `PrimaryMonitor` marker) and resizes the
|
||||
primary window to ~70 % of the monitor's *logical* size on the
|
||||
first frame. Before, every fresh launch opened at 1280×800
|
||||
regardless of monitor; on a 4K monitor that's a comparatively
|
||||
tiny window in one corner. Logical size already accounts for
|
||||
the OS's HiDPI scale factor, so a Retina display reporting
|
||||
scale_factor 2.0 yields the same physical inches as a 1080p
|
||||
display reporting 1.0. Skipped entirely when saved geometry was
|
||||
applied — the player's chosen size always wins.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Duplicate "You Win" toast on game-won** (`55c235b`). The
|
||||
post-win UI was firing two celebration surfaces: a 4-second
|
||||
toast banner ("You Win! Score: X Time: Y") on top of the
|
||||
`win_summary_plugin`'s "You Won!" modal. In screenshots the
|
||||
toast banner was partially clipped behind the modal card,
|
||||
peeking out on either side. The toast predated the modal and is
|
||||
strictly subsumed by it; removed. The cards-fly-off cascade
|
||||
animation (`MotionCurve::Expressive` per-card rotation drift)
|
||||
is unchanged — that's the visual celebration, distinct from
|
||||
the textual celebration the modal owns. `WIN_TOAST_SECS` const
|
||||
removed.
|
||||
- **Double-click on a single card with no destination now plays
|
||||
the reject animation** (`d7ffb16`). `handle_double_click` only
|
||||
fired `MoveRejectedEvent` for multi-card stacks with no
|
||||
destination; a double-click on a single card whose top didn't
|
||||
fit any foundation or tableau slot produced zero feedback —
|
||||
no `card_invalid.wav`, no source-pile shake. Both priorities'
|
||||
failure paths now converge on a single rejection at the end of
|
||||
the double-click branch, so single-card and stack misses get
|
||||
the same feedback shape as drag-and-drop rejections.
|
||||
- **Double-click move animation no longer plays twice**
|
||||
(`6037596`). On a successful double-click, the slide-to-
|
||||
destination animation rendered twice — once from the move's
|
||||
`StateChangedEvent` landing, then again from the release's
|
||||
`end_drag` firing a redundant `StateChangedEvent` mid-slide.
|
||||
`sync_cards_on_change` saw the card mid-CardAnim (`cur ≠
|
||||
target`) and replaced the in-flight tween with a fresh one
|
||||
starting at the mid-position, visibly restarting the slide. The
|
||||
defensive `StateChangedEvent` write in `end_drag`'s
|
||||
uncommitted-drag branch is removed; `start_drag` only mutates
|
||||
`DragState` (never card transforms), so an uncommitted drag
|
||||
has no visual side effect to undo. The committed-drag branch
|
||||
keeps its `StateChangedEvent` since real drag snap-backs do
|
||||
need a resync.
|
||||
- **`auto_save_writes_after_30_seconds` test flake** (`91b7605`).
|
||||
The test's single-frame `app.update()` was sensitive to
|
||||
first-frame `Time::delta_secs()` variance under heavy parallel
|
||||
cargo-test load, and to production-disk
|
||||
`~/.local/share/solitaire_quest/game_state.json` state leaking
|
||||
into the test world via `GamePlugin::build`'s load path.
|
||||
`test_app` now resets `PendingRestoredGame(None)` after plugin
|
||||
build (preventing the dev machine's saved-game state from
|
||||
tripping the auto-save guard) and the test re-arms the timer in
|
||||
a small bounded loop until the file appears (robust against
|
||||
first-frame Time variance). No production-code change.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1170 passing tests (was 1166 at v0.18.0 close — net +4 from
|
||||
the persistent share URL backwards-compat test, the three
|
||||
async-hint tests, minus the dropped synchronous hint tests).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.18.0] — 2026-05-06
|
||||
|
||||
The launch-experience round. The engine used to drop the player on a
|
||||
|
||||
Generated
+236
-11
@@ -126,6 +126,19 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -719,7 +732,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"console_error_panic_hook",
|
||||
"ctrlc",
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"log",
|
||||
"thiserror 2.0.18",
|
||||
"variadics_please",
|
||||
@@ -753,7 +766,7 @@ dependencies = [
|
||||
"crossbeam-channel",
|
||||
"derive_more",
|
||||
"disqualified",
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"either",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
@@ -801,7 +814,7 @@ dependencies = [
|
||||
"bevy_utils",
|
||||
"bevy_window",
|
||||
"derive_more",
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
@@ -1200,7 +1213,7 @@ dependencies = [
|
||||
"bevy_utils",
|
||||
"derive_more",
|
||||
"disqualified",
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"erased-serde",
|
||||
"foldhash 0.2.0",
|
||||
"glam 0.30.10",
|
||||
@@ -1259,7 +1272,7 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bytemuck",
|
||||
"derive_more",
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"encase",
|
||||
"fixedbitset",
|
||||
"glam 0.30.10",
|
||||
@@ -1854,6 +1867,18 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
|
||||
dependencies = [
|
||||
"calloop",
|
||||
"rustix 0.38.44",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
@@ -2709,6 +2734,12 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "2.0.2"
|
||||
@@ -5792,6 +5823,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -6165,7 +6205,7 @@ dependencies = [
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -6502,6 +6542,19 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"log",
|
||||
"memmap2",
|
||||
"smithay-client-toolkit",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
@@ -6846,6 +6899,31 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 0.38.44",
|
||||
"thiserror 1.0.69",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-csd-frame",
|
||||
"wayland-cursor",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.2.2"
|
||||
@@ -6938,7 +7016,7 @@ dependencies = [
|
||||
"solitaire_sync",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
"tokio",
|
||||
"usvg",
|
||||
"uuid",
|
||||
@@ -7525,7 +7603,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"crossbeam-channel",
|
||||
"datasketches",
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"fastdivide",
|
||||
"fnv",
|
||||
"fs4",
|
||||
@@ -7577,7 +7655,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc"
|
||||
dependencies = [
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"fastdivide",
|
||||
"itertools 0.14.0",
|
||||
"serde",
|
||||
@@ -7765,6 +7843,20 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
@@ -7777,7 +7869,18 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png 0.18.1",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8548,7 +8651,7 @@ dependencies = [
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"ttf-parser",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
@@ -8754,6 +8857,114 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs 1.2.1",
|
||||
"rustix 1.1.4",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"rustix 1.1.4",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-csd-frame"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cursor-icon",
|
||||
"wayland-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-cursor"
|
||||
version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"wayland-client",
|
||||
"xcursor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.97"
|
||||
@@ -9569,6 +9780,7 @@ version = "0.30.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"android-activity",
|
||||
"atomic-waker",
|
||||
"bitflags 2.11.1",
|
||||
@@ -9583,6 +9795,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -9594,11 +9807,17 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-plasma",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"windows-sys 0.52.0",
|
||||
@@ -9766,6 +9985,12 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xcursor"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
|
||||
|
||||
[[package]]
|
||||
name = "xkbcommon-dl"
|
||||
version = "0.4.2"
|
||||
|
||||
+6
-1
@@ -54,11 +54,16 @@ bevy = { version = "0.18", default-features = false, features = [
|
||||
"bevy_window",
|
||||
"custom_cursor",
|
||||
"reflect_auto_register",
|
||||
# default_platform (desktop subset; no android/wayland/webgl/gilrs/sysinfo)
|
||||
# default_platform (desktop subset; no android/webgl/gilrs/sysinfo)
|
||||
"std",
|
||||
"bevy_winit",
|
||||
"default_font",
|
||||
"multi_threaded",
|
||||
# winit prefers Wayland when WAYLAND_DISPLAY is set on the
|
||||
# session and falls through to X11 otherwise. Without `wayland`,
|
||||
# winit-on-Wayland-session falls back to XWayland which renders
|
||||
# the game in an X11 frame inside the Wayland compositor.
|
||||
"wayland",
|
||||
"x11",
|
||||
# common_api
|
||||
"bevy_color",
|
||||
|
||||
+114
-113
@@ -1,58 +1,59 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-06 (post-v0.18.0 draft) — 24 commits since
|
||||
the v0.17.0 tag bundle the launch-experience round (Restore prompt +
|
||||
auto-show Home / mode picker), the MSSC-style Home picker rework
|
||||
(header chips, draw-mode chips, picture-tile mode cards, Today's
|
||||
Event callout, glyph fixes), the last solver hot path moving onto
|
||||
`AsyncComputeTaskPool`, "Won before" HUD chip, "Copy share link"
|
||||
Stats button, the `N` keybinding finally routing through the real
|
||||
Confirm/Cancel modal, Esc-on-modal layering fixes, and the
|
||||
unified-3.0 Claude rule set (CLAUDE.md / CLAUDE_SPEC.md /
|
||||
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md). Test-discipline prune
|
||||
removed 43 low-value tests in the same window.
|
||||
**Last updated:** 2026-05-06 (post-v0.19.0) — Tagged + pushed at
|
||||
`6037596`. v0.19.0 closes the v0.18.0 punch list (async H-key hint,
|
||||
persistent replay share URLs), expands desktop platform fit (Wayland
|
||||
session support + monitor-aware default window size), polishes the
|
||||
win-celebration and double-click animation paths, and clears two
|
||||
test-flake contributors. A short-lived "Rusty Pixel" pixel-art card
|
||||
theme was prototyped and reverted in the same window.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD on origin:** `v0.17.0-24-gc497c31` (24 ahead of v0.17.0,
|
||||
not yet tagged).
|
||||
- **Working tree:** clean.
|
||||
- **HEAD on origin:** `6037596` (post-tag commit; the tag itself
|
||||
points at this commit).
|
||||
- **Working tree:** modified — `CHANGELOG.md` and
|
||||
`SESSION_HANDOFF.md` carry the v0.19.0 promotion + this refresh,
|
||||
ready to commit.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean (verified this session).
|
||||
- **Tests:** **1166 passing / 0 failing** across the workspace
|
||||
(verified this session). The first run flaked once on
|
||||
`solitaire_engine::game_plugin::tests::auto_save_writes_after_30_seconds`
|
||||
— a one-frame `app.update()` test that depends on `time.delta_secs()`
|
||||
on an otherwise-fresh `App`. Reproduced clean on the second run;
|
||||
passes in isolation. Worth tightening if it flakes again, but
|
||||
not blocking the v0.18.0 cut.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.17.0`.
|
||||
- **CHANGELOG:** v0.18.0 entry drafted in `[Unreleased]`'s slot —
|
||||
ready for tag once build + tests are reverified.
|
||||
- **Tests:** **1170 passing / 0 failing** across the workspace
|
||||
(verified this session). One known flake remains:
|
||||
`solitaire_engine::sync_plugin::tests::pull_failure_sets_error_status`
|
||||
occasionally fails when cargo-test parallelism starves the
|
||||
`AsyncComputeTaskPool` within the test's 5-update budget. Same
|
||||
shape as the auto-save flake before v0.19.0's hardening; could be
|
||||
fixed similarly with a wall-clock-bounded loop.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.18.0` (v0.19.0 ready to
|
||||
push once committed).
|
||||
|
||||
## Where we are
|
||||
|
||||
v0.17.0's punch list had four candidates (A–D); two of the three
|
||||
non-packaging items shipped in this round:
|
||||
v0.18.0's resume-prompt menu (A–D) is closed:
|
||||
|
||||
- **B — "Won previously" HUD indicator:** shipped in `bdac754`.
|
||||
- **C — Replay sharing:** shipped in `540869c` ("Copy share link"
|
||||
Stats button + clipboard via `arboard`, in-memory `LastSharedReplayUrl`).
|
||||
- ~~**A — Tag v0.18.0:**~~ shipped at `bfcd05f`.
|
||||
- ~~**B — Solver-on-`AsyncComputeTaskPool` for the H-key hint:**~~
|
||||
shipped at `3e11e9e`.
|
||||
- **C — Desktop packaging:** still gated on artwork + signing
|
||||
certs. Icon export PNGs (11 sizes, 16–1024 px) sit in
|
||||
`artwork/` from the v0.18-era export; not yet wired into the
|
||||
Bevy window or assembled into `.icns` / `.ico`. App icon is
|
||||
the first natural step.
|
||||
- ~~**D — Persistent share link:**~~ shipped at `42d90b1`.
|
||||
|
||||
Item **A** (solver-on-`AsyncComputeTaskPool`) shipped *partially* in
|
||||
`d489e7a` — the winnable-only seed-selection path is now async with
|
||||
cancel-on-replace. The hint path (`H` key,
|
||||
`try_solve_with_first_move` / `try_solve_from_state`) is still
|
||||
synchronous. The proven `PendingNewGameSeed` template is the
|
||||
template for the hint port.
|
||||
The Rusty Pixel theme arc is documented as a sub-history but
|
||||
not part of v0.19.0's content:
|
||||
|
||||
Item **D** (desktop packaging) is unchanged — still gated on
|
||||
artwork + signing certs from the player.
|
||||
| Commit | Status |
|
||||
|---|---|
|
||||
| `de47511` PNG-format thumbnail support | reverted |
|
||||
| `17e3112` `pixel_art: bool` field + nearest-sampling opt-in | reverted |
|
||||
| `21ec03b` bundle Rusty Pixel as `embedded://` theme | reverted |
|
||||
| `aad8bb9` / `e41def8` / `0b3140a` reverts | landed |
|
||||
|
||||
The launch experience is also substantially different from v0.17.0:
|
||||
on first launch with a saved game the player now sees the Restore
|
||||
prompt; on every launch (after splash + restore resolution) they see
|
||||
the auto-show Home / mode picker.
|
||||
The arc remains in commit history for archaeology but the
|
||||
codebase reaches v0.19.0's HEAD identical to where it would be if
|
||||
the arc had never landed.
|
||||
|
||||
### Design direction (unchanged)
|
||||
|
||||
@@ -60,91 +61,92 @@ the auto-show Home / mode picker.
|
||||
satisfying micro-interactions.
|
||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm
|
||||
magenta secondary.
|
||||
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md`
|
||||
(machine-local).
|
||||
|
||||
### Canonical remote
|
||||
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
||||
Always push there.
|
||||
|
||||
## v0.18.0 (drafted 2026-05-06, not yet tagged)
|
||||
## v0.19.0 (2026-05-06)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
| Area | Commits | What landed |
|
||||
|---|---|---|
|
||||
| Restore prompt | `3c7a0eb` + `f863d85` | Welcome-back modal on launch when an in-progress save exists; save preserved across exits while the prompt is unanswered. |
|
||||
| Async winnable-only seeds | `d489e7a` | `PendingNewGameSeed` resource + `poll_pending_new_game_seed` running `.before(GameMutation)`. Fixes the worst-case 6 s UI stall on a New Game click. Cancel-on-replace contract covered by tests. |
|
||||
| Won-before HUD chip | `bdac754` | Reads `ReplayHistoryResource`; lights `✓ Won before` on tier-2 row when current `(seed, draw_mode, mode)` is in history. |
|
||||
| Copy share link | `540869c` | `arboard` clipboard + new Stats button + `SyncProvider::push_replay` returning the share URL. In-memory only; per-session sharing. |
|
||||
| MSSC Home picker | `ae40a1d`, `b73d246`, `9fe650f`, `40d6e0a`, `c30b04e`, `d065d49` | Header stats strip (clickable → Profile), draw-mode chips, per-mode score/streak chips, Today's Event callout on Daily, picture-tile 2-up grid with FiraMono-covered glyphs (♣ ◆ ○ ▲ →). |
|
||||
| Auto-show Home | `dd63261`, `b7c3a49`, `c497c31` | Auto-shows after splash; gated on Restore prompt; freezes timers (elapsed + Time Attack) while up. |
|
||||
| `N` opens real modal | `93660c2` | Removes the "Press N again" double-tap; routes through `ConfirmNewGameScreen`. `Shift+N` retains the bypass. |
|
||||
| Win Summary keyboard | `17e0737` | Enter dismisses + starts a fresh deal. |
|
||||
| Esc-on-modal fixes | `08b006f`, `d48b948`, `9aa0dd2` | Esc no longer opens Pause underneath the modal it just closed; Home maps Esc to Cancel; Restore maps Esc to Continue; topmost-modal-wins when Profile stacks on Home. |
|
||||
| Layout fixes | `a4bc063`, `cc63532` | Settings rows full-width with label-spacer-cluster; popover rows excluded from action-bar auto-fade. |
|
||||
| Empty-state copy | `56e2e6f` | Leaderboard / Achievements onboarding hints; volume hotkeys emit toast feedback. |
|
||||
| Test prune | `a49a340` | −43 low-value tests; future briefs request behaviour contracts only. |
|
||||
| Docs unified-3.0 | `f2f30c8` | Adopts CLAUDE.md / CLAUDE_SPEC.md / CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md; trims duplicated rule passages. |
|
||||
| Async H-key hint | `3e11e9e` | New `pending_hint.rs` module: `PendingHintTask` resource, `poll_pending_hint_task` + `drop_pending_hint_on_state_change` systems, cancel-on-replace, stale-state guard via `move_count_at_spawn`. Removes the last synchronous solver hot path. |
|
||||
| Persistent share URLs | `42d90b1` | `Replay.share_url: Option<String>` with `#[serde(default)]`. `poll_replay_upload_result` writes into `replays[0].share_url` + persists. Stats Copy button reads from selected replay. `LastSharedReplayUrl` deleted. |
|
||||
| Auto-save flake fix | `91b7605` | `test_app` clears `PendingRestoredGame(None)` after plugin build; test re-arms the timer in a bounded loop. No production-code change. |
|
||||
| Wayland support | `b57db01` | Adds `wayland` to Bevy features. winit prefers Wayland when `WAYLAND_DISPLAY` is set, falls back to X11. Native Wayland surface instead of XWayland frame. |
|
||||
| Smart default window size | `b57db01` | New `apply_smart_default_window_size` Update system queries `PrimaryMonitor` and resizes the window to ~70 % of monitor's logical size on the first frame. Skipped when saved geometry was applied. |
|
||||
| Win-celebration cleanup | `55c235b` | Drops the duplicate "You Win" toast that rendered behind the WinSummary modal. Cards-fly-off cascade kept; toast removed. |
|
||||
| Double-click reject animation | `d7ffb16` | Single-card double-clicks with no destination now play the same shake + sound as multi-card stack misses. Both priorities' failure paths converge on one `MoveRejectedEvent` write. |
|
||||
| Double-click animation dedup | `6037596` | Drops the redundant `StateChangedEvent` write in `end_drag`'s uncommitted-drag branch; previously raced an in-flight CardAnim and restarted the slide visibly. |
|
||||
|
||||
## Open punch list
|
||||
|
||||
### Carried forward from v0.17.0
|
||||
### Carried forward
|
||||
|
||||
- **Solver-on-`AsyncComputeTaskPool` for the H-key hint** —
|
||||
remaining synchronous solver hot path. The seed-selection port
|
||||
in `d489e7a` is the template: `PendingHintTask` resource, polling
|
||||
system running `.before(GameMutation)`, cancel-on-replace, fall
|
||||
back to the heuristic on inconclusive. Diff should stay scoped
|
||||
to `input_plugin.rs` plus a small `pending_hint.rs`.
|
||||
- **Desktop packaging** per `ARCHITECTURE.md §17`. Arch PKGBUILD
|
||||
exists in `/home/manage/solitaire-quest-pkgbuild/` (separate
|
||||
repo). Pending: app icon, macOS `.icns` + notarisation cert,
|
||||
Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||
- **Desktop packaging** per `ARCHITECTURE.md §17`. Eleven icon
|
||||
PNG sizes (16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 1024)
|
||||
exported via `artwork/Icon Export.html` sit in `artwork/`
|
||||
pending wiring. Pending: actual Bevy window-icon hookup,
|
||||
macOS `.icns` assembly via `iconutil`, Windows `.ico` via
|
||||
`magick convert`, Linux hicolor PNG hierarchy install,
|
||||
AppImage recipe, macOS notarisation cert, Windows
|
||||
Authenticode cert.
|
||||
|
||||
### New this round
|
||||
### Possible next-round candidates
|
||||
|
||||
- **Persistent share link.** `LastSharedReplayUrl` is in-memory only
|
||||
— the player must share within the session of the win. If
|
||||
cross-session sharing turns into a real ask, persist alongside
|
||||
the rolling replay history.
|
||||
- **Per-mode artwork.** Picture tiles use Unicode glyphs as
|
||||
placeholders chosen from FiraMono's actual coverage. When real
|
||||
artwork lands, swap each tile's `Text` node for an `Image` node
|
||||
— tile layout, focus order, click handling, and chip rendering
|
||||
are unchanged.
|
||||
- **App icon round** — wire the icon into the Bevy window via
|
||||
`Window::icon`, generate `.icns` and `.ico` from the existing
|
||||
PNGs. Half-day task; doesn't depend on signing certs.
|
||||
- **`pull_failure_sets_error_status` flake fix** — same pattern
|
||||
as the auto-save flake. Wall-clock-bounded loop instead of
|
||||
fixed 5-update budget. ~10 lines.
|
||||
- **Settings UI for "open at this size on launch"** — once the
|
||||
smart-default-size system is shipping, expose a checkbox to
|
||||
*disable* it (player who specifically wants 1280×800 every
|
||||
time). Trivial.
|
||||
- **Persistent share link URL on selector caption** — surface
|
||||
whether the currently-selected replay has a `share_url`
|
||||
populated (e.g. "Replay 3 / 8 \u{2022} Shareable") so players
|
||||
know which entries the Copy button can copy.
|
||||
|
||||
### Process notes (from this round)
|
||||
|
||||
- **Test inflation pattern (resolved this round):** older agent
|
||||
briefs reflexively asked for ≥3 tests per feature, producing 43
|
||||
low-value coverage entries on stdlib/serde-derive mechanics. Going
|
||||
forward, ask for tests that pin behaviour contracts or
|
||||
regressions on real bugs only. See
|
||||
`feedback_test_discipline.md` in auto-memory.
|
||||
- **Solver async refactor sequencing (worked this round):** rather
|
||||
than porting the whole solver-on-main-thread surface in one PR
|
||||
(the rollback case from before v0.17.0), the
|
||||
`PendingNewGameSeed` work shipped one well-bounded path with two
|
||||
tests covering the happy path and cancel-on-replace. The hint
|
||||
port should follow the same shape.
|
||||
- **Async port template (worked again):** the H-key port
|
||||
followed `d489e7a`'s `PendingNewGameSeed` shape one-to-one
|
||||
and the second async port required no new infrastructure.
|
||||
Future async ports (e.g. moving `try_solve_with_first_move`'s
|
||||
full-search variant, if it ever surfaces in the picker UI)
|
||||
should follow the same shape.
|
||||
- **Rusty Pixel reverted cleanly:** `git revert` of three
|
||||
contiguous feature commits produced a clean three-revert
|
||||
sequence with no manual conflict resolution. Bisect remains
|
||||
fast over the full v0.19.0 history because the reverts are
|
||||
individual commits, not a squash.
|
||||
- **Defensive event writes pattern:** the
|
||||
`auto_save_writes_after_30_seconds` flake AND the
|
||||
`end_drag` double-animation bug shared a root cause:
|
||||
defensive `MessageWriter` writes that originally covered an
|
||||
edge case which no longer holds, but became load-bearing
|
||||
once another system started paying attention to the event.
|
||||
Worth a periodic pass: any event write that doesn't
|
||||
correspond to a real state change is a candidate for
|
||||
removal.
|
||||
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||
Branch: master. Direction is OPEN — v0.18.0 has been drafted but
|
||||
not tagged: 24 commits past v0.17.0 cover the launch-experience
|
||||
round, MSSC Home picker, async winnable-only seeds, Won-before
|
||||
HUD, Copy share link, N-key flow rework, Esc-layering fixes, and
|
||||
the unified-3.0 Claude rule set.
|
||||
Branch: master. v0.19.0 just shipped. The next natural item is
|
||||
desktop-packaging follow-through, starting with the app icon.
|
||||
|
||||
State: HEAD at v0.17.0-24-gc497c31. Working tree clean.
|
||||
CHANGELOG.md has the v0.18.0 entry slotted under [Unreleased].
|
||||
State: HEAD at 6037596 + the v0.19.0 docs commit on top (this
|
||||
session). Tag v0.19.0 points at the docs commit.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — v0.18.0 draft entry
|
||||
2. CHANGELOG.md — [Unreleased] is empty; [0.19.0] just landed
|
||||
3. CLAUDE.md — unified-3.0 rule set
|
||||
4. CLAUDE_SPEC.md — formal architecture spec
|
||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
@@ -154,26 +156,25 @@ READ FIRST (in order, before doing anything):
|
||||
fresh machine)
|
||||
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. Tag v0.18.0 — promote `[Unreleased]` to `[0.18.0]` (already
|
||||
done in this session's draft), reverify build + clippy +
|
||||
tests, tag, push. Mechanical close-out.
|
||||
B. Solver-on-AsyncComputeTaskPool for the H-key hint, using the
|
||||
`d489e7a` seed-selection port as template. Last synchronous
|
||||
solver hot path. Smallest delta on the open punch list.
|
||||
C. Desktop packaging — needs artwork + signing certs from the
|
||||
player; can't be driven by the agent alone.
|
||||
D. Persistent share link — store the URL alongside replay
|
||||
history so cross-session sharing works.
|
||||
A. App icon — wire artwork/icon-{size}.png into Bevy's
|
||||
Window::icon, generate .icns + .ico, drop into Linux
|
||||
hicolor hierarchy. Half-day task. No cert dependency.
|
||||
B. Desktop packaging continued — AppImage recipe, .desktop
|
||||
file, install scripts. Larger task; unlocks distro
|
||||
packaging. No cert dependency.
|
||||
C. macOS / Windows signing cert acquisition — needs user
|
||||
action; agent can't drive.
|
||||
D. `pull_failure_sets_error_status` flake fix — small, well-
|
||||
scoped. Same pattern as the v0.19.0 auto-save flake fix.
|
||||
|
||||
WORKFLOW NOTES:
|
||||
- Commits use:
|
||||
git -c user.name=funman300 -c user.email=root@vscode.infinity \
|
||||
commit -m "..."
|
||||
- Use the system git config (already correct).
|
||||
- When attributing playtester feedback in commits/docs, use
|
||||
"Quat" not "Rhys" (saved feedback memory).
|
||||
- Sub-agents stage + verify only; orchestrator commits.
|
||||
- Every commit must pass build / clippy / test before pushing.
|
||||
- Push to GitHub (origin) — that is the canonical remote.
|
||||
- Push to GitHub (origin) — gh auth setup-git is already
|
||||
wired on this machine.
|
||||
|
||||
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
||||
```
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
use bevy::window::{
|
||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||
};
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
@@ -43,8 +45,10 @@ fn main() {
|
||||
|
||||
// Restore the previous window geometry if the player has one saved.
|
||||
// Otherwise open at the platform default (1280×800, centred on the
|
||||
// primary monitor). The window_geometry field is None on first run
|
||||
// and after upgrading from a build that didn't persist geometry.
|
||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||
// sessions don't end up with a comparatively tiny window.
|
||||
let had_saved_geometry = settings.window_geometry.is_some();
|
||||
let (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
(geom.width, geom.height).into(),
|
||||
@@ -141,8 +145,78 @@ fn main() {
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin)
|
||||
.run();
|
||||
.add_plugins(SplashPlugin);
|
||||
|
||||
// Smart default window sizing: when no saved geometry was loaded,
|
||||
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
||||
// monitor's logical size on the first frame. Without this, a 4K
|
||||
// monitor opens the same 1280×800 window that a 1080p monitor
|
||||
// does — visually tiny relative to screen. Skipped entirely when
|
||||
// saved geometry was applied; the player's preference always wins.
|
||||
if !had_saved_geometry {
|
||||
app.add_systems(Update, apply_smart_default_window_size);
|
||||
}
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
/// One-shot Update system that runs only on launches without saved
|
||||
/// window geometry. Resizes the primary window to a fraction of the
|
||||
/// primary monitor's *logical* size — bigger monitors get bigger
|
||||
/// windows automatically. Logical size already accounts for the OS's
|
||||
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
|
||||
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
|
||||
/// target window — same physical inches as a 1920×1080 monitor with
|
||||
/// scale_factor 1.0 yielding 1344×756.
|
||||
///
|
||||
/// Uses `Local<bool>` to make itself one-shot rather than introducing
|
||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||
/// populates the `Monitor` entities asynchronously after winit's
|
||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||
fn apply_smart_default_window_size(
|
||||
mut applied: Local<bool>,
|
||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
if *applied {
|
||||
return;
|
||||
}
|
||||
let Ok(monitor) = monitors.single() else {
|
||||
// Primary monitor not yet spawned by bevy_winit. Try again
|
||||
// next frame; the cost is one early-exit per tick until
|
||||
// monitors arrive (typically frame 1 or 2).
|
||||
return;
|
||||
};
|
||||
let Ok(mut window) = windows.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = monitor.scale_factor as f32;
|
||||
if scale <= 0.0 {
|
||||
// Defensive: a zero or negative scale factor would NaN the
|
||||
// arithmetic below. Bail and accept the default size.
|
||||
*applied = true;
|
||||
return;
|
||||
}
|
||||
let logical_w = monitor.physical_width as f32 / scale;
|
||||
let logical_h = monitor.physical_height as f32 / scale;
|
||||
|
||||
// Target 70 % of monitor in each dimension, clamped to the
|
||||
// existing 800×600 minimum and the monitor's own logical size
|
||||
// (so we never request a window larger than the screen).
|
||||
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
|
||||
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
|
||||
|
||||
// Resize only when the change is meaningful — at exactly 1280×800
|
||||
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
|
||||
// wider), worth the resize; at the same default on an 800×600
|
||||
// monitor the clamp pins us at 800×600 and we shouldn't resize.
|
||||
let curr_w = window.resolution.width();
|
||||
let curr_h = window.resolution.height();
|
||||
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
|
||||
window.resolution.set(target_w, target_h);
|
||||
}
|
||||
*applied = true;
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
|
||||
@@ -138,6 +138,15 @@ pub struct Replay {
|
||||
/// Ordered move list. Each entry is what the player did, replayable
|
||||
/// against a fresh `GameState` constructed from the seed.
|
||||
pub moves: Vec<ReplayMove>,
|
||||
/// Public share URL for this replay on the active sync backend, set
|
||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||
/// task resolves. `None` when the player won on a local-only
|
||||
/// backend, the upload failed, or the replay pre-dates v0.19.0
|
||||
/// share-link persistence. `#[serde(default)]` keeps older
|
||||
/// `replays.json` files loadable without bumping
|
||||
/// [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default)]
|
||||
pub share_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Replay {
|
||||
@@ -162,6 +171,7 @@ impl Replay {
|
||||
final_score,
|
||||
recorded_at,
|
||||
moves,
|
||||
share_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,6 +491,34 @@ mod tests {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Backwards-compat: a `Replay` record persisted before v0.19.0
|
||||
/// share-link persistence carries no `share_url` field on disk.
|
||||
/// `#[serde(default)]` must let it deserialise cleanly with
|
||||
/// `share_url == None`, so existing players don't see their
|
||||
/// rolling history wiped on the v0.19.0 update.
|
||||
#[test]
|
||||
fn replay_loads_when_share_url_field_is_absent() {
|
||||
let pre_v019_json = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2025-01-01",
|
||||
"moves": []
|
||||
}}"#,
|
||||
schema = REPLAY_SCHEMA_VERSION,
|
||||
);
|
||||
let parsed: Replay = serde_json::from_str(&pre_v019_json)
|
||||
.expect("pre-v0.19.0 replay JSON must still deserialise");
|
||||
assert!(
|
||||
parsed.share_url.is_none(),
|
||||
"missing share_url field must default to None",
|
||||
);
|
||||
}
|
||||
|
||||
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||
/// `save_latest_replay_to` returns. Mirrors the same check that
|
||||
/// guards `save_game_state_to` in `storage.rs`.
|
||||
|
||||
@@ -61,7 +61,6 @@ fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
|
||||
scaled_duration(MOTION_SLIDE_SECS, *speed)
|
||||
}
|
||||
|
||||
const WIN_TOAST_SECS: f32 = 4.0;
|
||||
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
||||
const LEVELUP_TOAST_SECS: f32 = 3.0;
|
||||
const DAILY_TOAST_SECS: f32 = 3.0;
|
||||
@@ -266,9 +265,15 @@ fn handle_win_cascade(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
) {
|
||||
let Some(ev) = events.read().next() else {
|
||||
// Drain the event reader; the cascade visual is the only thing
|
||||
// this system contributes — the post-win "You Won!" modal
|
||||
// (`win_summary_plugin`) consumes the same `GameWonEvent` and
|
||||
// carries score / time / achievements / XP itself, so a duplicate
|
||||
// toast saying "You Win! Score X Time Y" rendered behind the modal
|
||||
// in earlier builds. Removed.
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
let margin = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||
|
||||
@@ -284,11 +289,6 @@ fn handle_win_cascade(
|
||||
Vec3::new(-margin, 0.0, 300.0),
|
||||
];
|
||||
|
||||
let m = ev.time_seconds / 60;
|
||||
let s = ev.time_seconds % 60;
|
||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
||||
|
||||
let step = settings
|
||||
.as_ref()
|
||||
.map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed));
|
||||
|
||||
@@ -1264,6 +1264,14 @@ mod tests {
|
||||
// plugin's build path; clearing them keeps tests self-contained.
|
||||
app.insert_resource(GameStatePath(None));
|
||||
app.insert_resource(ReplayPath(None));
|
||||
// Force `PendingRestoredGame` empty so production saved-game
|
||||
// state on the dev machine's disk (loaded by `GamePlugin::build`)
|
||||
// can't leak into per-test world state and trip the
|
||||
// `pending.0.is_some()` guard in `auto_save_game_state` /
|
||||
// `save_game_state_on_exit`. Without this clear, an
|
||||
// unrelated `~/.local/share/solitaire_quest/game_state.json`
|
||||
// would silently disable the auto-save path under test.
|
||||
app.insert_resource(PendingRestoredGame(None));
|
||||
// Override the system-time seed with a known value.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
@@ -1516,6 +1524,16 @@ mod tests {
|
||||
}
|
||||
|
||||
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
|
||||
///
|
||||
/// The timer is pre-seeded just past the threshold and the test
|
||||
/// re-arms it before each `app.update()` in a small bounded loop:
|
||||
/// under `MinimalPlugins` the first frame's `Time::delta_secs()`
|
||||
/// can be 0.0 (or, under heavy parallel cargo-test load, large
|
||||
/// enough that the pre-seeded margin is consumed by it), so a
|
||||
/// single-frame check is fragile. Looping until the file appears
|
||||
/// (or hitting the bound) makes the test robust against
|
||||
/// first-frame Time variance without changing the underlying
|
||||
/// behaviour contract.
|
||||
#[test]
|
||||
fn auto_save_writes_after_30_seconds() {
|
||||
use solitaire_data::load_game_state_from;
|
||||
@@ -1531,10 +1549,18 @@ mod tests {
|
||||
.0
|
||||
.move_count = 1;
|
||||
|
||||
// Pre-seed the timer just past the threshold. The system will trigger
|
||||
// on the very next update() without needing to control Time::delta_secs().
|
||||
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
|
||||
app.update();
|
||||
// Re-arm the timer past the threshold every frame and pump
|
||||
// updates until the save fires. Caps at 16 iterations — a
|
||||
// healthy run hits it on the first or second frame; the cap
|
||||
// prevents an infinite loop if a future regression skips
|
||||
// the save unconditionally.
|
||||
for _ in 0..16 {
|
||||
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 1.0));
|
||||
app.update();
|
||||
if path.exists() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(path.exists(), "auto-save file must exist after timer crosses threshold");
|
||||
let loaded = load_game_state_from(&path).expect("file must be loadable");
|
||||
|
||||
@@ -84,6 +84,7 @@ impl Plugin for InputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<HintCycleIndex>()
|
||||
.init_resource::<HintSolverConfig>()
|
||||
.init_resource::<crate::pending_hint::PendingHintTask>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
@@ -109,7 +110,18 @@ impl Plugin for InputPlugin {
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(Update, handle_fullscreen)
|
||||
.add_systems(Update, reset_hint_cycle_on_state_change);
|
||||
.add_systems(Update, reset_hint_cycle_on_state_change)
|
||||
// Async hint pipeline: state-change drop runs before the
|
||||
// poll system so a move applied this frame cancels any
|
||||
// in-flight task before its result can be surfaced.
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
crate::pending_hint::drop_pending_hint_on_state_change,
|
||||
crate::pending_hint::poll_pending_hint_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,36 +231,29 @@ fn handle_keyboard_core(
|
||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||
}
|
||||
|
||||
/// Handles the H key: surface the solver's provably-best first move when
|
||||
/// the position is winnable; otherwise fall back to cycling through the
|
||||
/// heuristic hints.
|
||||
/// Handles the H key: spawn an async solver task on
|
||||
/// `AsyncComputeTaskPool` whose result `pending_hint::poll_pending_hint_task`
|
||||
/// turns into hint visuals one frame later.
|
||||
///
|
||||
/// The solver (`solitaire_core::solver::try_solve_from_state`) is run
|
||||
/// synchronously on each H press — median ~2 ms on real positions, with a
|
||||
/// hard cap from `SolverConfig::default()`'s budgets. When the verdict is
|
||||
/// `Winnable`, the returned `first_move` is shown as a single, stable hint
|
||||
/// (no cycling — the optimal move doesn't change between identical
|
||||
/// presses). When the verdict is `Unwinnable` or `Inconclusive`, the
|
||||
/// handler falls back to the legacy heuristic in `all_hints`, which still
|
||||
/// cycles through every legal move.
|
||||
/// Median solve time is ~2 ms but pathological positions can hit the
|
||||
/// `SolverConfig::default()` cap at ~120 ms; running synchronously
|
||||
/// (the v0.17.0 behaviour) blocked the main thread on the same frame
|
||||
/// the player pressed H. Cancel-on-replace lives in
|
||||
/// `PendingHintTask::spawn` — a fresh H press while a previous task
|
||||
/// is in flight drops the previous task's handle.
|
||||
///
|
||||
/// When no moves are available a "No hints available" toast is shown
|
||||
/// instead. The H key always produces a hint when any legal move exists.
|
||||
///
|
||||
/// TODO: if profiling ever shows >100 ms solver calls in practice, move
|
||||
/// the solver call to `AsyncComputeTaskPool` to keep input latency low.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Special-cases: when the game is already won, surface a "Game won!"
|
||||
/// toast instead of asking the solver. The poll system handles the
|
||||
/// "no legal moves" toast on the heuristic fallback path so the
|
||||
/// handler here only needs to dispatch.
|
||||
fn handle_keyboard_hint(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
solver_config: Res<HintSolverConfig>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
mut pending_hint: ResMut<crate::pending_hint::PendingHintTask>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -266,43 +271,37 @@ fn handle_keyboard_hint(
|
||||
|
||||
let Some(_layout_res) = layout else { return };
|
||||
|
||||
// First pass: ask the solver for the provably-best move. The
|
||||
// solver is deterministic, so repeated H presses on the same
|
||||
// position keep showing the same hint (cycling is reserved for
|
||||
// the heuristic fallback path).
|
||||
use solitaire_core::solver::{try_solve_from_state, SolverResult};
|
||||
let outcome = try_solve_from_state(&g.0, &solver_config.0);
|
||||
if outcome.result == SolverResult::Winnable
|
||||
&& let Some(mv) = outcome.first_move
|
||||
{
|
||||
let from = mv.source.clone();
|
||||
let to = mv.dest.clone();
|
||||
emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual);
|
||||
return;
|
||||
}
|
||||
pending_hint.spawn(g.0.clone(), solver_config.0);
|
||||
}
|
||||
|
||||
// Fallback: heuristic cycling hint. Used when the solver verdict
|
||||
// is `Unwinnable` (no legal winning path — but a legal *move* may
|
||||
// still exist, e.g. drawing from stock) or `Inconclusive` (budget
|
||||
// exhausted on a complex mid-game position).
|
||||
let hints = all_hints(&g.0);
|
||||
/// Heuristic hint helper used by `pending_hint::poll_pending_hint_task`
|
||||
/// when the solver returns `Inconclusive` or `Unwinnable`.
|
||||
///
|
||||
/// Picks the hint at `HintCycleIndex % hints.len()` (wrapping) and
|
||||
/// advances the index so successive H presses on a stuck position
|
||||
/// cycle through every legal move. Returns `None` when no legal move
|
||||
/// exists at all — the caller surfaces a "No hints available" toast.
|
||||
pub fn find_heuristic_hint(
|
||||
game: &GameState,
|
||||
hint_cycle: &mut HintCycleIndex,
|
||||
) -> Option<(PileType, PileType)> {
|
||||
let hints = all_hints(game);
|
||||
if hints.is_empty() {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Pick the hint at the current cycle index (wrapping) and advance.
|
||||
let idx = hint_cycle.0 % hints.len();
|
||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||
let (from, to, _count) = hints[idx].clone();
|
||||
emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual);
|
||||
Some((from, to))
|
||||
}
|
||||
|
||||
/// Apply the visual + toast effects for a single chosen hint move.
|
||||
///
|
||||
/// Shared between the solver-driven and heuristic-driven hint paths so
|
||||
/// both produce identical player-facing feedback.
|
||||
fn emit_hint_visuals(
|
||||
/// both produce identical player-facing feedback. Called from
|
||||
/// `pending_hint::poll_pending_hint_task` once the async solver task
|
||||
/// resolves.
|
||||
pub fn emit_hint_visuals(
|
||||
game: &GameState,
|
||||
from: &PileType,
|
||||
to: &PileType,
|
||||
@@ -645,10 +644,23 @@ fn end_drag(
|
||||
}
|
||||
|
||||
// If the drag was never committed (user tapped without moving far enough),
|
||||
// treat it as a click: just cancel the pending drag and resync card positions.
|
||||
// treat it as a click: cancel the pending drag and exit. We deliberately
|
||||
// do NOT fire `StateChangedEvent` here — `start_drag` only mutates the
|
||||
// `DragState` resource on press, never card transforms, so an uncommitted
|
||||
// drag has no visual side effect to undo.
|
||||
//
|
||||
// Firing one would race a CardAnim that's already in flight on the same
|
||||
// card. Specifically: on a successful double-click, `handle_double_click`
|
||||
// fires `MoveRequestEvent`, `start_drag` picks the card up the same
|
||||
// frame (uncommitted), and `handle_move` queues a `StateChangedEvent` →
|
||||
// `sync_cards_on_change` starts a slide animation. When the player
|
||||
// releases the button mid-slide, `end_drag` would fire a second
|
||||
// `StateChangedEvent`, `sync_cards_on_change` would see the card mid-
|
||||
// animation (`cur != target`), and replace the in-flight CardAnim with
|
||||
// a fresh one — restarting the slide and reading on screen as the move
|
||||
// animation playing twice.
|
||||
if !drag.committed {
|
||||
drag.clear();
|
||||
changed.write(StateChangedEvent);
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else {
|
||||
@@ -1311,32 +1323,37 @@ fn handle_double_click(
|
||||
// Priority 2: if the player clicked the base of a multi-card face-up
|
||||
// stack (card_ids.len() > 1), try moving the whole stack to another
|
||||
// tableau column.
|
||||
if card_ids.len() > 1 {
|
||||
let Some(bottom_card) = game.0.piles.get(&pile)
|
||||
.and_then(|p| p.cards.get(stack_index)) else { return };
|
||||
if let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
if card_ids.len() > 1
|
||||
&& let Some(bottom_card) = game.0.piles.get(&pile)
|
||||
.and_then(|p| p.cards.get(stack_index))
|
||||
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
bottom_card,
|
||||
&pile,
|
||||
&game.0,
|
||||
card_ids.len(),
|
||||
) {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile,
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
} else {
|
||||
// No legal destination for the stack — play the invalid-move
|
||||
// sound and shake the source pile cards as feedback.
|
||||
// `MoveRejectedEvent` with `from == to` routes the shake to
|
||||
// the source pile (which `start_shake_anim` reads from `ev.to`).
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile,
|
||||
count: card_ids.len(),
|
||||
});
|
||||
}
|
||||
)
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile,
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Both priorities failed — play the invalid-move sound and shake
|
||||
// the source pile as feedback. `MoveRejectedEvent` with
|
||||
// `from == to` routes the shake to the source pile (which
|
||||
// `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
|
||||
// only fired for multi-card stacks, so a double-click on a
|
||||
// single card with no legal destination did nothing — no
|
||||
// sound, no shake. Now both single-card and stack misses get
|
||||
// the same feedback.
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile,
|
||||
count: card_ids.len(),
|
||||
});
|
||||
} else {
|
||||
// Single click — record the time.
|
||||
last_click.insert(top_card_id, now);
|
||||
@@ -2149,191 +2166,50 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hint system — solver promotion (v0.16.0+)
|
||||
// Hint system — async port (v0.18.0+)
|
||||
//
|
||||
// The H-key hint is now backed by `solitaire_core::solver::try_solve_from_state`.
|
||||
// When the solver proves the position winnable, the hint is the
|
||||
// first move on the solver's solution path. When the solver returns
|
||||
// Inconclusive (budget exhausted) or Unwinnable, the legacy
|
||||
// heuristic in `all_hints` supplies the hint instead so the H key
|
||||
// always produces feedback while any legal move exists.
|
||||
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||
// system in `pending_hint` turns into hint visuals one frame
|
||||
// later. The behaviour contract this section pins is "pressing H
|
||||
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||
// covered end-to-end in `pending_hint::tests`.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal Bevy app that registers only the resources and
|
||||
/// messages needed to drive `handle_keyboard_hint` end-to-end.
|
||||
/// Skips every other input system — the test only exercises the hint
|
||||
/// path and we want the assertions to be unaffected by other handlers.
|
||||
fn hint_test_app() -> App {
|
||||
/// Pressing H on a non-paused, non-won game with a live
|
||||
/// `GameStateResource` + `LayoutResource` must populate
|
||||
/// `PendingHintTask`. The polling system, exercised in
|
||||
/// `pending_hint::tests`, drives the result to a visual event.
|
||||
#[test]
|
||||
fn pressing_h_spawns_pending_hint_task() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<InfoToastEvent>();
|
||||
app.add_message::<HintVisualEvent>();
|
||||
app.init_resource::<HintCycleIndex>();
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
// Layout: a fixed 1280x800 layout — `handle_keyboard_hint` only
|
||||
// checks the resource is present, never reads coordinates.
|
||||
app.insert_resource(crate::layout::LayoutResource(
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
app
|
||||
}
|
||||
|
||||
/// Helper: simulate "the player just pressed H this frame".
|
||||
fn press_h(app: &mut App) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
}
|
||||
|
||||
/// Build a near-finished `GameState`: foundations hold A..Q for each
|
||||
/// suit, four Kings sit on tableau columns 0..3, stock and waste
|
||||
/// empty. Solver-side equivalent of the `near_finished_game_state`
|
||||
/// helper in `solitaire_core::solver::tests`.
|
||||
fn near_finished_game_state() -> GameState {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
// Simulate the H key being pressed this frame.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
}
|
||||
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 suit_for_slot = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let ranks_below_king = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
Rank::Jack, Rank::Queen,
|
||||
];
|
||||
for (slot, suit) in suit_for_slot.iter().enumerate() {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Foundation(slot as u8))
|
||||
.unwrap();
|
||||
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||
pile.cards.push(Card {
|
||||
id: (slot as u32) * 13 + i as u32,
|
||||
suit: *suit,
|
||||
rank: *rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (col, suit) in suit_for_slot.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
|
||||
}
|
||||
|
||||
/// When the solver verdict is Winnable, the hint must come from the
|
||||
/// solver: in our near-finished fixture, four Tableau→Foundation
|
||||
/// moves are legal and the solver returns one of them. The
|
||||
/// `HintVisualEvent` source card must be one of the four Kings and
|
||||
/// the destination must be a foundation slot.
|
||||
#[test]
|
||||
fn hint_uses_solver_when_winnable() {
|
||||
use solitaire_core::card::Rank;
|
||||
let mut app = hint_test_app();
|
||||
let game = near_finished_game_state();
|
||||
// Track the 4 King ids so we can assert the hint source matches.
|
||||
let king_ids: Vec<u32> = (0..4_u8)
|
||||
.map(|c| {
|
||||
game.piles
|
||||
.get(&PileType::Tableau(c as usize))
|
||||
.unwrap()
|
||||
.cards
|
||||
.last()
|
||||
.filter(|c| c.rank == Rank::King)
|
||||
.map(|c| c.id)
|
||||
.expect("each tableau col 0..3 has a King on top")
|
||||
})
|
||||
.collect();
|
||||
|
||||
app.insert_resource(GameStateResource(game));
|
||||
press_h(&mut app);
|
||||
app.update();
|
||||
|
||||
// Read out the messages via the standard cursor API.
|
||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||
assert_eq!(
|
||||
collected.len(), 1,
|
||||
"exactly one HintVisualEvent must fire on a winnable solver verdict"
|
||||
);
|
||||
let event = &collected[0];
|
||||
assert!(
|
||||
king_ids.contains(&event.source_card_id),
|
||||
"solver hint must point at one of the four Kings; got id {}",
|
||||
event.source_card_id
|
||||
);
|
||||
assert!(
|
||||
matches!(event.dest_pile, PileType::Foundation(_)),
|
||||
"solver hint destination must be a foundation slot; got {:?}",
|
||||
event.dest_pile
|
||||
);
|
||||
}
|
||||
|
||||
/// When the solver returns Inconclusive (e.g. tight budgets force an
|
||||
/// early bail), the heuristic fallback must still produce a hint
|
||||
/// event so the H key never feels broken.
|
||||
///
|
||||
/// We force the solver inconclusive by setting both budgets to 0 —
|
||||
/// the search bails on the very first iteration, returning
|
||||
/// `SolverResult::Inconclusive`. The heuristic fallback then runs on
|
||||
/// the fresh deal and finds at least one legal move.
|
||||
#[test]
|
||||
fn hint_falls_back_to_heuristic_when_solver_inconclusive() {
|
||||
use solitaire_core::solver::SolverConfig;
|
||||
let mut app = hint_test_app();
|
||||
// Force solver to bail before exploring anything.
|
||||
app.insert_resource(HintSolverConfig(SolverConfig {
|
||||
move_budget: 0,
|
||||
state_budget: 0,
|
||||
}));
|
||||
// A fresh seeded deal — guaranteed to have at least one legal
|
||||
// move (the standard Klondike opening always has draws available
|
||||
// even if no immediate tableau move exists).
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
app.insert_resource(GameStateResource(game));
|
||||
press_h(&mut app);
|
||||
app.update();
|
||||
|
||||
let world = app.world();
|
||||
let visuals = world.resource::<Messages<HintVisualEvent>>();
|
||||
let mut visual_cursor = visuals.get_cursor();
|
||||
let collected: Vec<HintVisualEvent> = visual_cursor.read(visuals).cloned().collect();
|
||||
// Either a card-move hint (most fresh deals) or a draw suggestion.
|
||||
// A draw suggestion fires no `HintVisualEvent` (only an
|
||||
// `InfoToastEvent`), so we accept zero-or-one HintVisualEvent so
|
||||
// long as at least one feedback signal was emitted overall.
|
||||
let toasts = world.resource::<Messages<InfoToastEvent>>();
|
||||
let mut toast_cursor = toasts.get_cursor();
|
||||
let toast_count = toast_cursor.read(toasts).count();
|
||||
assert!(
|
||||
!collected.is_empty() || toast_count > 0,
|
||||
"heuristic fallback must produce a hint signal (visual or toast)"
|
||||
app.world()
|
||||
.resource::<crate::pending_hint::PendingHintTask>()
|
||||
.is_pending(),
|
||||
"pressing H must spawn an async hint task",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod input_plugin;
|
||||
pub mod layout;
|
||||
pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
pub mod pending_hint;
|
||||
pub mod profile_plugin;
|
||||
pub mod radial_menu;
|
||||
pub mod replay_overlay;
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in
|
||||
//! `game_plugin`.
|
||||
//!
|
||||
//! The synchronous version (v0.17.0) called
|
||||
//! `solitaire_core::solver::try_solve_from_state` on the main thread on
|
||||
//! every H press. Median latency was ~2 ms but pathological positions
|
||||
//! can hit the `SolverConfig::default()` cap at ~120 ms, which is a
|
||||
//! noticeable input-stall on the same frame the player sees the hint
|
||||
//! request.
|
||||
//!
|
||||
//! This module hosts the resource and polling system that move the
|
||||
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
|
||||
//! (input_plugin) becomes a thin spawn point: snapshot the state,
|
||||
//! spawn the task, store the handle. The polling system takes the
|
||||
//! result one frame later and surfaces the hint visuals via the
|
||||
//! shared `emit_hint_visuals` helper.
|
||||
//!
|
||||
//! Cancel-on-replace: a fresh H press while a previous task is in
|
||||
//! flight drops the previous task. Bevy's `Task` `Drop` cancels
|
||||
//! cooperatively at the next await point.
|
||||
//!
|
||||
//! Stale-state drop: any `StateChangedEvent` (move applied, undo,
|
||||
//! new game) drops the in-flight task — the position the solver was
|
||||
//! reasoning about no longer exists, and surfacing a hint for the
|
||||
//! old state would be confusing.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::solver::{try_solve_from_state, SolverConfig, SolverResult};
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
|
||||
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint};
|
||||
use crate::resources::{GameStateResource, HintCycleIndex};
|
||||
|
||||
/// In-flight async work for the H-key hint.
|
||||
///
|
||||
/// `handle_keyboard_hint` writes here when the player presses H;
|
||||
/// `poll_pending_hint_task` reads from here, polls the task, and
|
||||
/// emits the hint visuals once the task completes. At most one task
|
||||
/// is ever in flight: a fresh H press while a previous task is
|
||||
/// running drops the previous task and queues the new one.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PendingHintTask {
|
||||
/// `Some` while the solver is still working on a verdict.
|
||||
inner: Option<HintTask>,
|
||||
}
|
||||
|
||||
impl PendingHintTask {
|
||||
/// Whether a hint task is currently in flight.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.inner.is_some()
|
||||
}
|
||||
|
||||
/// Drop any in-flight task. Bevy's `Task` `Drop` cancels the
|
||||
/// underlying future cooperatively at the next await point.
|
||||
pub fn cancel(&mut self) {
|
||||
self.inner = None;
|
||||
}
|
||||
|
||||
/// Spawn a new solver task for `state` with `config`. Drops any
|
||||
/// previously in-flight task first (cancel-on-replace).
|
||||
pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
|
||||
let move_count_at_spawn = state.move_count;
|
||||
let handle = AsyncComputeTaskPool::get().spawn(async move {
|
||||
let outcome = try_solve_from_state(&state, &config);
|
||||
match outcome.result {
|
||||
SolverResult::Winnable => outcome
|
||||
.first_move
|
||||
.map(|mv| HintTaskOutput::SolverMove {
|
||||
from: mv.source,
|
||||
to: mv.dest,
|
||||
})
|
||||
.unwrap_or(HintTaskOutput::NeedsHeuristic),
|
||||
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||
HintTaskOutput::NeedsHeuristic
|
||||
}
|
||||
}
|
||||
});
|
||||
self.inner = Some(HintTask {
|
||||
handle,
|
||||
move_count_at_spawn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One in-flight hint search plus the snapshot data needed to detect
|
||||
/// a stale result if the live state moved while the solver ran.
|
||||
struct HintTask {
|
||||
handle: Task<HintTaskOutput>,
|
||||
/// `GameState.move_count` at spawn time. The poll system discards
|
||||
/// the result if the live move_count has advanced — the player
|
||||
/// applied a move while the solver ran, so the hint would be
|
||||
/// stale even if the StateChangedEvent drop didn't fire first.
|
||||
move_count_at_spawn: u32,
|
||||
}
|
||||
|
||||
/// What the solver task carries back to the main thread.
|
||||
enum HintTaskOutput {
|
||||
/// Solver verdict was `Winnable`; here is the first move on the
|
||||
/// solution path.
|
||||
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.
|
||||
NeedsHeuristic,
|
||||
}
|
||||
|
||||
/// Drop the in-flight hint task whenever the live `GameState` shifts.
|
||||
///
|
||||
/// The position the solver was reasoning about no longer matches the
|
||||
/// live state, so its result would be stale. Mirrors the semantics
|
||||
/// of `reset_hint_cycle_on_state_change` for `HintCycleIndex`.
|
||||
pub fn drop_pending_hint_on_state_change(
|
||||
mut state_events: MessageReader<StateChangedEvent>,
|
||||
mut pending: ResMut<PendingHintTask>,
|
||||
) {
|
||||
if state_events.read().next().is_some() {
|
||||
pending.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the in-flight hint solver task. When the task resolves, run
|
||||
/// `emit_hint_visuals` on the result — either the solver's
|
||||
/// provably-best first move (Winnable verdict) or a heuristic hint
|
||||
/// over the live state (Unwinnable / Inconclusive).
|
||||
///
|
||||
/// Discards the result when `GameState.move_count` has moved past the
|
||||
/// snapshot taken at spawn time — the player applied a move during
|
||||
/// the solve and `drop_pending_hint_on_state_change` should have
|
||||
/// already cleared the resource, but we double-check here for the
|
||||
/// rare case where the solver task completed in the same frame the
|
||||
/// move was applied.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn poll_pending_hint_task(
|
||||
mut pending: ResMut<PendingHintTask>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
let Some(p) = pending.inner.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(output) = future::block_on(future::poll_once(&mut p.handle)) else {
|
||||
return;
|
||||
};
|
||||
let move_count_at_spawn = p.move_count_at_spawn;
|
||||
pending.inner = None;
|
||||
|
||||
let Some(g) = game else { return };
|
||||
if g.0.move_count != move_count_at_spawn {
|
||||
return;
|
||||
}
|
||||
|
||||
let (from, to) = match output {
|
||||
HintTaskOutput::SolverMove { from, to } => (from, to),
|
||||
HintTaskOutput::NeedsHeuristic => {
|
||||
match find_heuristic_hint(&g.0, &mut hint_cycle) {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
emit_hint_visuals(
|
||||
&g.0,
|
||||
&from,
|
||||
&to,
|
||||
&mut commands,
|
||||
card_entities,
|
||||
&mut info_toast,
|
||||
&mut hint_visual,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::HintVisualEvent;
|
||||
use crate::input_plugin::HintSolverConfig;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
/// Build a minimal Bevy app exercising only the polling system
|
||||
/// and the resources/messages it touches.
|
||||
fn pending_hint_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<InfoToastEvent>();
|
||||
app.add_message::<HintVisualEvent>();
|
||||
app.add_message::<StateChangedEvent>();
|
||||
app.init_resource::<HintCycleIndex>();
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<PendingHintTask>();
|
||||
// Chain the drop-on-state-change system before the poll
|
||||
// system, mirroring how `InputPlugin::build` wires them.
|
||||
// Without this, system order is unspecified and the
|
||||
// state_change_drops_in_flight_task test sometimes sees the
|
||||
// poll fire before the drop.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
drop_pending_hint_on_state_change,
|
||||
poll_pending_hint_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
/// Same near-finished fixture used by the v0.17 hint tests:
|
||||
/// foundations hold A..Q for each suit, four Kings sit on
|
||||
/// tableau columns 0..3, stock and waste empty.
|
||||
fn near_finished_state() -> GameState {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
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, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
Rank::Jack, Rank::Queen,
|
||||
];
|
||||
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() {
|
||||
pile.cards.push(Card {
|
||||
id: (slot as u32) * 13 + i as u32,
|
||||
suit: *suit,
|
||||
rank: *rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// Spawning a task and pumping update() until it completes must
|
||||
/// emit a HintVisualEvent. Mirrors the `winnable_seed_search_*`
|
||||
/// pattern in game_plugin tests — drives a wall-clock-bounded
|
||||
/// loop so the shared AsyncComputeTaskPool can schedule the
|
||||
/// future under cargo-test parallelism.
|
||||
#[test]
|
||||
fn winnable_solver_emits_hint_after_async_completes() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"hint task should have completed within 15 s wall-clock",
|
||||
);
|
||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||
assert_eq!(
|
||||
collected.len(), 1,
|
||||
"exactly one HintVisualEvent must fire when the solver returns Winnable",
|
||||
);
|
||||
assert!(
|
||||
matches!(collected[0].dest_pile, PileType::Foundation(_)),
|
||||
"solver hint destination must be a foundation slot; got {:?}",
|
||||
collected[0].dest_pile,
|
||||
);
|
||||
}
|
||||
|
||||
/// A StateChangedEvent fired while the task is in flight must
|
||||
/// drop the task; the polling system must not emit any visuals
|
||||
/// once the result eventually arrives.
|
||||
#[test]
|
||||
fn state_change_drops_in_flight_task() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
assert!(
|
||||
app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"task is in flight after spawn",
|
||||
);
|
||||
|
||||
// Fire a StateChangedEvent before draining the task. The
|
||||
// drop-on-state-change system runs in the same Update tick
|
||||
// and clears the resource.
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
!app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"StateChangedEvent must drop the in-flight hint task",
|
||||
);
|
||||
// No HintVisualEvent should ever have fired.
|
||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
assert_eq!(
|
||||
cursor.read(messages).count(),
|
||||
0,
|
||||
"dropped hint task must not emit any visuals",
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel-on-replace: spawning a fresh task while a previous one
|
||||
/// is in flight must drop the previous task. Only the second
|
||||
/// spawn's result is allowed to surface.
|
||||
#[test]
|
||||
fn second_spawn_drops_first_in_flight_task() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
|
||||
// First spawn.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||
assert!(first_handle_present);
|
||||
|
||||
// Second spawn. The `spawn` helper drops the prior task
|
||||
// before assigning the new one — at no point are two tasks
|
||||
// in flight.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
// Resource still pending (the second task), but the first
|
||||
// is gone. We can't directly observe the first handle once
|
||||
// it's been overwritten — what we *can* assert is that the
|
||||
// resource still holds a single task, and that task
|
||||
// eventually completes producing exactly one hint visual.
|
||||
assert!(app.world().resource::<PendingHintTask>().is_pending());
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"second hint task should have completed within 15 s wall-clock",
|
||||
);
|
||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||
assert_eq!(
|
||||
collected.len(), 1,
|
||||
"cancel-on-replace: only the surviving task's result emits a visual",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -74,23 +74,13 @@ pub struct StatsCell;
|
||||
#[derive(Resource, Debug, Default, Clone)]
|
||||
pub struct ReplayHistoryResource(pub ReplayHistory);
|
||||
|
||||
/// Most recent shareable replay URL written by `sync_plugin` after the
|
||||
/// `SyncProvider::push_replay` task completes successfully. `None`
|
||||
/// until the player wins a game on a server-backed sync backend;
|
||||
/// repopulated on each subsequent win.
|
||||
///
|
||||
/// The Stats overlay's "Copy share link" button reads from here and
|
||||
/// writes the URL to the OS clipboard via `arboard`. Not persisted to
|
||||
/// disk — the URL is recoverable by re-uploading the same replay
|
||||
/// (still in `replays.json`), so the session-bound lifetime is fine
|
||||
/// for a v1 share affordance.
|
||||
#[derive(Resource, Debug, Default, Clone)]
|
||||
pub struct LastSharedReplayUrl(pub Option<String>);
|
||||
|
||||
/// Marker on the "Copy share link" button inside the Stats modal.
|
||||
/// Click writes [`LastSharedReplayUrl`] to the OS clipboard via
|
||||
/// `arboard` and surfaces a confirmation toast. Hidden / disabled
|
||||
/// when no shareable URL is available.
|
||||
/// Click reads the share URL from the currently-selected replay
|
||||
/// (`history.0.replays[selected.0].share_url`) and writes it to the
|
||||
/// OS clipboard via `arboard`, surfacing a confirmation toast. The
|
||||
/// share URL is populated by `sync_plugin::poll_replay_upload_result`
|
||||
/// when the corresponding win's upload completes and is persisted to
|
||||
/// `replays.json` so it survives a restart.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CopyShareLinkButton;
|
||||
|
||||
@@ -195,7 +185,6 @@ impl Plugin for StatsPlugin {
|
||||
.insert_resource(ReplayHistoryResource(initial_history))
|
||||
.init_resource::<SelectedReplayIndex>()
|
||||
.insert_resource(LatestReplayPath(replay_path))
|
||||
.init_resource::<LastSharedReplayUrl>()
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
@@ -299,24 +288,32 @@ fn refresh_replay_history_on_win(
|
||||
/// resets the live game to the recorded deal and ticks through the
|
||||
/// move list via [`crate::replay_playback`]; the
|
||||
/// [`crate::replay_overlay`] banner surfaces while playback runs.
|
||||
/// Copies [`LastSharedReplayUrl`] to the OS clipboard via `arboard`
|
||||
/// and surfaces a confirmation toast. When no URL is in hand (no win
|
||||
/// yet on a server-backed sync backend, local-only mode, or upload
|
||||
/// failed) the button still acknowledges the click but explains why
|
||||
/// the clipboard wasn't written. `arboard::Clipboard::new()` failures
|
||||
/// are logged + surfaced as a generic "couldn't reach the clipboard"
|
||||
/// toast rather than swallowed — they're rare but worth diagnosing.
|
||||
/// Copies the currently-selected replay's `share_url` to the OS
|
||||
/// clipboard via `arboard` and surfaces a confirmation toast. When no
|
||||
/// URL is in hand on the selected entry (replay never uploaded — the
|
||||
/// player won on a local-only backend, the upload failed, or the
|
||||
/// replay pre-dates v0.19.0 share-link persistence) the button still
|
||||
/// acknowledges the click but explains why the clipboard wasn't
|
||||
/// written. `arboard::Clipboard::new()` failures are logged + surfaced
|
||||
/// as a generic "couldn't reach the clipboard" toast rather than
|
||||
/// swallowed — they're rare but worth diagnosing.
|
||||
fn handle_copy_share_link_button(
|
||||
buttons: Query<&Interaction, (With<CopyShareLinkButton>, Changed<Interaction>)>,
|
||||
last_url: Res<LastSharedReplayUrl>,
|
||||
history: Res<ReplayHistoryResource>,
|
||||
selected: Res<SelectedReplayIndex>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
let Some(url) = last_url.0.as_ref() else {
|
||||
let Some(url) = history
|
||||
.0
|
||||
.replays
|
||||
.get(selected.0)
|
||||
.and_then(|r| r.share_url.as_ref())
|
||||
else {
|
||||
toast.write(InfoToastEvent(
|
||||
"No share link yet \u{2014} win a game on a server-backed sync to upload one.".to_string(),
|
||||
"No share link for this replay \u{2014} win a game on a server-backed sync to upload one.".to_string(),
|
||||
));
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -19,8 +19,8 @@ use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use solitaire_data::{
|
||||
save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress,
|
||||
Replay, StatsSnapshot, SyncError, SyncProvider,
|
||||
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
|
||||
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
|
||||
};
|
||||
use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
||||
|
||||
@@ -29,7 +29,7 @@ use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent};
|
||||
use crate::game_plugin::RecordingReplay;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
|
||||
use crate::stats_plugin::{LastSharedReplayUrl, StatsResource, StatsStoragePath};
|
||||
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public resources
|
||||
@@ -324,14 +324,18 @@ fn push_replay_on_win(
|
||||
}
|
||||
|
||||
/// Update-schedule system: harvests the upload task's result on the
|
||||
/// main thread once it resolves. On success writes the share URL to
|
||||
/// [`LastSharedReplayUrl`] so the Stats overlay's Copy button has
|
||||
/// something to send to the clipboard. On `UnsupportedPlatform` (the
|
||||
/// `LocalOnlyProvider` no-op path) clears the URL silently. Real
|
||||
/// network / auth errors log a warn and clear the URL.
|
||||
/// main thread once it resolves. On success writes the share URL into
|
||||
/// the most-recent entry of [`ReplayHistoryResource`] (`replays[0]`,
|
||||
/// guaranteed by `record_replay_on_win` to be the win this upload
|
||||
/// covers, since `cancel-on-replace` in `push_replay_on_win` drops any
|
||||
/// older in-flight task) and persists the updated history to disk so
|
||||
/// the URL survives a restart. `UnsupportedPlatform` (the
|
||||
/// `LocalOnlyProvider` no-op path) is silently absorbed; real network
|
||||
/// / auth errors log a warn but never clobber an existing URL.
|
||||
fn poll_replay_upload_result(
|
||||
mut pending: ResMut<PendingReplayUpload>,
|
||||
mut last_url: ResMut<LastSharedReplayUrl>,
|
||||
mut history: ResMut<ReplayHistoryResource>,
|
||||
replay_path: Res<LatestReplayPath>,
|
||||
) {
|
||||
let Some(task) = pending.0.as_mut() else {
|
||||
return;
|
||||
@@ -340,13 +344,25 @@ fn poll_replay_upload_result(
|
||||
return;
|
||||
};
|
||||
pending.0 = None;
|
||||
match result {
|
||||
Ok(url) => last_url.0 = Some(url),
|
||||
Err(SyncError::UnsupportedPlatform) => last_url.0 = None,
|
||||
let url = match result {
|
||||
Ok(url) => url,
|
||||
Err(SyncError::UnsupportedPlatform) => return,
|
||||
Err(e) => {
|
||||
warn!("replay upload failed: {e}");
|
||||
last_url.0 = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(entry) = history.0.replays.first_mut() else {
|
||||
// Defensive: `push_replay_on_win` only fires after a win, so a
|
||||
// missing replays[0] means another system cleared the history
|
||||
// mid-upload. Drop the URL silently rather than panicking.
|
||||
return;
|
||||
};
|
||||
entry.share_url = Some(url);
|
||||
if let Some(path) = replay_path.0.as_deref()
|
||||
&& let Err(e) = save_replay_history_to(path, &history.0)
|
||||
{
|
||||
warn!("failed to persist share URL into replay history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,4 +530,87 @@ mod tests {
|
||||
let payload = build_payload(&stats, &[], &PlayerProgress::default());
|
||||
assert_eq!(payload.stats.games_played, 42);
|
||||
}
|
||||
|
||||
/// `poll_replay_upload_result` must write the resolved share URL
|
||||
/// into `replays[0].share_url` AND persist the updated history to
|
||||
/// disk so the URL survives a restart. Pins v0.19.0's persistent
|
||||
/// share-link contract — the v0.18.0 ephemeral
|
||||
/// `LastSharedReplayUrl` resource is gone, so a regression here
|
||||
/// would silently drop the link.
|
||||
#[test]
|
||||
fn upload_result_writes_share_url_into_replay_and_persists() {
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_data::{
|
||||
load_replay_history_from, save_replay_history_to, Replay, ReplayHistory,
|
||||
};
|
||||
|
||||
let mut app = headless_app_with(NoOpProvider);
|
||||
let path = std::env::temp_dir()
|
||||
.join("solitaire_test_replay_share_url_persist.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Seed the in-memory history with a single replay carrying no
|
||||
// share_url — the upload-poll path must populate it.
|
||||
let initial = Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
500,
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
|
||||
vec![],
|
||||
);
|
||||
let history = ReplayHistory {
|
||||
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![initial],
|
||||
};
|
||||
save_replay_history_to(&path, &history).expect("seed history on disk");
|
||||
app.insert_resource(crate::stats_plugin::ReplayHistoryResource(history));
|
||||
app.insert_resource(crate::stats_plugin::LatestReplayPath(Some(path.clone())));
|
||||
|
||||
// Pre-resolved task carrying the URL the production path would
|
||||
// get back from the server.
|
||||
let url = "https://example.test/replays/abc123".to_string();
|
||||
let task = AsyncComputeTaskPool::get().spawn({
|
||||
let url = url.clone();
|
||||
async move { Ok::<String, SyncError>(url) }
|
||||
});
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingReplayUpload>()
|
||||
.0 = Some(task);
|
||||
|
||||
// Pump frames until the polling system observes the task as
|
||||
// ready and clears `PendingReplayUpload`.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingReplayUpload>().0.is_some() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
app.world().resource::<PendingReplayUpload>().0.is_none(),
|
||||
"upload task should have been consumed within 15 s wall-clock",
|
||||
);
|
||||
|
||||
// In-memory contract: replays[0].share_url is now Some(url).
|
||||
let live = app
|
||||
.world()
|
||||
.resource::<crate::stats_plugin::ReplayHistoryResource>();
|
||||
assert_eq!(
|
||||
live.0.replays.first().and_then(|r| r.share_url.clone()),
|
||||
Some(url.clone()),
|
||||
"share URL must be written into replays[0].share_url",
|
||||
);
|
||||
// Persistence contract: a fresh load picks up the same URL.
|
||||
let on_disk = load_replay_history_from(&path).expect("history must reload");
|
||||
assert_eq!(
|
||||
on_disk.replays.first().and_then(|r| r.share_url.clone()),
|
||||
Some(url),
|
||||
"share URL must survive a save/load round-trip",
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user