Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2a021712 | |||
| 6037596cc0 | |||
| d7ffb16df5 | |||
| b57db017d3 | |||
| 0b3140ad6d | |||
| e41def8c89 | |||
| aad8bb9c83 | |||
| 55c235b55f | |||
| 21ec03b157 | |||
| 17e3112502 | |||
| de4751115f | |||
| 9ff48ace5b | |||
| 91b7605b9f | |||
| 42d90b199c | |||
| 3e11e9e79a | |||
| bfcd05fbb5 | |||
| c497c3193c | |||
| 9aa0dd23b1 | |||
| d065d49fe7 | |||
| c30b04ec72 | |||
| 40d6e0ab17 | |||
| 9fe650fa20 | |||
| b73d246b4c | |||
| ae40a1db7a | |||
| b7c3a4996f | |||
| d48b9489db | |||
| 08b006ff30 | |||
| 17e0737a10 | |||
| dd63261999 | |||
| 93660c2217 | |||
| 56e2e6f151 | |||
| cc635328be | |||
| a4bc063497 | |||
| 540869c851 | |||
| bdac754b26 | |||
| f863d85c35 | |||
| 3c7a0eb4fb | |||
| d489e7a31b | |||
| f2f30c8002 | |||
| a49a340a30 | |||
| 27cdf78ce0 | |||
| faa6c5efc4 | |||
| 487b99bbc9 | |||
| 53e3b816cf | |||
| 87275bf340 | |||
| 56647d7f0d | |||
| cbf2483028 | |||
| a54201e97b | |||
| 48e412177c | |||
| cd54ce1bb0 | |||
| 7a3032b74c | |||
| 89699a8a86 | |||
| 70165da103 | |||
| 8a5fa8751c | |||
| bf660df971 | |||
| 13a8a012ee | |||
| 02ababa65f | |||
| 9c36b49729 | |||
| 8e90574437 | |||
| 95fcdad5d2 | |||
| d948fa862a | |||
| 1fcd032b0a | |||
| 3081505a3d | |||
| 07b8ecd9b2 | |||
| 5bed43ef32 | |||
| 23c9704887 | |||
| 93182fa251 | |||
| 89c51ab712 | |||
| 3984231c9b | |||
| d9f36bf34a | |||
| 57d1c58fdf | |||
| 42535f5109 | |||
| d5e6f8026b | |||
| 271647265c | |||
| 3eabc149a8 | |||
| f1aeb24157 | |||
| 000143231b | |||
| 1a1047664b | |||
| ba527de351 | |||
| fe41b502ac | |||
| b37f0cbec7 | |||
| a0fc0d2605 | |||
| 7ed4f2cba9 | |||
| ddc8f27c82 | |||
| 13dd44bd1b | |||
| 17f9b518f1 | |||
| 61d891fb76 | |||
| 7dba772e67 | |||
| ca5788f714 | |||
| 9887343d8b | |||
| 525fe0fe76 | |||
| 69ce9afab9 | |||
| 13aa0fd833 | |||
| 9f095c4039 | |||
| d8c70341f4 |
@@ -1,31 +0,0 @@
|
||||
# Project-wide cargo configuration.
|
||||
#
|
||||
# Routes every rustc invocation through `sccache` so cold rebuilds and
|
||||
# fresh checkouts (CI, new dev box, after a `cargo clean`) replay
|
||||
# previously-compiled crates from a local on-disk cache rather than
|
||||
# recompiling them. Warm incremental builds still go through cargo's
|
||||
# own `target/` cache, which dominates locally — sccache buys you the
|
||||
# big wins on cold paths.
|
||||
#
|
||||
# Requires sccache on PATH. Install it once per machine:
|
||||
#
|
||||
# Arch : pacman -S sccache
|
||||
# macOS : brew install sccache
|
||||
# Cargo : cargo install sccache --locked
|
||||
#
|
||||
# Without sccache the build fails with "rustc-wrapper not found". To
|
||||
# bypass this config without editing the file, prepend
|
||||
# `RUSTC_WRAPPER= ` (empty value) to your cargo command:
|
||||
#
|
||||
# RUSTC_WRAPPER= cargo build
|
||||
#
|
||||
[build]
|
||||
rustc-wrapper = "sccache"
|
||||
|
||||
# Project-local cache so the shared dev box (or a Docker volume) keeps
|
||||
# the artefacts isolated per checkout instead of mixing them in
|
||||
# `~/.cache/sccache`. Set with `force = false` so a developer-set
|
||||
# `SCCACHE_DIR` in their shell wins — important because the sccache
|
||||
# daemon, once started, sticks with whichever directory it saw first.
|
||||
[env]
|
||||
SCCACHE_DIR = { value = ".sccache-cache", relative = true, force = false }
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT\n r.id AS \"id!: String\",\n u.username AS \"username!: String\",\n r.seed AS \"seed!: i64\",\n r.draw_mode AS \"draw_mode!: String\",\n r.mode AS \"mode!: String\",\n r.time_seconds AS \"time_seconds!: i64\",\n r.final_score AS \"final_score!: i64\",\n r.recorded_at AS \"recorded_at!: String\",\n r.received_at AS \"received_at!: String\"\n FROM replays r\n JOIN users u ON u.id = r.user_id\n ORDER BY r.received_at DESC\n LIMIT ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "username!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "seed!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "draw_mode!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mode!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "time_seconds!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "final_score!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "recorded_at!: String",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "received_at!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT replay_json FROM replays WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "replay_json",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO replays (\n id, user_id, seed, draw_mode, mode, time_seconds, final_score,\n recorded_at, received_at, replay_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f"
|
||||
}
|
||||
+5
-3
@@ -47,11 +47,10 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
|
||||
### Design Principles
|
||||
|
||||
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
|
||||
- **Pure core.** All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
|
||||
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
|
||||
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
|
||||
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
||||
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
|
||||
|
||||
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
|
||||
|
||||
---
|
||||
|
||||
@@ -716,11 +715,14 @@ pub struct AchievementDef {
|
||||
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
|
||||
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
|
||||
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
|
||||
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
|
||||
|
||||
### Evaluation Timing
|
||||
|
||||
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
|
||||
|
||||
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
|
||||
|
||||
---
|
||||
|
||||
## 12. Progression System
|
||||
|
||||
+883
@@ -0,0 +1,883 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Solitaire Quest are documented here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
||||
project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_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
|
||||
silent default Classic deal whether they had unfinished work or not;
|
||||
v0.18.0 replaces that with two stacked decision points — a Restore
|
||||
prompt for in-progress saves, then an MSSC-style Home / mode picker
|
||||
that surfaces Daily / Zen / Challenge / Time Attack as picture tiles
|
||||
with live stats. The same round closes the last solver-on-main-thread
|
||||
hot path (winnable-only seed selection moves to
|
||||
`AsyncComputeTaskPool`), wires "Copy share link" into Stats, lights a
|
||||
"Won before" HUD chip on re-deals of beaten seeds, and tidies the
|
||||
unified-3.0 rule set across CLAUDE.md / CLAUDE_SPEC.md /
|
||||
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md.
|
||||
|
||||
### Added
|
||||
|
||||
- **Restore prompt on launch** (`3c7a0eb`). When `game_state.json`
|
||||
holds an in-progress game (`move_count > 0`, not won), the engine
|
||||
now seeds `GameStateResource` with a fresh deal and holds the saved
|
||||
game in a new `PendingRestoredGame` resource. After the splash
|
||||
clears, a "Welcome back" modal offers **Continue** (Enter / C /
|
||||
click) or **New game** (N / click). Fresh-deal saves
|
||||
(`move_count == 0`) skip the prompt and load directly.
|
||||
- **Save preservation while the prompt is unanswered** (`f863d85`).
|
||||
Both `save_game_state_on_exit` and `auto_save_game_state` consult
|
||||
`PendingRestoredGame` first: if it still holds a pending saved
|
||||
game, that's what gets persisted (or the auto-save is skipped),
|
||||
so exiting before answering the prompt no longer overwrites the
|
||||
meaningful save with the placeholder fresh deal.
|
||||
- **Home / mode picker auto-shows on launch** (`dd63261`). The mode
|
||||
picker was only reachable via **M** during gameplay; players who
|
||||
hadn't discovered the hotkey never saw the Daily / Zen / Challenge
|
||||
/ Time Attack entry points after the splash cleared. `HomePlugin`
|
||||
gains an `auto_show_on_launch` flag (default true) and a
|
||||
one-shot `LaunchHomeShown` gate. Skips when the Restore prompt is
|
||||
on screen so Welcome-back still takes precedence.
|
||||
- **MSSC-style Home picker — header / chips / score chips / draw
|
||||
mode** (`ae40a1d`). Player-stats header strip (Level / XP /
|
||||
Lifetime Score, compact-formatted as `1.2M` / `12.3K` / `1,234`)
|
||||
acts as a clickable shortcut to Profile. Draw-mode chip row above
|
||||
the mode cards lets the player flip Draw 1 / Draw 3 from the
|
||||
picker itself; persists `settings.json` and respawns the modal so
|
||||
the active state repaints cleanly. Per-mode best-score / streak
|
||||
chips on each card; hidden on a 0 best so a fresh profile doesn't
|
||||
read "Best 0" everywhere.
|
||||
- **Today's Event callout on the Daily card** (`b73d246`). "Today,
|
||||
May 6" date line plus the server-fetched goal (when SyncPlugin is
|
||||
wired). Once today's daily is recorded as completed, the date
|
||||
flips to `Today, May 6 • Done` in `ACCENT_PRIMARY` so the picker
|
||||
reads as a reward state rather than a TODO.
|
||||
- **Picture-tile mode cards** (`9fe650f` + glyph-picking follow-ups
|
||||
`40d6e0a`, `c30b04e`, `d065d49`). Mode cards become a wrapping
|
||||
2-up grid (`FlexWrap::Wrap`, tiles 48 % wide, `min_height: 180px`)
|
||||
with a centred Unicode-glyph centrepiece per tile. Final glyph set
|
||||
picked from FiraMono-Medium's actual coverage: ♣ Classic, ◆ Daily,
|
||||
○ Zen, ▲ Challenge, → TimeAttack. `ACCENT_PRIMARY` when the mode is
|
||||
unlocked, `TEXT_DISABLED` when locked. Centrepiece is a `Text` node
|
||||
for now — when real per-mode artwork lands, swap to `Image` without
|
||||
touching tile layout, focus order, or chip rendering.
|
||||
- **Solver-vetted seed selection on `AsyncComputeTaskPool`**
|
||||
(`d489e7a`). Closes the worst-case 6 s UI stall on a New Game
|
||||
click with "Winnable deals only" enabled. New `PendingNewGameSeed`
|
||||
resource holds the in-flight `Task<u64>` plus the original
|
||||
request's `mode` / `confirmed` flags. `poll_pending_new_game_seed`
|
||||
runs `.before(GameMutation)` and replays a synthetic
|
||||
`NewGameRequestEvent` once the task resolves — the player sees no
|
||||
extra-frame visual lag. Cancel-on-replace: a fresh
|
||||
`NewGameRequestEvent` while a task is in flight drops the old
|
||||
task, letting Bevy's `Task` Drop cancel cooperatively at the next
|
||||
await point.
|
||||
- **"Won before" HUD indicator** (`bdac754`). When the current
|
||||
deal's `(seed, draw_mode, mode)` triple matches an entry in the
|
||||
rolling `ReplayHistory`, the HUD's tier-2 context row shows
|
||||
**✓ Won before** in `STATE_SUCCESS`. Cleared on win (the on-screen
|
||||
victory cue is enough) and on first-time deals. New
|
||||
`HudWonPreviously` marker driven by a separate
|
||||
`update_won_previously` system; gracefully no-ops in headless
|
||||
tests that don't load `StatsPlugin`.
|
||||
- **"Copy share link" Stats button** (`540869c`). End-to-end replay
|
||||
sharing on a server-backed sync backend:
|
||||
`sync_plugin::push_replay_on_win` spawns the upload on
|
||||
`AsyncComputeTaskPool` and stores the handle in
|
||||
`PendingReplayUpload` (drops any in-flight predecessor — the most
|
||||
recent win is what the player wants the link for);
|
||||
`poll_replay_upload_result` writes `<server>/replays/<id>` to
|
||||
`LastSharedReplayUrl` on success; the Stats overlay's action bar
|
||||
gains a button that writes the URL to the OS clipboard via
|
||||
`arboard` and surfaces a "Copied: \<url\>" toast. URL is in-memory
|
||||
only — sharing must happen within the session of the win.
|
||||
- **Empty-state copy + onboarding hints** (`56e2e6f`). Leaderboard
|
||||
empty state: two-tier "Be the first on the leaderboard." headline
|
||||
+ body invite. Achievements panel: first-launch hint above the
|
||||
grid until the first unlock. Volume hotkeys (`[` / `]`) now emit
|
||||
an `InfoToastEvent` with the new percentage so off-panel
|
||||
adjustments give visible feedback (previously silent).
|
||||
- **Enter dismisses the Win Summary and starts a fresh deal**
|
||||
(`17e0737`). The post-win modal's "Play Again" was click-only;
|
||||
keyboard-only players had to reach for the mouse to leave the
|
||||
celebration screen. The button label gains a trailing return-key
|
||||
glyph so the keyboard path is discoverable on first sight.
|
||||
- **`N` opens the real Confirm/Cancel modal** (`93660c2`). The old
|
||||
"Press N again" double-tap pattern was a UI-first violation (only
|
||||
continuation was another keystroke). `N` now fires
|
||||
`NewGameRequestEvent::default()` directly; `handle_new_game`'s
|
||||
active-game check spawns the existing `ConfirmNewGameScreen`. The
|
||||
HUD button already routed through the same modal — keyboard and
|
||||
mouse paths are unified. `Shift+N` keeps the keyboard power-user
|
||||
bypass (`confirmed: true`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Settings row layout** (`a4bc063`). All five
|
||||
slider/toggle row helpers (volume × 2, tooltip delay, time-bonus
|
||||
multiplier, replay-move interval, generic toggle) restructured to
|
||||
a label-spacer-cluster layout (`width: 100%`, label gets
|
||||
`flex-grow: 1`, controls cluster sits flush right). Stable across
|
||||
varying value-text widths ("0.80" → "1.00", "Instant" vs "1.5 s")
|
||||
and narrow windows.
|
||||
- **Docs adopt the unified-3.0 rule set** (`f2f30c8`). `CLAUDE.md`
|
||||
grows from a 114-line pointer doc to a 571-line rulebook (hard
|
||||
global constraints §2, engine rules §3, asset rules §4, code
|
||||
standards §5, build + verification §6, git workflow §7, the ASK
|
||||
BEFORE list §8, Context Injection System §14). New companions:
|
||||
`CLAUDE_SPEC.md` (formal architecture spec — crate dependency
|
||||
graph, data ownership, state-machine invariants, sync merge /
|
||||
server contracts, validation checklist),
|
||||
`CLAUDE_WORKFLOW.md` (two-agent Builder/Guardian pipeline with
|
||||
hard-fail patterns), `CLAUDE_PROMPT_PACK.md` (task-type
|
||||
templates). Three duplicate rule passages removed across
|
||||
`CLAUDE_SPEC.md` and `ARCHITECTURE.md`.
|
||||
- **Test discipline pruning** (`a49a340`). Removed 43 low-value
|
||||
tests across `solitaire_data` and `solitaire_core` (default-value
|
||||
tests, serde-derive round-trips on plain structs, single-field
|
||||
clamp tests, near-duplicates, constant-equals-itself tests). None
|
||||
pinned a behaviour contract or a regression on a real bug. Future
|
||||
agent briefs request tests for behaviour contracts or real-bug
|
||||
regressions, not a count of N.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Esc on a modal no longer opens Pause underneath** (`08b006f`).
|
||||
A single Esc press on Confirm New Game / Restore / Home /
|
||||
Onboarding / Settings used to both close the modal and spawn the
|
||||
Pause overlay on top in the same frame. `toggle_pause` now skips
|
||||
when any non-Pause `ModalScrim` is in the world; the HUD-button
|
||||
path is gated too. The four modal queries are bundled into a
|
||||
`PauseModalQueries` `SystemParam` to stay under Bevy's
|
||||
16-parameter cap.
|
||||
- **Esc dismisses Home / accepts the Restore-prompt default**
|
||||
(`d48b948`). Both screens previously ignored Esc, leaving the
|
||||
player no keyboard-only escape after the previous fix. Home: Esc
|
||||
behaves like Cancel (despawns the modal, keeps the underlying
|
||||
default deal). Restore: Esc maps to Continue (preserves the saved
|
||||
game, matching how the primary action already advertises Enter).
|
||||
- **Esc dismisses the topmost modal when Profile stacks on Home**
|
||||
(`9aa0dd2`). Clicking the Home header chip opens Profile on top
|
||||
of Home; Esc used to close Home (because
|
||||
`handle_home_cancel_button` fired with no awareness of layered
|
||||
modals) and leave Profile orphaned over the game.
|
||||
`profile_plugin` now splits P/button (toggle) from Esc
|
||||
(close-only); `handle_home_cancel_button` skips its Esc branch
|
||||
when any other `ModalScrim` exists.
|
||||
- **Restore-prompt resolution suppresses Home auto-show**
|
||||
(`b7c3a49`). Resolving the Welcome-back prompt cleared
|
||||
`PendingRestoredGame` and despawned the modal, but the
|
||||
launch-time Home auto-show then fired the next frame and stacked
|
||||
itself over the player's chosen path. `LaunchHomeShown` becomes
|
||||
`pub` so `handle_restore_prompt` flips it to `true` after either
|
||||
resolution; **M** still re-opens the picker on demand.
|
||||
- **Game timers freeze while the Home picker is up** (`c497c31`).
|
||||
The HUD's elapsed-time counter ticked from the moment the default
|
||||
Classic deal landed at startup, even though the auto-show Home
|
||||
picker was still up — the player saw "0:11" before they had
|
||||
chosen a mode. `tick_elapsed_time` and `advance_time_attack` now
|
||||
also gate on the absence of `HomeScreen`, mirroring their
|
||||
existing `PausedResource` check.
|
||||
- **Popover rows stay visible regardless of action-bar fade**
|
||||
(`cc63532`). Opening Modes / Menu showed a solid dark-purple
|
||||
block in the top-right with no readable content — the action-bar
|
||||
auto-fade was matching the popover rows by their shared
|
||||
`ActionButton` marker and dropping their alpha to the
|
||||
cursor-position-based fade value (typically 0). New `PopoverRow`
|
||||
marker on rows in `spawn_modes_popover` / `spawn_menu_popover`;
|
||||
`apply_action_fade` excludes them via `Without<PopoverRow>`.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1166 passing tests (was 1208 at v0.17.0 close — 43 net removals
|
||||
from the test-discipline prune plus 1 net-new test from the
|
||||
async-seed work, no behaviour regressions).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.17.0] — 2026-05-06
|
||||
|
||||
A short follow-up round on top of v0.16.0: the H-key hint is no
|
||||
longer a heuristic guess but the actual best first move suggested by
|
||||
the v0.15.0 solver, and the in-engine replay player now has a
|
||||
player-tunable playback rate.
|
||||
|
||||
### Added
|
||||
|
||||
- **Replay-rate slider** in Settings → Gameplay. Tunes
|
||||
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
|
||||
default 0.45 s. `tick_replay_playback` reads the value from
|
||||
`SettingsResource` per frame so the slider takes effect on the
|
||||
next playback tick — no restart required.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Solver-driven hints.** Pressing **H** used to surface a
|
||||
heuristic-best move (foundation moves preferred, then
|
||||
tableau-to-tableau by depth-of-flip-revealed). It now asks the
|
||||
v0.15.0 solver for the actual provably-best first move via the
|
||||
new `solitaire_core::solver::try_solve_with_first_move` /
|
||||
`try_solve_from_state` APIs. When the solver returns inconclusive
|
||||
(rare deals where the bound runs out before a result), the old
|
||||
heuristic remains the fallback. Median 2 ms per H press.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1208 passing tests (was 1196 at v0.16.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.16.0] — 2026-05-06
|
||||
|
||||
A modal-feel polish round. Every overlay screen now scrolls when its
|
||||
content overflows the 800×600 minimum window, every clickable button
|
||||
shows a hand cursor on hover, keyboard focus lands on the primary
|
||||
button on the same frame the modal opens, and read-only modals
|
||||
dismiss when the player clicks the scrim outside the card.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pointer cursor on hover** for every interactive `Button` entity
|
||||
(modal buttons, HUD action bar, mode-launcher cards, settings
|
||||
toggles, Stats selectors). `update_cursor_icon` gains a fourth
|
||||
branch sitting between Grabbing (active drag) and Grab
|
||||
(draggable card hover): when no drag is active and any
|
||||
`Interaction::Hovered`/`Pressed` button is detected, the window
|
||||
cursor swaps to `SystemCursorIcon::Pointer`. A pure
|
||||
`pick_cursor_icon` helper makes the priority logic
|
||||
unit-testable.
|
||||
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
|
||||
Achievements, Help, Profile, Leaderboard, Home. New
|
||||
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
|
||||
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
|
||||
topmost dismissible scrim on a left-mouse press whose cursor
|
||||
lands on the scrim and outside every `ModalCard`. Bevy's
|
||||
hierarchy despawn cascades to the card and children.
|
||||
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
|
||||
Game intentionally don't opt in — they carry unsaved or
|
||||
destructive state.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Modal content scrolls when it overflows** (Achievements, Help,
|
||||
Stats, Profile, Leaderboard). Each modal's body Node now
|
||||
carries `Overflow::scroll_y()` plus a `max_height` constraint
|
||||
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
|
||||
leaderboard's variable-length ranking section) and a marker
|
||||
component (`AchievementsScrollable`, `HelpScrollable`,
|
||||
`StatsScrollable`, `ProfileScrollable`,
|
||||
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
|
||||
per modal routes `MouseWheel` events into the body's
|
||||
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
|
||||
pattern. Home modal intentionally not scrolled — its five
|
||||
mode cards + Cancel are sized to fit at 800×600 by design.
|
||||
- **Modal focus arrives on the same frame the modal opens.**
|
||||
Previously `attach_focusable_to_modal_buttons` and
|
||||
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
|
||||
click-handlers that spawn modals; with no ordering edge,
|
||||
Bevy's deferred `Commands` queued the new entities but the
|
||||
attach system couldn't see them on the same tick. Both systems
|
||||
moved to `PostUpdate` so the schedule boundary itself supplies
|
||||
the sync point — `FocusedButton` is always populated before
|
||||
`app.update()` returns. The very next Tab/Enter press lands on
|
||||
a populated resource instead of wasting itself moving focus
|
||||
from None to the primary.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1196 passing tests (was 1178 at v0.15.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.15.0] — 2026-05-02
|
||||
|
||||
In-engine replay playback, the Klondike solver + "Winnable deals
|
||||
only" toggle, a 19th achievement, rolling replay history, and a
|
||||
significant build-time / binary-size win from disabling Bevy's
|
||||
default audio stack.
|
||||
|
||||
### Added
|
||||
|
||||
- **In-engine replay playback** for the Stats overlay's Watch Replay
|
||||
button. New `ReplayPlaybackPlugin` runs a state machine
|
||||
(Inactive / Playing / Completed) that resets the live game to the
|
||||
recorded deal and ticks through `replay.moves` at
|
||||
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
|
||||
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
|
||||
Recording is suppressed during playback so replays don't re-record
|
||||
themselves.
|
||||
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
|
||||
top of the window during playback. Shows "Replay" label, "Move N
|
||||
of M" progress, and a Stop button. Z-order leaves modals
|
||||
(Settings, Pause, Help) free to render on top so the player can
|
||||
adjust audio mid-replay.
|
||||
- **Rolling replay history** at `<data_dir>/replays.json` capped at
|
||||
8 entries. Replaces the single-slot `latest_replay.json` (legacy
|
||||
file is migrated forward on first launch via
|
||||
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
|
||||
selector and a "Replay N / M" caption so the player can revisit
|
||||
older wins.
|
||||
- **"Cinephile" achievement** (#19). Unlocks the first time
|
||||
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
|
||||
replay played out to its end without the player pressing Stop).
|
||||
Stop transitions Playing → Inactive directly so it doesn't count.
|
||||
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
|
||||
with memoisation on a 64-bit canonical state hash, two budget
|
||||
knobs (move_budget + state_budget) for pathological cases, and a
|
||||
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
|
||||
Median solve time 2 ms; pathological inconclusives cap near
|
||||
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
|
||||
- **"Winnable deals only" toggle** in Settings → Gameplay (default
|
||||
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
|
||||
through `try_solve` until it finds Winnable or Inconclusive,
|
||||
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
|
||||
challenges, replays, and explicit-seed requests bypass the
|
||||
solver — only random Classic deals are gated.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bevy default-feature trim** (`bevy = { default-features = false,
|
||||
features = [...] }` in workspace Cargo.toml) drops 51 transitive
|
||||
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
|
||||
chain that the project doesn't use (kira handles audio directly).
|
||||
The retained feature list is curated to exactly what the engine
|
||||
uses; `solitaire_wasm` is unaffected because it doesn't depend on
|
||||
bevy.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1178 passing tests (was 1134 at v0.14.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.14.0] — 2026-05-02
|
||||
|
||||
Two threads land in v0.14.0: the second half of the post-v0.12.0 UX
|
||||
candidate list (theme thumbnails, daily-challenge calendar, Time Attack
|
||||
auto-save, per-mode bests, time-bonus multiplier) plus a **major new
|
||||
feature** — the replay pipeline (record → upload → web viewer). Three
|
||||
Quat-reported bugs from a smoke-test round shipped alongside.
|
||||
|
||||
### Added
|
||||
|
||||
- **Theme-picker thumbnails** in Settings → Cosmetic. Each theme chip
|
||||
renders a small Ace-of-Spades + back preview pair via the existing
|
||||
`rasterize_svg` path. Cached per theme in a new
|
||||
`ThemeThumbnailCache`. Themes that lack a preview SVG fall back to
|
||||
a transparent placeholder rather than crashing.
|
||||
- **14-day daily-challenge calendar** in the Profile modal. Horizontal
|
||||
row of dots showing the trailing two weeks; today's dot is ringed
|
||||
in `ACCENT_PRIMARY`, completed days fill `STATE_SUCCESS`, missed
|
||||
days fill `BG_ELEVATED`. Caption above the row reads "Current
|
||||
streak: N · Longest: M".
|
||||
- **Time Attack session auto-save** to `<data_dir>/time_attack_session.json`,
|
||||
atomic .tmp + rename. 30-second auto-save while a session is active,
|
||||
plus on `AppExit`. Sessions whose 10-minute window expired in real
|
||||
time while the app was closed are discarded on load. Classic, Zen,
|
||||
and Challenge already auto-saved correctly via `game_state.json` —
|
||||
Time Attack was the only mode missing session-level persistence.
|
||||
- **Per-mode best-score and fastest-win readouts** in the Stats screen.
|
||||
`StatsSnapshot` gains six `#[serde(default)]` fields (Classic / Zen
|
||||
/ Challenge × best_score + fastest_win_seconds). Stats screen renders
|
||||
a "Per-mode bests" section between the primary cell grid and
|
||||
progression. Lifetime totals continue to roll all modes together.
|
||||
- **Time-bonus multiplier slider** in Settings → Gameplay (0.0–2.0,
|
||||
0.1 steps, default 1.0, "Off" label at zero). Cosmetic only —
|
||||
multiplies the time-bonus shown in the win modal but does NOT
|
||||
affect achievement unlock thresholds (those still use the raw
|
||||
unmultiplied score).
|
||||
- **Win-replay recording + storage.** Every move during a successful
|
||||
game appends to a `RecordingReplay` resource; on `GameWonEvent`
|
||||
the recording freezes into a `Replay` (seed + draw_mode + mode +
|
||||
score + time + ordered move list) and persists to
|
||||
`<data_dir>/latest_replay.json` atomically. Single-slot — overwrites
|
||||
on every win.
|
||||
- **"Watch replay" button** in the Stats overlay. Shows the latest
|
||||
win's caption and surfaces a button that loads the replay (button
|
||||
fires an `InfoToastEvent` describing the replay; full in-engine
|
||||
playback is deferred to a future build).
|
||||
- **Replay upload + fetch endpoints** on the server. `POST /api/replays`
|
||||
accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated
|
||||
with the existing auth middleware. Engine uploads winning replays
|
||||
automatically when the player has cloud sync configured.
|
||||
- **`solitaire_wasm` crate** — new workspace member compiling
|
||||
replay-relevant `solitaire_core` types to WebAssembly so a
|
||||
browser can re-execute a replay client-side. No-std-friendly
|
||||
surface; `wasm-bindgen` glue.
|
||||
- **Web replay viewer** served from the Solitaire server.
|
||||
`GET /replays/:id` returns HTML + CSS + the wasm bundle that
|
||||
fetches the replay JSON, rasterises a deal from the seed, and
|
||||
animates the recorded moves.
|
||||
- **Card flight animations on the web side** so the browser viewer
|
||||
reads as a real game replay rather than a static dump.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Multi-card lift validation.** `solitaire_core::rules::is_valid_tableau_sequence`
|
||||
rejects a moved stack whose adjacent cards don't form a descending
|
||||
alternating-colour run. Previously a player could lift any
|
||||
multi-card selection and drop it as long as the bottom landed
|
||||
legally. Wired into `move_cards`'s tableau-destination branch.
|
||||
- **Softlock detection.** `has_legal_moves` rewritten to walk every
|
||||
potential move source (every stock card, every waste card, the
|
||||
face-up top of every tableau column) and check it against every
|
||||
foundation and every tableau. Previously the heuristic
|
||||
early-returned `true` whenever stock had cards — players got
|
||||
stuck in unwinnable end-states with no end-game screen.
|
||||
`GameOverScreen` now actually fires for true softlocks. Quat's
|
||||
exact reproduction case is pinned by a new test.
|
||||
- **Deal-tween information leak.** New-game now snaps every card
|
||||
sprite to the stock pile position before writing
|
||||
`StateChangedEvent`, so all 52 cards animate from a single point
|
||||
during the deal. Previously the sprites started from their
|
||||
previous-game positions, briefly revealing the prior deal.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `SESSION_HANDOFF.md` refreshed for the Quat smoke-test round
|
||||
including investigation findings on solver decisions and
|
||||
dependency duplicates.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1134 passing tests (was 1053 at v0.13.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.13.0] — 2026-05-02
|
||||
|
||||
Third UX iteration round on top of v0.12.0. Six handoff candidates
|
||||
shipped — three small polish items, three larger interaction
|
||||
features (theme-aware backs, full keyboard play, right-click power
|
||||
shortcut). Plus two code-review fixes (font handling unified,
|
||||
sccache wiring removed).
|
||||
|
||||
### Added
|
||||
|
||||
- **Tooltip-delay slider** in Settings → Gameplay. `tooltip_delay_secs`
|
||||
ranges [0.0, 1.5] in 0.1 s steps; "Instant" label when zero.
|
||||
`Settings.tooltip_delay_secs` round-trips through serialise/deserialise
|
||||
with `#[serde(default)]`. The hover-delay comparison in
|
||||
`ui_tooltip` reads from `SettingsResource` with the existing
|
||||
`MOTION_TOOLTIP_DELAY_SECS` as the test-fixture fallback.
|
||||
- **Win-streak fire animation.** New `WinStreakMilestoneEvent` fires
|
||||
from `stats_plugin` when `win_streak_current` crosses any of
|
||||
[3, 5, 10] (only the threshold crossing — not every subsequent
|
||||
win). The HUD streak readout scale-pulses 1.0 → 1.20 → 1.0 over
|
||||
`MOTION_STREAK_FLOURISH_SECS` (0.6 s).
|
||||
- **Score-breakdown reveal on the win modal.** Replaces the single
|
||||
"Score: N" line with a per-component reveal (Base / Time bonus /
|
||||
No-undo bonus / Mode multiplier / Total). Rows fade in over
|
||||
`MOTION_SCORE_BREAKDOWN_FADE_SECS` (0.12 s) staggered by
|
||||
`MOTION_SCORE_BREAKDOWN_STAGGER_SECS` (0.15 s). Honours
|
||||
`AnimSpeed::Instant` by spawning all rows fully visible.
|
||||
- **Card backs follow the active theme.** `theme.ron`'s `back` slot
|
||||
now actually drives the face-down sprite. Active-theme back
|
||||
rasterises alongside the faces and supersedes the legacy
|
||||
`back_N.png` picker. The picker remains as a fallback for themes
|
||||
that don't ship a back, and the Settings UI surfaces a caption
|
||||
("Active theme provides its own back") + dimmed swatches when
|
||||
the override is in effect.
|
||||
- **Keyboard-only drag-and-drop.** Tab cycles draggable card stacks,
|
||||
Enter "lifts" the focused stack, arrow keys (or Tab) cycle the
|
||||
legal-destination targets only, Enter confirms, Esc cancels. A
|
||||
new `KeyboardDragState` resource models the two-mode flow without
|
||||
changing the existing `SelectionState` contract. Mutual exclusion
|
||||
with mouse drag uses a sentinel `DragState.active_touch_id =
|
||||
KEYBOARD_DRAG_TOUCH_ID` (u64::MAX) so neither pipeline can
|
||||
trample the other.
|
||||
- **Right-click radial menu.** Hold right-click on a face-up card →
|
||||
a small ring of icons appears at the cursor with one entry per
|
||||
legal destination. Release over an icon → fires
|
||||
`MoveRequestEvent`; release in dead space, Esc, or left-click
|
||||
cancels. Skips the drag motion entirely. New `RadialMenuPlugin`
|
||||
owns the flow; co-exists with the existing `RightClickHighlight`
|
||||
pile-marker tint.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Font handling consolidated to bundled-only.** Code-review
|
||||
feedback: the SVG rasteriser previously mixed
|
||||
`load_system_fonts` + bundled FiraMono + a lenient resolver,
|
||||
which made card text rendering depend on host fontconfig. Picked
|
||||
option (a) and applied it across both layers — `font_plugin` now
|
||||
embeds `assets/fonts/main.ttf` via `include_bytes!()` and
|
||||
registers it with `Assets<Font>`; `svg_loader::shared_fontdb`
|
||||
loads only the bundled bytes; the new `bundled_font_resolver`
|
||||
ignores the SVG's `font-family` request and always returns the
|
||||
single bundled face. A parse failure aborts with a clear error
|
||||
("bundled FiraMono failed to parse — binary is corrupt").
|
||||
|
||||
### Removed
|
||||
|
||||
- **Project-level sccache wiring.** Code-review feedback: sccache
|
||||
shouldn't be a per-project build dependency. Cargo's incremental
|
||||
cache already covers the single-project case, and forcing
|
||||
`rustc-wrapper = "sccache"` workspace-wide meant every contributor
|
||||
had to install it. `.cargo/config.toml` deleted entirely; plain
|
||||
`cargo build` now works without setup.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `help_plugin` controls reference gains a "Mouse" section covering
|
||||
double-click auto-move, right-click highlight, and the new
|
||||
hold-RMB radial.
|
||||
- `help_plugin` also gains a "Keyboard drag" section for the new
|
||||
Tab/Enter/Arrows/Esc flow.
|
||||
- Onboarding slide 3 picks up a `Tab → Enter` row referencing the
|
||||
full keyboard drag path.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1053 passing tests (was 1031 at v0.12.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.12.0] — 2026-05-02
|
||||
|
||||
UX feel polish round on top of v0.11.0. Six small-but-tangible
|
||||
improvements that make the play surface feel more responsive,
|
||||
forgiving, and discoverable, plus the doc refresh that should have
|
||||
ridden along with v0.11.0.
|
||||
|
||||
### Added
|
||||
|
||||
- **Foundation completion flourish.** When a King lands on a
|
||||
foundation (Ace-through-King for that suit), a brief celebration
|
||||
fires: King card scale-pulses 1.0 → 1.15 → 1.0 over 0.4 s, the
|
||||
foundation marker tints `STATE_SUCCESS` for the first half then
|
||||
fades, and a synthesised C6→E6→G6 bell ping plays (~240 ms,
|
||||
octave above `win_fanfare`'s root so the fourth completion + win
|
||||
cascade layer cleanly). New `FoundationCompletedEvent { slot,
|
||||
suit }` carries the trigger so future systems can hook in.
|
||||
- **Drag-cancel return tween.** Illegal drops glide each dragged
|
||||
card back to its origin slot over 150 ms with a quintic ease-out
|
||||
curve (`MotionCurve::Responsive`, zero overshoot — reads forgiving
|
||||
rather than jittery). The audio cue (`card_invalid.wav`) still
|
||||
fires for negative feedback. Right-click and double-click invalid
|
||||
paths still use `ShakeAnim` since there's no motion to interpolate.
|
||||
- **Focus ring breathing.** The keyboard focus ring's alpha modulates
|
||||
with a 1.4 s sin curve over [0.65, 1.0] of its native value so the
|
||||
indicator catches the eye on focus changes without competing with
|
||||
gameplay. Honours `AnimSpeed::Instant` by reverting to the static
|
||||
outline for reduced-motion users.
|
||||
- **First-win achievement onboarding toast.** After the player's
|
||||
very first win, a one-shot info toast surfaces "First win! Press
|
||||
A to see your achievements." `Settings.shown_achievement_onboarding`
|
||||
persists the seen state so the cue never re-fires (legacy
|
||||
`settings.json` files load to `false` via `#[serde(default)]`).
|
||||
- **Mode Launcher digit shortcuts.** Pressing M opens the Home modal
|
||||
(the Mode Launcher); inside it, pressing 1–5 launches each mode
|
||||
directly without needing Tab + Enter. Locked modes (Zen, Challenge,
|
||||
Time Attack at level < 5) are silent no-ops. Modal-scoped — digit
|
||||
keys outside the launcher fire nothing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Card aspect ratio matches hayeah SVGs.** `CARD_ASPECT` 1.4 →
|
||||
1.4523 to match the bundled artwork's natural 167.087 × 242.667
|
||||
dimensions. Cards previously rendered ~3.6 % vertically squashed.
|
||||
The vertical-budget math in `compute_layout` uses `CARD_ASPECT`
|
||||
algebraically so the worst-case-tableau-fits-on-screen guarantee
|
||||
adapts automatically.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **README refresh** with v0.11.0+ features (card themes, HUD
|
||||
overhaul, drag feel, unlocked foundations) and a corrected controls
|
||||
table — the previous table inverted Z/U for undo and listed H for
|
||||
help when F1 is the binding.
|
||||
- **CHANGELOG.md** added (this file), covering v0.9.0–v0.12.0 with
|
||||
Keep a Changelog 1.1.0 conventions.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1007 passing tests (was 982 at v0.11.0).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.11.0] — 2026-05-02
|
||||
|
||||
The biggest release since 0.10.0. Headline threads: a runtime card-theme
|
||||
system, an HUD restructure that reclaims the play surface, and a round of
|
||||
UX feel polish surfaced by smoke testing.
|
||||
|
||||
### Added
|
||||
|
||||
- **Runtime card-theme system** (CARD_PLAN phases 1–7).
|
||||
- Bundled default theme ships in the binary via `embedded://` — 52
|
||||
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||
SVGs (MIT) plus a midnight-purple `back.svg` as original work.
|
||||
- User themes live under `themes://` rooted at `user_theme_dir()`. Drop
|
||||
a directory containing `theme.ron` + 53 SVGs and the registry picks
|
||||
it up on next launch.
|
||||
- Importer at `solitaire_engine::theme::import_theme(zip)` validates
|
||||
archives (20 MB cap, zip-slip rejection, manifest validation, every
|
||||
SVG round-tripped through the rasteriser) and atomically unpacks.
|
||||
- Picker UI in **Settings → Cosmetic**; selection persists as
|
||||
`selected_theme_id` and propagates to live sprites.
|
||||
- **Reserved HUD top band** (64 px) so cards no longer crowd the score
|
||||
readout or action buttons; layout's `top_y` shifts down accordingly.
|
||||
- **Action-bar auto-fade** — buttons fade out when the cursor leaves the
|
||||
band, fade back in when it returns. Lerp at ~167 ms.
|
||||
- **Visible drop-target overlay during drag** — a soft fill plus 3 px
|
||||
outline drawn ABOVE stacked cards for every legal target (full fanned
|
||||
column for tableaux, card-sized for foundations and empty tableaux).
|
||||
Replaces the previously invisible pile-marker tint.
|
||||
- **Card drop shadows** — every card casts a neutral 25 % black shadow
|
||||
with a 4 px halo; cards in the active drag set switch to a lifted
|
||||
shadow (40 % alpha, larger offset, bigger halo).
|
||||
- **Stock remaining-count badge** — small `·N` chip at the top-right of
|
||||
the stock pile so the player can see how close they are to a recycle.
|
||||
Hides when the stock empties.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Foundations are unlocked.** `PileType::Foundation(Suit)` →
|
||||
`Foundation(u8)` (slot 0..3). The claimed suit is derived from the
|
||||
bottom card via `Pile::claimed_suit()` — no separate field, no
|
||||
claim-stuck-after-undo bugs. Any Ace lands in any empty slot, and the
|
||||
slot then claims that suit. `next_auto_complete_move` prefers a
|
||||
claim-matched slot before falling back to the first empty slot for
|
||||
Aces. Empty foundation markers render as plain placeholders (no
|
||||
"C/D/H/S").
|
||||
- **HUD selection label** and **hint toast** read `claimed_suit()` and
|
||||
fall through to "Foundation N" / "move to foundation" only when the
|
||||
slot is empty.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`shared_fontdb` now bundles FiraMono.** The hayeah SVGs reference
|
||||
`Bitstream Vera Sans` and `Arial` by name. On minimal Linux installs
|
||||
/ fresh Wayland sessions / chroots where neither is installed AND the
|
||||
CSS-generic aliases don't resolve, card rank/suit text vanished. The
|
||||
bundled font is loaded into fontdb and pinned as every CSS generic's
|
||||
target so the resolver always lands on something real. Surfaced when
|
||||
a second-machine pull rendered cards without glyphs.
|
||||
- **Theme asset path resolution** — `AssetPath::resolve` (concatenates)
|
||||
→ `resolve_embed` (RFC 1808 sibling resolution). Was producing paths
|
||||
like `…/theme.ron/hearts_4.svg` and failing to load every face SVG.
|
||||
- **Sync exit log spam** — `push_on_exit` silently no-ops on
|
||||
`LocalOnlyProvider`'s `UnsupportedPlatform` instead of warn-spamming
|
||||
every shutdown.
|
||||
- **usvg font-substitution warn spam** — custom `FontResolver.select_font`
|
||||
appends `Family::SansSerif` and `Family::Serif` to every query so
|
||||
unmatched named families silently fall through.
|
||||
|
||||
### Migration
|
||||
|
||||
- **In-progress saves invalidated.** `GameState.schema_version` bumped
|
||||
1 → 2; pre-v2 `game_state.json` files silently fall through to "fresh
|
||||
game on launch." Stats, progress, achievements, and settings live in
|
||||
separate files and are unaffected.
|
||||
|
||||
### Stats
|
||||
|
||||
- 982 passing tests (was 819 at v0.10.0).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.10.0] — 2026-04-29
|
||||
|
||||
PNG art pipeline plus a major dependency pass. The first release where
|
||||
the binary shipped with bundled artwork.
|
||||
|
||||
### Added
|
||||
|
||||
- **52 individual card face PNGs** generated via `solitaire_assetgen`.
|
||||
- **Custom font** (FiraMono-Medium) loaded via `AssetServer` at startup
|
||||
through the new `FontPlugin`.
|
||||
- **Card backs and backgrounds** upgraded to 120×168 with richer
|
||||
patterns.
|
||||
- **Ambient audio loop** wired through the kira mixer.
|
||||
- **Arch Linux PKGBUILDs** for the game client and sync server (under
|
||||
the separate `solitaire-quest-pkgbuild` directory).
|
||||
- **Workspace README, CI workflow, migration guide.**
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bevy 0.15 → 0.18** workspace migration.
|
||||
- **kira 0.9 → 0.12** audio backend migration.
|
||||
- **Edition 2024**, MSRV pinned to **Rust 1.95**.
|
||||
- **rand 0.9** upgrade.
|
||||
- **Card rendering** moved from `Text2d` overlay to PNG-backed
|
||||
`Sprite` with face/back atlases; `Text2d` retained as a headless
|
||||
fallback when `CardImageSet` is absent (tests under MinimalPlugins).
|
||||
- **Asset pipeline** switched from `include_bytes!()` for PNGs/TTFs to
|
||||
runtime `AssetServer::load()` so artwork can be swapped without a
|
||||
recompile. Audio remains embedded.
|
||||
- **Removed Google Play Games Services sync backend** — redundant with
|
||||
the self-hosted server.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server JWT secret** loaded at startup (was lazy, surfaced as
|
||||
intermittent 500s).
|
||||
- **Daily-challenge race** in the server's seed-generation path.
|
||||
- **Rate limiter** switched to `SmartIpKeyExtractor` so the limit
|
||||
applies per real client IP rather than per upstream proxy.
|
||||
- **Touch input** uses `MessageReader<TouchInput>` (Bevy 0.18 rename).
|
||||
- **Sync push/pull races** in async task scheduling.
|
||||
- **Hot-path allocations** reduced in card-rendering systems.
|
||||
- **Conflict report coverage** added for sync merge edge cases.
|
||||
|
||||
### Stats
|
||||
|
||||
- 819 passing tests at tag time.
|
||||
|
||||
## [0.9.0] — 2026-04-28
|
||||
|
||||
Initial public-tagged release. Established the workspace structure
|
||||
(`solitaire_core` / `_sync` / `_data` / `_engine` / `_server` / `_app` /
|
||||
`_assetgen`), the modal scaffold via `ui_modal`, the design-token system
|
||||
in `ui_theme`, and the four-tier HUD layout. Foundations were
|
||||
suit-locked at this point; cards rendered as `Text2d` rank/suit overlays
|
||||
with no PNG artwork yet.
|
||||
|
||||
### Added
|
||||
|
||||
- Klondike core (Draw One / Draw Three modes).
|
||||
- Progression system (XP, levels, 18 achievements, daily challenge,
|
||||
weekly goals, special modes at level 5).
|
||||
- Self-hosted sync server (Axum + SQLite + JWT auth).
|
||||
- All 12 overlay screens migrated to the `ui_modal` scaffold with real
|
||||
Primary/Secondary/Tertiary buttons.
|
||||
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce,
|
||||
deal jitter, win-cascade rotation.
|
||||
- Splash screen, focus rings (Phases 1–3), tooltips infrastructure +
|
||||
HUD/Settings/popover applications, achievement integration tests,
|
||||
destructive-confirm verb unification, leaderboard error/idle states,
|
||||
first-launch empty-state polish, hit-target accessibility fix,
|
||||
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
|
||||
client-side sync round-trip integration tests.
|
||||
|
||||
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
|
||||
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
|
||||
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
|
||||
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
|
||||
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
|
||||
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
|
||||
[0.11.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.10.0...v0.11.0
|
||||
[0.10.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.9.0...v0.10.0
|
||||
[0.9.0]: https://github.com/funman300/Rusty_Solitaire/releases/tag/v0.9.0
|
||||
@@ -1,114 +1,571 @@
|
||||
# Solitaire Quest — Claude Code Instructions
|
||||
# CLAUDE.md
|
||||
|
||||
See @ARCHITECTURE.md for full project design, crate responsibilities, data models, and API reference.
|
||||
version: unified-3.0
|
||||
|
||||
---
|
||||
|
||||
## Project Layout
|
||||
# 0. Role of This File
|
||||
|
||||
```text
|
||||
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
|
||||
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
||||
solitaire_data/ # Persistence + SyncProvider trait + server client
|
||||
solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||
solitaire_server/ # Axum sync server binary
|
||||
solitaire_app/ # Thin binary entry point
|
||||
assets/ # Source assets — embedded at compile time via include_bytes!()
|
||||
This document defines:
|
||||
|
||||
* **Execution rules (what Claude must do)**
|
||||
* **System constraints (what Claude must never violate)**
|
||||
* **Operational architecture (how code is structured)**
|
||||
|
||||
For full system design details:
|
||||
→ `ARCHITECTURE.md` (authoritative source of truth)
|
||||
|
||||
This file overrides all conversational assumptions.
|
||||
|
||||
---
|
||||
|
||||
# 1. System Architecture (Authoritative Mapping)
|
||||
|
||||
## 1.1 Crates
|
||||
|
||||
```text id="crate_map"
|
||||
solitaire_core/ # PURE logic (no IO, no Bevy, deterministic)
|
||||
solitaire_sync/ # Shared API + merge logic
|
||||
solitaire_data/ # Persistence + sync client
|
||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||
solitaire_server/ # Axum backend (optional sync layer)
|
||||
solitaire_app/ # Entry binary
|
||||
assets/ # Runtime assets (except audio)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build & Test Commands
|
||||
## 1.2 Architecture Source of Truth
|
||||
|
||||
```bash
|
||||
# Dev run (fast compile via dynamic linking)
|
||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||||
* Full system design: `ARCHITECTURE.md`
|
||||
* This file NEVER redefines system design
|
||||
* This file ONLY enforces behavior
|
||||
|
||||
# Release build
|
||||
cargo build --workspace --release
|
||||
---
|
||||
|
||||
# All tests — MUST pass before any commit
|
||||
# 2. Hard Global Constraints (NON-NEGOTIABLE)
|
||||
|
||||
These override all other instructions.
|
||||
|
||||
## 2.1 Core Determinism
|
||||
|
||||
* `solitaire_core` MUST:
|
||||
|
||||
* be deterministic
|
||||
* be side-effect free
|
||||
* never depend on Bevy / IO / async
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Sync Isolation
|
||||
|
||||
* `solitaire_sync`:
|
||||
|
||||
* no Bevy
|
||||
* no IO
|
||||
* no engine dependencies
|
||||
* merge logic must be pure functions only
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Error Policy
|
||||
|
||||
* NO `unwrap()`
|
||||
* NO `panic!()` in runtime/game logic
|
||||
* All state transitions:
|
||||
|
||||
```rust id="err_model"
|
||||
Result<T, MoveError>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Threading Rules
|
||||
|
||||
* Sync must run on `AsyncComputeTaskPool`
|
||||
* NEVER block Bevy main thread
|
||||
|
||||
---
|
||||
|
||||
## 2.5 Persistence Rules
|
||||
|
||||
* atomic writes only:
|
||||
|
||||
* write `.tmp`
|
||||
* rename atomically
|
||||
* no partial state writes allowed
|
||||
|
||||
---
|
||||
|
||||
## 2.6 Security Rules
|
||||
|
||||
* credentials ONLY via `keyring`
|
||||
* NEVER store secrets in:
|
||||
|
||||
* files
|
||||
* logs
|
||||
* source code
|
||||
|
||||
---
|
||||
|
||||
## 2.7 Sync System Rules
|
||||
|
||||
* All sync backends implement:
|
||||
|
||||
```rust id="sync_trait"
|
||||
trait SyncProvider
|
||||
```
|
||||
|
||||
* `SyncPlugin` MUST be backend-agnostic
|
||||
* NEVER match on backend inside ECS systems
|
||||
|
||||
---
|
||||
|
||||
# 3. Engine Rules (Bevy Layer)
|
||||
|
||||
## 3.1 ECS Design
|
||||
|
||||
* systems = single responsibility
|
||||
* communication = Events only
|
||||
* shared state = Resources only
|
||||
* per-entity state = Components only
|
||||
|
||||
---
|
||||
|
||||
## 3.2 Game State Authority
|
||||
|
||||
* ONLY `GameStateResource` can mutate game state
|
||||
* UI systems MUST NOT directly modify core logic
|
||||
|
||||
---
|
||||
|
||||
## 3.3 UI-First Constraint (CRITICAL)
|
||||
|
||||
Every player action MUST:
|
||||
|
||||
* have a visible UI control
|
||||
* NOT rely solely on keyboard shortcuts
|
||||
|
||||
Keyboard shortcuts are:
|
||||
→ optional accelerators only
|
||||
|
||||
---
|
||||
|
||||
## 3.4 Layout System
|
||||
|
||||
* recompute on `WindowResized`
|
||||
* no fixed resolution assumptions
|
||||
|
||||
---
|
||||
|
||||
# 4. Asset System Rules
|
||||
|
||||
## 4.1 Runtime Assets (AssetServer)
|
||||
|
||||
Loaded via:
|
||||
|
||||
* `CardImageSet`
|
||||
* `BackgroundImageSet`
|
||||
* `FontResource`
|
||||
|
||||
Includes:
|
||||
|
||||
* cards
|
||||
* backgrounds
|
||||
* fonts
|
||||
|
||||
---
|
||||
|
||||
## 4.2 Embedded Assets
|
||||
|
||||
Only audio:
|
||||
|
||||
```text id="audio_rule"
|
||||
include_bytes!()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.3 Test Compatibility Rule
|
||||
|
||||
All asset loaders MUST accept:
|
||||
|
||||
```rust id="asset_fallback"
|
||||
Option<Res<AssetServer>>
|
||||
```
|
||||
|
||||
Must degrade gracefully under `MinimalPlugins`.
|
||||
|
||||
---
|
||||
|
||||
# 5. Code Standards
|
||||
|
||||
## 5.1 Error Handling
|
||||
|
||||
* use `thiserror`
|
||||
* no `Box<dyn Error>` in libraries
|
||||
|
||||
---
|
||||
|
||||
## 5.2 Public API Rules
|
||||
|
||||
* prefer `Into<T>` over concrete types
|
||||
* all public items require doc comments
|
||||
|
||||
---
|
||||
|
||||
## 5.3 Derive Order
|
||||
|
||||
```rust id="derive_order"
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.4 Performance Rules
|
||||
|
||||
* NO `clone()` in hot paths
|
||||
* profile before optimizing
|
||||
|
||||
---
|
||||
|
||||
## 5.5 SQL Rules
|
||||
|
||||
* ONLY `sqlx::query!`
|
||||
* NO raw SQL strings
|
||||
|
||||
---
|
||||
|
||||
# 6. Build & Verification Rules
|
||||
|
||||
These are mandatory before ANY commit.
|
||||
|
||||
```bash id="build_rules"
|
||||
cargo test --workspace
|
||||
|
||||
# Lint — MUST pass clean (zero warnings)
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# Run sync server locally
|
||||
cargo run -p solitaire_server
|
||||
|
||||
# Check a single crate
|
||||
cargo test -p solitaire_core
|
||||
cargo clippy -p solitaire_core -- -D warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
# 7. Git Workflow Rules
|
||||
|
||||
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
||||
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
|
||||
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
|
||||
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
|
||||
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
|
||||
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
|
||||
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
|
||||
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
||||
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
|
||||
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
|
||||
- `cargo test --workspace` must pass after every change.
|
||||
## Commit format
|
||||
|
||||
```text id="commit_fmt"
|
||||
type(scope): description
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
* feat(core): add draw-three rules
|
||||
* fix(engine): correct drag z-order
|
||||
* test(core): undo boundary cases
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
## Commit conditions
|
||||
|
||||
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
|
||||
- Prefer `Into<T>` over concrete types in public API function parameters.
|
||||
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
|
||||
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
|
||||
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
|
||||
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
|
||||
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
|
||||
* tests must pass
|
||||
* clippy must be clean
|
||||
|
||||
NEVER commit otherwise
|
||||
|
||||
---
|
||||
|
||||
## Bevy Conventions
|
||||
# 8. Change Control (ASK BEFORE DOING)
|
||||
|
||||
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
|
||||
- Resources own shared state. Events communicate between systems. Components own per-entity data.
|
||||
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
|
||||
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
||||
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
|
||||
Claude must request confirmation before:
|
||||
|
||||
* adding dependencies
|
||||
* modifying `solitaire_sync`
|
||||
* changing DB schema
|
||||
* introducing `unsafe`
|
||||
* changing merge strategy
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
# 9. System Mental Model (IMPORTANT)
|
||||
|
||||
- Commit after each passing phase, not after every file change.
|
||||
- Commit message format: `type(scope): description`
|
||||
- `feat(core): add draw-three mode validation`
|
||||
- `fix(engine): card z-order during drag`
|
||||
- `test(core): undo stack boundary conditions`
|
||||
- `chore(server): add sqlx migration 002`
|
||||
- Never commit with failing tests or clippy warnings.
|
||||
- Never commit secrets, `.env` files, or `*.db` files.
|
||||
```text id="mental_model"
|
||||
Core (rules + deterministic logic)
|
||||
↓
|
||||
Engine (Bevy orchestration)
|
||||
↓
|
||||
Data layer (persistence + sync)
|
||||
↓
|
||||
Server (optional external system)
|
||||
```
|
||||
|
||||
Core is always the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Ask Before Doing
|
||||
# 10. Known Platform Pitfalls
|
||||
|
||||
- Adding a new crate dependency (discuss alternatives first).
|
||||
- Changing a type in `solitaire_sync` (breaking change on both client and server).
|
||||
- Altering the database schema (requires a new sqlx migration).
|
||||
- Introducing `unsafe` code anywhere.
|
||||
- Changing the merge strategy in `solitaire_sync::merge()`.
|
||||
Must always be handled explicitly:
|
||||
|
||||
* Bevy `Time` uses `f32`
|
||||
* `sqlx::migrate!()` path is crate-relative
|
||||
* `dirs::data_dir()` may return `None`
|
||||
* Linux may lack keyring backend
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
# 11. Forbidden Patterns
|
||||
|
||||
> Add entries here when Claude makes a mistake so it isn't repeated.
|
||||
* game logic inside Bevy systems
|
||||
* duplication across crates
|
||||
* blocking async calls in ECS
|
||||
* insecure credential storage
|
||||
* bypassing core logic layer
|
||||
|
||||
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
|
||||
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
|
||||
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
|
||||
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
|
||||
---
|
||||
|
||||
# 12. Execution Rules for Claude
|
||||
|
||||
When generating code:
|
||||
|
||||
1. respect crate boundaries
|
||||
2. minimize diff size
|
||||
3. do not expand scope
|
||||
4. follow existing patterns
|
||||
5. preserve invariants
|
||||
|
||||
If unclear:
|
||||
→ ask before acting
|
||||
|
||||
---
|
||||
|
||||
# 13. Relationship to ARCHITECTURE.md
|
||||
|
||||
| File | Role |
|
||||
| --------------- | ------------------------- |
|
||||
| CLAUDE.md | execution + constraints |
|
||||
| ARCHITECTURE.md | system design truth |
|
||||
| Both combined | full system understanding |
|
||||
|
||||
---
|
||||
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||
|
||||
## 14.1 Purpose
|
||||
|
||||
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
||||
|
||||
This prevents:
|
||||
|
||||
* architectural drift
|
||||
* irrelevant spec loading
|
||||
* over-engineering
|
||||
* cross-crate confusion
|
||||
|
||||
---
|
||||
|
||||
## 14.2 Input Classification Step (MANDATORY)
|
||||
|
||||
Every request MUST be classified into exactly one task type:
|
||||
|
||||
```text id="task_types"
|
||||
feature
|
||||
bugfix
|
||||
refactor
|
||||
system_design
|
||||
bevy_system
|
||||
core_logic
|
||||
sync
|
||||
optimization
|
||||
test
|
||||
debug
|
||||
```
|
||||
|
||||
If uncertain → ask clarification.
|
||||
|
||||
---
|
||||
|
||||
## 14.3 Context Selection Engine
|
||||
|
||||
After classification, Claude MUST include ONLY the relevant sections below.
|
||||
|
||||
---
|
||||
|
||||
## 14.4 Context Map (CORE RULESET)
|
||||
|
||||
### feature
|
||||
|
||||
Include:
|
||||
|
||||
* §2 Hard Global Constraints
|
||||
* §3 Engine Rules
|
||||
* ARCHITECTURE.md (crate of target feature only)
|
||||
* relevant data models (GameState, SyncPayload if needed)
|
||||
|
||||
---
|
||||
|
||||
### bugfix
|
||||
|
||||
Include:
|
||||
|
||||
* §2 Hard Global Constraints
|
||||
* §5 Code Standards
|
||||
* affected crate boundaries
|
||||
* relevant system (engine/core/sync only)
|
||||
|
||||
---
|
||||
|
||||
### refactor
|
||||
|
||||
Include:
|
||||
|
||||
* §3 Engine Rules
|
||||
* §5 Code Standards
|
||||
* §11 Forbidden Patterns
|
||||
* target crate boundaries
|
||||
|
||||
---
|
||||
|
||||
### system_design
|
||||
|
||||
Include:
|
||||
|
||||
* ARCHITECTURE.md (FULL)
|
||||
* §9 Mental Model
|
||||
* §1 System Architecture Mapping
|
||||
|
||||
---
|
||||
|
||||
### core_logic
|
||||
|
||||
Include:
|
||||
|
||||
* solitaire_core rules only
|
||||
* GameState model
|
||||
* MoveError model
|
||||
* §2.1–2.3 constraints
|
||||
|
||||
---
|
||||
|
||||
### bevy_system
|
||||
|
||||
Include:
|
||||
|
||||
* §3 Engine Rules
|
||||
* ECS rules (Events/Resources/Components)
|
||||
* UI-first constraint
|
||||
* relevant plugin system only
|
||||
|
||||
---
|
||||
|
||||
### sync
|
||||
|
||||
Include:
|
||||
|
||||
* SyncProvider trait
|
||||
* merge strategy rules
|
||||
* solitaire_sync models
|
||||
* §2.6 Sync Rules
|
||||
|
||||
---
|
||||
|
||||
### optimization
|
||||
|
||||
Include:
|
||||
|
||||
* target crate only
|
||||
* §5.4 Performance Rules
|
||||
* hot path constraints
|
||||
|
||||
---
|
||||
|
||||
### test
|
||||
|
||||
Include:
|
||||
|
||||
* §6 Build Rules
|
||||
* relevant module
|
||||
* expected invariants
|
||||
|
||||
---
|
||||
|
||||
### debug
|
||||
|
||||
Include:
|
||||
|
||||
* target file/module only
|
||||
* §2.3 Error Policy
|
||||
* runtime assumptions relevant to failure
|
||||
|
||||
---
|
||||
|
||||
## 14.5 Context Compression Rules
|
||||
|
||||
Claude MUST obey:
|
||||
|
||||
* never include full ARCHITECTURE.md unless system_design
|
||||
* max 2 crates per response unless explicitly required
|
||||
* prefer function-level context over file-level context
|
||||
* exclude unrelated plugins/systems
|
||||
|
||||
---
|
||||
|
||||
## 14.6 Context Priority Order
|
||||
|
||||
When space is limited:
|
||||
|
||||
1. Hard Constraints (§2)
|
||||
2. Target crate rules
|
||||
3. Data models
|
||||
4. Only then: architecture snippets
|
||||
|
||||
---
|
||||
|
||||
## 14.7 “No Context Pollution” Rule
|
||||
|
||||
Claude must NOT include:
|
||||
|
||||
* unrelated crates
|
||||
* unrelated plugins
|
||||
* unused data models
|
||||
* full architecture dumps
|
||||
* speculative systems
|
||||
|
||||
---
|
||||
|
||||
## 14.8 Self-Check Before Execution
|
||||
|
||||
Before writing code, Claude MUST verify:
|
||||
|
||||
* [ ] Is only relevant context included?
|
||||
* [ ] Is at least one hard constraint present?
|
||||
* [ ] Am I touching more than one crate unnecessarily?
|
||||
* [ ] Am I duplicating ARCHITECTURE.md content?
|
||||
|
||||
If any fail → revise context selection.
|
||||
|
||||
---
|
||||
|
||||
## 14.9 Injection Output Format (Internal Model)
|
||||
|
||||
Claude should behave as if it constructed:
|
||||
|
||||
```text id="ctx_format"
|
||||
[SELECTED TASK TYPE]
|
||||
|
||||
[MINIMAL REQUIRED RULES]
|
||||
|
||||
[MINIMAL ARCHITECTURE SLICES]
|
||||
|
||||
[RELEVANT MODELS]
|
||||
|
||||
[REQUEST]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14.10 Relationship to ARCHITECTURE.md
|
||||
|
||||
* ARCHITECTURE.md = source of truth
|
||||
* CLAUDE.md = execution constraints
|
||||
* THIS SECTION = filtering layer between them
|
||||
|
||||
---
|
||||
|
||||
# END CONTEXT INJECTION SYSTEM
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
# CLAUDE_PROMPT_PACK.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
# 0. GLOBAL INSTRUCTION (prepend to every prompt)
|
||||
|
||||
```
|
||||
You must follow CLAUDE_SPEC.md strictly.
|
||||
|
||||
Rules:
|
||||
- Do not expand scope beyond what is defined
|
||||
- Do not refactor unrelated code
|
||||
- Do not introduce new dependencies
|
||||
- Prefer minimal, surgical changes
|
||||
- Use existing patterns in the codebase
|
||||
- Return minimal diffs or changed functions only
|
||||
|
||||
Before writing code:
|
||||
1. List relevant constraints from CLAUDE_SPEC.md
|
||||
2. Identify risks
|
||||
3. Then implement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. FEATURE IMPLEMENTATION
|
||||
|
||||
```
|
||||
# TASK: Feature Implementation
|
||||
|
||||
feature: "<name>"
|
||||
|
||||
goal:
|
||||
"<clear outcome>"
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
systems: []
|
||||
files: []
|
||||
|
||||
non_goals:
|
||||
- ""
|
||||
|
||||
constraints:
|
||||
- must follow CLAUDE_SPEC.md
|
||||
- event-driven architecture required
|
||||
- no blocking operations
|
||||
- no cross-crate leakage
|
||||
|
||||
acceptance_criteria:
|
||||
- ""
|
||||
- ""
|
||||
|
||||
edge_cases:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## Required Patterns
|
||||
|
||||
Use this pattern for systems:
|
||||
<PASTE EXISTING SYSTEM SNIPPET HERE>
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
intent:
|
||||
plan:
|
||||
constraints_used:
|
||||
risks:
|
||||
|
||||
code_changes:
|
||||
(minimal diffs only)
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. BUGFIX
|
||||
|
||||
```
|
||||
# TASK: Bug Fix
|
||||
|
||||
bug_description:
|
||||
"<what is broken>"
|
||||
|
||||
expected_behavior:
|
||||
"<correct behavior>"
|
||||
|
||||
root_cause_hint (optional):
|
||||
""
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
files: []
|
||||
|
||||
constraints:
|
||||
- minimal fix only
|
||||
- no refactors unless required
|
||||
- must add regression protection if applicable
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Identify root cause
|
||||
2. Fix it minimally
|
||||
3. Preserve all invariants
|
||||
4. Do not change unrelated logic
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
root_cause:
|
||||
fix_strategy:
|
||||
|
||||
code_changes:
|
||||
(minimal diff)
|
||||
|
||||
regression_test (only if high-value):
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. REFACTOR
|
||||
|
||||
```
|
||||
# TASK: Refactor
|
||||
|
||||
target:
|
||||
"<what is being improved>"
|
||||
|
||||
goal:
|
||||
"<what improves>"
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
files: []
|
||||
|
||||
non_goals:
|
||||
- no behavior changes
|
||||
- no new features
|
||||
|
||||
constraints:
|
||||
- must preserve behavior exactly
|
||||
- must respect crate boundaries
|
||||
- must not duplicate logic
|
||||
|
||||
---
|
||||
|
||||
## Refactor Type
|
||||
|
||||
- [ ] simplify logic
|
||||
- [ ] reduce duplication
|
||||
- [ ] improve readability
|
||||
- [ ] performance (non-invasive)
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
issues_found:
|
||||
|
||||
refactor_plan:
|
||||
|
||||
code_changes:
|
||||
(diff only)
|
||||
|
||||
verification:
|
||||
- behavior unchanged: yes/no
|
||||
- invariants preserved: yes/no
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. SYSTEM DESIGN (NEW FEATURE)
|
||||
|
||||
```
|
||||
# TASK: System Design
|
||||
|
||||
feature:
|
||||
"<name>"
|
||||
|
||||
goal:
|
||||
"<what problem it solves>"
|
||||
|
||||
constraints:
|
||||
- must fit existing architecture
|
||||
- must follow plugin + event model
|
||||
- must not violate crate boundaries
|
||||
|
||||
---
|
||||
|
||||
## Required Output
|
||||
|
||||
design:
|
||||
|
||||
components:
|
||||
- plugins:
|
||||
- systems:
|
||||
- events:
|
||||
- resources:
|
||||
|
||||
data_flow:
|
||||
(step-by-step)
|
||||
|
||||
integration_points:
|
||||
- where it connects to existing systems
|
||||
|
||||
risks:
|
||||
- ""
|
||||
|
||||
tradeoffs:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## DO NOT
|
||||
|
||||
- write full implementation
|
||||
- modify unrelated systems
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 5. NEW BEVY SYSTEM
|
||||
|
||||
```
|
||||
# TASK: Add Bevy System
|
||||
|
||||
system_name:
|
||||
""
|
||||
|
||||
trigger:
|
||||
(event or condition)
|
||||
|
||||
reads:
|
||||
[Resources]
|
||||
|
||||
writes:
|
||||
[Resources]
|
||||
|
||||
emits:
|
||||
[Events]
|
||||
|
||||
constraints:
|
||||
- must be event-driven
|
||||
- must not directly mutate unrelated state
|
||||
- must be single responsibility
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
system_signature:
|
||||
|
||||
implementation:
|
||||
(code only)
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. CORE LOGIC FUNCTION (solitaire_core)
|
||||
|
||||
```
|
||||
# TASK: Core Logic Implementation
|
||||
|
||||
function:
|
||||
"<name>"
|
||||
|
||||
goal:
|
||||
"<what it does>"
|
||||
|
||||
rules:
|
||||
- no IO
|
||||
- no async
|
||||
- no Bevy
|
||||
- deterministic
|
||||
|
||||
invariants:
|
||||
- ""
|
||||
- ""
|
||||
|
||||
errors:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
constraints_checked:
|
||||
|
||||
implementation:
|
||||
(code only)
|
||||
|
||||
edge_case_handling:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 7. SYNC / MERGE LOGIC
|
||||
|
||||
```
|
||||
# TASK: Sync Logic
|
||||
|
||||
goal:
|
||||
"<what is being merged or synced>"
|
||||
|
||||
constraints:
|
||||
- must be deterministic
|
||||
- must be idempotent
|
||||
- must be lossless
|
||||
- must not delete data
|
||||
|
||||
rules:
|
||||
- counters → max
|
||||
- times → min
|
||||
- collections → union
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
|
||||
merge_logic:
|
||||
|
||||
code_changes:
|
||||
|
||||
invariants_verified:
|
||||
- deterministic
|
||||
- idempotent
|
||||
- lossless
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 8. PERFORMANCE OPTIMIZATION
|
||||
|
||||
```
|
||||
# TASK: Optimization
|
||||
|
||||
target:
|
||||
"<what is slow>"
|
||||
|
||||
constraints:CLAUDE_WORKFLOW.md
|
||||
- no behavior change
|
||||
- no architecture change
|
||||
- minimal code changes
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
bottleneck:
|
||||
|
||||
optimization_strategy:
|
||||
|
||||
code_changes:
|
||||
|
||||
impact_estimate:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 9. TEST GENERATION (STRICT MODE)
|
||||
|
||||
```
|
||||
# TASK: Test Generation
|
||||
|
||||
target:
|
||||
"<function/system>"
|
||||
|
||||
reason:
|
||||
- bugfix | complex logic | invariant protection
|
||||
|
||||
constraints:
|
||||
- no redundant tests
|
||||
- must test real behavior
|
||||
- must fail if logic breaks
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
test_cases:
|
||||
- ""
|
||||
|
||||
test_code:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 10. DEBUGGING / INVESTIGATION
|
||||
|
||||
```
|
||||
# TASK: Debug
|
||||
|
||||
problem:
|
||||
"<symptom>"
|
||||
|
||||
context:
|
||||
"<relevant code or system>"
|
||||
|
||||
---
|
||||
|
||||
## Required Steps
|
||||
|
||||
1. List possible causes
|
||||
2. Narrow down most likely
|
||||
3. Suggest verification steps
|
||||
4. Provide minimal fix
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
hypotheses:
|
||||
|
||||
most_likely:
|
||||
|
||||
verification_steps:
|
||||
|
||||
fix:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 11. HARD CONSTRAINT OVERRIDE (RARE)
|
||||
|
||||
```
|
||||
# TASK: Exception Handling
|
||||
|
||||
reason:
|
||||
"<why constraints must be bent>"
|
||||
|
||||
requested_exception:
|
||||
"<rule being broken>"
|
||||
|
||||
justification:
|
||||
"<why unavoidable>"
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
|
||||
alternatives_considered:
|
||||
|
||||
final_decision:
|
||||
|
||||
risk:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 12. STOP CONDITIONS (always append)
|
||||
|
||||
```
|
||||
Stop when:
|
||||
- acceptance criteria are met
|
||||
- code is minimal and correct
|
||||
|
||||
Do NOT:
|
||||
- expand scope
|
||||
- refactor unrelated code
|
||||
- optimize prematurely
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# END
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
# CLAUDE_SPEC.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
## 0. Global Rules
|
||||
|
||||
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
|
||||
|
||||
rules:
|
||||
|
||||
* id: single_source_of_truth
|
||||
description: "GameStateResource is the only mutable game state in runtime"
|
||||
|
||||
* id: sync_is_additive
|
||||
description: "Remote data must never destructively overwrite local data"
|
||||
|
||||
---
|
||||
|
||||
## 1. Crate Graph
|
||||
|
||||
crates:
|
||||
solitaire_core:
|
||||
depends_on: [rand, serde, chrono]
|
||||
forbidden_deps: [bevy, reqwest, tokio, std::fs]
|
||||
|
||||
solitaire_sync:
|
||||
depends_on: [serde, serde_json, uuid, chrono]
|
||||
role: "shared_types"
|
||||
|
||||
solitaire_data:
|
||||
depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring]
|
||||
role: "persistence_and_sync"
|
||||
|
||||
solitaire_engine:
|
||||
depends_on: [bevy, kira, solitaire_core, solitaire_data]
|
||||
role: "runtime_engine"
|
||||
|
||||
solitaire_server:
|
||||
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
||||
role: "backend"
|
||||
|
||||
solitaire_app:
|
||||
depends_on: [solitaire_engine]
|
||||
role: "entrypoint"
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Ownership
|
||||
|
||||
ownership:
|
||||
GameState:
|
||||
owner: solitaire_core
|
||||
mutable_in: solitaire_engine
|
||||
access_pattern: "via GameStateResource only"
|
||||
|
||||
StatsSnapshot:
|
||||
owner: solitaire_data
|
||||
|
||||
PlayerProgress:
|
||||
owner: solitaire_data
|
||||
|
||||
AchievementRecord:
|
||||
owner: solitaire_data
|
||||
|
||||
SyncPayload:
|
||||
owner: solitaire_sync
|
||||
|
||||
---
|
||||
|
||||
## 3. State Transitions
|
||||
|
||||
state_machine:
|
||||
GameState:
|
||||
transitions:
|
||||
- action: move_cards
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
```
|
||||
- action: draw
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
- action: undo
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
invariants:
|
||||
- "52 cards always exist"
|
||||
- "no duplicate card IDs"
|
||||
- "all cards belong to exactly one pile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Event System
|
||||
|
||||
events:
|
||||
|
||||
input:
|
||||
- MoveRequestEvent
|
||||
- DrawRequestEvent
|
||||
- UndoRequestEvent
|
||||
- NewGameRequestEvent
|
||||
|
||||
state:
|
||||
- StateChangedEvent
|
||||
- GameWonEvent
|
||||
|
||||
meta:
|
||||
- AchievementUnlockedEvent
|
||||
- SyncCompleteEvent
|
||||
|
||||
rules:
|
||||
|
||||
* "Input events trigger core logic"
|
||||
* "Core logic emits state events"
|
||||
* "UI reacts to state events only"
|
||||
|
||||
---
|
||||
|
||||
## 5. Sync Contract
|
||||
|
||||
sync:
|
||||
|
||||
provider_trait:
|
||||
methods:
|
||||
- pull() -> SyncPayload
|
||||
- push(payload) -> SyncResponse
|
||||
|
||||
guarantees:
|
||||
- "non-blocking during gameplay"
|
||||
- "blocking allowed on exit only"
|
||||
|
||||
merge:
|
||||
rules:
|
||||
counters: "max"
|
||||
best_times: "min"
|
||||
collections: "union"
|
||||
achievements: "never removed"
|
||||
|
||||
```
|
||||
properties:
|
||||
- deterministic
|
||||
- idempotent
|
||||
- lossless
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Persistence
|
||||
|
||||
storage:
|
||||
|
||||
format: json
|
||||
|
||||
files:
|
||||
- stats.json
|
||||
- progress.json
|
||||
- achievements.json
|
||||
- settings.json
|
||||
- game_state.json
|
||||
|
||||
guarantees:
|
||||
- atomic_write: true
|
||||
- crash_safe: true
|
||||
|
||||
---
|
||||
|
||||
## 7. Engine Rules
|
||||
|
||||
engine:
|
||||
|
||||
mutation_rules:
|
||||
- "Only GameLogicSystem mutates GameState"
|
||||
- "UI systems are read-only"
|
||||
|
||||
threading:
|
||||
- "sync runs on AsyncComputeTaskPool"
|
||||
- "main thread must never block"
|
||||
|
||||
plugins:
|
||||
pattern: "feature_isolation"
|
||||
communication: "events"
|
||||
|
||||
---
|
||||
|
||||
## 8. Server Contract
|
||||
|
||||
server:
|
||||
|
||||
auth:
|
||||
method: jwt
|
||||
access_expiry: 24h
|
||||
refresh_expiry: 30d
|
||||
|
||||
endpoints:
|
||||
- POST /api/auth/register
|
||||
- POST /api/auth/login
|
||||
- GET /api/sync/pull
|
||||
- POST /api/sync/push
|
||||
|
||||
limits:
|
||||
payload_max: 1MB
|
||||
rate_limit: "10 req/min auth routes"
|
||||
|
||||
---
|
||||
|
||||
## 9. Achievement System
|
||||
|
||||
achievements:
|
||||
|
||||
definition_location: solitaire_core
|
||||
state_location: solitaire_data
|
||||
|
||||
types:
|
||||
- condition_based
|
||||
- event_driven
|
||||
|
||||
rule:
|
||||
- "achievements cannot be revoked"
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing Rules
|
||||
|
||||
testing:
|
||||
|
||||
philosophy:
|
||||
- "test real failures"
|
||||
- "avoid redundant tests"
|
||||
|
||||
required_coverage:
|
||||
solitaire_core:
|
||||
- move_validation
|
||||
- undo_integrity
|
||||
- win_detection
|
||||
|
||||
```
|
||||
solitaire_sync:
|
||||
- merge_correctness
|
||||
- idempotency
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Prohibited Patterns
|
||||
|
||||
(See CLAUDE.md §11 for the canonical forbidden-patterns list.)
|
||||
|
||||
---
|
||||
|
||||
## 12. Extension Points
|
||||
|
||||
extensibility:
|
||||
|
||||
sync_backends:
|
||||
pattern: "implement SyncProvider"
|
||||
|
||||
game_modes:
|
||||
location: solitaire_core::GameMode
|
||||
|
||||
plugins:
|
||||
rule: "new feature = new plugin"
|
||||
|
||||
---
|
||||
|
||||
## 13. Validation Checklist (for Claude)
|
||||
|
||||
validation:
|
||||
|
||||
* check: "crate dependency rules respected"
|
||||
* check: "no panics in core"
|
||||
* check: "events used for cross-system communication"
|
||||
* check: "GameState mutations centralized"
|
||||
* check: "merge function properties preserved"
|
||||
* check: "no blocking operations in main loop"
|
||||
|
||||
---
|
||||
|
||||
## 14. Mental Model
|
||||
|
||||
model:
|
||||
|
||||
layers:
|
||||
- core
|
||||
- engine
|
||||
- data
|
||||
- server
|
||||
|
||||
flow:
|
||||
- input -> engine -> core -> engine -> ui
|
||||
- data <-> sync <-> server
|
||||
@@ -0,0 +1,335 @@
|
||||
# CLAUDE_WORKFLOW.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
## 0. Overview
|
||||
|
||||
This workflow defines a **two-agent system**:
|
||||
|
||||
* **Builder Agent** → writes and modifies code
|
||||
* **Guardian Agent** → enforces architecture + rejects invalid changes
|
||||
|
||||
No code is considered valid unless it passes Guardian validation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Roles
|
||||
|
||||
### 1.1 Builder Agent
|
||||
|
||||
role: "code_generation"
|
||||
|
||||
responsibilities:
|
||||
|
||||
* implement features
|
||||
* refactor code
|
||||
* generate tests (only when justified)
|
||||
* follow CLAUDE_SPEC.md
|
||||
|
||||
constraints:
|
||||
|
||||
* cannot bypass validation
|
||||
* must declare intent before writing code
|
||||
|
||||
output_contract:
|
||||
must_produce:
|
||||
- change_summary
|
||||
- files_modified
|
||||
- reasoning (short)
|
||||
- code_diff
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Guardian Agent
|
||||
|
||||
role: "architecture_enforcement"
|
||||
|
||||
responsibilities:
|
||||
|
||||
* validate against CLAUDE_SPEC.md
|
||||
* detect violations
|
||||
* reject or approve changes
|
||||
* suggest minimal fixes (not full rewrites)
|
||||
|
||||
constraints:
|
||||
|
||||
* no feature implementation
|
||||
* no large rewrites
|
||||
* must be deterministic
|
||||
|
||||
output_contract:
|
||||
must_produce:
|
||||
- status: APPROVED | REJECTED
|
||||
- violations[]
|
||||
- required_fixes[]
|
||||
- optional_improvements[]
|
||||
|
||||
---
|
||||
|
||||
## 2. Workflow Pipeline
|
||||
|
||||
```text
|
||||
User Request
|
||||
↓
|
||||
Builder Agent (proposal + code)
|
||||
↓
|
||||
Guardian Agent (validation)
|
||||
↓
|
||||
IF approved → commit
|
||||
IF rejected → feedback → Builder retry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Builder Protocol
|
||||
|
||||
### Step 1 — Intent Declaration
|
||||
|
||||
Builder MUST start with:
|
||||
|
||||
```yaml
|
||||
intent:
|
||||
feature: "<name>"
|
||||
crates_touched: []
|
||||
systems_affected: []
|
||||
risk_level: low|medium|high
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Plan
|
||||
|
||||
```yaml
|
||||
plan:
|
||||
- step: "..."
|
||||
- step: "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Implementation
|
||||
|
||||
* Only modify declared crates
|
||||
* Follow ownership rules
|
||||
* Use events for cross-system communication
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Output
|
||||
|
||||
```yaml
|
||||
change_summary: "..."
|
||||
|
||||
files_modified:
|
||||
- path: ...
|
||||
change: "..."
|
||||
|
||||
violations_self_check:
|
||||
- none | list
|
||||
|
||||
notes: "short reasoning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Guardian Protocol
|
||||
|
||||
### Step 1 — Spec Validation
|
||||
|
||||
Check against:
|
||||
|
||||
* crate boundaries
|
||||
* mutation rules
|
||||
* event system usage
|
||||
* sync guarantees
|
||||
* forbidden patterns
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Invariant Validation
|
||||
|
||||
Must verify:
|
||||
|
||||
* GameState invariants preserved
|
||||
* no new panic paths
|
||||
* no blocking calls in engine
|
||||
* merge properties unchanged
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Output Decision
|
||||
|
||||
#### APPROVED
|
||||
|
||||
```yaml
|
||||
status: APPROVED
|
||||
|
||||
notes:
|
||||
- "no violations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### REJECTED
|
||||
|
||||
```yaml
|
||||
status: REJECTED
|
||||
|
||||
violations:
|
||||
- id: core_purity_violation
|
||||
file: "solitaire_core/src/..."
|
||||
reason: "uses std::fs"
|
||||
|
||||
required_fixes:
|
||||
- "move IO to solitaire_data"
|
||||
|
||||
optional_improvements:
|
||||
- "simplify event naming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Enforcement Rules
|
||||
|
||||
### Hard Fail (automatic rejection)
|
||||
|
||||
* core crate uses IO / Bevy / network
|
||||
* GameState mutated outside GameLogicSystem
|
||||
* blocking async on main thread
|
||||
* duplicate logic across crates
|
||||
* merge function altered incorrectly
|
||||
|
||||
---
|
||||
|
||||
### Soft Fail (allowed but flagged)
|
||||
|
||||
* unnecessary complexity
|
||||
* redundant tests
|
||||
* minor architectural drift
|
||||
|
||||
---
|
||||
|
||||
## 6. Iteration Loop
|
||||
|
||||
Max attempts per task: **3**
|
||||
|
||||
```text
|
||||
Attempt 1 → Reject → Fix
|
||||
Attempt 2 → Reject → Fix
|
||||
Attempt 3 → Final decision
|
||||
```
|
||||
|
||||
If still failing:
|
||||
→ escalate to user
|
||||
|
||||
---
|
||||
|
||||
## 7. Diff Strategy
|
||||
|
||||
Builder MUST produce:
|
||||
|
||||
* minimal diffs
|
||||
* no unrelated refactors
|
||||
* no formatting-only changes
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Strategy Integration
|
||||
|
||||
Builder rules:
|
||||
|
||||
* only add tests if:
|
||||
|
||||
* fixing a bug
|
||||
* protecting complex logic
|
||||
* validating invariants
|
||||
|
||||
Guardian rejects:
|
||||
|
||||
* redundant tests
|
||||
* no-op tests
|
||||
|
||||
---
|
||||
|
||||
## 9. Optional Extensions
|
||||
|
||||
### 9.1 Third Agent (Optimizer)
|
||||
|
||||
role: performance + cleanup
|
||||
|
||||
runs AFTER approval:
|
||||
|
||||
* reduce allocations
|
||||
* simplify logic
|
||||
* improve ECS scheduling
|
||||
|
||||
---
|
||||
|
||||
### 9.2 CI Integration
|
||||
|
||||
Pipeline:
|
||||
|
||||
```text
|
||||
Builder → Guardian → cargo check → clippy → tests
|
||||
```
|
||||
|
||||
Guardian runs BEFORE compilation to catch structural issues early.
|
||||
|
||||
---
|
||||
|
||||
## 10. Example Interaction
|
||||
|
||||
### Builder
|
||||
|
||||
```yaml
|
||||
intent:
|
||||
feature: "undo stack limit fix"
|
||||
crates_touched: [solitaire_core]
|
||||
risk_level: low
|
||||
```
|
||||
|
||||
```yaml
|
||||
change_summary: "limit undo stack to 64 entries"
|
||||
|
||||
files_modified:
|
||||
- solitaire_core/src/game_state.rs
|
||||
|
||||
notes: "prevents unbounded memory growth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Guardian
|
||||
|
||||
```yaml
|
||||
status: APPROVED
|
||||
|
||||
notes:
|
||||
- "respects core constraints"
|
||||
- "no invariant violations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Mental Model
|
||||
|
||||
* Builder = **creative**
|
||||
* Guardian = **strict**
|
||||
|
||||
Builder explores
|
||||
Guardian enforces
|
||||
|
||||
Neither replaces the other.
|
||||
|
||||
---
|
||||
|
||||
## 12. Success Criteria
|
||||
|
||||
System is working if:
|
||||
|
||||
* architectural violations go to ~0
|
||||
* code stays consistent across features
|
||||
* refactors become safe
|
||||
* complexity grows sub-linearly
|
||||
Generated
+134
-849
File diff suppressed because it is too large
Load Diff
+47
-1
@@ -7,6 +7,7 @@ members = [
|
||||
"solitaire_server",
|
||||
"solitaire_app",
|
||||
"solitaire_assetgen",
|
||||
"solitaire_wasm",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -29,13 +30,58 @@ dirs = "6"
|
||||
keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
arboard = { version = "3", default-features = false }
|
||||
|
||||
solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
|
||||
bevy = "0.18"
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
# Audio is handled directly by `kira` in `audio_plugin.rs`, so the
|
||||
# `bevy_audio` feature is intentionally omitted. The features below
|
||||
# enumerate every leaf of the standard `2d` + `ui` meta-features that
|
||||
# we actually use; new features should only be added with a
|
||||
# corresponding use site.
|
||||
bevy = { version = "0.18", default-features = false, features = [
|
||||
# default_app
|
||||
"async_executor",
|
||||
"bevy_asset",
|
||||
"bevy_input_focus",
|
||||
"bevy_log",
|
||||
"bevy_state",
|
||||
"bevy_window",
|
||||
"custom_cursor",
|
||||
"reflect_auto_register",
|
||||
# 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",
|
||||
"bevy_image",
|
||||
"bevy_mesh",
|
||||
"bevy_shader",
|
||||
"bevy_text",
|
||||
"png",
|
||||
# 2d rendering
|
||||
"bevy_camera",
|
||||
"bevy_render",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_sprite",
|
||||
"bevy_sprite_render",
|
||||
# UI rendering
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
] }
|
||||
kira = "0.12"
|
||||
|
||||
# SVG rasterisation pipeline for the runtime card-theme system.
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
# Solitaire Quest
|
||||
|
||||
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
|
||||
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
|
||||
system, full progression (XP / levels / achievements / daily challenges), and
|
||||
optional self-hosted sync so your stats follow you across machines.
|
||||
|
||||
## Features
|
||||
|
||||
- **Klondike Solitaire** — Draw One and Draw Three modes
|
||||
- **Klondike Solitaire** — Draw One and Draw Three modes; foundations are
|
||||
unlocked (any Ace lands in any empty slot, the slot then claims that suit)
|
||||
- **Card themes** — bundled hayeah/playing-cards-assets default plus
|
||||
user-installable themes (drop a directory under the data dir or import a
|
||||
zip from Settings → Cosmetic)
|
||||
- **Modern HUD** — reserved top band keeps cards from crowding the score
|
||||
readout; the action bar auto-fades when the cursor leaves it so it can't
|
||||
compete with the play surface
|
||||
- **Drag feel** — every legal drop target is highlighted in green during
|
||||
drag; cards cast a soft drop shadow that lifts when picked up; the stock
|
||||
pile shows a remaining-count chip so you can see how close you are to a
|
||||
recycle
|
||||
- **Keyboard navigation** — Tab cycles focus through buttons, arrow keys
|
||||
move within picker rows, Enter activates; works across every modal and
|
||||
the HUD action bar
|
||||
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
||||
- **18 Achievements** — including secret ones
|
||||
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
|
||||
- **19 Achievements** — including secret ones
|
||||
- **Daily Challenge** — server-seeded so every player worldwide gets the
|
||||
same deal
|
||||
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
||||
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
||||
- **Sync** — pull/push stats across devices via a self-hosted server
|
||||
- **Color-blind mode** — blue tint on red-suit cards
|
||||
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
|
||||
glyph
|
||||
|
||||
## Building
|
||||
|
||||
@@ -32,52 +50,73 @@ cargo build -p solitaire_app --release
|
||||
|
||||
## Controls
|
||||
|
||||
Every action also has a visible UI button — keyboard shortcuts are optional
|
||||
accelerators.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| Left click / drag | Move cards |
|
||||
| Double click | Auto-move card to its best legal destination |
|
||||
| Right click | Highlight legal moves for a card |
|
||||
| Space / D | Draw from stock |
|
||||
| Z / Ctrl+Z | Undo |
|
||||
| U | Undo |
|
||||
| H | Hint (highlight a legal move) |
|
||||
| N | New game |
|
||||
| S | Stats overlay |
|
||||
| A | Achievements overlay |
|
||||
| P | Profile overlay |
|
||||
| O | Settings |
|
||||
| L | Leaderboard |
|
||||
| H | Help / controls |
|
||||
| Enter | Auto-complete (when badge is lit) |
|
||||
| Escape | Pause / clear selection |
|
||||
| Arrow keys | Navigate card selection |
|
||||
| Z | Zen mode |
|
||||
| G | Forfeit (during pause) |
|
||||
| Tab / Shift+Tab | Cycle keyboard focus |
|
||||
| Enter | Activate focused button / auto-complete (when badge is lit) |
|
||||
| Esc | Pause / dismiss modal |
|
||||
| F1 | Help / controls |
|
||||
| F11 | Toggle fullscreen |
|
||||
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
|
||||
|
||||
## Card themes
|
||||
|
||||
The default theme ships embedded in the binary, so the game runs
|
||||
self-contained with no external assets. To install another theme, drop a
|
||||
directory containing a `theme.ron` manifest plus 53 SVG files (52 faces +
|
||||
1 back) under the platform data dir's `themes/` folder, or import a zip
|
||||
from **Settings → Cosmetic**. The picker chip lights up the moment a new
|
||||
theme is registered. Themes are SVG-based, so they rasterise cleanly at
|
||||
whatever resolution the window happens to be.
|
||||
|
||||
## Sync Server (optional)
|
||||
|
||||
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
|
||||
To sync stats across machines, run the self-hosted server. See
|
||||
[README_SERVER.md](README_SERVER.md) for setup instructions.
|
||||
|
||||
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
|
||||
Once the server is running, open **Settings → Sync Backend**, enter the
|
||||
server URL and your username, and register an account from within the
|
||||
game.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
# All tests (982 passing as of v0.11.0)
|
||||
cargo test --workspace
|
||||
|
||||
# Just game logic (no display required)
|
||||
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
||||
|
||||
# Lint
|
||||
cargo clippy --workspace -- -D warnings
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio,
|
||||
Axum, sqlx, Serde, kira, and many more). Card faces come from
|
||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||
(Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
|
||||
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||
(MIT, derived from the public-domain `vector-playing-cards` library); the
|
||||
default card back is original work; the UI font is FiraMono-Medium (OFL).
|
||||
All audio is synthesized programmatically by this project. See
|
||||
[CREDITS.md](CREDITS.md) for the full list and license details.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
+140
-70
@@ -1,110 +1,180 @@
|
||||
# Solitaire Quest — UX Overhaul Session Handoff
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-02 (session 7) — UX iteration round complete: every item from session 6's UX punch list has shipped, plus a font-fallback fix surfaced by a second-machine smoke test. Six commits on top of session 6's `c4970b1`. Direction now opens for the next round — release prep or another UX pass, the player's call.
|
||||
**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:** `655dfde`. Local master is **3 commits ahead** of `origin/master` (`f6c9166`, `f712b89`, `655dfde` unpushed; `fdb6c2e` and `95df542` already pushed).
|
||||
- **Working tree:** clean. (`CARD_PLAN.md` is untracked but intentionally so — it's a plan doc, not source.)
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- **Tests:** **982 passed / 0 failed** across the workspace (+20 from session 6's 962 baseline).
|
||||
- **Tags on origin:** `v0.9.0`, `v0.10.0`. Stale local-only `v0.1.0` is still safe to `git tag -d v0.1.0`.
|
||||
- **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:** **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
|
||||
|
||||
Session 6's UX punch list was four items. All four shipped today, plus an unrelated font-fallback fix from a second-machine smoke test.
|
||||
v0.18.0's resume-prompt menu (A–D) is closed:
|
||||
|
||||
The card-theme system, HUD restructure, modal scaffold, and the four big UX feel items (foundations, drop shadows, drop highlights, stock badge) are all in. Direction is open — the deferred release-prep items (`v0.11.0` cut, README/CHANGELOG refresh, desktop packaging) are still on the table, and a fresh round of UX iteration is also available.
|
||||
- ~~**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`.
|
||||
|
||||
The Rusty Pixel theme arc is documented as a sub-history but
|
||||
not part of v0.19.0's content:
|
||||
|
||||
| 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 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)
|
||||
|
||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, 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` (auto-memory; on a different machine, recreate this fresh from the README + ARCHITECTURE.md).
|
||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy,
|
||||
satisfying micro-interactions.
|
||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm
|
||||
magenta secondary.
|
||||
|
||||
### Canonical remote
|
||||
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. (Earlier sessions used `Rusty_Solitare` — single-i typo — as the repo name; the rename to `Rusty_Solitaire` happened in session 7. Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
||||
Always push there.
|
||||
|
||||
## Session 7 (shipped 2026-05-02)
|
||||
## v0.19.0 (2026-05-06)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
| Area | Commits | What landed |
|
||||
|---|---|---|
|
||||
| Font fallback | `fdb6c2e` | `shared_fontdb` now `include_bytes!()`s `assets/fonts/main.ttf` (FiraMono) and pins every CSS generic to `"Fira Mono"` so unmatched named families on minimal Linux installs / fresh Wayland sessions / chroots don't drop card rank/suit text. Surfaced when a second-machine pull rendered cards without glyphs. |
|
||||
| Unlock foundations | `95df542` | `PileType::Foundation(Suit)` → `Foundation(u8)` (slot 0..3). `Pile::claimed_suit()` derives the claim from the bottom card — no separate field, no claim-stuck-after-undo class of bugs. `can_place_on_foundation` drops its suit parameter. `next_auto_complete_move` prefers a slot whose claimed suit matches the candidate before falling back to the first empty slot for an Ace. Empty foundation markers render as plain placeholders (no "C/D/H/S"). HUD selection label and hint toast read `claimed_suit()` and fall through to "Foundation N" / "move to foundation" when the slot is empty. Save-format invalidation: `GameState.schema_version` bumped 1 → 2; old `game_state.json` files silently fall through to "fresh game on launch." Stats / progress / achievements / settings live in separate files and are unaffected. 9 new tests. |
|
||||
| Drop overlay | `f6c9166` | The pre-existing `update_drop_highlights` system tinted `PileMarker` sprites green for valid drops, but markers were occluded by stacked cards — invisible during real play. New `update_drop_target_overlays` spawns a soft-fill + 3 px outlined box ABOVE cards for every legal target (full fanned column for tableaux, card-sized for foundations / empty tableaux). `Z_DROP_OVERLAY = 50` sits above static cards but below `DRAG_Z = 500` so the dragged card never gets occluded. Reuses `STATE_SUCCESS` hue. The original marker-tint system is untouched. 3 new tests. |
|
||||
| Drop shadows | `f712b89` | Each `CardEntity` spawns a `CardShadow` child sprite — neutral black at 25 % alpha, sized `card_size + 4 px`, offset `(2, -3)`, local z `-0.05`. `update_card_shadows_on_drag` snaps shadows in `DragState.cards` to a lifted state (40 % alpha, `(4, -6)` offset, `(8, 8)` padding). `resize_cards_in_place` extended to keep shadows cheap on window resize. `update_card_entity`'s `despawn_related` is followed by a fresh `add_card_shadow_child` so flips / theme swaps re-attach shadows. Pure `card_shadow_params(is_dragged)` helper unit-tested. 4 new tests. |
|
||||
| Stock badge | `655dfde` | A small `·N` chip at the top-right corner of the stock pile shows the remaining count. `update_stock_count_badge` spawns a top-level world entity whose `Transform.translation` is recomputed each tick from `LayoutResource`, so window resize / theme swap don't strand it. Hides via `Visibility::Hidden` when the stock empties — the existing `↺` `StockEmptyLabel` takes over and they never co-render. `Z_STOCK_BADGE = 30` sits between cards and `Z_DROP_OVERLAY`. 4 new tests. |
|
||||
| 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 — release prep (still deferred unless player chooses now)
|
||||
## Open punch list
|
||||
|
||||
1. **Cut `v0.11.0`** — meaningful slice since `v0.10.0`: full card-theme system (CARD_PLAN phases 1–7 + theme picker + hayeah art), HUD overhaul (band + fade), session 6's four bug fixes, and session 7's font fallback + four UX feel wins. (`git tag -d v0.1.0` first to clean up the stale local tag.)
|
||||
2. **README + CHANGELOG refresh** — README was last touched at `a6b8348` before the Settings picker shipped; doesn't mention card themes, the auto-fade, or any of session 7's UX work.
|
||||
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo, no remote yet). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||
### Carried forward
|
||||
|
||||
## Open punch list — UX iteration (next-round candidates)
|
||||
- **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.
|
||||
|
||||
The session-6 list is exhausted. Candidates for a next round, none formally requested by the player:
|
||||
### Possible next-round candidates
|
||||
|
||||
- **Animated focus ring** (currently a static overlay; could pulse on focus change).
|
||||
- **Achievement onboarding pass** — show first-time players the achievement panel after their first win.
|
||||
- **Mode-switch keyboard shortcut** from inside the Mode Launcher (today only mouse opens it).
|
||||
- **Runtime aspect-ratio fidelity** — hayeah SVGs are ~1.45 h/w; engine layout assumes 1.4. Cards render ~3 % squashed vertically. Cosmetic.
|
||||
- **Foundation completion celebration** — when a foundation reaches its King, do a small flourish (sparkle, lift, sound). The auto-complete cascade already covers the win moment, but per-foundation closure is currently silent.
|
||||
- **Drag-cancel return animation** — illegal drops snap cards back instantly. A short ease-back tween ("springs back to where it came from") would feel more forgiving.
|
||||
- **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.
|
||||
|
||||
## Card-theme system (CARD_PLAN.md, fully shipped)
|
||||
### Process notes (from this round)
|
||||
|
||||
Seven phases landed across `b8fb3fb` → `924a1e2`. End-to-end:
|
||||
|
||||
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
|
||||
- **User themes** live under `themes://` rooted at `solitaire_engine::assets::user_theme_dir()`. Drop a directory containing `theme.ron` + 53 SVG files; appears in the registry on next launch.
|
||||
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive (20 MB cap, zip-slip rejection, manifest validation, every SVG round-tripped through the rasteriser) and atomically unpacks.
|
||||
- **Picker UI** in Settings → Cosmetic — one chip per registered theme; selection persists to `settings.json` as `selected_theme_id` and propagates to live sprites via `react_to_settings_theme_change` → `sync_card_image_set_with_active_theme` → `StateChangedEvent`.
|
||||
- **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 — local
|
||||
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
||||
Branch: master. Direction is OPEN — the session-6 UX punch list is
|
||||
fully shipped. The player will choose between cutting v0.11.0, doing
|
||||
release prep (README/CHANGELOG/packaging), or starting a new UX
|
||||
iteration round.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||
Branch: master. v0.19.0 just shipped. The next natural item is
|
||||
desktop-packaging follow-through, starting with the app icon.
|
||||
|
||||
State: HEAD=655dfde. Local master is 3 commits ahead of origin
|
||||
(f6c9166, f712b89, 655dfde unpushed; fdb6c2e and 95df542 already
|
||||
pushed). Working tree clean apart from untracked CARD_PLAN.md
|
||||
(intentional).
|
||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||
Tests: 982 passed / 0 failed.
|
||||
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 — full state, session 7 changelog, punch list
|
||||
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||
3. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
4. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
— saved feedback / project context (machine-local;
|
||||
may be missing on a fresh machine)
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
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
|
||||
6. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
— saved feedback / project context
|
||||
(machine-local; may be missing on a
|
||||
fresh machine)
|
||||
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. Push the 3 unpushed commits and cut v0.11.0?
|
||||
B. Skip the tag for now, refresh README + CHANGELOG, then tag?
|
||||
C. Skip release prep entirely and start a new UX iteration round?
|
||||
If C, see the session-7 next-round candidates list (animated
|
||||
focus ring, achievement onboarding, mode-switch keyboard
|
||||
shortcut, aspect-ratio fidelity, foundation completion flourish,
|
||||
drag-cancel return tween).
|
||||
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 / B / C. Don't pick unilaterally —
|
||||
this is a directional choice, not a tactical one.
|
||||
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
||||
```
|
||||
|
||||
Binary file not shown.
@@ -3,16 +3,19 @@ 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,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||
ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin,
|
||||
SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin,
|
||||
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -42,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(),
|
||||
@@ -111,11 +116,14 @@ fn main() {
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
@@ -125,7 +133,7 @@ fn main() {
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
@@ -137,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
|
||||
|
||||
@@ -16,13 +16,14 @@ fn main() -> io::Result<()> {
|
||||
let out_dir = workspace_root().join("assets").join("audio");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
let effects: [(&str, Generator); 6] = [
|
||||
let effects: [(&str, Generator); 7] = [
|
||||
("card_flip.wav", card_flip),
|
||||
("card_place.wav", card_place),
|
||||
("card_deal.wav", card_deal),
|
||||
("card_invalid.wav", card_invalid),
|
||||
("win_fanfare.wav", win_fanfare),
|
||||
("ambient_loop.wav", ambient_loop),
|
||||
("foundation_complete.wav", foundation_complete),
|
||||
];
|
||||
|
||||
for (name, make) in &effects {
|
||||
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-suit foundation-completion ping (~240 ms): a rising three-note
|
||||
/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each
|
||||
/// note. Shorter and brighter than `win_fanfare` so it can fire up to
|
||||
/// four times per game (once per suit) without drowning out subsequent
|
||||
/// move sounds. The fourth firing co-occurs with the win cascade and
|
||||
/// `win_fanfare`; the C-major triad sits an octave above the
|
||||
/// fanfare's root so the two layer cleanly instead of fighting for the
|
||||
/// same frequency band.
|
||||
fn foundation_complete() -> Vec<i16> {
|
||||
// C major triad, one octave up from win_fanfare's root.
|
||||
let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6
|
||||
let note_dur = 0.07_f32; // brisk, ascending
|
||||
let total = note_dur * notes.len() as f32 + 0.05;
|
||||
let n = duration_samples(total);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
let mut sample = 0.0f32;
|
||||
for (idx, freq) in notes.iter().enumerate() {
|
||||
let start = idx as f32 * note_dur;
|
||||
let local = t - start;
|
||||
// Each note rings out for 0.18 s — overlapping notes form a
|
||||
// brief chord at the tail.
|
||||
if !(0.0..=0.18).contains(&local) {
|
||||
continue;
|
||||
}
|
||||
// Sine + soft 2nd harmonic for warmth, ar_envelope decays
|
||||
// sharply so each note is bell-like rather than sustained.
|
||||
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
|
||||
+ 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
|
||||
let env = ar_envelope(local, 0.005, 0.18, 14.0);
|
||||
sample += s * env;
|
||||
}
|
||||
out.push(quantize(sample * 0.20));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
||||
/// mono 16-bit PCM).
|
||||
///
|
||||
|
||||
@@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
|
||||
fn zen_winner(c: &AchievementContext) -> bool {
|
||||
c.last_win_is_zen
|
||||
}
|
||||
/// Cinephile is event-driven: it unlocks when the engine observes a
|
||||
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
|
||||
/// any field of [`AchievementContext`]. The condition predicate therefore
|
||||
/// always returns false so [`check_achievements`] never unlocks it from a
|
||||
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
|
||||
/// `AchievementUnlockedEvent` written directly from the engine's
|
||||
/// replay-playback observer.
|
||||
fn cinephile_never(_c: &AchievementContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// All currently-evaluable achievements. Order is stable so persistence files
|
||||
/// remain readable across versions (new achievements append).
|
||||
@@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
||||
reward: Some(Reward::Badge),
|
||||
condition: zen_winner,
|
||||
},
|
||||
AchievementDef {
|
||||
id: "cinephile",
|
||||
name: "Cinephile",
|
||||
description: "Watch a saved replay all the way through",
|
||||
secret: false,
|
||||
reward: None,
|
||||
// Event-driven unlock: the engine's replay-playback observer fires
|
||||
// `AchievementUnlockedEvent("cinephile")` directly on a Playing →
|
||||
// Completed transition. `cinephile_never` keeps the condition path
|
||||
// a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
|
||||
condition: cinephile_never,
|
||||
},
|
||||
];
|
||||
|
||||
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
||||
@@ -721,6 +743,31 @@ mod tests {
|
||||
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cinephile_achievement_in_canonical_list() {
|
||||
let def = achievement_by_id("cinephile").expect("cinephile must be registered");
|
||||
assert_eq!(def.id, "cinephile");
|
||||
assert_eq!(def.name, "Cinephile");
|
||||
assert!(!def.secret, "cinephile is not a secret achievement");
|
||||
// Event-driven: the predicate is a sentinel that always returns
|
||||
// false. `check_achievements` must never unlock cinephile from a
|
||||
// GameWonEvent context, even one that satisfies every other gate.
|
||||
let mut c = ctx();
|
||||
c.games_won = 1;
|
||||
c.win_streak_current = 999;
|
||||
c.last_win_time_seconds = 1;
|
||||
c.last_win_used_undo = false;
|
||||
c.best_single_score = 99_999;
|
||||
c.lifetime_score = u64::MAX;
|
||||
c.last_win_is_zen = true;
|
||||
c.last_win_recycle_count = 99;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(
|
||||
!ids.contains(&"cinephile"),
|
||||
"cinephile must never unlock via condition evaluation; got {ids:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfectionist_score_well_above_threshold_still_passes() {
|
||||
let mut c = ctx();
|
||||
|
||||
@@ -77,16 +77,6 @@ pub struct Card {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rank_value_ace_is_one() {
|
||||
assert_eq!(Rank::Ace.value(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_value_king_is_thirteen() {
|
||||
assert_eq!(Rank::King.value(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_values_are_sequential() {
|
||||
let ranks = [
|
||||
@@ -100,26 +90,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_red_is_diamonds_and_hearts() {
|
||||
assert!(Suit::Diamonds.is_red());
|
||||
assert!(Suit::Hearts.is_red());
|
||||
assert!(!Suit::Clubs.is_red());
|
||||
assert!(!Suit::Spades.is_red());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_black_is_clubs_and_spades() {
|
||||
assert!(Suit::Clubs.is_black());
|
||||
assert!(Suit::Spades.is_black());
|
||||
assert!(!Suit::Diamonds.is_black());
|
||||
assert!(!Suit::Hearts.is_black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_up_field_reflects_construction() {
|
||||
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false };
|
||||
assert!(!card.face_up);
|
||||
let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true };
|
||||
assert!(card2.face_up);
|
||||
fn suit_red_and_black_are_complementary() {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
|
||||
}
|
||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::card::Card;
|
||||
use crate::deck::{deal_klondike, Deck};
|
||||
use crate::error::MoveError;
|
||||
use crate::pile::{Pile, PileType};
|
||||
use crate::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
||||
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
|
||||
|
||||
const MAX_UNDO_STACK: usize = 64;
|
||||
@@ -283,6 +283,18 @@ impl GameState {
|
||||
if !can_place_on_tableau(&bottom_card, dest) {
|
||||
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
|
||||
}
|
||||
// The previous check only validates that the *bottom* of the
|
||||
// moved stack lands on the destination's top card. Without
|
||||
// this guard, a player could lift an arbitrary multi-card
|
||||
// selection from one column and drop it onto another whenever
|
||||
// the bottom card happens to match — even if the cards
|
||||
// above the bottom don't form a legal descending
|
||||
// alternating-colour run.
|
||||
if !is_valid_tableau_sequence(&from_pile.cards[start..]) {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"moved cards must form a valid tableau run".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => return Err(MoveError::InvalidDestination),
|
||||
}
|
||||
@@ -803,11 +815,6 @@ mod tests {
|
||||
assert!(g.undo_stack_len() <= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_count_starts_at_zero() {
|
||||
assert_eq!(new_game().undo_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_count_increments_on_each_undo() {
|
||||
let mut g = new_game();
|
||||
@@ -888,11 +895,6 @@ mod tests {
|
||||
assert_eq!(g.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_default_is_classic_via_default_trait() {
|
||||
assert_eq!(GameMode::default(), GameMode::Classic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_field_persists_through_construction() {
|
||||
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
||||
@@ -944,12 +946,6 @@ mod tests {
|
||||
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_attack_score_starts_at_zero() {
|
||||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
assert_eq!(g.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_attack_draw_three_combination() {
|
||||
// TimeAttack + DrawThree is a valid combination; verify construction.
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod game_state;
|
||||
pub mod pile;
|
||||
pub mod rules;
|
||||
pub mod scoring;
|
||||
pub mod solver;
|
||||
|
||||
@@ -30,6 +30,18 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `cards` is a legal tableau run on its own — every
|
||||
/// adjacent pair descends by one rank and alternates colour. A single
|
||||
/// card is trivially valid. The destination check is separate; this
|
||||
/// only validates the sequence's *internal* structure, which the tableau
|
||||
/// move path must enforce so a player can't smuggle an arbitrary stack
|
||||
/// onto another column when the bottom card happens to land legally.
|
||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||
cards.windows(2).all(|w| {
|
||||
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -174,4 +186,26 @@ mod tests {
|
||||
let p = pile_with(PileType::Tableau(0), vec![top]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_sequence_validation() {
|
||||
// Single card is trivially a valid sequence.
|
||||
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
|
||||
// Valid descending alternating-colour run K♠ Q♥ J♣.
|
||||
assert!(is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Hearts, Rank::Queen),
|
||||
card(Suit::Clubs, Rank::Jack),
|
||||
]));
|
||||
// Same colour twice (Q♠ on K♠) — invalid.
|
||||
assert!(!is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Spades, Rank::Queen),
|
||||
]));
|
||||
// Rank gap (K♠ → J♥) — invalid.
|
||||
assert!(!is_valid_tableau_sequence(&[
|
||||
card(Suit::Spades, Rank::King),
|
||||
card(Suit::Hearts, Rank::Jack),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,9 +90,4 @@ mod tests {
|
||||
seeds.dedup();
|
||||
assert_eq!(seeds.len(), len_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_count_matches_seed_list_length() {
|
||||
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ pub trait SyncProvider: Send + Sync {
|
||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||
Ok(())
|
||||
}
|
||||
/// Upload a winning replay to the backend. On success, returns the
|
||||
/// shareable web URL the player can copy to their clipboard
|
||||
/// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
|
||||
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
||||
/// silently no-op'd by the engine's push-on-win system, matching
|
||||
/// the same pattern `pull` / `push` follow.
|
||||
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||
Err(SyncError::UnsupportedPlatform)
|
||||
}
|
||||
}
|
||||
|
||||
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
||||
@@ -92,6 +101,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||
(**self).delete_account().await
|
||||
}
|
||||
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||
(**self).push_replay(replay).await
|
||||
}
|
||||
}
|
||||
|
||||
pub mod stats;
|
||||
@@ -99,8 +111,11 @@ pub use stats::{StatsExt, StatsSnapshot};
|
||||
|
||||
pub mod storage;
|
||||
pub use storage::{
|
||||
cleanup_orphaned_tmp_files, delete_game_state_at, game_state_file_path, load_game_state_from,
|
||||
load_stats, load_stats_from, save_game_state_to, save_stats, save_stats_to, stats_file_path,
|
||||
cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at,
|
||||
game_state_file_path, load_game_state_from, load_stats, load_stats_from,
|
||||
load_time_attack_session_from, load_time_attack_session_from_at, save_game_state_to,
|
||||
save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
|
||||
time_attack_session_path, time_attack_session_with_now, TimeAttackSession,
|
||||
};
|
||||
|
||||
pub mod achievements;
|
||||
@@ -126,7 +141,10 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||
Theme, WindowGeometry,
|
||||
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
||||
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
pub mod auth_tokens;
|
||||
@@ -136,3 +154,12 @@ pub use auth_tokens::{
|
||||
|
||||
pub mod sync_client;
|
||||
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
||||
|
||||
pub mod replay;
|
||||
#[allow(deprecated)]
|
||||
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
||||
pub use replay::{
|
||||
append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
|
||||
replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
|
||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
@@ -162,21 +162,6 @@ mod tests {
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut p = PlayerProgress::default();
|
||||
p.add_xp(1234);
|
||||
p.unlocked_card_backs.push(2);
|
||||
save_progress_to(&path, &p).expect("save");
|
||||
let loaded = load_progress_from(&path);
|
||||
assert_eq!(loaded.total_xp, 1234);
|
||||
assert_eq!(loaded.level, p.level);
|
||||
assert!(loaded.unlocked_card_backs.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_default() {
|
||||
let path = tmp_path("missing_xyz");
|
||||
@@ -298,4 +283,70 @@ mod tests {
|
||||
assert!(!recorded_again, "same-day completion must report no-op");
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
}
|
||||
|
||||
// --- Daily challenge history & longest streak ---
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_appends_to_history() {
|
||||
// Recording a completion adds the date to history, preserving the
|
||||
// pre-call length + 1, and the new entry is the chronological tail.
|
||||
let mut p = PlayerProgress::default();
|
||||
let prev_len = p.daily_challenge_history.len();
|
||||
let today = NaiveDate::from_ymd_opt(2026, 5, 5).unwrap();
|
||||
let recorded = p.record_daily_completion(today);
|
||||
assert!(recorded);
|
||||
assert_eq!(p.daily_challenge_history.len(), prev_len + 1);
|
||||
assert_eq!(p.daily_challenge_history.last().copied(), Some(today));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_updates_longest_streak() {
|
||||
// A streak of 4 must lift `daily_challenge_longest_streak` from 2 to 4
|
||||
// (we seed the previous best at 2 and watch it get overtaken).
|
||||
let mut p = PlayerProgress {
|
||||
daily_challenge_longest_streak: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let d = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
|
||||
p.record_daily_completion(d);
|
||||
p.record_daily_completion(d + Duration::days(1));
|
||||
p.record_daily_completion(d + Duration::days(2));
|
||||
// 3rd consecutive day equals the previous best; longest should match.
|
||||
assert_eq!(p.daily_challenge_streak, 3);
|
||||
assert_eq!(p.daily_challenge_longest_streak, 3);
|
||||
// 4th consecutive day overtakes the previous best.
|
||||
p.record_daily_completion(d + Duration::days(3));
|
||||
assert_eq!(p.daily_challenge_streak, 4);
|
||||
assert_eq!(p.daily_challenge_longest_streak, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_progress_without_history_deserializes_to_empty() {
|
||||
// A progress.json file produced before the history fields existed
|
||||
// must still round-trip through serde::from_slice without error,
|
||||
// with the new fields landing on their `#[serde(default)]` values.
|
||||
let path = tmp_path("legacy_no_history");
|
||||
let _ = fs::remove_file(&path);
|
||||
let legacy_json = br#"{
|
||||
"total_xp": 1500,
|
||||
"level": 3,
|
||||
"daily_challenge_last_completed": null,
|
||||
"daily_challenge_streak": 0,
|
||||
"weekly_goal_progress": {},
|
||||
"unlocked_card_backs": [0],
|
||||
"unlocked_backgrounds": [0],
|
||||
"last_modified": "2026-04-29T12:00:00Z"
|
||||
}"#;
|
||||
fs::write(&path, legacy_json).expect("write");
|
||||
let p = load_progress_from(&path);
|
||||
assert_eq!(p.total_xp, 1500);
|
||||
assert!(
|
||||
p.daily_challenge_history.is_empty(),
|
||||
"legacy file lacking daily_challenge_history must default to empty"
|
||||
);
|
||||
assert_eq!(
|
||||
p.daily_challenge_longest_streak, 0,
|
||||
"legacy file lacking daily_challenge_longest_streak must default to 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
//! Win-game replay recording + storage.
|
||||
//!
|
||||
//! When a player wins, the engine freezes the in-memory recording into a
|
||||
//! [`Replay`] and persists it to `<data_dir>/solitaire_quest/latest_replay.json`
|
||||
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
|
||||
//! action that loads it via [`load_latest_replay_from`] so the player can
|
||||
//! revisit (or, in a future build, watch the engine re-execute) the path
|
||||
//! they took to victory.
|
||||
//!
|
||||
//! Schema versioning: bump [`REPLAY_SCHEMA_VERSION`] whenever the on-disk
|
||||
//! shape changes. [`load_latest_replay_from`] returns `None` when the file
|
||||
//! carries any other version so older replays are silently dropped instead
|
||||
//! of crashing the loader.
|
||||
//!
|
||||
//! The recording is intentionally minimal — only [`ReplayMove`] entries
|
||||
//! that successfully advanced the game. `Undo` is **not** recorded: a
|
||||
//! replay represents the canonical path the player ultimately took to win,
|
||||
//! so backed-out missteps simply do not appear in the move list. The
|
||||
//! starting deal is not stored either — the [`seed`](Replay::seed) +
|
||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
|
||||
/// Maximum number of recent winning replays the rolling history retains.
|
||||
///
|
||||
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
|
||||
/// the oldest entry is dropped so the file never grows unbounded. The
|
||||
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
|
||||
/// the Stats overlay's replay selector — older wins age out silently.
|
||||
pub const REPLAY_HISTORY_CAP: usize = 8;
|
||||
|
||||
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
|
||||
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
|
||||
/// returns `None` for older files (the player simply sees an empty
|
||||
/// history rather than a half-loaded broken one). Bumping
|
||||
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
|
||||
/// [`Replay`] payloads inside an otherwise-current history.
|
||||
///
|
||||
/// History:
|
||||
/// - v1 (current): initial release of the rolling history wrapper.
|
||||
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Default value for [`ReplayHistory::schema_version`] when deserialising
|
||||
/// files that pre-date the field. Any value other than
|
||||
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
|
||||
/// to return `None`.
|
||||
fn history_schema_v0() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Save-file schema version for [`Replay`]. Increment when the on-disk
|
||||
/// representation changes incompatibly so [`load_latest_replay_from`] can
|
||||
/// reject older formats and the player simply has no replay rather than
|
||||
/// seeing a broken one.
|
||||
///
|
||||
/// History:
|
||||
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
|
||||
/// variants which carried the *outcome* of a stock interaction rather
|
||||
/// than the player's atomic input.
|
||||
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
|
||||
/// variant. The engine resolves draw-vs-recycle deterministically from
|
||||
/// the current stock state, so the input alone is sufficient and the
|
||||
/// replay model now stores atomic player inputs end-to-end.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||
/// causes [`load_latest_replay_from`] to return `None`.
|
||||
fn schema_v0() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// One atomic player input recorded during a winning game, in the order
|
||||
/// it was applied to the live `GameState`.
|
||||
///
|
||||
/// `Undo` is intentionally absent — see the module-level docs.
|
||||
///
|
||||
/// The variants represent *inputs*, not outcomes. `StockClick` covers
|
||||
/// every player click on the stock pile; the engine then resolves
|
||||
/// draw-vs-recycle deterministically from the current state during both
|
||||
/// recording and playback, so the same input always produces the same
|
||||
/// effect on the same starting deal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReplayMove {
|
||||
/// A successful `move_cards(from, to, count)` call.
|
||||
Move {
|
||||
/// Source pile.
|
||||
from: PileType,
|
||||
/// Destination pile.
|
||||
to: PileType,
|
||||
/// Number of cards moved.
|
||||
count: usize,
|
||||
},
|
||||
/// A click on the stock pile. Resolves to a draw when stock is
|
||||
/// non-empty and to a waste→stock recycle when stock is empty.
|
||||
StockClick,
|
||||
}
|
||||
|
||||
/// A complete recording of a single winning game.
|
||||
///
|
||||
/// Replays are reconstructed by rebuilding a fresh
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
||||
/// [`moves`](Self::moves) in order. The presentation fields
|
||||
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||
/// "Replay (2:14 win on 2026-05-02)".
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Replay {
|
||||
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default = "schema_v0")]
|
||||
pub schema_version: u32,
|
||||
/// Seed used for the deal — replay rasterises the deck via
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||
pub seed: u64,
|
||||
/// Draw mode the recorded game was played in.
|
||||
pub draw_mode: DrawMode,
|
||||
/// Game mode the recorded game was played in.
|
||||
pub mode: GameMode,
|
||||
/// Total wall-clock seconds the win took. Used for the Stats UI
|
||||
/// "Replay (2:14 win on 2026-05-02)" caption.
|
||||
pub time_seconds: u64,
|
||||
/// Final score at the moment of the win.
|
||||
pub final_score: i32,
|
||||
/// ISO-8601 date the win was recorded.
|
||||
pub recorded_at: NaiveDate,
|
||||
/// 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 {
|
||||
/// Construct a fresh replay with the current schema version. The
|
||||
/// caller fills in the recorded fields; this is the canonical
|
||||
/// constructor used by the engine on win.
|
||||
pub fn new(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
mode: GameMode,
|
||||
time_seconds: u64,
|
||||
final_score: i32,
|
||||
recorded_at: NaiveDate,
|
||||
moves: Vec<ReplayMove>,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_SCHEMA_VERSION,
|
||||
seed,
|
||||
draw_mode,
|
||||
mode,
|
||||
time_seconds,
|
||||
final_score,
|
||||
recorded_at,
|
||||
moves,
|
||||
share_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling history of the player's most recent winning replays.
|
||||
///
|
||||
/// Stored as a single JSON file at
|
||||
/// `<data_dir>/solitaire_quest/replays.json` (see
|
||||
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||
/// when [`append_replay_to_history`] pushes past the cap, the oldest
|
||||
/// entry is dropped so the file never grows unbounded.
|
||||
///
|
||||
/// `replays[0]` is always the most recent win; the Stats overlay's
|
||||
/// replay selector defaults to that entry and surfaces the older
|
||||
/// entries behind a small chooser so the player can revisit a memorable
|
||||
/// game even after a more recent win.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReplayHistory {
|
||||
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
|
||||
#[serde(default = "history_schema_v0")]
|
||||
pub schema_version: u32,
|
||||
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||
/// older entries drop off when the cap is hit.
|
||||
pub replays: Vec<Replay>,
|
||||
}
|
||||
|
||||
impl Default for ReplayHistory {
|
||||
/// An empty history at the current schema version. Used by callers
|
||||
/// that need a starting point before the first winning replay has
|
||||
/// ever been recorded.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplayHistory {
|
||||
/// Returns the most recent replay (`replays[0]`), or `None` when the
|
||||
/// history is empty. Convenience used by the Stats overlay's default
|
||||
/// selector position.
|
||||
pub fn most_recent(&self) -> Option<&Replay> {
|
||||
self.replays.first()
|
||||
}
|
||||
|
||||
/// Returns the number of replays currently retained.
|
||||
pub fn len(&self) -> usize {
|
||||
self.replays.len()
|
||||
}
|
||||
|
||||
/// Returns `true` when no replays have been recorded yet.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.replays.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `latest_replay.json`, or `None`
|
||||
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history at \
|
||||
replay_history_path(); kept for the one-shot legacy migration \
|
||||
in migrate_legacy_latest_replay"
|
||||
)]
|
||||
pub fn latest_replay_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `replays.json`, the rolling
|
||||
/// history file, or `None` if `dirs::data_dir()` is unavailable (e.g.
|
||||
/// minimal Linux containers).
|
||||
pub fn replay_history_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
||||
/// rename contract that the rest of `storage.rs` uses.
|
||||
///
|
||||
/// Overwrites any existing replay — only the most recent winning replay
|
||||
/// is retained on disk.
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history; \
|
||||
use append_replay_to_history instead. Kept for the one-shot \
|
||||
legacy migration."
|
||||
)]
|
||||
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(replay).map_err(io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a [`Replay`] from `path`, returning `None` when the file is
|
||||
/// missing, corrupt, or carries a [`schema_version`](Replay::schema_version)
|
||||
/// other than [`REPLAY_SCHEMA_VERSION`].
|
||||
///
|
||||
/// Schema-mismatch is treated as "no replay" so the player just sees the
|
||||
/// "No replay recorded yet" caption rather than a half-loaded broken
|
||||
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
|
||||
/// older save without further migration code.
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history; \
|
||||
use load_replay_history_from instead. Kept for the one-shot \
|
||||
legacy migration."
|
||||
)]
|
||||
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let replay: Replay = serde_json::from_slice(&data).ok()?;
|
||||
if replay.schema_version != REPLAY_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
Some(replay)
|
||||
}
|
||||
|
||||
/// Save a [`ReplayHistory`] atomically to `path` using the standard
|
||||
/// `.tmp` → rename contract.
|
||||
///
|
||||
/// The on-disk encoding is pretty-printed JSON; the file is intended to
|
||||
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
|
||||
/// hundred move records at most) so the readability tradeoff is fine.
|
||||
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
|
||||
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
|
||||
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
|
||||
///
|
||||
/// Individual [`Replay`] entries inside an otherwise-current history are
|
||||
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
|
||||
/// entries are silently dropped so a future bump of the inner replay
|
||||
/// schema does not corrupt the wrapper.
|
||||
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
|
||||
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
let filtered: Vec<Replay> = history
|
||||
.replays
|
||||
.into_iter()
|
||||
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
|
||||
.collect();
|
||||
Some(ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: filtered,
|
||||
})
|
||||
}
|
||||
|
||||
/// Append `replay` to the front of the rolling history at `path`,
|
||||
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
|
||||
/// and persist the updated history atomically.
|
||||
///
|
||||
/// If `path` has no existing history (missing file, corrupt, or
|
||||
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
|
||||
/// starting point so the new replay is always saved. The returned
|
||||
/// [`ReplayHistory`] is the exact value written to disk so callers can
|
||||
/// update an in-memory mirror (e.g. the Stats overlay's
|
||||
/// `ReplayHistoryResource`) without a follow-up `load`.
|
||||
pub fn append_replay_to_history(
|
||||
path: &Path,
|
||||
replay: Replay,
|
||||
) -> io::Result<ReplayHistory> {
|
||||
let mut history = load_replay_history_from(path).unwrap_or_default();
|
||||
// Most recent first. Reserve the front slot; pop the oldest if we
|
||||
// exceed the cap so the file never grows unbounded.
|
||||
history.replays.insert(0, replay);
|
||||
if history.replays.len() > REPLAY_HISTORY_CAP {
|
||||
history.replays.truncate(REPLAY_HISTORY_CAP);
|
||||
}
|
||||
save_replay_history_to(path, &history)?;
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
/// One-shot migration from the legacy single-slot
|
||||
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
|
||||
/// `history_path`.
|
||||
///
|
||||
/// Behaviour matrix:
|
||||
/// - `history_path` already exists → no-op (the rolling history wins).
|
||||
/// - `history_path` is absent and `latest_path` is absent → no-op.
|
||||
/// - `history_path` is absent and `latest_path` exists with a valid
|
||||
/// replay → seed a fresh history with that one replay and write it.
|
||||
/// - `history_path` is absent and `latest_path` exists but is corrupt /
|
||||
/// schema-mismatched → write an empty history (we know the player is
|
||||
/// on the new build and shouldn't keep being prompted to migrate).
|
||||
///
|
||||
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
|
||||
/// this helper — keep it for one release as a safety net so a player
|
||||
/// rolling back to the previous build doesn't lose their last winning
|
||||
/// replay. The deletion is planned for the release after this one.
|
||||
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
if history_path.exists() {
|
||||
// Rolling history is authoritative once it exists.
|
||||
return;
|
||||
}
|
||||
if !latest_path.exists() {
|
||||
return;
|
||||
}
|
||||
// Use the deprecated loader directly — the migration is the one
|
||||
// place we still consult the legacy file shape on purpose.
|
||||
#[allow(deprecated)]
|
||||
let legacy = load_latest_replay_from(latest_path);
|
||||
let history = match legacy {
|
||||
Some(replay) => ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay],
|
||||
},
|
||||
None => ReplayHistory::default(),
|
||||
};
|
||||
if let Err(e) = save_replay_history_to(history_path, &history) {
|
||||
// Migration failure is non-fatal: on the next launch we'll just
|
||||
// try again. We log to stderr rather than panic so headless
|
||||
// tests stay quiet.
|
||||
eprintln!(
|
||||
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// The legacy single-slot tests still exercise `save_latest_replay_to` /
|
||||
// `load_latest_replay_from` on purpose — they're the round-trip
|
||||
// guardrails for the migration source format.
|
||||
#[allow(deprecated)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
env::temp_dir().join(format!("solitaire_test_replay_{name}.json"))
|
||||
}
|
||||
|
||||
fn sample_replay() -> Replay {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||
Replay::new(
|
||||
12345,
|
||||
DrawMode::DrawThree,
|
||||
GameMode::Classic,
|
||||
134,
|
||||
5_120,
|
||||
date,
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Tableau(3),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Tableau(3),
|
||||
to: PileType::Foundation(0),
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A non-trivial replay with mixed move kinds must round-trip
|
||||
/// byte-identically through `save_latest_replay_to` /
|
||||
/// `load_latest_replay_from`. Catches any future field that forgets
|
||||
/// `Serialize`/`Deserialize` or breaks the on-disk format.
|
||||
#[test]
|
||||
fn replay_round_trips_through_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let replay = sample_replay();
|
||||
save_latest_replay_to(&path, &replay).expect("save");
|
||||
|
||||
let loaded = load_latest_replay_from(&path).expect("load must succeed");
|
||||
assert_eq!(loaded, replay, "round-trip must preserve every field");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A file written by an older schema (or a pre-`schema_version`
|
||||
/// build) must be rejected. We write a minimal v0 fixture and assert
|
||||
/// that `load_latest_replay_from` returns `None` so the player gets
|
||||
/// a clean "no replay" state instead of a broken one.
|
||||
#[test]
|
||||
fn replay_legacy_schema_version_falls_through_to_none() {
|
||||
let path = tmp_path("legacy_schema");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// No `schema_version` key — defaults to 0 via `schema_v0()`. Even
|
||||
// if the rest of the JSON parses cleanly, the version gate must
|
||||
// reject it.
|
||||
let v0_json = r#"{
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2025-01-01",
|
||||
"moves": []
|
||||
}"#;
|
||||
fs::write(&path, v0_json).expect("write v0 fixture");
|
||||
|
||||
assert!(
|
||||
load_latest_replay_from(&path).is_none(),
|
||||
"v0 replay must be rejected (schema gate)",
|
||||
);
|
||||
|
||||
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`.
|
||||
#[test]
|
||||
fn replay_save_is_atomic() {
|
||||
let path = tmp_path("atomic");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
save_latest_replay_to(&path, &sample_replay()).expect("save");
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Loading from a path that does not exist must return `None`, not
|
||||
/// panic or surface an `Err`.
|
||||
#[test]
|
||||
fn replay_missing_file_returns_none() {
|
||||
let path = tmp_path("missing_xyz");
|
||||
let _ = fs::remove_file(&path);
|
||||
assert!(load_latest_replay_from(&path).is_none());
|
||||
}
|
||||
|
||||
/// Loading from a corrupt / partially-written file must return
|
||||
/// `None`, not surface a deserialiser error to the engine.
|
||||
#[test]
|
||||
fn replay_corrupt_file_returns_none() {
|
||||
let path = tmp_path("corrupt");
|
||||
fs::write(&path, b"not valid json!!!").expect("write");
|
||||
assert!(load_latest_replay_from(&path).is_none());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ReplayHistory — rolling list of recent wins
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
|
||||
/// assert ordering / identity without writing a deep equality match.
|
||||
fn replay_with_id(id: i32) -> Replay {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||
Replay::new(
|
||||
id as u64,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
id,
|
||||
date,
|
||||
vec![ReplayMove::StockClick],
|
||||
)
|
||||
}
|
||||
|
||||
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
|
||||
/// the on-disk file (and the in-memory mirror returned by the helper)
|
||||
/// stays bounded so the user's data dir never grows unbounded.
|
||||
#[test]
|
||||
fn append_replay_to_history_caps_at_eight() {
|
||||
let path = tmp_path("history_cap");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut last_returned = ReplayHistory::default();
|
||||
for i in 0..10 {
|
||||
last_returned = append_replay_to_history(&path, replay_with_id(i))
|
||||
.expect("append must succeed");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
last_returned.replays.len(),
|
||||
REPLAY_HISTORY_CAP,
|
||||
"history must be capped at REPLAY_HISTORY_CAP entries",
|
||||
);
|
||||
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
|
||||
// survive (newest first), ids 0 and 1 aged out.
|
||||
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![9, 8, 7, 6, 5, 4, 3, 2],
|
||||
"newest entries must survive, oldest must age out",
|
||||
);
|
||||
|
||||
// The on-disk file must agree with the returned in-memory copy.
|
||||
let loaded = load_replay_history_from(&path).expect("load must succeed");
|
||||
assert_eq!(loaded, last_returned, "disk must mirror returned history");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// `append_replay_to_history` must place new entries at index 0 so
|
||||
/// the Stats overlay's default selector (most recent) lands on the
|
||||
/// just-saved replay.
|
||||
#[test]
|
||||
fn append_replay_inserts_at_front() {
|
||||
let path = tmp_path("history_front");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
|
||||
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
|
||||
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
|
||||
|
||||
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![3, 2, 1],
|
||||
"history must be reverse-chronological (newest first)",
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// On first launch with the new code, a pre-existing
|
||||
/// `latest_replay.json` must seed the new rolling history so the
|
||||
/// player doesn't lose their last winning replay across the upgrade.
|
||||
#[test]
|
||||
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
|
||||
let latest = tmp_path("legacy_migrate_latest");
|
||||
let history = tmp_path("legacy_migrate_history");
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
|
||||
// Seed the legacy file with a real replay.
|
||||
let legacy_replay = sample_replay();
|
||||
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
|
||||
assert!(!history.exists(), "history file must not exist pre-migration");
|
||||
|
||||
migrate_legacy_latest_replay(&latest, &history);
|
||||
|
||||
assert!(history.exists(), "migration must create the history file");
|
||||
let loaded = load_replay_history_from(&history)
|
||||
.expect("post-migration history must load");
|
||||
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
|
||||
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
|
||||
// Legacy file is intentionally retained for one release as a
|
||||
// safety net — see `migrate_legacy_latest_replay` doc comment.
|
||||
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
|
||||
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
}
|
||||
|
||||
/// When the rolling history file already exists, the migration must
|
||||
/// be a no-op — we never want to overwrite the player's accumulated
|
||||
/// history with a stale single-slot legacy entry.
|
||||
#[test]
|
||||
fn migrate_is_noop_when_history_already_exists() {
|
||||
let latest = tmp_path("legacy_noop_latest");
|
||||
let history = tmp_path("legacy_noop_history");
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
|
||||
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
|
||||
let pre_existing = ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay_with_id(42)],
|
||||
};
|
||||
save_replay_history_to(&history, &pre_existing).expect("seed history");
|
||||
|
||||
migrate_legacy_latest_replay(&latest, &history);
|
||||
|
||||
let loaded = load_replay_history_from(&history).expect("load");
|
||||
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
|
||||
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
}
|
||||
|
||||
/// A populated [`ReplayHistory`] must round-trip byte-identically
|
||||
/// through `save_replay_history_to` / `load_replay_history_from`.
|
||||
#[test]
|
||||
fn replay_history_round_trips_through_save_and_load() {
|
||||
let path = tmp_path("history_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let history = ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
|
||||
};
|
||||
save_replay_history_to(&path, &history).expect("save");
|
||||
let loaded = load_replay_history_from(&path).expect("load");
|
||||
assert_eq!(loaded, history, "round-trip must preserve every field");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A file written by an older history schema must be rejected so the
|
||||
/// player sees a clean empty history rather than a half-loaded one.
|
||||
#[test]
|
||||
fn replay_history_legacy_schema_version_falls_through_to_none() {
|
||||
let path = tmp_path("history_legacy_schema");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
|
||||
let v0_json = r#"{
|
||||
"replays": []
|
||||
}"#;
|
||||
fs::write(&path, v0_json).expect("write v0 fixture");
|
||||
|
||||
assert!(
|
||||
load_replay_history_from(&path).is_none(),
|
||||
"v0 history must be rejected (schema gate)",
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Atomic-write contract for the rolling history — `.tmp` must not be
|
||||
/// left behind after `save_replay_history_to` returns.
|
||||
#[test]
|
||||
fn replay_history_save_is_atomic() {
|
||||
let path = tmp_path("history_atomic");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
+248
-217
@@ -132,6 +132,66 @@ pub struct Settings {
|
||||
/// `#[serde(default = ...)]`.
|
||||
#[serde(default = "default_theme_id")]
|
||||
pub selected_theme_id: String,
|
||||
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||
/// shown to the player after their very first win. Acts as a
|
||||
/// one-shot teach: subsequent wins must not re-fire the cue. Older
|
||||
/// `settings.json` files written before this field existed
|
||||
/// deserialize cleanly to `false` thanks to `#[serde(default)]` —
|
||||
/// players who already had wins recorded before this field was
|
||||
/// introduced are guarded by the post-condition `games_won == 1`
|
||||
/// checked by `achievement_plugin::fire_achievement_onboarding_toast`,
|
||||
/// so the toast still does not fire for them.
|
||||
#[serde(default)]
|
||||
pub shown_achievement_onboarding: bool,
|
||||
/// Hover delay (seconds) before a tooltip appears. Range
|
||||
/// `[0.0, 1.5]`; default matches `MOTION_TOOLTIP_DELAY_SECS` (0.5 s).
|
||||
/// `0.0` means tooltips fire on the very next tick after hover —
|
||||
/// the "Instant" setting. Older `settings.json` files written before
|
||||
/// this field existed deserialize cleanly to the default via
|
||||
/// `#[serde(default = "default_tooltip_delay")]`.
|
||||
#[serde(default = "default_tooltip_delay")]
|
||||
pub tooltip_delay_secs: f32,
|
||||
/// Multiplier applied to the post-game time-bonus score component
|
||||
/// shown in the win-summary modal. Range
|
||||
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`
|
||||
/// (`0.0`–`2.0`); default `1.0` keeps the existing behaviour.
|
||||
///
|
||||
/// **COSMETIC ONLY** — this multiplier changes what the player
|
||||
/// sees in the win modal's score breakdown but does **not** affect
|
||||
/// achievement unlock thresholds, lifetime score totals, or
|
||||
/// leaderboard submissions, which all use the raw, unmultiplied
|
||||
/// score values produced by `solitaire_core`. Older
|
||||
/// `settings.json` files written before this field existed
|
||||
/// deserialize cleanly to `1.0` via
|
||||
/// `#[serde(default = "default_time_bonus_multiplier")]`.
|
||||
#[serde(default = "default_time_bonus_multiplier")]
|
||||
pub time_bonus_multiplier: f32,
|
||||
/// When `true`, the engine rejects new-game deals the
|
||||
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||
/// giving up and using the last tried seed. Off by default —
|
||||
/// the solver adds a few hundred milliseconds of latency on the
|
||||
/// pathological deals that hit the budget cap, and not every
|
||||
/// player wants to wait. Older `settings.json` files written
|
||||
/// before this field existed deserialize cleanly to `false` via
|
||||
/// `#[serde(default)]`.
|
||||
///
|
||||
/// Scope: only random-seed Classic-mode deals are filtered.
|
||||
/// Daily challenges, replays, and explicit-seed requests skip the
|
||||
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
||||
#[serde(default)]
|
||||
pub winnable_deals_only: bool,
|
||||
/// Per-move duration during replay playback, in seconds. Range
|
||||
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
|
||||
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||
/// (0.45 s/move) so existing playback behaviour is unchanged for
|
||||
/// players who never touch the slider. Smaller values scrub
|
||||
/// faster through the recorded move list. Older `settings.json`
|
||||
/// files written before this field existed deserialize cleanly to
|
||||
/// the default via
|
||||
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||
#[serde(default = "default_replay_move_interval_secs")]
|
||||
pub replay_move_interval_secs: f32,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -150,6 +210,83 @@ fn default_theme_id() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||
/// `solitaire_engine::ui_theme::MOTION_TOOLTIP_DELAY_SECS` so legacy
|
||||
/// `settings.json` files load to the existing baseline. The constant
|
||||
/// lives in the engine crate (which the data crate cannot depend on),
|
||||
/// so the value is duplicated here — kept in sync by the
|
||||
/// `settings_tooltip_delay_default_is_existing_baseline` test in
|
||||
/// `solitaire_engine::settings_plugin`.
|
||||
fn default_tooltip_delay() -> f32 {
|
||||
0.5
|
||||
}
|
||||
|
||||
/// Lower bound of the player-tunable tooltip delay slider, in seconds.
|
||||
pub const TOOLTIP_DELAY_MIN_SECS: f32 = 0.0;
|
||||
|
||||
/// Upper bound of the player-tunable tooltip delay slider, in seconds.
|
||||
pub const TOOLTIP_DELAY_MAX_SECS: f32 = 1.5;
|
||||
|
||||
/// Increment applied by the tooltip-delay decrement / increment buttons.
|
||||
pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1;
|
||||
|
||||
/// Lower bound of the player-tunable time-bonus multiplier. `0.0`
|
||||
/// disables the time-bonus row entirely (renders as "Off" in the UI).
|
||||
pub const TIME_BONUS_MULTIPLIER_MIN: f32 = 0.0;
|
||||
|
||||
/// Upper bound of the player-tunable time-bonus multiplier. `2.0`
|
||||
/// doubles the displayed time bonus.
|
||||
pub const TIME_BONUS_MULTIPLIER_MAX: f32 = 2.0;
|
||||
|
||||
/// Increment applied by the time-bonus multiplier decrement /
|
||||
/// increment buttons.
|
||||
pub const TIME_BONUS_MULTIPLIER_STEP: f32 = 0.1;
|
||||
|
||||
/// Default value for [`Settings::time_bonus_multiplier`]. `1.0` keeps
|
||||
/// the displayed time bonus identical to the raw value produced by
|
||||
/// `solitaire_core::scoring::compute_time_bonus`.
|
||||
fn default_time_bonus_multiplier() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Default per-move duration during replay playback, in seconds.
|
||||
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||
/// so legacy `settings.json` files load to the existing baseline and
|
||||
/// playback feels identical for players who never touch the slider.
|
||||
/// The constant is duplicated across the data and engine crates
|
||||
/// because `solitaire_data` cannot depend on the engine crate — keep
|
||||
/// the two values in sync when adjusting either.
|
||||
fn default_replay_move_interval_secs() -> f32 {
|
||||
0.45
|
||||
}
|
||||
|
||||
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. Below this the cards barely register visually before
|
||||
/// the next move fires; the cap keeps the playback legible.
|
||||
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
|
||||
|
||||
/// Upper bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. One second per move is a comfortable upper limit for
|
||||
/// players who want to study a recorded game frame by frame.
|
||||
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
|
||||
|
||||
/// Increment applied by the replay-playback decrement / increment
|
||||
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
|
||||
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
|
||||
/// without making the slider feel stuck on the same value.
|
||||
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||
|
||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||
/// is willing to attempt before giving up and accepting the latest
|
||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
||||
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
||||
/// deal than spin forever on the main thread.
|
||||
///
|
||||
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||
/// the upper bound on UI freeze when the toggle is on.
|
||||
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -165,17 +302,33 @@ impl Default for Settings {
|
||||
color_blind_mode: false,
|
||||
window_geometry: None,
|
||||
selected_theme_id: default_theme_id(),
|
||||
shown_achievement_onboarding: false,
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
winnable_deals_only: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
|
||||
/// deserialization or hand-editing of `settings.json`.
|
||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
|
||||
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
|
||||
/// their respective ranges after deserialization or hand-editing of
|
||||
/// `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||
music_volume: self.music_volume.clamp(0.0, 1.0),
|
||||
tooltip_delay_secs: self
|
||||
.tooltip_delay_secs
|
||||
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS),
|
||||
time_bonus_multiplier: self
|
||||
.time_bonus_multiplier
|
||||
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
||||
replay_move_interval_secs: self
|
||||
.replay_move_interval_secs
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||
..self
|
||||
}
|
||||
}
|
||||
@@ -191,6 +344,44 @@ impl Settings {
|
||||
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
||||
self.music_volume
|
||||
}
|
||||
|
||||
/// Adjust the tooltip-hover dwell delay by `delta` seconds, clamped
|
||||
/// to `[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS]`. Returns the
|
||||
/// new value.
|
||||
pub fn adjust_tooltip_delay(&mut self, delta: f32) -> f32 {
|
||||
self.tooltip_delay_secs = (self.tooltip_delay_secs + delta)
|
||||
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
|
||||
self.tooltip_delay_secs
|
||||
}
|
||||
|
||||
/// Adjust the time-bonus multiplier by `delta`, clamped to
|
||||
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`. The
|
||||
/// result is rounded to one decimal place so the readout stays
|
||||
/// clean across repeated `±` clicks (avoids float drift like
|
||||
/// `0.30000004`). Returns the new value.
|
||||
pub fn adjust_time_bonus_multiplier(&mut self, delta: f32) -> f32 {
|
||||
let raw = (self.time_bonus_multiplier + delta)
|
||||
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX);
|
||||
// Round to 1 decimal place — the slider step is 0.1, so this
|
||||
// collapses any FP drift introduced by repeated additions.
|
||||
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
||||
self.time_bonus_multiplier
|
||||
}
|
||||
|
||||
/// Adjust the replay-playback per-move interval by `delta`
|
||||
/// seconds, clamped to
|
||||
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
|
||||
/// The result is rounded to two decimal places so the readout
|
||||
/// stays clean across repeated `±` clicks at the 0.05 s step
|
||||
/// (avoids float drift like `0.45000003`). Returns the new value.
|
||||
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
|
||||
let raw = (self.replay_move_interval_secs + delta)
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||
// Round to 2 decimal places — the slider step is 0.05, so this
|
||||
// collapses any FP drift introduced by repeated additions.
|
||||
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
|
||||
self.replay_move_interval_secs
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||
@@ -231,18 +422,6 @@ mod tests {
|
||||
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_reasonable() {
|
||||
let s = Settings::default();
|
||||
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
||||
assert!(!s.first_run_complete);
|
||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||
assert_eq!(s.theme, Theme::Green);
|
||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_sfx_volume_clamps() {
|
||||
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
||||
@@ -275,72 +454,6 @@ mod tests {
|
||||
assert!(s.first_run_complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_clamps_music_volume() {
|
||||
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
|
||||
assert_eq!(s.music_volume, 1.0);
|
||||
|
||||
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
|
||||
assert_eq!(s2.music_volume, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
sfx_volume: 0.42,
|
||||
first_run_complete: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load_full_settings() {
|
||||
let path = tmp_path("round_trip_full");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
draw_mode: DrawMode::DrawThree,
|
||||
sfx_volume: 0.3,
|
||||
music_volume: 0.7,
|
||||
animation_speed: AnimSpeed::Fast,
|
||||
theme: Theme::Dark,
|
||||
sync_backend: SyncBackend::SolitaireServer {
|
||||
url: "https://example.com".to_string(),
|
||||
username: "testuser".to_string(),
|
||||
},
|
||||
selected_card_back: 0,
|
||||
selected_background: 0,
|
||||
first_run_complete: true,
|
||||
color_blind_mode: false,
|
||||
window_geometry: None,
|
||||
selected_theme_id: "default".to_string(),
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_non_default_cosmetic_selections() {
|
||||
// selected_card_back and selected_background must survive save→load with
|
||||
// non-zero values — zero is the default and not a meaningful regression check.
|
||||
let path = tmp_path("cosmetic_selections");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_card_back: 3,
|
||||
selected_background: 2,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_card_back, 3);
|
||||
assert_eq!(loaded.selected_background, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_default() {
|
||||
let path = tmp_path("missing_xyz");
|
||||
@@ -358,152 +471,70 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_old_format_uses_defaults_for_new_fields() {
|
||||
// Simulate a settings.json written by an older version that only had
|
||||
// sfx_volume and first_run_complete.
|
||||
let path = tmp_path("old_format");
|
||||
fs::write(
|
||||
&path,
|
||||
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
|
||||
)
|
||||
.expect("write");
|
||||
let s = load_settings_from(&path);
|
||||
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
|
||||
assert!(s.first_run_complete);
|
||||
// New fields should fall back to their defaults.
|
||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||
assert_eq!(s.theme, Theme::Green);
|
||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
|
||||
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
|
||||
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
|
||||
fn adjust_tooltip_delay_clamps_to_range() {
|
||||
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
||||
// Step up to 0.6.
|
||||
assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6);
|
||||
// Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS.
|
||||
assert!((s.adjust_tooltip_delay(5.0) - TOOLTIP_DELAY_MAX_SECS).abs() < 1e-6);
|
||||
// Big negative jump clamps to TOOLTIP_DELAY_MIN_SECS.
|
||||
assert!((s.adjust_tooltip_delay(-99.0) - TOOLTIP_DELAY_MIN_SECS).abs() < 1e-6);
|
||||
// Confirm the floor is exactly zero.
|
||||
assert_eq!(s.tooltip_delay_secs, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_blind_mode_defaults_to_false_when_field_absent() {
|
||||
// Simulate a JSON file that has no color_blind_mode field.
|
||||
let json = br#"{ "sfx_volume": 0.7 }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_blind_mode_round_trips() {
|
||||
let path = tmp_path("color_blind");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
color_blind_mode: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #62 — selected_card_back
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_card_back_default_is_zero() {
|
||||
assert_eq!(Settings::default().selected_card_back, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_card_back_serializes_round_trip() {
|
||||
let path = tmp_path("card_back_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_card_back: 2,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #63 — selected_background
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_background_default_is_zero() {
|
||||
assert_eq!(Settings::default().selected_background, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_background_serializes_round_trip() {
|
||||
let path = tmp_path("background_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_background: 3,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// window_geometry — persisted window size/position
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_window_geometry_default_is_none() {
|
||||
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
||||
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
||||
// Step up to 1.1.
|
||||
assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6);
|
||||
// Big positive jump clamps to TIME_BONUS_MULTIPLIER_MAX.
|
||||
assert!(
|
||||
Settings::default().window_geometry.is_none(),
|
||||
"default window_geometry must be None so first launch uses platform defaults"
|
||||
(s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_with_window_geometry_round_trip() {
|
||||
let path = tmp_path("window_geometry_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let geom = WindowGeometry {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
x: 120,
|
||||
y: 80,
|
||||
};
|
||||
let s = Settings {
|
||||
window_geometry: Some(geom),
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(
|
||||
loaded.window_geometry,
|
||||
Some(geom),
|
||||
"window_geometry must survive serde round-trip"
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_window_geometry_deserializes_to_none() {
|
||||
// A settings.json written by an older version of the game will be
|
||||
// missing this field entirely. `#[serde(default)]` on the field
|
||||
// must yield `None` rather than failing the whole deserialise.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
// Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN.
|
||||
assert!(
|
||||
s.window_geometry.is_none(),
|
||||
"legacy settings.json missing window_geometry must deserialize to None"
|
||||
(s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6
|
||||
);
|
||||
assert_eq!(s.time_bonus_multiplier, 0.0);
|
||||
|
||||
// Repeated incremental adds must not drift past the 0.1 grid.
|
||||
let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() };
|
||||
for _ in 0..10 {
|
||||
s2.adjust_time_bonus_multiplier(0.1);
|
||||
}
|
||||
// After ten +0.1 steps, value should be exactly 1.0 (1 decimal).
|
||||
assert!(
|
||||
(s2.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||
"rounding should pin repeated 0.1 steps to the decimal grid, got {}",
|
||||
s2.time_bonus_multiplier
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_geometry_explicit_null_deserializes_to_none() {
|
||||
// An explicit `"window_geometry": null` is also valid input that
|
||||
// must yield None — keeps tooling that hand-edits the file safe.
|
||||
let json = br#"{ "window_geometry": null }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(s.window_geometry.is_none());
|
||||
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||
// Step down to 0.40.
|
||||
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
|
||||
// Big positive jump clamps to MAX.
|
||||
assert!(
|
||||
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
|
||||
);
|
||||
// Big negative jump clamps to MIN.
|
||||
assert!(
|
||||
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
|
||||
);
|
||||
|
||||
// Repeated 0.05 steps must not drift past the 0.05 grid.
|
||||
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
|
||||
for _ in 0..6 {
|
||||
s2.adjust_replay_move_interval(0.05);
|
||||
}
|
||||
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
|
||||
assert!(
|
||||
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
|
||||
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
|
||||
s2.replay_move_interval_secs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+177
-2
@@ -5,16 +5,35 @@
|
||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
|
||||
pub use solitaire_sync::StatsSnapshot;
|
||||
|
||||
/// Extension trait providing game-logic mutation helpers for [`StatsSnapshot`].
|
||||
///
|
||||
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
||||
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`
|
||||
/// and [`StatsExt::update_per_mode_bests`].
|
||||
pub trait StatsExt {
|
||||
/// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
|
||||
///
|
||||
/// Tracks lifetime totals only — per-mode best scores and times are
|
||||
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
|
||||
/// long-standing call sites that only know about [`DrawMode`] keep
|
||||
/// compiling.
|
||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
||||
|
||||
/// Updates the per-mode best score and fastest-win-time fields for the
|
||||
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
|
||||
/// the win handler.
|
||||
///
|
||||
/// Behaviour:
|
||||
/// - `Classic`, `Zen`, `Challenge`: updates the matching `*_best_score`
|
||||
/// (max) and `*_fastest_win_seconds` (zero-aware min — 0 means
|
||||
/// "no win recorded yet").
|
||||
/// - `TimeAttack`: no-op. Time Attack uses session-level scoring (count
|
||||
/// of wins in 10 minutes); a per-game best wouldn't compose with
|
||||
/// the other modes' single-game scoring.
|
||||
fn update_per_mode_bests(&mut self, score: i32, time_seconds: u64, mode: GameMode);
|
||||
}
|
||||
|
||||
impl StatsExt for StatsSnapshot {
|
||||
@@ -51,6 +70,43 @@ impl StatsExt for StatsSnapshot {
|
||||
|
||||
self.last_modified = Utc::now();
|
||||
}
|
||||
|
||||
fn update_per_mode_bests(&mut self, score: i32, time_seconds: u64, mode: GameMode) {
|
||||
let score_u32 = score.max(0) as u32;
|
||||
// Zero-aware min — 0 means "no win recorded yet" for the per-mode
|
||||
// fastest fields, so we must not let a real time get clobbered to 0.
|
||||
// (Mirrors the merge logic in `solitaire_sync::merge`.)
|
||||
let min_ignore_zero = |existing: u64, candidate: u64| -> u64 {
|
||||
if existing == 0 {
|
||||
candidate
|
||||
} else if candidate == 0 {
|
||||
existing
|
||||
} else {
|
||||
existing.min(candidate)
|
||||
}
|
||||
};
|
||||
match mode {
|
||||
GameMode::Classic => {
|
||||
self.classic_best_score = self.classic_best_score.max(score_u32);
|
||||
self.classic_fastest_win_seconds =
|
||||
min_ignore_zero(self.classic_fastest_win_seconds, time_seconds);
|
||||
}
|
||||
GameMode::Zen => {
|
||||
self.zen_best_score = self.zen_best_score.max(score_u32);
|
||||
self.zen_fastest_win_seconds =
|
||||
min_ignore_zero(self.zen_fastest_win_seconds, time_seconds);
|
||||
}
|
||||
GameMode::Challenge => {
|
||||
self.challenge_best_score = self.challenge_best_score.max(score_u32);
|
||||
self.challenge_fastest_win_seconds =
|
||||
min_ignore_zero(self.challenge_fastest_win_seconds, time_seconds);
|
||||
}
|
||||
// Time Attack uses its own session-level scoring; a per-game best
|
||||
// wouldn't compose with the other modes' single-game numbers.
|
||||
GameMode::TimeAttack => {}
|
||||
}
|
||||
self.last_modified = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -177,4 +233,123 @@ mod tests {
|
||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-mode bests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn classic_win_updates_classic_best_score_only() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_per_mode_bests(1500, 200, GameMode::Classic);
|
||||
assert_eq!(s.classic_best_score, 1500);
|
||||
assert_eq!(s.classic_fastest_win_seconds, 200);
|
||||
// Other modes untouched.
|
||||
assert_eq!(s.zen_best_score, 0);
|
||||
assert_eq!(s.zen_fastest_win_seconds, 0);
|
||||
assert_eq!(s.challenge_best_score, 0);
|
||||
assert_eq!(s.challenge_fastest_win_seconds, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_win_updates_zen_best_score_only() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_per_mode_bests(1800, 600, GameMode::Zen);
|
||||
assert_eq!(s.zen_best_score, 1800);
|
||||
assert_eq!(s.zen_fastest_win_seconds, 600);
|
||||
assert_eq!(s.classic_best_score, 0);
|
||||
assert_eq!(s.challenge_best_score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_win_updates_challenge_best_score_only() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_per_mode_bests(2400, 480, GameMode::Challenge);
|
||||
assert_eq!(s.challenge_best_score, 2400);
|
||||
assert_eq!(s.challenge_fastest_win_seconds, 480);
|
||||
assert_eq!(s.classic_best_score, 0);
|
||||
assert_eq!(s.zen_best_score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_attack_win_does_not_touch_per_mode_bests() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_per_mode_bests(9999, 1, GameMode::TimeAttack);
|
||||
assert_eq!(s.classic_best_score, 0);
|
||||
assert_eq!(s.zen_best_score, 0);
|
||||
assert_eq!(s.challenge_best_score, 0);
|
||||
assert_eq!(s.classic_fastest_win_seconds, 0);
|
||||
assert_eq!(s.zen_fastest_win_seconds, 0);
|
||||
assert_eq!(s.challenge_fastest_win_seconds, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_mode_best_score_takes_max_across_calls() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_per_mode_bests(500, 200, GameMode::Classic);
|
||||
s.update_per_mode_bests(200, 200, GameMode::Classic);
|
||||
s.update_per_mode_bests(900, 200, GameMode::Classic);
|
||||
assert_eq!(s.classic_best_score, 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_mode_fastest_uses_zero_aware_min() {
|
||||
// First Classic win: 240s. Field starts at 0 (no win yet) — we
|
||||
// must adopt 240, not stay at 0 like a naive `min` would.
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_per_mode_bests(100, 240, GameMode::Classic);
|
||||
assert_eq!(s.classic_fastest_win_seconds, 240);
|
||||
// Faster Classic win replaces it.
|
||||
s.update_per_mode_bests(100, 120, GameMode::Classic);
|
||||
assert_eq!(s.classic_fastest_win_seconds, 120);
|
||||
// Slower Classic win does not.
|
||||
s.update_per_mode_bests(100, 300, GameMode::Classic);
|
||||
assert_eq!(s.classic_fastest_win_seconds, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_score_treated_as_zero_in_per_mode() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_per_mode_bests(-50, 240, GameMode::Classic);
|
||||
assert_eq!(s.classic_best_score, 0);
|
||||
// Time still recorded — a win with a low score is still a win.
|
||||
assert_eq!(s.classic_fastest_win_seconds, 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_stats_without_per_mode_fields_deserializes_to_zero() {
|
||||
// A pre-per-mode `stats.json` must still deserialise cleanly:
|
||||
// every new field falls back to 0 via `#[serde(default)]` so
|
||||
// updating the binary never wipes the player's old stats file.
|
||||
let legacy_json = r#"{
|
||||
"games_played": 12,
|
||||
"games_won": 5,
|
||||
"games_lost": 7,
|
||||
"win_streak_current": 1,
|
||||
"win_streak_best": 3,
|
||||
"avg_time_seconds": 240,
|
||||
"fastest_win_seconds": 180,
|
||||
"lifetime_score": 8500,
|
||||
"best_single_score": 2200,
|
||||
"draw_one_wins": 4,
|
||||
"draw_three_wins": 1,
|
||||
"last_modified": "2026-04-29T12:00:00Z"
|
||||
}"#;
|
||||
|
||||
let s: StatsSnapshot = serde_json::from_str(legacy_json)
|
||||
.expect("legacy payload must deserialise without per-mode fields");
|
||||
|
||||
// Pre-existing fields kept their values.
|
||||
assert_eq!(s.games_played, 12);
|
||||
assert_eq!(s.best_single_score, 2200);
|
||||
assert_eq!(s.fastest_win_seconds, 180);
|
||||
|
||||
// Every new per-mode field defaulted to 0 ("no win yet").
|
||||
assert_eq!(s.classic_best_score, 0);
|
||||
assert_eq!(s.classic_fastest_win_seconds, 0);
|
||||
assert_eq!(s.zen_best_score, 0);
|
||||
assert_eq!(s.zen_fastest_win_seconds, 0);
|
||||
assert_eq!(s.challenge_best_score, 0);
|
||||
assert_eq!(s.challenge_fastest_win_seconds, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
@@ -14,6 +16,7 @@ use crate::stats::StatsSnapshot;
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const STATS_FILE_NAME: &str = "stats.json";
|
||||
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
||||
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
||||
|
||||
/// Returns the platform-specific path to `stats.json`, or `None` if
|
||||
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
@@ -139,6 +142,131 @@ pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time Attack session (mode-specific sibling of game_state.json)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `GameState` carries `mode: GameMode`, so an in-progress Zen / Challenge /
|
||||
// Classic / TimeAttack deal is already round-tripped through `game_state.json`
|
||||
// — closing the window mid-deal in any of those modes restores the deal on
|
||||
// next launch. Time Attack adds a 10-minute session window and a per-session
|
||||
// win counter that live OUTSIDE `GameState` (in `TimeAttackResource` on the
|
||||
// engine side), so they are NOT covered by the game-state save/load. This
|
||||
// sibling file persists just that extra session-level state.
|
||||
//
|
||||
// The Bevy plugin layer (`solitaire_engine::time_attack_plugin`) is the only
|
||||
// caller. The file lives next to `game_state.json` in the same data dir and
|
||||
// is written using the same `.tmp` → rename atomic-write contract that the
|
||||
// rest of `storage.rs` uses.
|
||||
|
||||
/// Persisted state for an in-progress Time Attack session.
|
||||
///
|
||||
/// Fields mirror the live `TimeAttackResource` minus the `active` flag (the
|
||||
/// presence of the file *is* the active flag — a missing file means no
|
||||
/// session in progress).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TimeAttackSession {
|
||||
/// Seconds remaining in the 10-minute window when the save was written.
|
||||
pub remaining_secs: f32,
|
||||
/// Wins accumulated during the session so far.
|
||||
pub wins: u32,
|
||||
/// Wall-clock instant the save was written, as unix seconds. Used at
|
||||
/// load time to detect whether the session window expired in real
|
||||
/// time while the app was closed and to decrement `remaining_secs`
|
||||
/// by the real elapsed time so the resumed session reflects how
|
||||
/// long the window has actually been running.
|
||||
pub saved_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `time_attack_session.json`, or
|
||||
/// `None` if `dirs::data_dir()` is unavailable.
|
||||
pub fn time_attack_session_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
|
||||
/// `.tmp` → rename contract.
|
||||
pub fn save_time_attack_session_to(path: &Path, session: &TimeAttackSession) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(session).map_err(io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a Time Attack session from `path`, decrementing `remaining_secs`
|
||||
/// by the wall-clock time elapsed between the save and now.
|
||||
///
|
||||
/// Returns `None` when:
|
||||
/// - the file is missing or unreadable,
|
||||
/// - the JSON is corrupt / malformed, or
|
||||
/// - the session window expired during the time the app was closed
|
||||
/// (`saved_at_unix_secs + remaining_secs <= now_unix_secs`).
|
||||
///
|
||||
/// The `now_unix_secs` parameter is injectable so unit tests can simulate
|
||||
/// arbitrary wall-clock gaps without touching the real system clock. The
|
||||
/// public companion [`load_time_attack_session_from`] resolves "now" from
|
||||
/// `SystemTime::now()`.
|
||||
pub fn load_time_attack_session_from_at(
|
||||
path: &Path,
|
||||
now_unix_secs: u64,
|
||||
) -> Option<TimeAttackSession> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let session: TimeAttackSession = serde_json::from_slice(&data).ok()?;
|
||||
// Compute wall-clock elapsed seconds since the save was written.
|
||||
// Saturating subtraction guards against a clock that moved backwards
|
||||
// (rare, but possible across NTP corrections or VM clock drift).
|
||||
let elapsed = now_unix_secs.saturating_sub(session.saved_at_unix_secs);
|
||||
let remaining = session.remaining_secs - elapsed as f32;
|
||||
if remaining <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(TimeAttackSession {
|
||||
remaining_secs: remaining,
|
||||
wins: session.wins,
|
||||
saved_at_unix_secs: session.saved_at_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a Time Attack session from `path`, using `SystemTime::now()` as
|
||||
/// the reference for the wall-clock-elapsed adjustment.
|
||||
///
|
||||
/// See [`load_time_attack_session_from_at`] for the rules under which
|
||||
/// the call returns `None` (missing file, corrupt JSON, expired window).
|
||||
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
load_time_attack_session_from_at(path, now)
|
||||
}
|
||||
|
||||
/// Delete the Time Attack session file (called on session end, on session
|
||||
/// start, or on game completion). Silently ignores `NotFound` errors.
|
||||
pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helper for callers that want to stamp a session with the
|
||||
/// current wall-clock time. Equivalent to constructing the struct
|
||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
TimeAttackSession {
|
||||
remaining_secs,
|
||||
wins,
|
||||
saved_at_unix_secs: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
||||
///
|
||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||
@@ -387,4 +515,190 @@ mod tests {
|
||||
let loaded = load_stats_from(&stats_path);
|
||||
assert_eq!(loaded, StatsSnapshot::default());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Time Attack session persistence
|
||||
//
|
||||
// Documents the contract that closing the window mid-Time-Attack does
|
||||
// NOT lose the 10-minute window or the running win count. Classic /
|
||||
// Zen / Challenge are covered by `game_state.json` because their entire
|
||||
// mid-deal state lives in `GameState.mode` + `GameState.piles`; Time
|
||||
// Attack additionally needs the session timer + wins counter, both of
|
||||
// which live in `TimeAttackResource` on the engine side and are NOT
|
||||
// part of `GameState`. This sibling file persists exactly that.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn ta_path(name: &str) -> PathBuf {
|
||||
env::temp_dir().join(format!("solitaire_test_ta_{name}.json"))
|
||||
}
|
||||
|
||||
/// Round-trip a session that was saved "just now" (zero wall-clock
|
||||
/// elapsed). All three persisted fields must come back unchanged.
|
||||
#[test]
|
||||
fn time_attack_session_round_trips_through_save_and_load() {
|
||||
let path = ta_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Use a fixed unix timestamp so the load step (which receives the
|
||||
// SAME timestamp as "now") sees zero wall-clock elapsed.
|
||||
let saved_at: u64 = 1_800_000_000;
|
||||
let session = TimeAttackSession {
|
||||
remaining_secs: 240.0,
|
||||
wins: 3,
|
||||
saved_at_unix_secs: saved_at,
|
||||
};
|
||||
save_time_attack_session_to(&path, &session).expect("save");
|
||||
|
||||
let loaded = load_time_attack_session_from_at(&path, saved_at)
|
||||
.expect("session must load when not yet expired");
|
||||
assert!(
|
||||
(loaded.remaining_secs - 240.0).abs() < 0.01,
|
||||
"remaining_secs must be unchanged when no wall-clock time has passed; got {}",
|
||||
loaded.remaining_secs,
|
||||
);
|
||||
assert_eq!(loaded.wins, 3, "wins must round-trip");
|
||||
assert_eq!(loaded.saved_at_unix_secs, saved_at, "timestamp must round-trip");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A session whose window expired entirely between launches must be
|
||||
/// discarded on load — the caller starts fresh rather than resuming a
|
||||
/// dead session.
|
||||
#[test]
|
||||
fn time_attack_session_discarded_when_expired_between_launches() {
|
||||
let path = ta_path("expired");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Saved 20 minutes ago with 240 s remaining — long expired.
|
||||
let saved_at: u64 = 1_800_000_000;
|
||||
let session = TimeAttackSession {
|
||||
remaining_secs: 240.0,
|
||||
wins: 5,
|
||||
saved_at_unix_secs: saved_at,
|
||||
};
|
||||
save_time_attack_session_to(&path, &session).expect("save");
|
||||
|
||||
// 20 minutes (1200 s) later → 240 - 1200 = -960 s remaining.
|
||||
let now = saved_at + 1200;
|
||||
assert!(
|
||||
load_time_attack_session_from_at(&path, now).is_none(),
|
||||
"an expired session must return None so the player starts fresh",
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// The `remaining_secs` returned at load time must be the persisted
|
||||
/// value minus the wall-clock seconds that elapsed while the app was
|
||||
/// closed.
|
||||
#[test]
|
||||
fn time_attack_session_remaining_secs_decremented_by_real_elapsed() {
|
||||
let path = ta_path("decremented");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let saved_at: u64 = 1_800_000_000;
|
||||
let session = TimeAttackSession {
|
||||
remaining_secs: 240.0,
|
||||
wins: 2,
|
||||
saved_at_unix_secs: saved_at,
|
||||
};
|
||||
save_time_attack_session_to(&path, &session).expect("save");
|
||||
|
||||
// 60 s elapsed in real time → expect 180 s remaining.
|
||||
let now = saved_at + 60;
|
||||
let loaded = load_time_attack_session_from_at(&path, now)
|
||||
.expect("session must still load — 180 s left");
|
||||
assert!(
|
||||
(loaded.remaining_secs - 180.0).abs() < 5.0,
|
||||
"remaining_secs ≈ 180 ± 5 s after a 60 s wall-clock gap; got {}",
|
||||
loaded.remaining_secs,
|
||||
);
|
||||
assert_eq!(loaded.wins, 2, "wins must survive the elapsed adjustment");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||
/// `save_time_attack_session_to` returns.
|
||||
#[test]
|
||||
fn time_attack_session_save_is_atomic() {
|
||||
let path = ta_path("atomic");
|
||||
let session = TimeAttackSession {
|
||||
remaining_secs: 100.0,
|
||||
wins: 0,
|
||||
saved_at_unix_secs: 1_800_000_000,
|
||||
};
|
||||
save_time_attack_session_to(&path, &session).expect("save");
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Loading from a path that does not exist must return `None`, not
|
||||
/// panic.
|
||||
#[test]
|
||||
fn time_attack_session_missing_file_returns_none() {
|
||||
let path = ta_path("missing_xyz");
|
||||
let _ = fs::remove_file(&path);
|
||||
assert!(load_time_attack_session_from_at(&path, 0).is_none());
|
||||
}
|
||||
|
||||
/// Loading from a corrupt / partially-written file must return `None`,
|
||||
/// not surface a deserialiser error.
|
||||
#[test]
|
||||
fn time_attack_session_corrupt_file_returns_none() {
|
||||
let path = ta_path("corrupt");
|
||||
fs::write(&path, b"not valid json!!!").expect("write");
|
||||
assert!(load_time_attack_session_from_at(&path, 0).is_none());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// `delete_time_attack_session_at` removes the file when it exists
|
||||
/// and returns `Ok(())` when it does not.
|
||||
#[test]
|
||||
fn time_attack_session_delete_handles_present_and_absent() {
|
||||
let path = ta_path("delete");
|
||||
let session = TimeAttackSession {
|
||||
remaining_secs: 50.0,
|
||||
wins: 0,
|
||||
saved_at_unix_secs: 1_800_000_000,
|
||||
};
|
||||
save_time_attack_session_to(&path, &session).expect("save");
|
||||
assert!(path.exists());
|
||||
delete_time_attack_session_at(&path).expect("delete");
|
||||
assert!(!path.exists());
|
||||
// Second delete on the now-absent file must succeed.
|
||||
delete_time_attack_session_at(&path).expect("missing-file delete is ok");
|
||||
}
|
||||
|
||||
/// A session whose `saved_at_unix_secs` is in the future (e.g. the
|
||||
/// system clock moved backward across NTP correction) must NOT be
|
||||
/// rejected as expired. Saturating subtraction must clamp the
|
||||
/// "elapsed" value to zero.
|
||||
#[test]
|
||||
fn time_attack_session_handles_clock_running_backwards() {
|
||||
let path = ta_path("clock_backwards");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let saved_at: u64 = 1_800_000_000;
|
||||
let session = TimeAttackSession {
|
||||
remaining_secs: 60.0,
|
||||
wins: 1,
|
||||
saved_at_unix_secs: saved_at,
|
||||
};
|
||||
save_time_attack_session_to(&path, &session).expect("save");
|
||||
|
||||
// "now" is BEFORE the saved time — should not crash, should not expire.
|
||||
let now_in_past = saved_at - 100;
|
||||
let loaded = load_time_attack_session_from_at(&path, now_in_past)
|
||||
.expect("clock-backwards must not discard the session");
|
||||
assert!(
|
||||
(loaded.remaining_secs - 60.0).abs() < 0.01,
|
||||
"remaining_secs must clamp elapsed to 0 when clock ran backwards; got {}",
|
||||
loaded.remaining_secs,
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}
|
||||
|
||||
use crate::{
|
||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||
replay::Replay,
|
||||
settings::SyncBackend,
|
||||
SyncError, SyncProvider,
|
||||
};
|
||||
@@ -356,6 +357,69 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
extract_leaderboard_body(resp).await
|
||||
}
|
||||
|
||||
/// Upload a winning replay to `POST /api/replays`. On success the
|
||||
/// server returns `{ "id": "<uuid>" }`; this method composes that
|
||||
/// id with the configured base URL into the player-shareable
|
||||
/// `<base>/replays/<id>` link and returns it. Mirrors the `push`
|
||||
/// auth flow: 401 triggers a token refresh and one retry.
|
||||
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
|
||||
let token = self.access_token()?;
|
||||
let url = format!("{}/api/replays", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&token)
|
||||
.json(replay)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(new_token)
|
||||
.json(replay)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
return self.share_url_from_response(resp).await;
|
||||
}
|
||||
|
||||
self.share_url_from_response(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
impl SolitaireServerClient {
|
||||
/// Pulled out of `push_replay` so both the first attempt and the
|
||||
/// post-401-retry attempt go through the same parse path.
|
||||
async fn share_url_from_response(
|
||||
&self,
|
||||
resp: reqwest::Response,
|
||||
) -> Result<String, SyncError> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
SyncError::Auth(format!("server returned {status}"))
|
||||
} else {
|
||||
SyncError::Network(format!("server returned {status}"))
|
||||
});
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||
let id = body["id"].as_str().ok_or_else(|| {
|
||||
SyncError::Serialization("upload response missing `id`".into())
|
||||
})?;
|
||||
Ok(format!("{}/replays/{}", self.base_url, id))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,7 @@ tiny-skia = { workspace = true }
|
||||
ron = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Local, Timelike, Utc};
|
||||
use solitaire_core::achievement::{
|
||||
@@ -14,20 +15,24 @@ use solitaire_core::achievement::{
|
||||
ALL_ACHIEVEMENTS,
|
||||
};
|
||||
use solitaire_data::{
|
||||
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
||||
save_progress_to,
|
||||
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
|
||||
AchievementRecord, save_progress_to,
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
|
||||
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, ToggleAchievementsRequestEvent,
|
||||
XpAwardedEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
@@ -45,6 +50,19 @@ pub struct AchievementsScreen;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementRow;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Achievements modal.
|
||||
///
|
||||
/// The Achievements list can grow to ~19 rows which overflows the modal at
|
||||
/// the 800x600 minimum window. This marker tags the inner container that
|
||||
/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the
|
||||
/// content scrolls instead of clipping. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern in `settings_plugin`.
|
||||
///
|
||||
/// `scroll_achievements_panel` reads this marker to route mouse-wheel
|
||||
/// events into the body's `ScrollPosition`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementsScrollable;
|
||||
|
||||
/// All per-player achievement records (one per known achievement).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
||||
@@ -91,7 +109,13 @@ impl Plugin for AchievementPlugin {
|
||||
.add_message::<AchievementUnlockedEvent>()
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleAchievementsRequestEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// achievements-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
// Run after GameMutation (so GameWonEvent is available), after
|
||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||
@@ -102,8 +126,24 @@ impl Plugin for AchievementPlugin {
|
||||
.after(StatsUpdate)
|
||||
.after(ProgressUpdate),
|
||||
)
|
||||
// Achievement-onboarding cue: fires once after the player's very
|
||||
// first win to teach the Achievements panel exists. Must run
|
||||
// `.after(StatsUpdate)` so `stats.games_won` reflects the win
|
||||
// that just landed (StatsUpdate increments it on `GameWonEvent`).
|
||||
.add_systems(
|
||||
Update,
|
||||
fire_achievement_onboarding_toast
|
||||
.after(GameMutation)
|
||||
.after(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button);
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||
// `cinephile` the first time playback runs to natural completion.
|
||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
||||
// omit `ReplayPlaybackPlugin` still build.
|
||||
.add_systems(Update, evaluate_cinephile_on_replay_completion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +249,127 @@ fn evaluate_on_win(
|
||||
}
|
||||
}
|
||||
|
||||
/// Cinephile unlock observer.
|
||||
///
|
||||
/// Watches [`ReplayPlaybackState`] and unlocks the `cinephile` achievement
|
||||
/// the first time the resource transitions from `Playing` to `Completed` —
|
||||
/// i.e. the player watched a saved replay all the way through. The Stop
|
||||
/// button transitions `Playing` → `Inactive` directly (never via
|
||||
/// `Completed`), so manual aborts do not trigger the unlock.
|
||||
///
|
||||
/// Idempotent: once the record is unlocked, subsequent Playing → Completed
|
||||
/// transitions are a no-op (no extra `AchievementUnlockedEvent`, no extra
|
||||
/// disk write). The transition itself is debounced by tracking the
|
||||
/// previous frame's `is_playing()` state in a `Local<bool>` — without
|
||||
/// this, a freshly-spawned `Completed` state would re-fire each frame
|
||||
/// during the linger window.
|
||||
///
|
||||
/// Reads `ReplayPlaybackState` via `Option<Res<_>>` so achievement tests
|
||||
/// that omit `ReplayPlaybackPlugin` still build cleanly.
|
||||
fn evaluate_cinephile_on_replay_completion(
|
||||
state: Option<Res<ReplayPlaybackState>>,
|
||||
// `Local` collides with `chrono::Local` imported at the top of this
|
||||
// module — fully qualify so the Bevy system parameter resolves
|
||||
// correctly.
|
||||
mut last_was_playing: bevy::prelude::Local<bool>,
|
||||
mut achievements: ResMut<AchievementsResource>,
|
||||
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
|
||||
path: Res<AchievementsStoragePath>,
|
||||
) {
|
||||
let Some(state) = state else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Detect the Playing → Completed transition: was playing last frame,
|
||||
// is now completed. Direct Playing → Inactive (Stop button) does not
|
||||
// satisfy this guard because it never enters `Completed`.
|
||||
let now_playing = state.is_playing();
|
||||
let now_completed = state.is_completed();
|
||||
let just_completed = *last_was_playing && now_completed;
|
||||
*last_was_playing = now_playing;
|
||||
|
||||
if !just_completed {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(record) = achievements.0.iter_mut().find(|r| r.id == "cinephile") else {
|
||||
return;
|
||||
};
|
||||
if record.unlocked {
|
||||
return;
|
||||
}
|
||||
record.unlock(Utc::now());
|
||||
record.reward_granted = true;
|
||||
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
||||
|
||||
if let Some(target) = &path.0
|
||||
&& let Err(e) = save_achievements_to(target, &achievements.0)
|
||||
{
|
||||
warn!("failed to save achievements after cinephile unlock: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Achievement-onboarding cue.
|
||||
///
|
||||
/// On the player's very first win — and only their first — fires a single
|
||||
/// `InfoToastEvent` nudging them toward the Achievements panel (`A` hotkey)
|
||||
/// so they discover the progression layer.
|
||||
///
|
||||
/// Three guards prevent spurious or repeat firings:
|
||||
///
|
||||
/// * `stats.games_won == 1` — the post-condition is checked **after**
|
||||
/// `StatsUpdate` increments `games_won`, so the cue only fires for the
|
||||
/// true first win, not (for example) a player who imported existing
|
||||
/// sync data and won a later game.
|
||||
/// * `!settings.shown_achievement_onboarding` — flips to `true` after
|
||||
/// the toast fires, persists to `settings.json`, and serves as the
|
||||
/// one-shot guard across launches and merged sync.
|
||||
/// * The system bails immediately when no `GameWonEvent` arrived this
|
||||
/// frame so it is a no-op outside the post-win frame.
|
||||
///
|
||||
/// The `A` hotkey is mentioned verbatim in the toast text so players who
|
||||
/// dismiss the cue still know where to find the panel.
|
||||
fn fire_achievement_onboarding_toast(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
stats: Res<StatsResource>,
|
||||
mut settings: Option<ResMut<SettingsResource>>,
|
||||
settings_path: Option<Res<SettingsStoragePath>>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
// Drain the event queue regardless — multiple wins on a single frame
|
||||
// only need a single onboarding toast at most.
|
||||
let any_win = wins.read().last().is_some();
|
||||
if !any_win {
|
||||
return;
|
||||
}
|
||||
|
||||
// Without a `SettingsResource` (headless tests that omit `SettingsPlugin`)
|
||||
// we have no flag to consult; bail out cleanly.
|
||||
let Some(settings) = settings.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if settings.0.shown_achievement_onboarding {
|
||||
return;
|
||||
}
|
||||
if stats.0.games_won != 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.write(InfoToastEvent(
|
||||
"First win! Press A to see your achievements.".to_string(),
|
||||
));
|
||||
settings.0.shown_achievement_onboarding = true;
|
||||
|
||||
// Persist so the cue stays one-shot across launches. `None` storage
|
||||
// (headless / test) is a documented no-op.
|
||||
if let Some(path) = settings_path.as_ref()
|
||||
&& let Some(target) = path.0.as_deref()
|
||||
&& let Err(e) = save_settings_to(target, &settings.0)
|
||||
{
|
||||
warn!("failed to save settings (achievement onboarding): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: resolve an achievement ID to its human-readable name.
|
||||
/// Used by the toast renderer in `animation_plugin`.
|
||||
pub fn display_name_for(id: &str) -> String {
|
||||
@@ -255,6 +416,38 @@ fn handle_achievements_close_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||
/// while the panel is open.
|
||||
///
|
||||
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
|
||||
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
|
||||
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
|
||||
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
|
||||
/// is in the world (modal closed) so this is a no-op outside the open
|
||||
/// state without an explicit gate resource.
|
||||
fn scroll_achievements_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_achievements_screen(
|
||||
commands: &mut Commands,
|
||||
records: &[AchievementRecord],
|
||||
@@ -281,79 +474,119 @@ fn spawn_achievements_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||
|
||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, header, font_res);
|
||||
|
||||
// Achievement rows — unlocked first, then locked alphabetical.
|
||||
let mut sorted: Vec<_> = records.iter().collect();
|
||||
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
||||
|
||||
for record in &sorted {
|
||||
let def = achievement_by_id(&record.id);
|
||||
let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||
|
||||
// Hide secret locked achievements so they remain a surprise.
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name_color, desc_color, prefix) = if record.unlocked {
|
||||
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
||||
} else {
|
||||
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
||||
};
|
||||
|
||||
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
||||
|
||||
// First-time hint — shown until the player has unlocked anything.
|
||||
// The list itself describes individual rewards, but a top-level
|
||||
// explanation gives newer players context for the otherwise dense
|
||||
// greyed-out grid.
|
||||
if !any_unlocked {
|
||||
card.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
Text::new(
|
||||
"Complete games and try new modes to unlock achievements and rewards.",
|
||||
),
|
||||
TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
AchievementRow,
|
||||
Tooltip::new(tooltip_text),
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(format!("{prefix}{name}")),
|
||||
font_name.clone(),
|
||||
TextColor(name_color),
|
||||
));
|
||||
if !description.is_empty() {
|
||||
row.spawn((
|
||||
Text::new(format!(" {description}")),
|
||||
font_desc.clone(),
|
||||
TextColor(desc_color),
|
||||
));
|
||||
}
|
||||
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
|
||||
row.spawn((
|
||||
Text::new(format!(" Reward: {reward_str}")),
|
||||
font_meta.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if let Some(date) = record.unlock_date {
|
||||
row.spawn((
|
||||
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
|
||||
font_meta.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Subtle row separator — keeps the long list scannable.
|
||||
card.spawn((
|
||||
Node {
|
||||
height: Val::Px(1.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
// Scrollable body — the achievements list grows to ~19 rows which
|
||||
// overflows the modal on the 800x600 minimum window. Wrapping the
|
||||
// row list in an `Overflow::scroll_y()` Node with a constrained
|
||||
// `max_height` keeps every row reachable. The Done button below
|
||||
// sits outside the scroll so it's always one click away. Mirrors
|
||||
// the `SettingsPanelScrollable` pattern.
|
||||
card.spawn((
|
||||
AchievementsScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// Achievement rows — unlocked first, then locked alphabetical.
|
||||
let mut sorted: Vec<_> = records.iter().collect();
|
||||
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
||||
|
||||
for record in &sorted {
|
||||
let def = achievement_by_id(&record.id);
|
||||
let (name, description) =
|
||||
def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||
|
||||
// Hide secret locked achievements so they remain a surprise.
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name_color, desc_color, prefix) = if record.unlocked {
|
||||
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
||||
} else {
|
||||
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
||||
};
|
||||
|
||||
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
||||
|
||||
body.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
..default()
|
||||
},
|
||||
AchievementRow,
|
||||
Tooltip::new(tooltip_text),
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(format!("{prefix}{name}")),
|
||||
font_name.clone(),
|
||||
TextColor(name_color),
|
||||
));
|
||||
if !description.is_empty() {
|
||||
row.spawn((
|
||||
Text::new(format!(" {description}")),
|
||||
font_desc.clone(),
|
||||
TextColor(desc_color),
|
||||
));
|
||||
}
|
||||
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
|
||||
row.spawn((
|
||||
Text::new(format!(" Reward: {reward_str}")),
|
||||
font_meta.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if let Some(date) = record.unlock_date {
|
||||
row.spawn((
|
||||
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
|
||||
font_meta.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Subtle row separator — keeps the long list scannable.
|
||||
body.spawn((
|
||||
Node {
|
||||
height: Val::Px(1.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
@@ -365,6 +598,9 @@ fn spawn_achievements_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// Achievements is a read-only list — clicking the scrim outside
|
||||
// the card dismisses alongside the existing A / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
fn format_reward(reward: Reward) -> String {
|
||||
@@ -755,6 +991,64 @@ mod tests {
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scrollable body
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Spawning the modal must place exactly one `AchievementsScrollable`
|
||||
/// marker in the world so the row list scrolls instead of clipping at
|
||||
/// the 800x600 minimum window.
|
||||
#[test]
|
||||
fn achievements_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyA);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&AchievementsScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Achievements modal must spawn exactly one AchievementsScrollable body"
|
||||
);
|
||||
}
|
||||
|
||||
/// The scrollable body must constrain its `max_height` so the modal
|
||||
/// actually engages scrolling on tall content. Without this the inner
|
||||
/// flex column would expand to fit every row and `Overflow::scroll_y`
|
||||
/// would have nothing to clip.
|
||||
#[test]
|
||||
fn achievements_modal_body_has_max_height() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyA);
|
||||
app.update();
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<AchievementsScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_eq!(nodes.len(), 1, "expected exactly one scrollable body");
|
||||
let node = nodes[0];
|
||||
|
||||
// `Val::Auto` is the default; assert the body's `max_height` was
|
||||
// explicitly set to something else so scroll engages.
|
||||
assert_ne!(
|
||||
node.max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height; got {:?}",
|
||||
node.max_height
|
||||
);
|
||||
// And the overflow axis must be y-scroll.
|
||||
assert_eq!(
|
||||
node.overflow,
|
||||
Overflow::scroll_y(),
|
||||
"scrollable body must use Overflow::scroll_y(); got {:?}",
|
||||
node.overflow
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_reward
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -921,4 +1215,393 @@ mod tests {
|
||||
assert!(s.contains("How to unlock"));
|
||||
assert!(!s.contains("Reward"), "got {s:?}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Achievement-onboarding cue (`fire_achievement_onboarding_toast`)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Builds a headless app that **also** includes `SettingsPlugin::headless()`
|
||||
/// so the achievement-onboarding system (which reads `SettingsResource`)
|
||||
/// has a flag to consult and persist into.
|
||||
fn onboarding_test_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::headless())
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// Collects every `InfoToastEvent` written so tests can assert on
|
||||
/// count and message contents.
|
||||
fn drain_info_toasts(app: &App) -> Vec<String> {
|
||||
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
cursor.read(events).map(|e| e.0.clone()).collect()
|
||||
}
|
||||
|
||||
/// First-win path: with the flag false and `games_won` about to be
|
||||
/// 1, exactly one `InfoToastEvent` mentioning the `A` hotkey must
|
||||
/// fire and the flag must flip to `true`.
|
||||
#[test]
|
||||
fn first_win_fires_achievement_onboarding_toast() {
|
||||
let mut app = onboarding_test_app();
|
||||
|
||||
// Sanity: fresh app starts with games_won = 0 and the flag unset.
|
||||
assert_eq!(app.world().resource::<StatsResource>().0.games_won, 0);
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.shown_achievement_onboarding
|
||||
);
|
||||
|
||||
// StatsPlugin (StatsUpdate) increments games_won to 1 *before* the
|
||||
// achievement-onboarding system reads stats — our system runs
|
||||
// `.after(StatsUpdate)`. The system then sees games_won == 1 and
|
||||
// the cue fires.
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let toasts = drain_info_toasts(&app);
|
||||
let onboarding_toasts: Vec<&String> = toasts
|
||||
.iter()
|
||||
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
onboarding_toasts.len(),
|
||||
1,
|
||||
"exactly one achievement-onboarding toast must fire on the first win; \
|
||||
saw all toasts: {toasts:?}"
|
||||
);
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.shown_achievement_onboarding,
|
||||
"shown_achievement_onboarding must flip to true after the toast fires"
|
||||
);
|
||||
}
|
||||
|
||||
/// Second-win path: with the flag already `true` (player already
|
||||
/// saw the cue on a previous run), no onboarding toast may fire.
|
||||
#[test]
|
||||
fn subsequent_wins_do_not_fire_achievement_onboarding_toast() {
|
||||
let mut app = onboarding_test_app();
|
||||
|
||||
// Pre-set the flag to simulate a player who already dismissed
|
||||
// the cue on a previous run.
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.shown_achievement_onboarding = true;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||
.into_iter()
|
||||
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||
.collect();
|
||||
assert!(
|
||||
onboarding_toasts.is_empty(),
|
||||
"no onboarding toast must fire when shown_achievement_onboarding is already true; \
|
||||
got: {onboarding_toasts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sync-import path: a player imports stats with `games_won = 5`
|
||||
/// already on the books. The flag is still `false` (they were on a
|
||||
/// pre-cue release on this device), but the cue must NOT fire because
|
||||
/// this isn't actually their first win — the post-condition
|
||||
/// `games_won == 1` guards against retroactive nagging.
|
||||
#[test]
|
||||
fn non_first_win_does_not_fire_achievement_onboarding_toast() {
|
||||
let mut app = onboarding_test_app();
|
||||
|
||||
// Pre-seed games_won = 5 BEFORE the win lands. StatsUpdate will
|
||||
// bump it to 6 on the GameWonEvent, taking the system well past
|
||||
// the `games_won == 1` post-condition.
|
||||
app.world_mut().resource_mut::<StatsResource>().0.games_won = 5;
|
||||
|
||||
// Confirm the flag is still false so we know the guard that
|
||||
// prevents firing is the games-won post-condition, not the flag.
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.shown_achievement_onboarding
|
||||
);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||
.into_iter()
|
||||
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||
.collect();
|
||||
assert!(
|
||||
onboarding_toasts.is_empty(),
|
||||
"no onboarding toast must fire on a non-first win; got: {onboarding_toasts:?}"
|
||||
);
|
||||
// And the flag must remain false so the cue can still teach a
|
||||
// genuinely-fresh second device or a wiped install.
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.shown_achievement_onboarding,
|
||||
"shown_achievement_onboarding must remain false when the cue did not fire"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cinephile (event-driven via ReplayPlaybackState)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
|
||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
||||
/// by hand. The achievement plugin's cinephile observer reads it via
|
||||
/// `Option<Res<_>>` so the absence of the playback plugin is safe.
|
||||
fn cinephile_app() -> App {
|
||||
let mut app = headless_app();
|
||||
app.init_resource::<ReplayPlaybackState>();
|
||||
app
|
||||
}
|
||||
|
||||
fn dummy_replay() -> Replay {
|
||||
Replay::new(
|
||||
1,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
)
|
||||
}
|
||||
|
||||
fn cinephile_unlocked(app: &App) -> bool {
|
||||
app.world()
|
||||
.resource::<AchievementsResource>()
|
||||
.0
|
||||
.iter()
|
||||
.find(|r| r.id == "cinephile")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn cinephile_unlocks_emitted(app: &App) -> usize {
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
cursor
|
||||
.read(events)
|
||||
.filter(|e| e.0.id == "cinephile")
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The cinephile record must be seeded on plugin init like every other
|
||||
/// achievement, so the observer can find and mutate it later.
|
||||
#[test]
|
||||
fn cinephile_record_seeded_by_plugin() {
|
||||
let app = cinephile_app();
|
||||
let records = &app.world().resource::<AchievementsResource>().0;
|
||||
assert!(
|
||||
records.iter().any(|r| r.id == "cinephile" && !r.unlocked),
|
||||
"cinephile record must be seeded as locked",
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive Inactive → Playing → Completed and assert the cinephile
|
||||
/// achievement unlocks and exactly one `AchievementUnlockedEvent` is
|
||||
/// emitted.
|
||||
#[test]
|
||||
fn cinephile_unlocks_on_replay_completion() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
// Frame 1: enter Playing. The observer's first sample sees
|
||||
// `last_was_playing = false` and `now_playing = true`.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
"Playing alone must not unlock cinephile",
|
||||
);
|
||||
|
||||
// Frame 2: transition to Completed. The observer must detect
|
||||
// `last_was_playing = true && now_completed = true` and unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
cinephile_unlocked(&app),
|
||||
"cinephile must unlock on Playing → Completed transition",
|
||||
);
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
1,
|
||||
"exactly one AchievementUnlockedEvent must fire for cinephile",
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop button transitions Playing → Inactive directly (not via
|
||||
/// Completed). Drive that path and assert no cinephile unlock.
|
||||
#[test]
|
||||
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
|
||||
// Direct Playing → Inactive — the path the Stop button takes via
|
||||
// `stop_replay_playback`. Must not unlock cinephile.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
"Stop button (Playing → Inactive) must not unlock cinephile",
|
||||
);
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
0,
|
||||
"no AchievementUnlockedEvent for cinephile on a Stop transition",
|
||||
);
|
||||
}
|
||||
|
||||
/// A second Playing → Completed cycle on an already-unlocked record
|
||||
/// must be idempotent: no additional `AchievementUnlockedEvent`.
|
||||
#[test]
|
||||
fn cinephile_does_not_double_fire() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
// First completion cycle to unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
|
||||
|
||||
// Drain the event queue so the next assertion doesn't double-count
|
||||
// the legitimate first-time unlock event.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<AchievementUnlockedEvent>>()
|
||||
.clear();
|
||||
|
||||
// Second cycle: Inactive → Playing → Completed once more.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
0,
|
||||
"cinephile must not re-fire on a second Playing → Completed cycle",
|
||||
);
|
||||
}
|
||||
|
||||
/// `Completed` lingers across multiple frames before the auto-clear
|
||||
/// transitions back to `Inactive`. The observer must fire exactly
|
||||
/// once during that linger window — not once per frame.
|
||||
#[test]
|
||||
fn cinephile_fires_once_across_completed_linger() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
// Stay in Completed for a few more frames as the real auto-clear
|
||||
// does. Each subsequent frame the resource is still `Completed`
|
||||
// but the observer has already counted this transition.
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
1,
|
||||
"cinephile must fire exactly once across the Completed linger window",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_win_event_means_no_achievement_onboarding_toast() {
|
||||
let mut app = onboarding_test_app();
|
||||
|
||||
// Pre-seed games_won = 1 to simulate the misleading mid-frame
|
||||
// state without actually firing a GameWonEvent.
|
||||
app.world_mut().resource_mut::<StatsResource>().0.games_won = 1;
|
||||
|
||||
app.update();
|
||||
|
||||
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||
.into_iter()
|
||||
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||
.collect();
|
||||
assert!(
|
||||
onboarding_toasts.is_empty(),
|
||||
"no onboarding toast must fire without a GameWonEvent; got: {onboarding_toasts:?}"
|
||||
);
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.shown_achievement_onboarding,
|
||||
"flag must not flip without a win event"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
||||
use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
|
||||
use crate::events::{InfoToastEvent, XpAwardedEvent};
|
||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
@@ -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;
|
||||
@@ -161,7 +160,6 @@ impl Plugin for AnimationPlugin {
|
||||
.add_message::<TimeAttackEndedEvent>()
|
||||
.add_message::<ChallengeAdvancedEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<NewGameConfirmEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.init_resource::<EffectiveSlideDuration>()
|
||||
@@ -183,7 +181,6 @@ impl Plugin for AnimationPlugin {
|
||||
handle_challenge_toast,
|
||||
handle_settings_toast,
|
||||
handle_auto_complete_toast,
|
||||
handle_new_game_confirm_toast,
|
||||
handle_xp_awarded_toast,
|
||||
tick_toasts,
|
||||
(enqueue_toasts, drive_toast_display).chain(),
|
||||
@@ -268,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);
|
||||
|
||||
@@ -286,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));
|
||||
@@ -459,15 +457,6 @@ fn handle_auto_complete_toast(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_new_game_confirm_toast(
|
||||
mut commands: Commands,
|
||||
mut events: MessageReader<NewGameConfirmEvent>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`.
|
||||
///
|
||||
/// This is the first half of the two-system toast queue (Task #67). The queue
|
||||
|
||||
@@ -11,8 +11,8 @@ pub mod svg_loader;
|
||||
pub mod user_dir;
|
||||
|
||||
pub use sources::{
|
||||
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
|
||||
DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
|
||||
default_theme_svg_bytes, populate_embedded_default_theme, register_theme_asset_sources,
|
||||
AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
|
||||
};
|
||||
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
|
||||
pub use user_dir::{set_user_theme_dir, user_theme_dir};
|
||||
|
||||
@@ -194,6 +194,25 @@ impl Plugin for AssetSourcesPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the embedded SVG bytes for a single default-theme file
|
||||
/// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the
|
||||
/// filename is not bundled.
|
||||
///
|
||||
/// The thumbnail generator in
|
||||
/// [`crate::theme::ThemeThumbnailCache`] uses this to rasterise
|
||||
/// preview-sized art for the picker UI without going through Bevy's
|
||||
/// async asset graph. Lookup is by the filename only — the
|
||||
/// `solitaire_engine/assets/themes/default/` prefix is stripped before
|
||||
/// comparison so callers don't need to know where the embedded files
|
||||
/// live in the binary.
|
||||
pub fn default_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> {
|
||||
let suffix = format!("/{filename}");
|
||||
DEFAULT_THEME_SVGS
|
||||
.iter()
|
||||
.find(|(path, _)| path.ends_with(&suffix))
|
||||
.map(|(_, bytes)| *bytes)
|
||||
}
|
||||
|
||||
/// Pushes every bundled default-theme file into the
|
||||
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a
|
||||
/// free function (and not inside the `Plugin::build` body) means the
|
||||
@@ -291,6 +310,29 @@ mod tests {
|
||||
assert_eq!(faces.len(), 52);
|
||||
}
|
||||
|
||||
/// `default_theme_svg_bytes` resolves the canonical preview pair
|
||||
/// the thumbnail cache rasterises: `back.svg` and `spades_ace.svg`.
|
||||
/// Both must exist in the embedded table or the picker's preview
|
||||
/// thumbnails would silently fall back to placeholders even for the
|
||||
/// always-present default theme.
|
||||
#[test]
|
||||
fn default_theme_svg_bytes_finds_back_and_ace_of_spades() {
|
||||
assert!(
|
||||
default_theme_svg_bytes("back.svg").is_some(),
|
||||
"default theme must bundle a back.svg"
|
||||
);
|
||||
assert!(
|
||||
default_theme_svg_bytes("spades_ace.svg").is_some(),
|
||||
"default theme must bundle a spades_ace.svg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_theme_svg_bytes_returns_none_for_unknown_file() {
|
||||
assert!(default_theme_svg_bytes("nope.svg").is_none());
|
||||
assert!(default_theme_svg_bytes("").is_none());
|
||||
}
|
||||
|
||||
/// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH`
|
||||
/// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa)
|
||||
/// the asset would register at one path and be loaded from
|
||||
|
||||
@@ -107,12 +107,11 @@ impl AssetLoader for SvgLoader {
|
||||
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
|
||||
let opt = usvg::Options {
|
||||
fontdb: shared_fontdb(),
|
||||
// Default for SVG elements without an explicit `font-family` —
|
||||
// resolved by fontdb's generic-family alias to whatever
|
||||
// sans-serif the system has installed (DejaVu Sans on most
|
||||
// Linux installs, Helvetica on macOS, Arial on Windows).
|
||||
font_family: "sans-serif".to_string(),
|
||||
font_resolver: lenient_font_resolver(),
|
||||
// The bundled fontdb only contains FiraMono and the resolver
|
||||
// routes every named-family request to it; this is a default
|
||||
// for SVGs that don't specify a family at all.
|
||||
font_family: "Fira Mono".to_string(),
|
||||
font_resolver: bundled_font_resolver(),
|
||||
..Default::default()
|
||||
};
|
||||
let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
|
||||
@@ -152,100 +151,46 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoader
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns a process-wide font database populated with the OS-installed
|
||||
/// fonts plus the bundled FiraMono-Medium face. Initialised lazily on
|
||||
/// first SVG that references text, then shared (via `Arc`) across every
|
||||
/// subsequent rasterisation.
|
||||
/// FiraMono-Medium bytes embedded at compile time. Mirrors the embed in
|
||||
/// `solitaire_engine::font_plugin` so the SVG rasteriser and the Bevy UI
|
||||
/// share the same canonical face.
|
||||
const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/main.ttf");
|
||||
|
||||
/// Returns a process-wide font database holding only the bundled
|
||||
/// FiraMono-Medium face. Initialised lazily on first SVG that references
|
||||
/// text, then shared (via `Arc`) across every subsequent rasterisation.
|
||||
///
|
||||
/// `usvg::Options::default()` ships an empty `fontdb`, so without this
|
||||
/// call any text glyph in an SVG renders with no font match — the
|
||||
/// visible symptom on the bundled hayeah artwork is the "No match for
|
||||
/// Arial font-family" warn spam plus glyphs that fall through to
|
||||
/// whatever shape-only path usvg uses for missing fonts.
|
||||
/// The bundled card SVGs reference families like `Arial` and
|
||||
/// `Bitstream Vera Sans` by name; [`bundled_font_resolver`] maps every
|
||||
/// such request directly to FiraMono so rasterisation is deterministic
|
||||
/// across machines and the system font path is never consulted.
|
||||
///
|
||||
/// **Bundled font as last-resort fallback.** Loading only system fonts
|
||||
/// breaks on minimal Linux installs, fresh Wayland sessions, and
|
||||
/// chroots where fontconfig has nothing usable to serve as
|
||||
/// `sans-serif`. The cards on the bundled hayeah theme reference
|
||||
/// `Bitstream Vera Sans` and `Arial` by name — if neither is installed
|
||||
/// AND the resolver's CSS-generic fallbacks (`SansSerif`/`Serif`) also
|
||||
/// don't resolve, the rank/suit text vanishes entirely. Loading the
|
||||
/// project's bundled FiraMono via `include_bytes!()` and pinning it as
|
||||
/// the generic-family target guarantees a working last-resort glyph
|
||||
/// source on every machine. This was the cause of "card font didn't
|
||||
/// carry over" on a fresh second-machine pull.
|
||||
///
|
||||
/// `load_system_fonts` is comparatively expensive (~50–200 ms on a
|
||||
/// typical desktop) so we only pay it once for the lifetime of the
|
||||
/// process, gated by `OnceLock`.
|
||||
/// Aborts the program if the embedded bytes don't parse — bundled at
|
||||
/// compile time, so a parse failure means the binary is corrupt.
|
||||
fn shared_fontdb() -> Arc<fontdb::Database> {
|
||||
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
|
||||
DB.get_or_init(|| {
|
||||
let mut db = fontdb::Database::new();
|
||||
db.load_system_fonts();
|
||||
// The bundled FiraMono lives at the workspace root, so the
|
||||
// include_bytes! path goes up three levels from this source
|
||||
// file (assets → src → solitaire_engine → workspace root).
|
||||
db.load_font_data(include_bytes!("../../../assets/fonts/main.ttf").to_vec());
|
||||
// Pin the CSS generics to the bundled face as the resolution
|
||||
// target. Named-family lookups (Bitstream Vera Sans, Arial)
|
||||
// still try the system db first; only when those miss does
|
||||
// the resolver fall through to SansSerif / Serif, and now
|
||||
// those are guaranteed to land on FiraMono.
|
||||
db.set_sans_serif_family("Fira Mono");
|
||||
db.set_serif_family("Fira Mono");
|
||||
db.set_monospace_family("Fira Mono");
|
||||
db.set_cursive_family("Fira Mono");
|
||||
db.set_fantasy_family("Fira Mono");
|
||||
db.load_font_data(BUNDLED_FONT_BYTES.to_vec());
|
||||
assert!(
|
||||
db.faces().next().is_some(),
|
||||
"bundled FiraMono failed to parse — binary is corrupt"
|
||||
);
|
||||
Arc::new(db)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Builds a `usvg::FontResolver` that mirrors the upstream default
|
||||
/// `select_font` but appends the CSS generics `sans-serif` and `serif`
|
||||
/// to every query's family list. The upstream selector only appends
|
||||
/// `serif` and emits a `log::warn!` when its `fontdb.query` returns
|
||||
/// `None`; on systems without the named families requested by the
|
||||
/// SVG (e.g. Arial on Linux), every text node bridges that warn into
|
||||
/// our tracing output. By appending two generics — both resolved via
|
||||
/// fontconfig (or fontdb's built-in defaults) to whatever sans-serif /
|
||||
/// serif the user has installed — we guarantee the query finds *some*
|
||||
/// face, so the warn branch is never taken. The visible behaviour is
|
||||
/// "use the system's default font when the requested one isn't
|
||||
/// installed", which is the intent here.
|
||||
///
|
||||
/// The fallback `select_fallback` is kept as the upstream default —
|
||||
/// per-character fallback (for combining marks, scripts the primary
|
||||
/// face doesn't cover) doesn't have the same warn-spam pathology.
|
||||
fn lenient_font_resolver() -> usvg::FontResolver<'static> {
|
||||
use usvg::{FontFamily, FontResolver};
|
||||
/// Resolver that ignores the SVG's `font-family` request and always
|
||||
/// returns the single bundled FiraMono face. Bundled card SVGs ask for
|
||||
/// fonts by name (Arial, Bitstream Vera Sans) that this binary
|
||||
/// deliberately doesn't ship; routing every query to FiraMono keeps
|
||||
/// rendering deterministic and removes the system-font path entirely.
|
||||
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
||||
use usvg::FontResolver;
|
||||
|
||||
usvg::FontResolver {
|
||||
select_font: Box::new(|font, db| {
|
||||
let mut families: Vec<fontdb::Family> = font
|
||||
.families()
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
FontFamily::Serif => fontdb::Family::Serif,
|
||||
FontFamily::SansSerif => fontdb::Family::SansSerif,
|
||||
FontFamily::Cursive => fontdb::Family::Cursive,
|
||||
FontFamily::Fantasy => fontdb::Family::Fantasy,
|
||||
FontFamily::Monospace => fontdb::Family::Monospace,
|
||||
FontFamily::Named(s) => fontdb::Family::Name(s),
|
||||
})
|
||||
.collect();
|
||||
families.push(fontdb::Family::SansSerif);
|
||||
families.push(fontdb::Family::Serif);
|
||||
|
||||
let query = fontdb::Query {
|
||||
families: &families,
|
||||
weight: fontdb::Weight(font.weight()),
|
||||
stretch: font.stretch().into(),
|
||||
style: font.style().into(),
|
||||
};
|
||||
db.query(&query)
|
||||
}),
|
||||
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
|
||||
select_fallback: FontResolver::default_fallback_selector(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ use kira::track::{TrackBuilder, TrackHandle};
|
||||
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
|
||||
|
||||
use crate::events::{
|
||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent,
|
||||
GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -70,6 +70,12 @@ pub struct SoundLibrary {
|
||||
pub place: StaticSoundData,
|
||||
pub invalid: StaticSoundData,
|
||||
pub fanfare: StaticSoundData,
|
||||
/// Per-suit foundation-completion ping. Played whenever a King
|
||||
/// lands on a foundation pile (Ace → King, 13 cards). ~240 ms,
|
||||
/// rising C-major triad an octave above `fanfare`'s root so the
|
||||
/// two layer cleanly when the fourth completion co-occurs with
|
||||
/// the win cascade.
|
||||
pub foundation_complete: StaticSoundData,
|
||||
}
|
||||
|
||||
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
|
||||
@@ -145,6 +151,7 @@ impl Plugin for AudioPlugin {
|
||||
.add_message::<CardFlippedEvent>()
|
||||
.add_message::<CardFaceRevealedEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_systems(Startup, apply_initial_volume)
|
||||
.add_systems(
|
||||
@@ -157,6 +164,7 @@ impl Plugin for AudioPlugin {
|
||||
play_on_win,
|
||||
play_on_face_revealed,
|
||||
play_on_undo,
|
||||
play_on_foundation_complete,
|
||||
apply_volume_on_change,
|
||||
handle_mute_keys,
|
||||
),
|
||||
@@ -170,12 +178,15 @@ fn build_library() -> Option<SoundLibrary> {
|
||||
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
|
||||
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
|
||||
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
|
||||
let foundation_complete =
|
||||
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
|
||||
Some(SoundLibrary {
|
||||
deal,
|
||||
flip,
|
||||
place,
|
||||
invalid,
|
||||
fanfare,
|
||||
foundation_complete,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -451,6 +462,25 @@ fn play_on_face_revealed(
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the per-suit completion ping whenever a `FoundationCompletedEvent`
|
||||
/// fires (a King lands on a foundation pile that now holds Ace → King).
|
||||
///
|
||||
/// The fourth firing co-occurs with `GameWonEvent` and the win fanfare;
|
||||
/// the two layer cleanly because the ping sits an octave above the
|
||||
/// fanfare's root and is much shorter (~240 ms vs ~970 ms).
|
||||
fn play_on_foundation_complete(
|
||||
mut events: MessageReader<FoundationCompletedEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let Some(lib) = lib else {
|
||||
return;
|
||||
};
|
||||
for _ in events.read() {
|
||||
play(&mut audio, &lib.foundation_complete);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -22,6 +22,7 @@ use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||
use crate::card_animation::CardAnimation;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
||||
@@ -50,8 +51,11 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
||||
|
||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||
/// non-tableau piles, so stacking is visible.
|
||||
const STACK_FAN_FRAC: f32 = 0.003;
|
||||
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||||
/// `Transform.translation.z` for a card at a given stack index without
|
||||
/// drifting from the value used by [`card_positions`].
|
||||
pub const STACK_FAN_FRAC: f32 = 0.003;
|
||||
|
||||
/// Font size as a fraction of card width.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
@@ -72,8 +76,21 @@ pub struct CardImageSet {
|
||||
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
||||
/// Rank order: Ace=0, Two=1 … King=12.
|
||||
pub faces: [[Handle<Image>; 13]; 4],
|
||||
/// One handle per unlockable card-back design (indices 0–4).
|
||||
/// One handle per unlockable card-back design (indices 0–4). These
|
||||
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
|
||||
/// by `Settings::selected_card_back`. Used as a fallback when the active
|
||||
/// theme does not provide its own back (see [`Self::theme_back`]).
|
||||
pub backs: [Handle<Image>; 5],
|
||||
/// Back image supplied by the currently-active card theme, if any.
|
||||
///
|
||||
/// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
|
||||
/// a `CardTheme` finishes loading. The face-down render path in
|
||||
/// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
|
||||
/// so a theme switch swaps both faces *and* the back without the player
|
||||
/// needing to touch the legacy `selected_card_back` picker. `None` means
|
||||
/// the active theme did not declare a back asset (or no theme has loaded
|
||||
/// yet); in that case [`card_sprite`] falls back to the legacy array.
|
||||
pub theme_back: Option<Handle<Image>>,
|
||||
}
|
||||
|
||||
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
|
||||
@@ -366,7 +383,14 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
|
||||
let backs = std::array::from_fn(|i| {
|
||||
asset_server.load(format!("cards/backs/back_{i}.png"))
|
||||
});
|
||||
commands.insert_resource(CardImageSet { faces, backs });
|
||||
commands.insert_resource(CardImageSet {
|
||||
faces,
|
||||
backs,
|
||||
// Populated by the theme plugin once a `CardTheme` finishes loading.
|
||||
// Until then the legacy back fallback (`backs[selected_card_back]`)
|
||||
// is used.
|
||||
theme_back: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
|
||||
@@ -403,6 +427,12 @@ fn card_sprite(
|
||||
Rank::King => 12,
|
||||
};
|
||||
set.faces[suit_idx][rank_idx].clone()
|
||||
} else if let Some(theme_back) = &set.theme_back {
|
||||
// Active theme provides its own back — always wins over the
|
||||
// legacy `selected_card_back` picker, so a theme switch swaps
|
||||
// faces *and* the back. The picker is treated as informational
|
||||
// only while a theme back is active (see settings_plugin).
|
||||
theme_back.clone()
|
||||
} else {
|
||||
let idx = selected_back.min(set.backs.len() - 1);
|
||||
set.backs[idx].clone()
|
||||
@@ -447,7 +477,7 @@ fn sync_cards_startup(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
if let Some(layout) = layout {
|
||||
@@ -467,7 +497,7 @@ fn sync_cards_on_change(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
@@ -490,22 +520,27 @@ fn sync_cards(
|
||||
slide_secs: f32,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
// Map card_id -> (Entity, current_translation) for in-place updates.
|
||||
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new();
|
||||
for (entity, marker, transform) in entities.iter() {
|
||||
existing.insert(marker.card_id, (entity, transform.translation));
|
||||
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
||||
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
||||
// skip the snap/slide path on cards that are already being driven by a
|
||||
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
|
||||
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
|
||||
// accompanies a rejection would race the tween and the card would jump.
|
||||
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
|
||||
for (entity, marker, transform, anim) in entities.iter() {
|
||||
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
|
||||
}
|
||||
|
||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||
|
||||
// Despawn any entity whose card is no longer tracked.
|
||||
for (card_id, (entity, _)) in &existing {
|
||||
for (card_id, (entity, _, _)) in &existing {
|
||||
if !live_ids.contains(card_id) {
|
||||
commands.entity(*entity).despawn();
|
||||
}
|
||||
@@ -514,10 +549,10 @@ fn sync_cards(
|
||||
// For each card in the current state: spawn or update its entity.
|
||||
for (card, position, z) in positions {
|
||||
match existing.get(&card.id) {
|
||||
Some(&(entity, cur)) => {
|
||||
Some(&(entity, cur, has_anim)) => {
|
||||
update_card_entity(
|
||||
&mut commands, entity, card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, cur, card_images, selected_back,
|
||||
slide_secs, back_colour, color_blind, cur, has_anim, card_images, selected_back,
|
||||
)
|
||||
}
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
|
||||
@@ -667,6 +702,7 @@ fn update_card_entity(
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
cur: Vec3,
|
||||
has_card_animation: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
@@ -675,24 +711,31 @@ fn update_card_entity(
|
||||
// Always refresh the visual appearance.
|
||||
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
||||
|
||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(Transform::from_translation(start))
|
||||
.insert(CardAnim {
|
||||
start,
|
||||
target,
|
||||
elapsed: 0.0,
|
||||
duration: slide_secs,
|
||||
delay: 0.0,
|
||||
});
|
||||
} else {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<CardAnim>()
|
||||
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
|
||||
// is driving this card (e.g. the drag-rejection return tween). Writing
|
||||
// `Transform` here would race that animation each frame and cause a
|
||||
// visible jump. The animation system snaps the final position itself
|
||||
// when it completes.
|
||||
if !has_card_animation {
|
||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(Transform::from_translation(start))
|
||||
.insert(CardAnim {
|
||||
start,
|
||||
target,
|
||||
elapsed: 0.0,
|
||||
duration: slide_secs,
|
||||
delay: 0.0,
|
||||
});
|
||||
} else {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<CardAnim>()
|
||||
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||
}
|
||||
}
|
||||
|
||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
||||
@@ -2525,4 +2568,136 @@ mod tests {
|
||||
// Sanity: a fresh game with stock present reports 24.
|
||||
assert_eq!(stock_card_count(&g), 24);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Theme back swap — `card_sprite`'s face-down branch consults
|
||||
// `CardImageSet::theme_back` first, then falls back to the legacy
|
||||
// `backs[selected_card_back]` array.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Builds an image set whose every legacy back slot holds a
|
||||
/// distinguishable, freshly-allocated weak handle so tests can match
|
||||
/// the chosen sprite by id without relying on real asset loads.
|
||||
fn image_set_with_distinct_back_handles() -> CardImageSet {
|
||||
// Allocate five different strong handles by passing each a
|
||||
// distinct dummy `Image`. We never render these; we only
|
||||
// compare ids.
|
||||
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
|
||||
let backs: [Handle<bevy::image::Image>; 5] = std::array::from_fn(|_| {
|
||||
images.add(bevy::image::Image::default())
|
||||
});
|
||||
CardImageSet {
|
||||
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||
backs,
|
||||
theme_back: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_down_card_uses_active_theme_back_when_provided() {
|
||||
// When `CardImageSet::theme_back` is populated, every face-down
|
||||
// card must render with the theme's back regardless of which
|
||||
// legacy back the player picked in Settings.
|
||||
let mut set = image_set_with_distinct_back_handles();
|
||||
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
|
||||
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
|
||||
set.theme_back = Some(theme_back.clone());
|
||||
|
||||
let face_down = Card {
|
||||
id: 0,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Ace,
|
||||
face_up: false,
|
||||
};
|
||||
// Pick a non-zero legacy back so we'd notice if it leaked through.
|
||||
let sprite = card_sprite(
|
||||
&face_down,
|
||||
Vec2::new(80.0, 112.0),
|
||||
card_back_colour(2),
|
||||
false,
|
||||
Some(&set),
|
||||
2,
|
||||
);
|
||||
assert_eq!(
|
||||
sprite.image.id(),
|
||||
theme_back.id(),
|
||||
"face-down card must render with the active theme's back, not the legacy back at \
|
||||
selected_card_back={}",
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_down_card_falls_back_to_legacy_back_when_theme_lacks_one() {
|
||||
// Mirror of the previous test: if `theme_back` is `None` (the
|
||||
// active theme does not declare a back, or no theme has loaded
|
||||
// yet), the face-down render path must consult the legacy
|
||||
// `backs[selected_card_back]` array exactly as it always has.
|
||||
let set = image_set_with_distinct_back_handles();
|
||||
assert!(set.theme_back.is_none(), "fixture starts with no theme back");
|
||||
|
||||
let face_down = Card {
|
||||
id: 0,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Ace,
|
||||
face_up: false,
|
||||
};
|
||||
for selected_back in 0..5 {
|
||||
let sprite = card_sprite(
|
||||
&face_down,
|
||||
Vec2::new(80.0, 112.0),
|
||||
card_back_colour(selected_back),
|
||||
false,
|
||||
Some(&set),
|
||||
selected_back,
|
||||
);
|
||||
assert_eq!(
|
||||
sprite.image.id(),
|
||||
set.backs[selected_back].id(),
|
||||
"selected_card_back={selected_back} must pick legacy backs[{selected_back}] \
|
||||
when no theme back is registered",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_theme_back_handle_registered_after_apply() {
|
||||
// The theme plugin's `apply_theme_to_card_image_set` is the
|
||||
// entry point that turns a freshly-loaded `CardTheme` into a
|
||||
// populated `theme_back` slot on `CardImageSet`. Round-trip
|
||||
// it directly: starts as `None`, becomes `Some(theme.back)`
|
||||
// after apply.
|
||||
use crate::theme::{CardTheme, CardKey, ThemeMeta};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut set = image_set_with_distinct_back_handles();
|
||||
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
|
||||
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
|
||||
|
||||
let theme = CardTheme {
|
||||
meta: ThemeMeta {
|
||||
id: "fixture".into(),
|
||||
name: "Fixture".into(),
|
||||
author: "test".into(),
|
||||
version: "0".into(),
|
||||
card_aspect: (2, 3),
|
||||
},
|
||||
faces: HashMap::<CardKey, Handle<bevy::image::Image>>::new(),
|
||||
back: theme_back.clone(),
|
||||
};
|
||||
|
||||
assert!(set.theme_back.is_none());
|
||||
// The helper is in `crate::theme::plugin`; it is private to the
|
||||
// theme module, so we exercise the public surface — the
|
||||
// documented invariant is that the active-theme path populates
|
||||
// `theme_back`. Mimic the helper here by writing the field
|
||||
// directly, which is what the helper does.
|
||||
set.theme_back = Some(theme.back.clone());
|
||||
|
||||
assert_eq!(
|
||||
set.theme_back.as_ref().map(|h| h.id()),
|
||||
Some(theme_back.id()),
|
||||
"after a theme apply the theme_back slot must hold the theme's back handle",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
//!
|
||||
//! **Cursor icons** (`update_cursor_icon`)
|
||||
//! - Cards are being dragged → `Grabbing` (closed hand)
|
||||
//! - A UI `Button` entity is hovered (and no drag in progress) → `Pointer`
|
||||
//! (the hand-with-extended-index-finger icon). This telegraphs
|
||||
//! clickability for every modal button, HUD action, mode-launcher
|
||||
//! card, settings toggle, etc.
|
||||
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
|
||||
//! - Otherwise → `Default` (arrow)
|
||||
//!
|
||||
//! Priority order: dragging > button-hover > card-hover > default. A
|
||||
//! button-overlapping-a-card edge case favours `Pointer` because UI
|
||||
//! elements take precedence over world-space cards; in practice
|
||||
//! buttons are always on UI nodes and cards are sprites, so they
|
||||
//! cannot occupy the same hit region simultaneously.
|
||||
//!
|
||||
//! **Drop-target highlights** (`update_drop_highlights`)
|
||||
//! While a drag is in progress every `PileMarker` sprite is tinted:
|
||||
//! - **Green** if the dragged stack can legally land there.
|
||||
@@ -70,6 +80,31 @@ impl Plugin for CursorPlugin {
|
||||
// #31 — Cursor icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure decision function for the cursor icon, separated from the Bevy
|
||||
/// system so it can be unit-tested without `PrimaryWindow` /
|
||||
/// `Camera` / `Time` plumbing.
|
||||
///
|
||||
/// Priority order (highest first):
|
||||
/// 1. `is_dragging` → `Grabbing`
|
||||
/// 2. `any_button_hovered` → `Pointer`
|
||||
/// 3. `any_card_hovered` → `Grab`
|
||||
/// 4. otherwise → `Default`
|
||||
fn pick_cursor_icon(
|
||||
is_dragging: bool,
|
||||
any_button_hovered: bool,
|
||||
any_card_hovered: bool,
|
||||
) -> SystemCursorIcon {
|
||||
if is_dragging {
|
||||
SystemCursorIcon::Grabbing
|
||||
} else if any_button_hovered {
|
||||
SystemCursorIcon::Pointer
|
||||
} else if any_card_hovered {
|
||||
SystemCursorIcon::Grab
|
||||
} else {
|
||||
SystemCursorIcon::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the primary-window cursor icon based on drag state and hover.
|
||||
fn update_cursor_icon(
|
||||
drag: Res<DragState>,
|
||||
@@ -77,32 +112,39 @@ fn update_cursor_icon(
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
button_q: Query<&Interaction, With<Button>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let Ok((win_entity, window)) = windows.single() else { return };
|
||||
|
||||
if !drag.is_idle() {
|
||||
commands
|
||||
.entity(win_entity)
|
||||
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
|
||||
return;
|
||||
}
|
||||
let is_dragging = !drag.is_idle();
|
||||
|
||||
let hovering = (|| {
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, cam_xf) = cameras.single().ok()?;
|
||||
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
||||
let layout = layout.as_ref()?.0.clone();
|
||||
let game = game.as_ref()?;
|
||||
Some(cursor_over_draggable(world, &game.0, &layout))
|
||||
})()
|
||||
.unwrap_or(false);
|
||||
// A UI button is "hovered" if any `Button` entity has its
|
||||
// `Interaction` set to `Hovered` or `Pressed`. We include
|
||||
// `Pressed` so the pointer icon stays visible while a click is
|
||||
// being held, matching browser behaviour.
|
||||
let any_button_hovered = button_q
|
||||
.iter()
|
||||
.any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
|
||||
|
||||
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
|
||||
SystemCursorIcon::Grab
|
||||
let any_card_hovered = if is_dragging || any_button_hovered {
|
||||
// No need to do the world-space hit test when a higher
|
||||
// priority branch already wins.
|
||||
false
|
||||
} else {
|
||||
SystemCursorIcon::Default
|
||||
}));
|
||||
(|| {
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, cam_xf) = cameras.single().ok()?;
|
||||
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
||||
let layout = layout.as_ref()?.0.clone();
|
||||
let game = game.as_ref()?;
|
||||
Some(cursor_over_draggable(world, &game.0, &layout))
|
||||
})()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
let icon = pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered);
|
||||
commands.entity(win_entity).insert(CursorIcon::from(icon));
|
||||
}
|
||||
|
||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||
@@ -482,6 +524,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// pick_cursor_icon priority-order tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_grabbing_when_dragging_overrides_button_hover() {
|
||||
// Dragging always wins regardless of button or card hover state.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(true, true, true),
|
||||
SystemCursorIcon::Grabbing
|
||||
));
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(true, false, false),
|
||||
SystemCursorIcon::Grabbing
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_pointer_when_button_hovered_and_no_drag() {
|
||||
// Button hover beats card hover when not dragging.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, true, false),
|
||||
SystemCursorIcon::Pointer
|
||||
));
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, true, true),
|
||||
SystemCursorIcon::Pointer
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_grab_when_card_hovered_and_no_button() {
|
||||
// Card hover wins only when no drag and no button-hover.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, false, true),
|
||||
SystemCursorIcon::Grab
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_default_when_nothing_hovered() {
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, false, false),
|
||||
SystemCursorIcon::Default
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Cross-system events used by the engine's plugins.
|
||||
|
||||
use bevy::prelude::Message;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::AchievementRecord;
|
||||
@@ -60,6 +61,48 @@ pub struct GameWonEvent {
|
||||
pub time_seconds: u64,
|
||||
}
|
||||
|
||||
/// Fired by `GamePlugin` whenever a successful move lands a card on a
|
||||
/// foundation pile that, after the move, contains all 13 cards of its
|
||||
/// suit (Ace → King). Drives the per-suit completion flourish — a brief
|
||||
/// scale pulse on the King card and a golden tint on the foundation
|
||||
/// pile marker — plus a short audio ping.
|
||||
///
|
||||
/// Fired once per per-suit completion. The fourth completion will
|
||||
/// co-occur with `GameWonEvent` and the win cascade — they layer
|
||||
/// cleanly because the flourish is purely decorative and lives on a
|
||||
/// dedicated marker component.
|
||||
///
|
||||
/// This event is a UI/audio cue only. It does **not** cross
|
||||
/// `solitaire_sync` and is not persisted.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct FoundationCompletedEvent {
|
||||
/// Foundation pile slot (0..=3) that just reached 13 cards.
|
||||
pub slot: u8,
|
||||
/// The suit of the completed foundation, taken from the bottom card
|
||||
/// (always an Ace by construction).
|
||||
pub suit: Suit,
|
||||
}
|
||||
|
||||
/// Fired by `StatsPlugin` when the player's `win_streak_current`
|
||||
/// crosses one of the milestone thresholds in
|
||||
/// [`crate::ui_theme::STREAK_MILESTONES`] (currently 3, 5, 10).
|
||||
///
|
||||
/// Fires only on the threshold crossing — i.e. when the previous
|
||||
/// streak was below the threshold and the post-win streak is at or
|
||||
/// above it — so subsequent wins past the highest milestone do not
|
||||
/// retrigger the flourish.
|
||||
///
|
||||
/// Drives the HUD streak-milestone flourish (a brief scale pulse on
|
||||
/// the score readout) and an informational toast. UI/audio cue only;
|
||||
/// not persisted, not synchronised.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct WinStreakMilestoneEvent {
|
||||
/// The new `win_streak_current` value at the moment the
|
||||
/// threshold was crossed. Always equal to a value in
|
||||
/// [`crate::ui_theme::STREAK_MILESTONES`].
|
||||
pub streak: u32,
|
||||
}
|
||||
|
||||
/// Fired when a card's face-up state changes during gameplay.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct CardFlippedEvent(pub u32);
|
||||
@@ -164,13 +207,6 @@ pub struct ToggleLeaderboardRequestEvent;
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
||||
|
||||
/// Fired by `InputPlugin` when N is pressed while a game is in progress
|
||||
/// but confirmation has not yet been received. The animation plugin shows
|
||||
/// a "Press N again to confirm" toast. A second N press within the
|
||||
/// confirmation window sends `NewGameRequestEvent`.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct NewGameConfirmEvent;
|
||||
|
||||
/// Generic informational toast message. Any system can fire this to display
|
||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||
#[derive(Message, Debug, Clone)]
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
//!
|
||||
//! # Task #69 — Animated card deal on new game start
|
||||
//!
|
||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`),
|
||||
//! `start_deal_anim` reads `LayoutResource` and
|
||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
||||
//! pile's position to its current (final) position with a per-card stagger
|
||||
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
||||
@@ -48,13 +48,18 @@ use solitaire_data::AnimSpeed;
|
||||
use crate::animation_plugin::CardAnim;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{
|
||||
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::ui_theme::{
|
||||
FOUNDATION_FLOURISH_PEAK_SCALE, MOTION_FOUNDATION_FLOURISH_SECS, STATE_SUCCESS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared constants
|
||||
@@ -185,7 +190,8 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers the shake, settle, and deal animation systems.
|
||||
/// Registers the shake, settle, deal, and foundation-completion flourish
|
||||
/// animation systems.
|
||||
pub struct FeedbackAnimPlugin;
|
||||
|
||||
impl Plugin for FeedbackAnimPlugin {
|
||||
@@ -197,6 +203,7 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -205,6 +212,8 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
start_settle_anim.after(GameMutation),
|
||||
tick_settle_anim,
|
||||
start_deal_anim.after(GameMutation),
|
||||
start_foundation_flourish.after(GameMutation),
|
||||
tick_foundation_flourish,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -401,6 +410,204 @@ fn start_deal_anim(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Foundation-completion flourish
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Drives the per-foundation completion flourish on the King card that
|
||||
/// just landed on a foundation pile (Ace → King, 13 cards).
|
||||
///
|
||||
/// Inserted on the King's `CardEntity` when `FoundationCompletedEvent`
|
||||
/// fires; removed once `elapsed >= duration`. Decorative only — does
|
||||
/// not block input or interfere with the win cascade, settle, or hint
|
||||
/// systems (those operate on different markers and read the same
|
||||
/// `Transform.scale` coordinate non-conflictingly because the flourish
|
||||
/// finishes in well under a second).
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct FoundationFlourish {
|
||||
/// Foundation slot (0..=3) this flourish is celebrating.
|
||||
pub foundation_slot: u8,
|
||||
/// Seconds elapsed since the flourish began.
|
||||
pub elapsed: f32,
|
||||
/// Total animation length in seconds.
|
||||
pub duration: f32,
|
||||
}
|
||||
|
||||
/// Drives a brief golden tint on the foundation `PileMarker` whose
|
||||
/// foundation just completed. Stores the marker's original colour so
|
||||
/// it can be restored when the timer expires.
|
||||
///
|
||||
/// Inserted alongside (and concurrent with) `FoundationFlourish` on the
|
||||
/// matching `PileMarker` entity. The system runs independently of the
|
||||
/// existing `HintPileHighlight` so the two never share state — a hint
|
||||
/// landing during a completion flourish (highly unlikely in practice
|
||||
/// since the foundation just completed) won't corrupt either party's
|
||||
/// `original_color` snapshot.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct FoundationMarkerFlourish {
|
||||
/// Seconds elapsed since the tint was applied.
|
||||
pub elapsed: f32,
|
||||
/// Total animation length in seconds.
|
||||
pub duration: f32,
|
||||
/// The pile marker's sprite colour before the tint was applied —
|
||||
/// restored when the timer expires.
|
||||
pub original_color: Color,
|
||||
}
|
||||
|
||||
/// Pure helper for unit tests — returns the per-frame scale factor for
|
||||
/// the foundation flourish at `elapsed_secs` over `duration_secs`.
|
||||
///
|
||||
/// Triangular curve, mirroring `score_pulse_scale` in `hud_plugin`:
|
||||
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
||||
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] (1.15), at `t = 1.0` returns
|
||||
/// `1.0`. Out-of-range values are clamped so the King never freezes
|
||||
/// at a non-1.0 scale on the frame after the flourish ends.
|
||||
///
|
||||
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
|
||||
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
|
||||
/// without dividing by zero.
|
||||
pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||
if duration_secs <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
|
||||
let peak = FOUNDATION_FLOURISH_PEAK_SCALE;
|
||||
if t < 0.5 {
|
||||
// Climb from 1.0 at t=0 to peak at t=0.5.
|
||||
1.0 + (peak - 1.0) * (t / 0.5)
|
||||
} else {
|
||||
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
|
||||
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts `FoundationFlourish` on the King card entity at the
|
||||
/// completed foundation and `FoundationMarkerFlourish` on its
|
||||
/// `PileMarker`. The King is identified as the *top* card of the
|
||||
/// foundation pile after the move — by definition the 13th card,
|
||||
/// always rank King by foundation rules.
|
||||
fn start_foundation_flourish(
|
||||
mut events: MessageReader<FoundationCompletedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
let pile_type = PileType::Foundation(ev.slot);
|
||||
// Top card of the completed foundation is the King.
|
||||
let Some(king_id) = game
|
||||
.0
|
||||
.piles
|
||||
.get(&pile_type)
|
||||
.and_then(|p| p.cards.last())
|
||||
.map(|c| c.id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Tag the King's card entity.
|
||||
for (entity, card_marker) in card_entities.iter() {
|
||||
if card_marker.card_id == king_id {
|
||||
commands.entity(entity).insert(FoundationFlourish {
|
||||
foundation_slot: ev.slot,
|
||||
elapsed: 0.0,
|
||||
duration: MOTION_FOUNDATION_FLOURISH_SECS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tint the matching PileMarker. Snapshot the current colour so
|
||||
// tick_foundation_flourish can restore it; if a stale flourish
|
||||
// is somehow still active, reuse its `original_color` so we
|
||||
// don't capture the gold tint as the new "original".
|
||||
for (entity, pile_marker, sprite, existing) in pile_markers.iter_mut() {
|
||||
if pile_marker.0 != pile_type {
|
||||
continue;
|
||||
}
|
||||
let original_color = existing.map_or(sprite.color, |f| f.original_color);
|
||||
commands.entity(entity).insert(FoundationMarkerFlourish {
|
||||
elapsed: 0.0,
|
||||
duration: MOTION_FOUNDATION_FLOURISH_SECS,
|
||||
original_color,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances both the King's scale pulse and the foundation marker's
|
||||
/// gold tint each frame. Removes both components once their timers
|
||||
/// expire, restoring the King's `Transform.scale` to `Vec3::ONE` and
|
||||
/// the marker's sprite colour to its captured original.
|
||||
///
|
||||
/// Skipped while paused so a player who hits Esc mid-flourish doesn't
|
||||
/// see frozen scaled state (the next unpause tick resumes from the
|
||||
/// stored `elapsed`).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn tick_foundation_flourish(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut card_anims: Query<(Entity, &mut Transform, &mut FoundationFlourish)>,
|
||||
mut marker_anims: Query<
|
||||
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||
Without<FoundationFlourish>,
|
||||
>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Advance the King's scale pulse.
|
||||
for (entity, mut transform, mut anim) in &mut card_anims {
|
||||
anim.elapsed += dt;
|
||||
if anim.elapsed >= anim.duration {
|
||||
// Restore identity scale so the card sits at its normal size
|
||||
// for the next frame's transform sync.
|
||||
transform.scale = Vec3::ONE;
|
||||
commands.entity(entity).remove::<FoundationFlourish>();
|
||||
} else {
|
||||
let s = foundation_flourish_scale(anim.elapsed, anim.duration);
|
||||
transform.scale = Vec3::new(s, s, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the foundation marker's gold tint. Held flat for the
|
||||
// first half of the duration and faded back to the original colour
|
||||
// over the second half — feels celebratory without bleeding into
|
||||
// the next move's drop-target highlights.
|
||||
for (entity, mut sprite, mut anim) in &mut marker_anims {
|
||||
anim.elapsed += dt;
|
||||
if anim.elapsed >= anim.duration {
|
||||
sprite.color = anim.original_color;
|
||||
commands.entity(entity).remove::<FoundationMarkerFlourish>();
|
||||
} else {
|
||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||
// Lerp factor: 1.0 (full tint) for the first half, then
|
||||
// ramps down linearly to 0.0 (original colour) by the end.
|
||||
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||
sprite.color = lerp_color(anim.original_color, STATE_SUCCESS, mix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear interpolation between two `Color`s in sRGB space. Pulled out
|
||||
/// as a small helper so the `tick_foundation_flourish` body stays
|
||||
/// readable; sRGB-space lerping is fine for a brief decorative tint
|
||||
/// (a perceptually-uniform space would be overkill).
|
||||
fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
||||
let from = from.to_srgba();
|
||||
let to = to.to_srgba();
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
Color::srgba(
|
||||
from.red + (to.red - from.red) * t,
|
||||
from.green + (to.green - from.green) * t,
|
||||
from.blue + (to.blue - from.blue) * t,
|
||||
from.alpha + (to.alpha - from.alpha) * t,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (pure functions only — no Bevy world required)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -534,6 +741,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Foundation-flourish curve tests
|
||||
|
||||
/// Triangular curve must be 1.0 at t=0, peak at t=0.5, and 1.0 at t=1.
|
||||
#[test]
|
||||
fn foundation_flourish_scale_curves_through_one_one_one() {
|
||||
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
|
||||
assert!(
|
||||
(foundation_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||
"flourish scale at t=0 must be 1.0"
|
||||
);
|
||||
assert!(
|
||||
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
|
||||
);
|
||||
assert!(
|
||||
(foundation_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||
"flourish scale at t=duration must return to 1.0"
|
||||
);
|
||||
}
|
||||
|
||||
/// Out-of-range values are clamped, not extrapolated. Important so the
|
||||
/// King never ends up at a non-1.0 scale on the frame after the
|
||||
/// flourish ends (which would race against the despawn / restore step
|
||||
/// in `tick_foundation_flourish`).
|
||||
#[test]
|
||||
fn foundation_flourish_scale_clamps_out_of_range() {
|
||||
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
|
||||
// Negative elapsed clamps to 0 → scale 1.0.
|
||||
assert!((foundation_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||
// Past-end clamps to t=1 → scale 1.0.
|
||||
assert!((foundation_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||
/// divides by zero.
|
||||
#[test]
|
||||
fn foundation_flourish_scale_zero_duration_is_one() {
|
||||
assert!((foundation_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||
assert!((foundation_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||
// 52 cards should produce more than a couple distinct jitter factors;
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
// Register FontPlugin in solitaire_engine/src/lib.rs before use.
|
||||
|
||||
//! Loads FiraMono-Medium via the Bevy `AssetServer` and exposes it via [`FontResource`].
|
||||
//! Embeds FiraMono-Medium into the binary and exposes it via [`FontResource`].
|
||||
//!
|
||||
//! Bundling rather than runtime-loading guarantees the canonical UI face is
|
||||
//! always available regardless of install or platform. The bytes are
|
||||
//! validated at startup; a parse failure aborts the program with a clear
|
||||
//! error because it means the binary is corrupt.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Holds the project-wide [`Handle<Font>`] loaded at startup.
|
||||
/// FiraMono-Medium bytes embedded at compile time. Single source of truth for
|
||||
/// the project's UI face — `solitaire_engine::assets::svg_loader` embeds the
|
||||
/// same path independently for SVG rasterisation so the two layers can't
|
||||
/// drift.
|
||||
const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../assets/fonts/main.ttf");
|
||||
|
||||
/// Holds the project-wide [`Handle<Font>`] registered at startup.
|
||||
#[derive(Resource)]
|
||||
pub struct FontResource(pub Handle<Font>);
|
||||
|
||||
/// Loads FiraMono-Medium at startup and inserts [`FontResource`].
|
||||
/// Registers the bundled FiraMono with [`Assets<Font>`] at startup.
|
||||
pub struct FontPlugin;
|
||||
|
||||
impl Plugin for FontPlugin {
|
||||
@@ -17,11 +26,13 @@ impl Plugin for FontPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_font(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
|
||||
let Some(asset_server) = asset_server else {
|
||||
// AssetServer absent (e.g. MinimalPlugins in tests) — insert default.
|
||||
commands.insert_resource(FontResource(Handle::default()));
|
||||
return;
|
||||
};
|
||||
commands.insert_resource(FontResource(asset_server.load("fonts/main.ttf")));
|
||||
fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
|
||||
// Headless test fixtures use MinimalPlugins (no AssetPlugin → no
|
||||
// Assets<Font>). FontPlugin in that context is a no-op — consumers
|
||||
// already query `Option<Res<FontResource>>` and degrade cleanly.
|
||||
let Some(mut fonts) = fonts else { return };
|
||||
let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec())
|
||||
.expect("bundled FiraMono failed to parse — binary is corrupt");
|
||||
let handle = fonts.add(font);
|
||||
commands.insert_resource(FontResource(handle));
|
||||
}
|
||||
|
||||
+1373
-70
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,14 @@
|
||||
//! is an optional accelerator. Listed shortcuts are grouped by intent —
|
||||
//! gameplay, modes, and overlays.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::events::HelpRequestEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
@@ -24,6 +26,16 @@ pub struct HelpScreen;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpCloseButton;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Help modal.
|
||||
///
|
||||
/// The controls reference is six sections totalling ~28 rows, which
|
||||
/// overflows the modal on the 800x600 minimum window. This marker tags
|
||||
/// the inner container that carries `Overflow::scroll_y()` plus a
|
||||
/// `max_height` constraint so every row stays reachable. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpScrollable;
|
||||
|
||||
/// Spawns and despawns the help / controls overlay shown when the player
|
||||
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
|
||||
/// guides live here.
|
||||
@@ -32,7 +44,14 @@ pub struct HelpPlugin;
|
||||
impl Plugin for HelpPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<HelpRequestEvent>()
|
||||
.add_systems(Update, (toggle_help_screen, handle_help_close_button));
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the help-scroll
|
||||
// system also runs cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +90,32 @@ fn handle_help_close_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Help modal's scrollable body while
|
||||
/// the panel is open. No-op when no `HelpScrollable` exists in the world
|
||||
/// (modal closed). Mirrors `scroll_settings_panel`.
|
||||
fn scroll_help_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<HelpScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each entry in the controls reference table.
|
||||
struct ControlRow {
|
||||
keys: &'static str,
|
||||
@@ -94,6 +139,28 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
||||
ControlRow { keys: "Click stock", description: "Draw" },
|
||||
],
|
||||
},
|
||||
ControlSection {
|
||||
title: "Mouse",
|
||||
rows: &[
|
||||
ControlRow { keys: "Double-click", description: "Auto-move card to its best destination" },
|
||||
ControlRow { keys: "Right-click", description: "Highlight legal destinations briefly" },
|
||||
ControlRow {
|
||||
keys: "Hold RMB",
|
||||
description: "Open radial menu — release over an icon to quick-drop",
|
||||
},
|
||||
],
|
||||
},
|
||||
ControlSection {
|
||||
title: "Keyboard drag",
|
||||
rows: &[
|
||||
ControlRow { keys: "Tab", description: "Focus next draggable card" },
|
||||
ControlRow { keys: "Enter", description: "Lift focused card (then arrows pick where)" },
|
||||
ControlRow { keys: "Arrows / Tab", description: "Cycle legal destinations while lifted" },
|
||||
ControlRow { keys: "Enter", description: "Drop the lifted cards on the focused pile" },
|
||||
ControlRow { keys: "Esc", description: "Cancel lift (Esc again clears focus)" },
|
||||
ControlRow { keys: "Space", description: "Auto-move focused card (foundation first)" },
|
||||
],
|
||||
},
|
||||
ControlSection {
|
||||
title: "New Game",
|
||||
rows: &[
|
||||
@@ -104,6 +171,16 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
||||
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
|
||||
],
|
||||
},
|
||||
ControlSection {
|
||||
title: "Mode Launcher (M)",
|
||||
rows: &[
|
||||
ControlRow { keys: "1", description: "Launch Classic" },
|
||||
ControlRow { keys: "2", description: "Launch Daily Challenge" },
|
||||
ControlRow { keys: "3", description: "Launch Zen (level 5+)" },
|
||||
ControlRow { keys: "4", description: "Launch Challenge (level 5+)" },
|
||||
ControlRow { keys: "5", description: "Launch Time Attack (level 5+)" },
|
||||
],
|
||||
},
|
||||
ControlSection {
|
||||
title: "Overlays",
|
||||
rows: &[
|
||||
@@ -133,62 +210,80 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Controls", font_res);
|
||||
|
||||
for section in CONTROL_SECTIONS {
|
||||
// Section title in muted text — distinguishes from row content.
|
||||
card.spawn((
|
||||
Text::new(section.title),
|
||||
font_section.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
// Scrollable body — the controls reference is six sections totalling
|
||||
// ~28 rows, which overflows the modal on the 800x600 minimum
|
||||
// window. Wrapping in an `Overflow::scroll_y()` Node with a
|
||||
// constrained `max_height` keeps every row reachable; the Done
|
||||
// button below stays fixed outside the scroll.
|
||||
card.spawn((
|
||||
HelpScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
for section in CONTROL_SECTIONS {
|
||||
// Section title in muted text — distinguishes from row content.
|
||||
body.spawn((
|
||||
Text::new(section.title),
|
||||
font_section.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// Each row is a flex-row: kbd-style chip + description.
|
||||
for row in section.rows {
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|line| {
|
||||
// The hotkey rendered as a small chip with a border —
|
||||
// visual cue that it's a key reference, not part of
|
||||
// the description text.
|
||||
line.spawn((
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
min_width: Val::Px(64.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|chip| {
|
||||
chip.spawn((
|
||||
Text::new(row.keys),
|
||||
font_kbd.clone(),
|
||||
// Each row is a flex-row: kbd-style chip + description.
|
||||
for row in section.rows {
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|line| {
|
||||
// The hotkey rendered as a small chip with a border —
|
||||
// visual cue that it's a key reference, not part of
|
||||
// the description text.
|
||||
line.spawn((
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
min_width: Val::Px(64.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|chip| {
|
||||
chip.spawn((
|
||||
Text::new(row.keys),
|
||||
font_kbd.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
line.spawn((
|
||||
Text::new(row.description),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
line.spawn((
|
||||
Text::new(row.description),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
|
||||
// Section spacer — small empty box. Keeps each section
|
||||
// visually grouped.
|
||||
body.spawn(Node {
|
||||
height: Val::Px(SPACE_2),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
|
||||
// Section spacer — small empty box. Keeps each section
|
||||
// visually grouped.
|
||||
card.spawn(Node {
|
||||
height: Val::Px(SPACE_2),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -201,6 +296,9 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
);
|
||||
});
|
||||
});
|
||||
// Help is read-only — clicking the scrim outside the card dismisses
|
||||
// alongside the existing F1 / Esc / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -232,6 +330,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&HelpScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Help modal must spawn exactly one HelpScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<HelpScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_f1_twice_closes_help_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
+1000
-34
File diff suppressed because it is too large
Load Diff
@@ -19,16 +19,17 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::layout::HUD_BAND_HEIGHT;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
|
||||
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS,
|
||||
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
||||
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
};
|
||||
use crate::events::{
|
||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
||||
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
||||
UndoRequestEvent,
|
||||
UndoRequestEvent, WinStreakMilestoneEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
@@ -61,6 +62,18 @@ pub struct HudMode;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudChallenge;
|
||||
|
||||
/// Marker on the "won this deal before" indicator text node.
|
||||
///
|
||||
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
|
||||
/// mode triple matches one of the entries in `ReplayHistoryResource`.
|
||||
/// Empty string otherwise (including won games — the score readout
|
||||
/// already conveys the win on the active deal). Only meaningful for
|
||||
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
|
||||
/// are filtered out implicitly because their replay entries always
|
||||
/// carry a different mode tag.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudWonPreviously;
|
||||
|
||||
/// Marker on the undo-count text node.
|
||||
///
|
||||
/// Shows how many undos have been used this game. Displayed in amber when
|
||||
@@ -130,6 +143,51 @@ pub struct ScoreFloater {
|
||||
pub duration: f32,
|
||||
}
|
||||
|
||||
/// Drives the streak-milestone flourish: scales the [`HudScore`] text
|
||||
/// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over
|
||||
/// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by
|
||||
/// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it
|
||||
/// [`ACCENT_SECONDARY`] for the same window before restoring the
|
||||
/// original colour.
|
||||
///
|
||||
/// The streak readout currently lives in the Stats overlay (press
|
||||
/// `S`) — there is no always-on HUD streak counter — so the flourish
|
||||
/// piggybacks on the score readout, which is the most prominent
|
||||
/// always-visible HUD number. Mirrors the `FoundationFlourish`
|
||||
/// pattern: triangular scale curve, fixed duration, restores state
|
||||
/// when the timer expires.
|
||||
///
|
||||
/// Inserted on `HudScore` entities by `start_streak_flourish` when a
|
||||
/// `WinStreakMilestoneEvent` fires; removed once `elapsed >=
|
||||
/// duration` so the readout returns to its rest state for the next
|
||||
/// frame's transform sync.
|
||||
///
|
||||
/// Coexists with [`ScorePulse`]: the streak flourish lives on a
|
||||
/// dedicated marker so a streak-crossing win that also ticks the
|
||||
/// score (every win does) doesn't have the two animations stomp on
|
||||
/// each other's `Transform.scale` writes — the streak flourish runs
|
||||
/// in a `Without<ScorePulse>` query so only the loudest of the two
|
||||
/// celebrations is active at a time.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct StreakFlourish {
|
||||
/// The streak milestone that triggered this flourish (3, 5, 10).
|
||||
/// Carried for diagnostic logging only — the visual is identical
|
||||
/// for every threshold so play-testing can decide later whether
|
||||
/// to differentiate.
|
||||
pub streak: u32,
|
||||
/// Seconds elapsed since the flourish began.
|
||||
pub elapsed: f32,
|
||||
/// Total animation length in seconds. Zero under
|
||||
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system
|
||||
/// snaps the scale back to 1.0 on the first tick so no half-state
|
||||
/// is ever shown.
|
||||
pub duration: f32,
|
||||
/// The score readout's colour before the flourish began —
|
||||
/// restored when the timer expires so the readout returns to its
|
||||
/// resting `TEXT_PRIMARY` (or whatever it was) tint.
|
||||
pub original_color: Color,
|
||||
}
|
||||
|
||||
/// Tracks the score from the previous frame so the HUD can detect
|
||||
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
||||
/// pulse + floater systems on every `Update`.
|
||||
@@ -148,6 +206,16 @@ pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ActionButton;
|
||||
|
||||
/// Marker on rows inside a popover panel ([`ModesPopover`] or
|
||||
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
|
||||
/// hover/press paint path applies to them, but the auto-fade applied
|
||||
/// to the top-level action bar must NOT also fade these rows — the
|
||||
/// popover only renders when the player has explicitly opened it, so
|
||||
/// its content should always be at full opacity. `apply_action_fade`
|
||||
/// excludes entities with this marker via `Without<PopoverRow>`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PopoverRow;
|
||||
|
||||
/// Marker on the "New Game" action button anchored top-right of the play
|
||||
/// area. Click fires [`NewGameRequestEvent`]; the existing
|
||||
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
|
||||
@@ -251,10 +319,12 @@ impl Plugin for HudPlugin {
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
.add_message::<WinStreakMilestoneEvent>()
|
||||
.init_resource::<PreviousScore>()
|
||||
.init_resource::<HudActionFade>()
|
||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||
.add_systems(Update, update_hud.after(GameMutation))
|
||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||
.add_systems(Update, update_selection_hud)
|
||||
.add_systems(
|
||||
@@ -267,6 +337,12 @@ impl Plugin for HudPlugin {
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(start_streak_flourish, advance_streak_flourish)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -428,6 +504,15 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
font_body.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
t2.spawn((
|
||||
HudWonPreviously,
|
||||
Tooltip::new(
|
||||
"You've won this deal before. Same seed in your replay history.",
|
||||
),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
||||
@@ -781,6 +866,7 @@ fn spawn_modes_popover(
|
||||
.spawn((
|
||||
option,
|
||||
ActionButton,
|
||||
PopoverRow,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
@@ -934,6 +1020,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
.spawn((
|
||||
option,
|
||||
ActionButton,
|
||||
PopoverRow,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
@@ -1064,9 +1151,20 @@ fn update_action_fade(
|
||||
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
||||
/// same frame doesn't override the fade with an opaque idle / hover
|
||||
/// colour.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn apply_action_fade(
|
||||
fade: Res<HudActionFade>,
|
||||
mut buttons: Query<(&Children, &mut BackgroundColor), With<ActionButton>>,
|
||||
// Excludes `PopoverRow` so the auto-fade only applies to the
|
||||
// top-level action bar buttons. Popover rows live inside an
|
||||
// explicitly-opened dropdown panel and need to stay visible
|
||||
// regardless of the bar's fade state — without the exclusion
|
||||
// the rows fade to invisible while the popover container stays
|
||||
// visible, leaving a solid background block with no readable
|
||||
// content.
|
||||
mut buttons: Query<
|
||||
(&Children, &mut BackgroundColor),
|
||||
(With<ActionButton>, Without<PopoverRow>),
|
||||
>,
|
||||
mut text_q: Query<&mut TextColor>,
|
||||
) {
|
||||
for (children, mut bg) in &mut buttons {
|
||||
@@ -1285,6 +1383,184 @@ fn advance_score_floater(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streak-milestone flourish
|
||||
//
|
||||
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
|
||||
// completion celebration; the streak flourish is its lifetime equivalent —
|
||||
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
|
||||
// readout pulses larger than a normal score-change pulse and tints magenta
|
||||
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
|
||||
//
|
||||
// Why the score readout: there is no always-on streak number on the HUD
|
||||
// today (the readout lives in the Stats overlay), and the score is the
|
||||
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
|
||||
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
|
||||
// player who isn't watching the score still sees the celebration land.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure helper for unit tests — returns the per-frame scale factor for
|
||||
/// the streak flourish at `elapsed_secs` over `duration_secs`.
|
||||
///
|
||||
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
|
||||
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
||||
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
|
||||
/// Out-of-range values are clamped so the score readout never freezes
|
||||
/// at a non-1.0 scale on the frame after the flourish ends.
|
||||
///
|
||||
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
|
||||
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
|
||||
/// without dividing by zero.
|
||||
pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||
if duration_secs <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
|
||||
let peak = STREAK_FLOURISH_PEAK_SCALE;
|
||||
if t < 0.5 {
|
||||
// Climb from 1.0 at t=0 to peak at t=0.5.
|
||||
1.0 + (peak - 1.0) * (t / 0.5)
|
||||
} else {
|
||||
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
|
||||
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a
|
||||
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
|
||||
/// `TextColor` so `advance_streak_flourish` can restore it when the
|
||||
/// timer expires; reuses any existing flourish's `original_color` so
|
||||
/// re-entering the system mid-flourish doesn't snapshot the magenta
|
||||
/// tint as the new "original".
|
||||
///
|
||||
/// Removes any concurrent [`ScorePulse`] from the same entity so the
|
||||
/// flourish takes over the scale slot cleanly — score pulses last
|
||||
/// 250 ms, the flourish 600 ms, and the streak crossing always
|
||||
/// coincides with a positive score delta, so the flourish is the
|
||||
/// louder of the two celebrations.
|
||||
fn start_streak_flourish(
|
||||
mut events: MessageReader<WinStreakMilestoneEvent>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let Some(latest) = events.read().last() else {
|
||||
return;
|
||||
};
|
||||
let speed = settings
|
||||
.as_ref()
|
||||
.map(|s| s.0.animation_speed)
|
||||
.unwrap_or_default();
|
||||
let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
|
||||
for (entity, color, existing) in &score_q {
|
||||
let original_color = existing.map_or(color.0, |f| f.original_color);
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<ScorePulse>()
|
||||
.insert(StreakFlourish {
|
||||
streak: latest.streak,
|
||||
elapsed: 0.0,
|
||||
duration,
|
||||
original_color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
|
||||
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
|
||||
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
|
||||
/// `original_color`. Removes the component once `elapsed >= duration`
|
||||
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
|
||||
/// where duration is 0) and pins the scale back to 1.0 / restores the
|
||||
/// original colour so no half-state is ever shown.
|
||||
///
|
||||
/// Filtered with `Without<ScorePulse>` so the streak flourish never
|
||||
/// races a score pulse for the same `Transform.scale` slot —
|
||||
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
|
||||
/// score entity before this system runs, so the filter is purely a
|
||||
/// belt-and-braces invariant.
|
||||
fn advance_streak_flourish(
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
mut q: Query<
|
||||
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
|
||||
Without<ScorePulse>,
|
||||
>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut anim, mut transform, mut color) in &mut q {
|
||||
let t = if anim.duration <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
anim.elapsed += dt;
|
||||
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
|
||||
};
|
||||
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
|
||||
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||
// Tint mix: full magenta at t=0..=0.5, fades back to the
|
||||
// original colour over t=0.5..=1.0.
|
||||
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
|
||||
if t >= 1.0 {
|
||||
transform.scale = Vec3::ONE;
|
||||
color.0 = anim.original_color;
|
||||
commands.entity(entity).remove::<StreakFlourish>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB-space linear interpolation between two `Color`s — small local
|
||||
/// helper so `advance_streak_flourish` stays readable. sRGB-space
|
||||
/// lerping is fine for a brief decorative tint (a perceptually-uniform
|
||||
/// space would be overkill).
|
||||
fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
||||
let from = from.to_srgba();
|
||||
let to = to.to_srgba();
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
Color::srgba(
|
||||
from.red + (to.red - from.red) * t,
|
||||
from.green + (to.green - from.green) * t,
|
||||
from.blue + (to.blue - from.blue) * t,
|
||||
from.alpha + (to.alpha - from.alpha) * t,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
|
||||
/// current deal's seed + draw_mode + mode triple matches an entry in
|
||||
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
|
||||
/// (the on-screen "Game won!" cue already conveys victory) and on
|
||||
/// fresh deals the player hasn't won before.
|
||||
///
|
||||
/// Lives in its own system rather than `update_hud` to keep this
|
||||
/// orthogonal: `update_hud`'s query disambiguation is already busy
|
||||
/// enough; threading another marker through every Without filter
|
||||
/// would touch ~10 unrelated queries for no benefit.
|
||||
fn update_won_previously(
|
||||
game: Res<GameStateResource>,
|
||||
// Optional because the HUD plugin's headless tests run without
|
||||
// `StatsPlugin` and therefore without this resource. With the
|
||||
// resource absent there's no history to compare against; the
|
||||
// indicator just stays empty.
|
||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||
mut q: Query<&mut Text, With<HudWonPreviously>>,
|
||||
) {
|
||||
let Ok(mut text) = q.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let won_before = !game.0.is_won
|
||||
&& history.as_ref().is_some_and(|h| {
|
||||
h.0.replays.iter().any(|r| {
|
||||
r.seed == game.0.seed
|
||||
&& r.draw_mode == game.0.draw_mode
|
||||
&& r.mode == game.0.mode
|
||||
})
|
||||
});
|
||||
let next = if won_before { "\u{2713} Won before" } else { "" };
|
||||
if text.0 != next {
|
||||
text.0 = next.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn update_hud(
|
||||
game: Res<GameStateResource>,
|
||||
@@ -2091,6 +2367,45 @@ mod tests {
|
||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
||||
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
||||
/// curve test — the two animations share a triangular shape so a
|
||||
/// future tweak that desyncs them shows up here.
|
||||
#[test]
|
||||
fn streak_flourish_scale_curves_through_one_one_one() {
|
||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||
assert!(
|
||||
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||
"streak flourish scale at t=0 must be 1.0",
|
||||
);
|
||||
assert!(
|
||||
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
||||
);
|
||||
assert!(
|
||||
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||
"streak flourish scale at t=duration must return to 1.0",
|
||||
);
|
||||
}
|
||||
|
||||
/// Out-of-range values are clamped, not extrapolated. Matches the
|
||||
/// foundation flourish's clamp behaviour so the score readout never
|
||||
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
||||
#[test]
|
||||
fn streak_flourish_scale_clamps_out_of_range() {
|
||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||
/// divides by zero.
|
||||
#[test]
|
||||
fn streak_flourish_scale_zero_duration_is_one() {
|
||||
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: keyboard focus ring — HUD action bar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -30,18 +30,20 @@ use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||
use crate::card_plugin::{
|
||||
CardEntity, HintHighlight, HintHighlightTimer, TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC,
|
||||
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
|
||||
TABLEAU_FAN_FRAC,
|
||||
};
|
||||
use crate::feedback_anim_plugin::ShakeAnim;
|
||||
use crate::ui_theme::MOTION_DRAG_REJECT_SECS;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, UndoRequestEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
||||
UndoRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
@@ -52,21 +54,15 @@ use crate::time_attack_plugin::TimeAttackResource;
|
||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||
const DRAG_Z: f32 = 500.0;
|
||||
|
||||
/// Shared countdown state for the new-game double-press confirmation
|
||||
/// flow.
|
||||
/// Solver budgets used by the H-key hint system.
|
||||
///
|
||||
/// Using a resource (instead of `Local`) lets the keyboard sub-systems
|
||||
/// share the same countdown state without needing to pass values
|
||||
/// between them. Forfeit no longer has a keyboard countdown — `G` now
|
||||
/// fires `ForfeitRequestEvent` and `PausePlugin` shows a real
|
||||
/// `ForfeitConfirmScreen` modal.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct KeyboardConfirmState {
|
||||
/// Seconds remaining in the new-game confirmation window (> 0 while open).
|
||||
new_game_countdown: f32,
|
||||
/// True while we are waiting for the second N press to confirm a new game.
|
||||
new_game_pending: bool,
|
||||
}
|
||||
/// Wraps `solitaire_core::solver::SolverConfig` as a Bevy resource so
|
||||
/// tests can inject tighter budgets to exercise the heuristic-fallback
|
||||
/// path. Production initialises this to `SolverConfig::default()` (100k
|
||||
/// move / 200k state budgets, the same numbers the new-game retry loop
|
||||
/// uses).
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
|
||||
|
||||
/// Registers keyboard, mouse, and touch input systems.
|
||||
///
|
||||
@@ -87,8 +83,8 @@ pub struct InputPlugin;
|
||||
impl Plugin for InputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<HintCycleIndex>()
|
||||
.init_resource::<KeyboardConfirmState>()
|
||||
.add_message::<NewGameConfirmEvent>()
|
||||
.init_resource::<HintSolverConfig>()
|
||||
.init_resource::<crate::pending_hint::PendingHintTask>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
@@ -114,13 +110,21 @@ 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds after the first N press during which a second N confirms new game.
|
||||
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
||||
|
||||
/// Bundles the event writers needed by the core keyboard handler.
|
||||
///
|
||||
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
|
||||
@@ -128,43 +132,39 @@ const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
||||
struct CoreKeyboardMessages<'w> {
|
||||
undo: MessageWriter<'w, UndoRequestEvent>,
|
||||
new_game: MessageWriter<'w, NewGameRequestEvent>,
|
||||
confirm_event: MessageWriter<'w, NewGameConfirmEvent>,
|
||||
info_toast: MessageWriter<'w, InfoToastEvent>,
|
||||
draw: MessageWriter<'w, DrawRequestEvent>,
|
||||
}
|
||||
|
||||
/// Handles the core keyboard shortcuts: U (undo), N (new game + confirmation
|
||||
/// window), Z (zen mode), D / Space (draw), and ticks down the new-game
|
||||
/// confirmation countdown each frame.
|
||||
/// Handles the core keyboard shortcuts: U (undo), N (new game), Z (zen mode),
|
||||
/// D / Space (draw).
|
||||
///
|
||||
/// `N` fires `NewGameRequestEvent` straight through; the existing
|
||||
/// `handle_new_game` flow shows the `ConfirmNewGameScreen` modal when
|
||||
/// the current game is in progress, so a single press surfaces a real
|
||||
/// Confirm / Cancel UI instead of a "press N again" toast. `Shift+N`
|
||||
/// keeps the keyboard power-user bypass by setting `confirmed: true`.
|
||||
///
|
||||
/// While the confirm modal or the restore prompt is already open, the
|
||||
/// system skips the N branch so those modals' own input handlers can
|
||||
/// process N (cancel / start-new-game) without us re-firing a request
|
||||
/// the same frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_keyboard_core(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
time: Res<Time>,
|
||||
mut confirm: ResMut<KeyboardConfirmState>,
|
||||
mut ev: CoreKeyboardMessages<'_>,
|
||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
mut zen_requests: MessageReader<StartZenRequestEvent>,
|
||||
confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
|
||||
restore_prompts: Query<(), With<RestorePromptScreen>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tick down the new-game confirmation window each frame.
|
||||
if confirm.new_game_countdown > 0.0 {
|
||||
confirm.new_game_countdown -= time.delta_secs();
|
||||
if confirm.new_game_countdown <= 0.0 {
|
||||
confirm.new_game_countdown = 0.0;
|
||||
if confirm.new_game_pending {
|
||||
confirm.new_game_pending = false;
|
||||
ev.info_toast.write(InfoToastEvent("New game cancelled".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::KeyU) {
|
||||
ev.undo.write(UndoRequestEvent);
|
||||
}
|
||||
@@ -181,27 +181,24 @@ fn handle_keyboard_core(
|
||||
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
||||
confirmed: false,
|
||||
});
|
||||
confirm.new_game_countdown = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
if shift_held || !active_game {
|
||||
// Shift+N or no active game — start immediately, no confirmation.
|
||||
ev.new_game.write(NewGameRequestEvent::default());
|
||||
confirm.new_game_countdown = 0.0;
|
||||
confirm.new_game_pending = false;
|
||||
} else if confirm.new_game_countdown > 0.0 {
|
||||
// Second press within the window — confirmed.
|
||||
ev.new_game.write(NewGameRequestEvent::default());
|
||||
confirm.new_game_countdown = 0.0;
|
||||
confirm.new_game_pending = false;
|
||||
// The confirm modal and restore prompt own N while they're up —
|
||||
// they cancel / accept respectively. Skipping here prevents us
|
||||
// from firing a fresh request the same frame those modals close.
|
||||
if !confirm_screens.is_empty() || !restore_prompts.is_empty() {
|
||||
// intentional: defer to those modals' input handlers.
|
||||
} else {
|
||||
// First press on an active game — require confirmation.
|
||||
confirm.new_game_countdown = NEW_GAME_CONFIRM_WINDOW;
|
||||
confirm.new_game_pending = true;
|
||||
ev.confirm_event.write(NewGameConfirmEvent);
|
||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
ev.new_game.write(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
// Shift+N skips the confirm modal for keyboard power-users;
|
||||
// bare N falls through `handle_new_game`'s active-game check
|
||||
// and shows the modal when a game is in progress.
|
||||
confirmed: shift_held,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,22 +231,29 @@ fn handle_keyboard_core(
|
||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||
}
|
||||
|
||||
/// Handles the H key: cycles through all available hints, highlighting the
|
||||
/// source card yellow for 2 s and showing a descriptive toast.
|
||||
/// 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 hint index wraps around once all hints have been cycled through. When no
|
||||
/// moves are available a "No hints available" toast is shown instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// 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.
|
||||
///
|
||||
/// 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>>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
solver_config: Res<HintSolverConfig>,
|
||||
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;
|
||||
@@ -267,23 +271,51 @@ fn handle_keyboard_hint(
|
||||
|
||||
let Some(_layout_res) = layout else { return };
|
||||
|
||||
let hints = all_hints(&g.0);
|
||||
if hints.is_empty() {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
return;
|
||||
}
|
||||
pending_hint.spawn(g.0.clone(), solver_config.0);
|
||||
}
|
||||
|
||||
// Pick the hint at the current cycle index (wrapping) and advance.
|
||||
/// 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() {
|
||||
return None;
|
||||
}
|
||||
let idx = hint_cycle.0 % hints.len();
|
||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||
let (from, to, _count) = &hints[idx];
|
||||
let (from, to, _count) = hints[idx].clone();
|
||||
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. 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,
|
||||
commands: &mut Commands,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
info_toast: &mut MessageWriter<InfoToastEvent>,
|
||||
hint_visual: &mut MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
// When the hint points at the stock (draw suggestion) there is no
|
||||
// face-up card to highlight — show a toast instead.
|
||||
// If the stock is empty, pressing D will recycle the waste rather
|
||||
// than draw a card, so the toast text must reflect that.
|
||||
if *from == PileType::Stock {
|
||||
let stock_empty = g.0.piles
|
||||
let stock_empty = game.piles
|
||||
.get(&PileType::Stock)
|
||||
.is_some_and(|p| p.cards.is_empty());
|
||||
let msg = if stock_empty {
|
||||
@@ -296,7 +328,7 @@ fn handle_keyboard_hint(
|
||||
}
|
||||
|
||||
// Find the top face-up card in the source pile and highlight it.
|
||||
let top_card_id = g.0.piles.get(from)
|
||||
let top_card_id = game.piles.get(from)
|
||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||
.map(|c| c.id);
|
||||
if let Some(card_id) = top_card_id {
|
||||
@@ -325,7 +357,7 @@ fn handle_keyboard_hint(
|
||||
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
||||
let msg = match to {
|
||||
PileType::Foundation(_) => {
|
||||
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
|
||||
let claimed = game.piles.get(to).and_then(|p| p.claimed_suit());
|
||||
if let Some(suit) = claimed {
|
||||
let suit_name = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
@@ -612,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 {
|
||||
@@ -666,14 +711,16 @@ fn end_drag(
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
// Shake each dragged card so the player gets immediate
|
||||
// visual feedback that the drop was rejected. ShakeAnim
|
||||
// restores translation.x to origin_x at the end of the
|
||||
// animation, so origin_x must be the target slot in the
|
||||
// origin pile — using the current drag transform would
|
||||
// pin the card at the drop location and fight the
|
||||
// sync_cards slide that StateChangedEvent triggers
|
||||
// (the symptom is "card lands beside the pile").
|
||||
// Smoothly glide each dragged card from its drop-time
|
||||
// transform back to its resting slot in the origin pile.
|
||||
// The audio cue (card_invalid.wav, played by AudioPlugin
|
||||
// on MoveRejectedEvent) still gives the player clear
|
||||
// negative feedback; this just replaces the old shake
|
||||
// wiggle with a forgiving ease-out tween.
|
||||
//
|
||||
// `update_card_entity` skips its own snap/slide while a
|
||||
// `CardAnimation` is present, so the StateChangedEvent
|
||||
// that fires below does not fight this tween.
|
||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
@@ -683,14 +730,23 @@ fn end_drag(
|
||||
};
|
||||
let target_pos =
|
||||
card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, _)) = card_entities
|
||||
if let Some((entity, _, transform)) = card_entities
|
||||
.iter()
|
||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
commands.entity(entity).insert(ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: target_pos.x,
|
||||
});
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
commands.entity(entity).insert(
|
||||
CardAnimation::slide(
|
||||
drag_pos,
|
||||
drag_z,
|
||||
target_pos,
|
||||
end_z,
|
||||
MotionCurve::Responsive,
|
||||
)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -899,9 +955,11 @@ fn touch_end_drag(
|
||||
fired = true;
|
||||
} else {
|
||||
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
|
||||
// See `end_drag` (mouse path) for the rationale: ShakeAnim
|
||||
// restores translation.x to origin_x, so origin_x must be
|
||||
// the origin pile's slot, not the drop location.
|
||||
// Smoothly glide each dragged card from its drop-time
|
||||
// transform back to its resting slot. See `end_drag`
|
||||
// (mouse path) for the full rationale; the touch path
|
||||
// mirrors it exactly so finger and mouse rejection
|
||||
// feel identical.
|
||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
@@ -911,13 +969,22 @@ fn touch_end_drag(
|
||||
};
|
||||
let target_pos =
|
||||
card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, _)) =
|
||||
if let Some((entity, _, transform)) =
|
||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
commands.entity(entity).insert(ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: target_pos.x,
|
||||
});
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
commands.entity(entity).insert(
|
||||
CardAnimation::slide(
|
||||
drag_pos,
|
||||
drag_z,
|
||||
target_pos,
|
||||
end_z,
|
||||
MotionCurve::Responsive,
|
||||
)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1256,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);
|
||||
@@ -1936,81 +2008,208 @@ mod tests {
|
||||
assert!(hints.is_empty(), "no hint should exist when the game is truly stuck");
|
||||
}
|
||||
|
||||
/// Const-assert that `NEW_GAME_CONFIRM_WINDOW` is positive so the
|
||||
/// confirmation countdown actually opens on the first N press.
|
||||
///
|
||||
/// Mirrors the existing `forfeit_confirm_window_is_positive` test.
|
||||
#[test]
|
||||
fn new_game_confirm_window_is_positive() {
|
||||
const { assert!(NEW_GAME_CONFIRM_WINDOW > 0.0, "NEW_GAME_CONFIRM_WINDOW must be > 0"); }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #57 — ShakeAnim insertion on rejected drag
|
||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||
// `ShakeAnim` on the dragged cards. The audio cue
|
||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||
// to a smooth ease-out glide back to the origin pile.
|
||||
//
|
||||
// These tests build the component values exactly as `end_drag` and
|
||||
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||
// and mouse-button input, so we exercise the data path the same way the
|
||||
// legacy `ShakeAnim` tests did.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Verifies that `ShakeAnim` constructed for a rejected drag has the
|
||||
/// correct initial values: `elapsed` starts at 0.0 and `origin_x` matches
|
||||
/// the **target slot in the origin pile** (where the card will rest after
|
||||
/// the rejection). Saving the drop-location X here was the root cause of
|
||||
/// the "card lands beside the pile" bug — `tick_shake_anim` restores
|
||||
/// `translation.x` to `origin_x` at the end of the shake, fighting the
|
||||
/// `sync_cards` slide that `StateChangedEvent` triggers.
|
||||
///
|
||||
/// The Bevy ECS part (Commands + Query) is exercised at runtime; this test
|
||||
/// covers the data path — that we build the component with the right values
|
||||
/// before handing it to `commands.entity(...).insert(...)`.
|
||||
#[test]
|
||||
fn shake_anim_for_rejected_drag_has_correct_initial_values() {
|
||||
use crate::feedback_anim_plugin::ShakeAnim;
|
||||
|
||||
// Simulate the X coordinate of the card's slot in its origin pile —
|
||||
// computed by `card_position(game, layout, &origin, stack_index)` at
|
||||
// rejection time, not the drop-location transform X.
|
||||
let target_slot_x = 123.5_f32;
|
||||
|
||||
// This mirrors the ShakeAnim construction in `end_drag` and
|
||||
// `touch_end_drag` after the bugfix: origin_x is the origin pile's
|
||||
// slot X, so the shake ends with the card at its correct resting
|
||||
// position.
|
||||
let anim = ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: target_slot_x,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
anim.elapsed, 0.0,
|
||||
"ShakeAnim must start with elapsed=0.0 so the animation plays from the beginning"
|
||||
);
|
||||
assert!(
|
||||
(anim.origin_x - target_slot_x).abs() < 1e-6,
|
||||
"ShakeAnim origin_x must match the origin pile slot's X (where the \
|
||||
card belongs after rejection), not the drop-location transform X. \
|
||||
Expected {target_slot_x}, got {}",
|
||||
anim.origin_x
|
||||
);
|
||||
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||
fn build_drag_reject_animation(
|
||||
drag_pos: Vec2,
|
||||
drag_z: f32,
|
||||
target_pos: Vec2,
|
||||
stack_index: usize,
|
||||
) -> CardAnimation {
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||
}
|
||||
|
||||
/// When a drag is rejected, every card id in `drag.cards` should receive a
|
||||
/// `ShakeAnim`. Verify that the set of card ids we would iterate matches
|
||||
/// exactly the ids stored in `DragState::cards` at rejection time.
|
||||
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||
/// rejection. With the shake → tween migration, the assertion changes
|
||||
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||
/// gets a CardAnimation" — same coverage, new component.
|
||||
#[test]
|
||||
fn rejected_drag_shakes_all_dragged_cards() {
|
||||
// Simulate a DragState with two card ids (a stack drag).
|
||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||
// Simulate a stack drag of two cards.
|
||||
let dragged_ids: Vec<u32> = vec![10, 11];
|
||||
|
||||
// In `end_drag`, we iterate `drag.cards` and look up each id in
|
||||
// `card_entities`. The ids we would insert ShakeAnim on must exactly
|
||||
// match the dragged set.
|
||||
let mut shaken: Vec<u32> = Vec::new();
|
||||
let mut animated: Vec<u32> = Vec::new();
|
||||
for &card_id in &dragged_ids {
|
||||
// Simulate finding the entity for card_id (always succeeds here).
|
||||
shaken.push(card_id);
|
||||
// In `end_drag` we iterate `drag.cards` and look up each id in
|
||||
// `card_entities`. The ids we would insert a `CardAnimation` on
|
||||
// must exactly match the dragged set.
|
||||
animated.push(card_id);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
shaken, dragged_ids,
|
||||
"every card id in drag.cards must receive a ShakeAnim on rejection"
|
||||
animated, dragged_ids,
|
||||
"every card id in drag.cards must receive a CardAnimation on rejection"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `end` field of the inserted tween must equal the card's resting
|
||||
/// slot in its origin pile — the position the card belongs at after a
|
||||
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||
/// and `sync_cards` would have to fight it back.
|
||||
#[test]
|
||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||
|
||||
assert!(
|
||||
(anim.end - target_pos).length() < 1e-6,
|
||||
"CardAnimation.end must match the origin slot's resting position. \
|
||||
Expected {target_pos:?}, got {:?}",
|
||||
anim.end
|
||||
);
|
||||
}
|
||||
|
||||
/// The `start` field of the inserted tween must equal the card's
|
||||
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||
/// released the card. This is what makes the glide feel like a
|
||||
/// continuous return rather than a teleport-then-shake.
|
||||
#[test]
|
||||
fn rejected_drag_animation_starts_from_drag_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0);
|
||||
let target_pos = Vec2::new(80.0, -120.0);
|
||||
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||
|
||||
assert!(
|
||||
(anim.start - drag_pos).length() < 1e-6,
|
||||
"CardAnimation.start must match the drop-time transform position \
|
||||
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||
anim.start
|
||||
);
|
||||
// And the start must be visibly distinct from the origin slot — the
|
||||
// whole point of the tween is that it visibly travels.
|
||||
assert!(
|
||||
(anim.start - anim.end).length() > 1.0,
|
||||
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||
anim.start,
|
||||
anim.end
|
||||
);
|
||||
}
|
||||
|
||||
/// The tween duration is taken from the project-wide motion token so
|
||||
/// designers can retune the feel from one place. Keeps the constant and
|
||||
/// the call site honest.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_correct_duration() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||
anim.duration
|
||||
);
|
||||
}
|
||||
|
||||
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||
/// cleanly into its rest position — overshoot on a rejection feels
|
||||
/// jittery rather than forgiving.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_responsive_curve() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
anim.curve,
|
||||
MotionCurve::Responsive,
|
||||
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||
so the card snaps back without bouncing past the slot"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `start_z` of the tween must equal the card's drop-time z
|
||||
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||
/// travels home, then settles at the correct resting z.
|
||||
#[test]
|
||||
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||
let stack_index = 2_usize;
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
stack_index,
|
||||
);
|
||||
assert!(
|
||||
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||
);
|
||||
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
assert!(
|
||||
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||
anim.end_z
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hint system — async port (v0.18.0+)
|
||||
//
|
||||
// `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`.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// 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>>();
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<crate::pending_hint::PendingHintTask>()
|
||||
.is_pending(),
|
||||
"pressing H must spawn an async hint task",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,11 @@ pub enum LayoutSystem {
|
||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
||||
|
||||
/// Aspect ratio (height / width) of a standard playing card.
|
||||
const CARD_ASPECT: f32 = 1.4;
|
||||
///
|
||||
/// Matches the bundled hayeah/playing-cards-assets SVG dimensions
|
||||
/// (167.087 × 242.667 → 1.4523). Pre-v0.11 the constant was 1.4,
|
||||
/// which rendered the cards ~3.6 % squashed vertically.
|
||||
const CARD_ASPECT: f32 = 1.4523;
|
||||
|
||||
/// Fraction of card height used as vertical padding between the top row and
|
||||
/// the tableau row.
|
||||
@@ -59,7 +63,7 @@ pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
pub struct Layout {
|
||||
/// Width and height of a single card, in world units (Bevy 2D world-space).
|
||||
///
|
||||
/// `x` is the card width; `y` is the card height (always `x * 1.4`).
|
||||
/// `x` is the card width; `y` is the card height (`x * CARD_ASPECT`).
|
||||
/// All pile positions and fan offsets are derived from this value.
|
||||
pub card_size: Vec2,
|
||||
/// Centre position of each pile, in 2D world coordinates.
|
||||
@@ -80,7 +84,8 @@ pub struct Layout {
|
||||
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
|
||||
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
|
||||
/// windows.
|
||||
/// - `card_height = card_width * 1.4`.
|
||||
/// - `card_height = card_width * CARD_ASPECT` (1.4523, matches the
|
||||
/// bundled hayeah card art's natural SVG dimensions).
|
||||
/// - Horizontal gap `h_gap = card_width / 4.0`.
|
||||
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
||||
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
||||
//! the panel shows "Not available" immediately.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
@@ -20,10 +21,11 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,6 +68,18 @@ struct LeaderboardFetchTask(Option<Task<Result<Vec<LeaderboardEntry>, String>>>)
|
||||
#[derive(Component, Debug)]
|
||||
pub struct LeaderboardScreen;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Leaderboard modal.
|
||||
///
|
||||
/// The leaderboard caps at the top 10 entries today, but rendering the
|
||||
/// caption + opt-in/opt-out row + 10 data rows on the 800x600 minimum
|
||||
/// window is right at the edge of overflowing — long display names or
|
||||
/// future row-count expansion would cut off entries below the fold.
|
||||
/// Wrapping the data section in an `Overflow::scroll_y()` Node with a
|
||||
/// constrained `max_height` keeps every row reachable. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct LeaderboardScrollable;
|
||||
|
||||
/// Marker on the "Opt In" button inside the leaderboard panel.
|
||||
#[derive(Component, Debug)]
|
||||
struct LeaderboardOptInButton;
|
||||
@@ -98,6 +112,11 @@ impl Plugin for LeaderboardPlugin {
|
||||
.init_resource::<OptInTask>()
|
||||
.init_resource::<OptOutTask>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// leaderboard-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -112,7 +131,8 @@ impl Plugin for LeaderboardPlugin {
|
||||
poll_opt_out_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
)
|
||||
.add_systems(Update, scroll_leaderboard_panel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +242,33 @@ fn update_leaderboard_panel(
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button — despawns the overlay.
|
||||
/// Routes mouse-wheel events into the Leaderboard modal's scrollable
|
||||
/// data body while the panel is open. No-op when no
|
||||
/// `LeaderboardScrollable` exists in the world (modal closed). Mirrors
|
||||
/// `scroll_settings_panel`.
|
||||
fn scroll_leaderboard_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<LeaderboardScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_leaderboard_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
||||
@@ -346,7 +393,7 @@ fn spawn_leaderboard_screen(
|
||||
remote_available: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Leaderboard", font_res);
|
||||
|
||||
// Subhead — what the screen does + what the buttons control.
|
||||
@@ -420,76 +467,99 @@ fn spawn_leaderboard_screen(
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
));
|
||||
|
||||
match data {
|
||||
LeaderboardResource::Idle => {
|
||||
card.spawn((
|
||||
Text::new("Fetching\u{2026}"),
|
||||
font_status.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Error(_) => {
|
||||
card.spawn((
|
||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||
card.spawn((
|
||||
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) => {
|
||||
// Column headers
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
header_cell(row, "#", 30.0, &font_header);
|
||||
header_cell(row, "Player", 160.0, &font_header);
|
||||
header_cell(row, "Best Score", 100.0, &font_header);
|
||||
header_cell(row, "Fastest Win", 110.0, &font_header);
|
||||
});
|
||||
|
||||
let mut sorted = rows.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
|
||||
|
||||
for (i, entry) in sorted.iter().take(10).enumerate() {
|
||||
// Top three get accent treatments to highlight the
|
||||
// podium without leaning on hand-picked metallic
|
||||
// colours that sit outside the token system.
|
||||
let rank_color = match i {
|
||||
0 => ACCENT_PRIMARY, // Balatro yellow for #1
|
||||
1 | 2 => TEXT_PRIMARY,
|
||||
_ => TEXT_SECONDARY,
|
||||
};
|
||||
|
||||
let time_str = entry
|
||||
.best_time_secs
|
||||
.map_or_else(|| "-".to_string(), format_secs);
|
||||
let score_str = entry
|
||||
.best_score
|
||||
.map_or_else(|| "-".to_string(), |s| s.to_string());
|
||||
|
||||
card.spawn(Node {
|
||||
// Scrollable data section — caps at top 10 rows today, but on the
|
||||
// 800x600 minimum window the header + caption + opt-in row + 10
|
||||
// entries crowds the modal. Wrapping in `Overflow::scroll_y()`
|
||||
// with a `max_height` keeps every entry reachable and survives
|
||||
// any future expansion of the row cap.
|
||||
card.spawn((
|
||||
LeaderboardScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
max_height: Val::Vh(50.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
match data {
|
||||
LeaderboardResource::Idle => {
|
||||
body.spawn((
|
||||
Text::new("Fetching\u{2026}"),
|
||||
font_status.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Error(_) => {
|
||||
body.spawn((
|
||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||
body.spawn((
|
||||
Text::new("Be the first on the leaderboard."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
body.spawn((
|
||||
Text::new("Win a game and opt in to appear here."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) => {
|
||||
// Column headers
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
|
||||
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
|
||||
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
|
||||
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
|
||||
header_cell(row, "#", 30.0, &font_header);
|
||||
header_cell(row, "Player", 160.0, &font_header);
|
||||
header_cell(row, "Best Score", 100.0, &font_header);
|
||||
header_cell(row, "Fastest Win", 110.0, &font_header);
|
||||
});
|
||||
|
||||
let mut sorted = rows.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
|
||||
|
||||
for (i, entry) in sorted.iter().take(10).enumerate() {
|
||||
// Top three get accent treatments to highlight the
|
||||
// podium without leaning on hand-picked metallic
|
||||
// colours that sit outside the token system.
|
||||
let rank_color = match i {
|
||||
0 => ACCENT_PRIMARY, // Balatro yellow for #1
|
||||
1 | 2 => TEXT_PRIMARY,
|
||||
_ => TEXT_SECONDARY,
|
||||
};
|
||||
|
||||
let time_str = entry
|
||||
.best_time_secs
|
||||
.map_or_else(|| "-".to_string(), format_secs);
|
||||
let score_str = entry
|
||||
.best_score
|
||||
.map_or_else(|| "-".to_string(), |s| s.to_string());
|
||||
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
|
||||
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
|
||||
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
|
||||
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -502,6 +572,8 @@ fn spawn_leaderboard_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// Leaderboard is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
|
||||
@@ -646,6 +718,34 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaderboard_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyL);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&LeaderboardScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Leaderboard modal must spawn exactly one LeaderboardScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<LeaderboardScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_l_twice_dismisses_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
+38
-11
@@ -22,7 +22,11 @@ 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;
|
||||
pub mod replay_playback;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
@@ -69,8 +73,9 @@ pub use card_animation::{
|
||||
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
||||
};
|
||||
pub use feedback_anim_plugin::{
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, shake_offset,
|
||||
settle_scale, FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim,
|
||||
ShakeAnim,
|
||||
};
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
@@ -82,35 +87,57 @@ pub use font_plugin::{FontPlugin, FontResource};
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||
ForfeitEvent, ForfeitRequestEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent,
|
||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||
ToggleStatsRequestEvent, UndoRequestEvent, XpAwardedEvent,
|
||||
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
||||
};
|
||||
pub use game_plugin::{
|
||||
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
||||
ReplayPath,
|
||||
};
|
||||
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||
pub use home_plugin::{HomePlugin, HomeScreen};
|
||||
pub use hud_plugin::{
|
||||
ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton, MenuOption, MenuPopover,
|
||||
ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton, UndoButton,
|
||||
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton,
|
||||
MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton,
|
||||
StreakFlourish, UndoButton,
|
||||
};
|
||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||
pub use input_plugin::InputPlugin;
|
||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
||||
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
||||
pub use radial_menu::{
|
||||
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
|
||||
RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
|
||||
};
|
||||
pub use replay_overlay::{
|
||||
ReplayOverlayBannerText, ReplayOverlayPlugin, ReplayOverlayProgressText, ReplayOverlayRoot,
|
||||
ReplayStopButton, Z_REPLAY_OVERLAY,
|
||||
};
|
||||
pub use replay_playback::{
|
||||
start_replay_playback, stop_replay_playback, ReplayPlaybackPlugin, ReplayPlaybackState,
|
||||
REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS,
|
||||
};
|
||||
pub use settings_plugin::{
|
||||
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
|
||||
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||
};
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
||||
pub use selection_plugin::{
|
||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||
};
|
||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
pub use stats_plugin::{
|
||||
format_replay_caption, LatestReplayPath, ReplayHistoryResource, ReplayNextButton,
|
||||
ReplayPrevButton, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource,
|
||||
StatsScreen, StatsUpdate, WatchReplayButton,
|
||||
};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
||||
pub use ui_modal::{
|
||||
|
||||
@@ -93,7 +93,9 @@ struct HotkeyRow {
|
||||
const HOTKEYS: &[HotkeyRow] = &[
|
||||
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
||||
HotkeyRow { keys: "U", description: "Undo last move" },
|
||||
HotkeyRow { keys: "Tab → Enter", description: "Pick a card; arrows pick where; Enter to drop" },
|
||||
HotkeyRow { keys: "N", description: "New Classic game" },
|
||||
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 1–5 to pick)" },
|
||||
HotkeyRow { keys: "S", description: "Stats & progression" },
|
||||
HotkeyRow { keys: "A", description: "Achievements" },
|
||||
HotkeyRow { keys: "O", description: "Settings" },
|
||||
|
||||
@@ -36,8 +36,9 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsSto
|
||||
use crate::stats_plugin::StatsResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant,
|
||||
spawn_modal_header, ButtonVariant, ModalScrim,
|
||||
};
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use crate::ui_theme::{
|
||||
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
|
||||
};
|
||||
@@ -126,15 +127,24 @@ impl Plugin for PausePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundles the modal-related queries `toggle_pause` reads each tick.
|
||||
/// Pulled into a [`SystemParam`] so the system stays under Bevy's 16-
|
||||
/// parameter cap after the cross-modal Esc guard query was added.
|
||||
#[derive(SystemParam)]
|
||||
struct PauseModalQueries<'w, 's> {
|
||||
pause_screens: Query<'w, 's, Entity, With<PauseScreen>>,
|
||||
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
|
||||
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
|
||||
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_pause(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<PauseRequestEvent>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
screens: Query<Entity, With<PauseScreen>>,
|
||||
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
modal_queries: PauseModalQueries<'_, '_>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
@@ -145,6 +155,13 @@ fn toggle_pause(
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
) {
|
||||
let PauseModalQueries {
|
||||
pause_screens: screens,
|
||||
forfeit_screens,
|
||||
game_over_screens,
|
||||
other_modal_scrims,
|
||||
} = modal_queries;
|
||||
|
||||
// Either Esc or a click on the HUD "Pause" button (which fires
|
||||
// PauseRequestEvent) opens or closes the overlay. Drain the queue so a
|
||||
// burst of clicks doesn't queue future toggles.
|
||||
@@ -157,6 +174,16 @@ fn toggle_pause(
|
||||
if !forfeit_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Any other modal (Confirm New Game, Restore, Home, Onboarding,
|
||||
// Settings, etc.) owns its own dismissal — pause must not stack
|
||||
// on top of it. Without this guard a single Esc both closes the
|
||||
// open modal AND spawns the pause overlay underneath, leaving the
|
||||
// player on a screen they didn't ask for. The HUD-button path
|
||||
// (`button_clicked`) is gated too; clicking Pause while another
|
||||
// modal is up is almost always an accident.
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
// If a card is currently selected, let SelectionPlugin handle this Escape
|
||||
// (it will clear the selection). Pause must not also open in the same frame.
|
||||
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@
|
||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||
//! despawned on the second.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Duration, Local, NaiveDate};
|
||||
use solitaire_core::achievement::achievement_by_id;
|
||||
use solitaire_data::SyncBackend;
|
||||
|
||||
@@ -18,16 +20,41 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
TYPE_BODY_LG, VAL_SPACE_2, Z_MODAL_PANEL,
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
/// Number of days surfaced in the daily-challenge calendar row.
|
||||
///
|
||||
/// 14 = trailing two weeks ending today. At ~12 px per dot with a 6 px gap
|
||||
/// the row is ~246 px wide — well inside the 360 px minimum modal width on
|
||||
/// the smallest supported window (800 px).
|
||||
const CALENDAR_DAYS: usize = 14;
|
||||
|
||||
/// Diameter of each calendar dot, in pixels.
|
||||
const CALENDAR_DOT_SIZE_PX: f32 = 12.0;
|
||||
|
||||
/// Marker component on the profile overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileScreen;
|
||||
|
||||
/// Marker on each daily-challenge calendar dot inside the Profile modal.
|
||||
///
|
||||
/// One entity per day in the trailing 14-day window — tests can query
|
||||
/// for this component to assert the row was rendered.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct DailyCalendarDot {
|
||||
/// The calendar date this dot represents.
|
||||
pub date: NaiveDate,
|
||||
/// Whether the player completed the daily challenge on `date`.
|
||||
pub completed: bool,
|
||||
/// `true` if `date == today` (the rightmost dot).
|
||||
pub is_today: bool,
|
||||
}
|
||||
|
||||
/// Registers the `P` key toggle for the profile overlay.
|
||||
pub struct ProfilePlugin;
|
||||
|
||||
@@ -35,10 +62,60 @@ pub struct ProfilePlugin;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileCloseButton;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Profile modal.
|
||||
///
|
||||
/// The Profile panel renders sync info, progression (incl. 14-day
|
||||
/// calendar), every unlocked achievement (up to ~18), and a stats
|
||||
/// summary, which can overflow the modal on the 800x600 minimum window
|
||||
/// once a player has unlocked several achievements. This marker tags
|
||||
/// the inner container that carries `Overflow::scroll_y()` plus a
|
||||
/// `max_height` constraint. Mirrors the `SettingsPanelScrollable`
|
||||
/// pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileScrollable;
|
||||
|
||||
impl Plugin for ProfilePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_systems(Update, (toggle_profile_screen, handle_profile_close_button));
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// profile-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
toggle_profile_screen,
|
||||
handle_profile_close_button,
|
||||
scroll_profile_panel,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Profile modal's scrollable body
|
||||
/// while the panel is open. No-op when no `ProfileScrollable` exists in
|
||||
/// the world (modal closed). Mirrors `scroll_settings_panel`.
|
||||
fn scroll_profile_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<ProfileScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +146,17 @@ fn toggle_profile_screen(
|
||||
screens: Query<Entity, With<ProfileScreen>>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyP) && !button_clicked {
|
||||
let p_pressed = keys.just_pressed(KeyCode::KeyP);
|
||||
let esc_pressed = keys.just_pressed(KeyCode::Escape);
|
||||
let already_open = !screens.is_empty();
|
||||
// P / button toggles open-or-close. Esc only ever closes — when
|
||||
// Profile is layered over Home (clicking the new Home stats chip
|
||||
// opens this on top), Esc must dismiss the *topmost* modal.
|
||||
// Without this branch, Esc fell through to Home's cancel handler
|
||||
// and closed the wrong modal.
|
||||
let want_open = !already_open && (p_pressed || button_clicked);
|
||||
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
|
||||
if !want_open && !want_close {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
@@ -108,176 +195,205 @@ fn spawn_profile_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Profile", font_res);
|
||||
|
||||
// First-launch welcome — only when the player has zero XP and
|
||||
// zero daily streak, so the profile doesn't read as a wall of
|
||||
// zeros to a brand-new player.
|
||||
if let Some(p) = progress
|
||||
&& p.0.total_xp == 0
|
||||
&& p.0.daily_challenge_streak == 0
|
||||
{
|
||||
card.spawn((
|
||||
Text::new("Welcome! Play games to earn XP and unlock achievements."),
|
||||
font_section.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
bottom: VAL_SPACE_2,
|
||||
// Scrollable body — the Profile panel renders sync info,
|
||||
// progression (incl. a 14-day calendar), every unlocked
|
||||
// achievement (up to ~18), and a stats summary, which can
|
||||
// overflow the modal on the 800x600 minimum window once the
|
||||
// player has unlocked several achievements. The Done action
|
||||
// stays fixed outside the scroll.
|
||||
card.spawn((
|
||||
ProfileScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// First-launch welcome — only when the player has zero XP and
|
||||
// zero daily streak, so the profile doesn't read as a wall of
|
||||
// zeros to a brand-new player.
|
||||
if let Some(p) = progress
|
||||
&& p.0.total_xp == 0
|
||||
&& p.0.daily_challenge_streak == 0
|
||||
{
|
||||
body.spawn((
|
||||
Text::new("Welcome! Play games to earn XP and unlock achievements."),
|
||||
font_section.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
bottom: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// ── Sync section ────────────────────────────────────────────
|
||||
card.spawn((
|
||||
Text::new("Sync"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(s) = settings {
|
||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||
card.spawn((
|
||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
if let Some(ss) = sync_status {
|
||||
let status_text = match &ss.0 {
|
||||
SyncStatus::Idle => "Sync: idle".to_string(),
|
||||
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
|
||||
SyncStatus::LastSynced(dt) => {
|
||||
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
|
||||
}
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(status_text),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Progression section ─────────────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Progression"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(p) = progress {
|
||||
let prog = &p.0;
|
||||
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
|
||||
let pct = if xp_span == 0 {
|
||||
100u64
|
||||
} else {
|
||||
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
|
||||
prog.level, prog.total_xp, xp_done, xp_span, pct
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
|
||||
prog.daily_challenge_streak,
|
||||
prog.unlocked_card_backs.len(),
|
||||
prog.unlocked_backgrounds.len(),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Achievements section ────────────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Achievements"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(ar) = achievements {
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
card.spawn((
|
||||
Text::new(format!("{unlocked_count} / 18 unlocked")),
|
||||
font_row.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
let mut any_unlocked = false;
|
||||
for record in records {
|
||||
let def = achievement_by_id(record.id.as_str());
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
if !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
any_unlocked = true;
|
||||
let name = def.map_or(record.id.as_str(), |d| d.name);
|
||||
let date_str = match record.unlock_date {
|
||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||
None => String::new(),
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(format!(" [x] {name}{date_str}")),
|
||||
font_row.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if !any_unlocked {
|
||||
card.spawn((
|
||||
Text::new(" No achievements unlocked yet."),
|
||||
|
||||
// ── Sync section ────────────────────────────────────────────
|
||||
body.spawn((
|
||||
Text::new("Sync"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(s) = settings {
|
||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||
body.spawn((
|
||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
if let Some(ss) = sync_status {
|
||||
let status_text = match &ss.0 {
|
||||
SyncStatus::Idle => "Sync: idle".to_string(),
|
||||
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
|
||||
SyncStatus::LastSynced(dt) => {
|
||||
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
|
||||
}
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
};
|
||||
body.spawn((
|
||||
Text::new(status_text),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statistics summary section ──────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Statistics Summary"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(sr) = stats {
|
||||
let s = &sr.0;
|
||||
let best_score_str = if s.best_single_score == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
s.best_single_score.to_string()
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
||||
s.games_played,
|
||||
s.games_won,
|
||||
format_win_rate(s),
|
||||
format_fastest_win(s.fastest_win_seconds),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
// ── Progression section ─────────────────────────────────────
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Progression"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Win streak: {} current, {} best | Best score: {}",
|
||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
if let Some(p) = progress {
|
||||
let prog = &p.0;
|
||||
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
|
||||
let pct = if xp_span == 0 {
|
||||
100u64
|
||||
} else {
|
||||
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
|
||||
};
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
|
||||
prog.level, prog.total_xp, xp_done, xp_span, pct
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
|
||||
prog.daily_challenge_streak,
|
||||
prog.unlocked_card_backs.len(),
|
||||
prog.unlocked_backgrounds.len(),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
|
||||
// 14-day daily-challenge calendar row.
|
||||
spawn_daily_calendar(
|
||||
body,
|
||||
&prog.daily_challenge_history,
|
||||
prog.daily_challenge_streak,
|
||||
prog.daily_challenge_longest_streak,
|
||||
Local::now().date_naive(),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Achievements section ────────────────────────────────────
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Achievements"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
if let Some(ar) = achievements {
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
body.spawn((
|
||||
Text::new(format!("{unlocked_count} / 18 unlocked")),
|
||||
font_row.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
let mut any_unlocked = false;
|
||||
for record in records {
|
||||
let def = achievement_by_id(record.id.as_str());
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
if !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
any_unlocked = true;
|
||||
let name = def.map_or(record.id.as_str(), |d| d.name);
|
||||
let date_str = match record.unlock_date {
|
||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||
None => String::new(),
|
||||
};
|
||||
body.spawn((
|
||||
Text::new(format!(" [x] {name}{date_str}")),
|
||||
font_row.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if !any_unlocked {
|
||||
body.spawn((
|
||||
Text::new(" No achievements unlocked yet."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statistics summary section ──────────────────────────────
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Statistics Summary"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(sr) = stats {
|
||||
let s = &sr.0;
|
||||
let best_score_str = if s.best_single_score == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
s.best_single_score.to_string()
|
||||
};
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
||||
s.games_played,
|
||||
s.games_won,
|
||||
format_win_rate(s),
|
||||
format_fastest_win(s.fastest_win_seconds),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Win streak: {} current, {} best | Best score: {}",
|
||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -290,6 +406,8 @@ fn spawn_profile_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// Profile is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// Spawn a fixed-height vertical spacer node.
|
||||
@@ -300,6 +418,98 @@ fn spawn_spacer(parent: &mut ChildSpawnerCommands, height: Val) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn the daily-challenge calendar row: a caption + 14 dots.
|
||||
///
|
||||
/// `history` is the player's full chronological completion history.
|
||||
/// `current_streak` and `longest_streak` are surfaced in the caption.
|
||||
/// `today` is passed in (rather than read directly) so the function is
|
||||
/// trivially testable with a fixed reference date.
|
||||
///
|
||||
/// Layout: caption row → row of 14 dots (~12 px each, 6 px gap). The
|
||||
/// rightmost dot represents today; past dots fill from oldest (left) to
|
||||
/// most recent (right). Each dot carries a [`DailyCalendarDot`] marker.
|
||||
fn spawn_daily_calendar(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
history: &[NaiveDate],
|
||||
current_streak: u32,
|
||||
longest_streak: u32,
|
||||
today: NaiveDate,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
use std::collections::HashSet;
|
||||
let history_set: HashSet<NaiveDate> = history.iter().copied().collect();
|
||||
|
||||
let font_caption = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
parent.spawn((
|
||||
Text::new(format!(
|
||||
"Current streak: {current_streak} \u{00B7} Longest: {longest_streak}"
|
||||
)),
|
||||
font_caption,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
top: VAL_SPACE_1,
|
||||
bottom: VAL_SPACE_1,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
));
|
||||
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(SPACE_1 + 2.0), // 6 px between dots
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Iterate from oldest (today − 13) to today (rightmost).
|
||||
for offset in (0..CALENDAR_DAYS as i64).rev() {
|
||||
let date = today - Duration::days(offset);
|
||||
let is_today = offset == 0;
|
||||
let completed = history_set.contains(&date);
|
||||
// Today's dot keeps the outlined-ring look (Balatro-yellow
|
||||
// accent border) regardless of completion; past days use a
|
||||
// subtle border so the row reads as a row of pills, not a
|
||||
// strip of bare squares.
|
||||
let border_color = if is_today { ACCENT_PRIMARY } else { BORDER_STRONG };
|
||||
let border_width = if is_today { 2.0 } else { 0.0 };
|
||||
row.spawn((
|
||||
DailyCalendarDot {
|
||||
date,
|
||||
completed,
|
||||
is_today,
|
||||
},
|
||||
Node {
|
||||
width: Val::Px(CALENDAR_DOT_SIZE_PX),
|
||||
height: Val::Px(CALENDAR_DOT_SIZE_PX),
|
||||
border: UiRect::all(Val::Px(border_width)),
|
||||
border_radius: BorderRadius::all(Val::Px(CALENDAR_DOT_SIZE_PX / 2.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(calendar_dot_color(completed)),
|
||||
BorderColor::all(border_color),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Background colour for a calendar dot. `STATE_SUCCESS` for completed
|
||||
/// days, `BG_ELEVATED` for missed/pending days.
|
||||
fn calendar_dot_color(completed: bool) -> Color {
|
||||
if completed {
|
||||
STATE_SUCCESS
|
||||
} else {
|
||||
BG_ELEVATED
|
||||
}
|
||||
}
|
||||
|
||||
/// Return `(backend_name, username_display)` for the given sync backend.
|
||||
fn sync_info(backend: &SyncBackend) -> (&'static str, String) {
|
||||
match backend {
|
||||
@@ -376,6 +586,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyP);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&ProfileScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Profile modal must spawn exactly one ProfileScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<ProfileScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_p_twice_closes_profile_screen() {
|
||||
let mut app = headless_app();
|
||||
@@ -417,4 +657,43 @@ mod tests {
|
||||
// Level 10 is the first post-table level (span = 1000, starts at 5000).
|
||||
assert_eq!(xp_progress(5_000, 10), (1_000, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_modal_renders_14_calendar_dots() {
|
||||
// Open the Profile modal and assert the 14-day calendar row was
|
||||
// populated with one DailyCalendarDot entity per day.
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyP);
|
||||
app.update();
|
||||
|
||||
let dot_count = app
|
||||
.world_mut()
|
||||
.query::<&DailyCalendarDot>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
dot_count, CALENDAR_DAYS,
|
||||
"Profile modal must render exactly {CALENDAR_DAYS} calendar dots"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_dot_today_marker_is_set_on_rightmost_dot_only() {
|
||||
// Exactly one of the 14 dots is the "today" dot (the rightmost).
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyP);
|
||||
app.update();
|
||||
|
||||
let today_count = app
|
||||
.world_mut()
|
||||
.query::<&DailyCalendarDot>()
|
||||
.iter(app.world())
|
||||
.filter(|d| d.is_today)
|
||||
.count();
|
||||
assert_eq!(today_count, 1, "exactly one dot must be marked is_today");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
//! Right-click radial menu for power-user quick-drops.
|
||||
//!
|
||||
//! Holding the right mouse button on a face-up draggable card pops up a
|
||||
//! small radial menu of icons, one per legal destination pile, arranged in
|
||||
//! a ring around the cursor. Releasing the button while the cursor is
|
||||
//! over an icon dispatches a [`MoveRequestEvent`] to that destination —
|
||||
//! the player skips the drag entirely. Releasing in empty space, or
|
||||
//! pressing `Esc`, cancels.
|
||||
//!
|
||||
//! # Relationship to [`crate::card_plugin::handle_right_click`]
|
||||
//!
|
||||
//! This plugin **augments** rather than replaces the legacy
|
||||
//! right-click-highlight tint. On the press frame `handle_right_click`
|
||||
//! still tints legal pile markers via [`RightClickHighlight`]; the radial
|
||||
//! overlay sits on top (Z = [`Z_RADIAL_MENU`]) and disappears with the
|
||||
//! release. The two paths read the same legal-destination set, so what
|
||||
//! the radial offers always matches what the highlights show.
|
||||
//!
|
||||
//! # State machine
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────────────┐ RMB press on face-up card
|
||||
//! │ Idle │ ──────────────────────────────────► Active
|
||||
//! └──────────────────┘
|
||||
//! Esc OR RMB release outside any icon
|
||||
//! OR pause / state change
|
||||
//! ┌──────────────────┐ ◄──────────────────────────────────┐
|
||||
//! │ Active │ │
|
||||
//! │ source_pile │ RMB release while hovered_index │
|
||||
//! │ count │ = Some(i) │
|
||||
//! │ cards │ ─── fire MoveRequestEvent ─────────┘
|
||||
//! │ destinations[] │
|
||||
//! │ hovered_index │
|
||||
//! └──────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! # Tests
|
||||
//!
|
||||
//! Tests live alongside the implementation. The cursor-tracking and
|
||||
//! release-confirm systems take a [`RadialCursorOverride`] resource that
|
||||
//! lets tests inject a world-space cursor position without spinning up a
|
||||
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
|
||||
//! neither.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::card::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::{TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC};
|
||||
use crate::events::MoveRequestEvent;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, STATE_SUCCESS};
|
||||
|
||||
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
|
||||
///
|
||||
/// One rung above [`crate::ui_theme::Z_DROP_OVERLAY`] (`50.0`) so the radial icons render
|
||||
/// in front of any drop-target wash that might still be active from a
|
||||
/// concurrent drag, but well below the lifted card stack at `DRAG_Z`.
|
||||
pub const Z_RADIAL_MENU: f32 = 60.0;
|
||||
|
||||
/// Pixel radius (world space) of the ring on which radial icons are
|
||||
/// placed, measured from the cursor centre.
|
||||
pub const RADIAL_RADIUS_PX: f32 = 80.0;
|
||||
|
||||
/// Side length (world-space pixels) of each radial icon's hit-box.
|
||||
///
|
||||
/// Sprites are rendered at this size; the cursor is considered "over" an
|
||||
/// icon when it lies within the axis-aligned square of this side length
|
||||
/// centred on the icon anchor.
|
||||
pub const RADIAL_ICON_SIZE_PX: f32 = 48.0;
|
||||
|
||||
/// Scale factor applied to the focused (hovered) icon for emphasis.
|
||||
pub const RADIAL_HOVER_SCALE: f32 = 1.15;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Right-click radial-menu state machine.
|
||||
///
|
||||
/// `Idle` is the resting state. `Active` is entered when right-mouse is
|
||||
/// just-pressed on a face-up draggable card with at least one legal
|
||||
/// destination; it is exited on right-mouse release, on `Escape`, or on
|
||||
/// any external state change (game mutation, pause).
|
||||
#[derive(Resource, Debug, Default, Clone, PartialEq)]
|
||||
pub enum RightClickRadialState {
|
||||
/// Resting state — the radial is closed and no overlay sprites exist.
|
||||
#[default]
|
||||
Idle,
|
||||
/// Radial is open. The player is holding right-mouse on
|
||||
/// `source_pile` and the cursor is currently over icon
|
||||
/// `hovered_index` (or none).
|
||||
Active {
|
||||
/// Pile the right-clicked card came from.
|
||||
source_pile: PileType,
|
||||
/// Number of cards that would be moved (always `1` — only the
|
||||
/// top face-up card is ever offered for a quick-drop, since the
|
||||
/// radial is built around single-card foundation/tableau
|
||||
/// shortcuts and that matches the right-click highlight set).
|
||||
count: usize,
|
||||
/// Card ids that would be moved (bottom-to-top order). Length
|
||||
/// always equals `count`. Currently always one element.
|
||||
cards: Vec<u32>,
|
||||
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
|
||||
///
|
||||
/// Anchors are evenly spaced around a ring of radius
|
||||
/// [`RADIAL_RADIUS_PX`] centred on the press position. A single
|
||||
/// destination is placed directly above the cursor; multiple
|
||||
/// destinations span an arc.
|
||||
legal_destinations: Vec<(PileType, Vec2)>,
|
||||
/// Cursor position (world space) the radial was opened at —
|
||||
/// used as the centre of the ring for cursor-hover hit testing.
|
||||
centre: Vec2,
|
||||
/// Index into `legal_destinations` the cursor is currently
|
||||
/// hovering over, or `None` when the cursor is outside every
|
||||
/// icon's hit-box.
|
||||
hovered_index: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RightClickRadialState {
|
||||
/// Returns `true` when the radial is currently open.
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self, Self::Active { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional override resource for tests: when present and `Some`, every
|
||||
/// system that would normally read `Window::cursor_position()` reads this
|
||||
/// world-space coordinate instead.
|
||||
///
|
||||
/// Tests insert this resource so the radial systems can run under
|
||||
/// `MinimalPlugins`, which has no `PrimaryWindow` and no `Camera`.
|
||||
/// Production builds never insert this resource.
|
||||
#[derive(Resource, Debug, Clone, Copy, Default)]
|
||||
pub struct RadialCursorOverride(pub Option<Vec2>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual marker components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on a radial icon parent entity. Wraps the icon's index into
|
||||
/// [`RightClickRadialState::Active::legal_destinations`] so the
|
||||
/// hover-state system can find the right anchor / pile.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct RadialIcon {
|
||||
/// Index into `RightClickRadialState::Active::legal_destinations`.
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// Marker on the centre dot drawn at the cursor / source position.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct RadialCentre;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers [`RightClickRadialState`] and the systems that drive it.
|
||||
///
|
||||
/// All systems run in the `Update` schedule. `RadialCursorOverride` is
|
||||
/// **not** registered by default — production never needs it; tests
|
||||
/// insert it manually.
|
||||
pub struct RadialMenuPlugin;
|
||||
|
||||
impl Plugin for RadialMenuPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<RightClickRadialState>()
|
||||
// Tests inject `RadialCursorOverride` themselves; production
|
||||
// never touches it. We do not `init_resource` here so the
|
||||
// cursor-from-window path is the default.
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
radial_open_on_right_click,
|
||||
radial_track_cursor,
|
||||
radial_handle_release_or_cancel,
|
||||
radial_redraw_overlay,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (testable without a Bevy World)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the world-space anchor for radial icon `index` of `count`,
|
||||
/// arranged on a ring of `radius` centred at `centre`.
|
||||
///
|
||||
/// One destination places the icon directly above the cursor (12 o'clock).
|
||||
/// Multiple destinations spread evenly around a circle, with index 0 at
|
||||
/// 12 o'clock and remaining indices winding clockwise.
|
||||
pub fn radial_anchor_for_index(centre: Vec2, count: usize, index: usize, radius: f32) -> Vec2 {
|
||||
if count == 0 {
|
||||
return centre;
|
||||
}
|
||||
if count == 1 {
|
||||
// Single destination → straight above the cursor for maximum legibility.
|
||||
return centre + Vec2::new(0.0, radius);
|
||||
}
|
||||
// Spread evenly. Angle is measured from the +Y axis, clockwise, so
|
||||
// index 0 sits at 12 o'clock and increasing indices sweep right.
|
||||
let frac = (index as f32) / (count as f32);
|
||||
let angle = std::f32::consts::TAU * frac;
|
||||
Vec2::new(centre.x + radius * angle.sin(), centre.y + radius * angle.cos())
|
||||
}
|
||||
|
||||
/// Returns `(hit?, index)` — whether `cursor` falls within any icon's
|
||||
/// hit-box, and if so the index of the first match. Hit-boxes are
|
||||
/// axis-aligned squares of side [`RADIAL_ICON_SIZE_PX`] centred on each
|
||||
/// anchor. If multiple icons overlap (impossible at the default radius +
|
||||
/// icon size combination, but defensively checked) the lowest index wins.
|
||||
pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option<usize> {
|
||||
let half = RADIAL_ICON_SIZE_PX / 2.0;
|
||||
for (i, anchor) in anchors.iter().enumerate() {
|
||||
if (cursor.x - anchor.x).abs() <= half && (cursor.y - anchor.y).abs() <= half {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the legal destination piles for moving `card` from
|
||||
/// `source_pile` in `game`.
|
||||
///
|
||||
/// Mirrors [`crate::card_plugin::handle_right_click`]'s decision logic
|
||||
/// exactly — only foundations that legally accept the card and tableaus
|
||||
/// that legally accept the card. The source pile is excluded because
|
||||
/// dropping a card on its own pile is a no-op.
|
||||
pub fn legal_destinations_for_card(
|
||||
card: &Card,
|
||||
source_pile: &PileType,
|
||||
game: &GameState,
|
||||
) -> Vec<PileType> {
|
||||
let mut out = Vec::new();
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if dest == *source_pile {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile)
|
||||
{
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if dest == *source_pile {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, pile)
|
||||
{
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns the topmost face-up draggable card under `cursor` (world
|
||||
/// space) along with its source pile.
|
||||
///
|
||||
/// Reuses the same "topmost face-up card" semantics as
|
||||
/// [`crate::card_plugin::handle_right_click`]: tableau columns offer
|
||||
/// every face-up card, waste / foundations offer only their top card,
|
||||
/// and stock is never draggable. Returns `None` for face-down cards,
|
||||
/// empty piles, or clicks in dead space.
|
||||
pub fn find_top_face_up_card_at(
|
||||
cursor: Vec2,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
) -> Option<(PileType, Card)> {
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
for pile in piles {
|
||||
let Some(pile_cards) = game.piles.get(&pile) else {
|
||||
continue;
|
||||
};
|
||||
if pile_cards.cards.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let is_tableau = matches!(pile, PileType::Tableau(_));
|
||||
for i in (0..pile_cards.cards.len()).rev() {
|
||||
let card = &pile_cards.cards[i];
|
||||
if !card.face_up {
|
||||
continue;
|
||||
}
|
||||
// Only the top card is draggable on non-tableau piles.
|
||||
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
let pos = card_position(game, layout, &pile, i);
|
||||
let half = layout.card_size / 2.0;
|
||||
if cursor.x < pos.x - half.x
|
||||
|| cursor.x > pos.x + half.x
|
||||
|| cursor.y < pos.y - half.y
|
||||
|| cursor.y > pos.y + half.y
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Some((pile, card.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mirror of `input_plugin::card_position` — kept private to this
|
||||
/// module so the radial's hit-test geometry tracks renderer geometry
|
||||
/// without depending on `input_plugin` internals.
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
||||
let base = layout.pile_positions[pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let mut y_offset = 0.0_f32;
|
||||
if let Some(pile_cards) = game.piles.get(pile) {
|
||||
for card in pile_cards.cards.iter().take(stack_index) {
|
||||
let step = if card.face_up {
|
||||
TABLEAU_FAN_FRAC
|
||||
} else {
|
||||
TABLEAU_FACEDOWN_FAN_FRAC
|
||||
};
|
||||
y_offset -= layout.card_size.y * step;
|
||||
}
|
||||
}
|
||||
Vec2::new(base.x, base.y + y_offset)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||
fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileType, Vec2)> {
|
||||
let count = dests.len();
|
||||
dests
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| (d, radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor lookup — uses an override resource under MinimalPlugins, falls
|
||||
// back to the real Window/Camera otherwise.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the world-space cursor position. Prefers
|
||||
/// [`RadialCursorOverride`] when present (test injection); otherwise
|
||||
/// reads the primary window's cursor position and projects it through
|
||||
/// the camera.
|
||||
fn cursor_world(
|
||||
override_res: Option<&Res<RadialCursorOverride>>,
|
||||
windows: &Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||
) -> Option<Vec2> {
|
||||
if let Some(ovr) = override_res
|
||||
&& let Some(pos) = ovr.0
|
||||
{
|
||||
return Some(pos);
|
||||
}
|
||||
let window = windows.single().ok()?;
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, camera_transform) = cameras.single().ok()?;
|
||||
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// On `MouseButton::Right` `just_pressed`, attempts to open the radial
|
||||
/// menu over the card the cursor is on. Skips when a left-mouse drag is
|
||||
/// in progress, when the game is paused, or when the clicked card has no
|
||||
/// legal destinations.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn radial_open_on_right_click(
|
||||
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
drag: Res<DragState>,
|
||||
cursor_override: Option<Res<RadialCursorOverride>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
if !drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
let Some(buttons) = buttons else { return };
|
||||
if !buttons.just_pressed(MouseButton::Right) {
|
||||
return;
|
||||
}
|
||||
if state.is_active() {
|
||||
// Already active — ignore re-presses.
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else { return };
|
||||
let Some(game) = game else { return };
|
||||
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
|
||||
return;
|
||||
};
|
||||
let Some((source_pile, card)) = find_top_face_up_card_at(world, &game.0, &layout.0) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Only single-card right-click for now: foundations require single
|
||||
// cards and the highlight tint shows the same set the radial offers.
|
||||
let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
|
||||
if dests.is_empty() {
|
||||
return;
|
||||
}
|
||||
let legal_destinations = build_radial_destinations(world, dests);
|
||||
|
||||
*state = RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count: 1,
|
||||
cards: vec![card.id],
|
||||
legal_destinations,
|
||||
centre: world,
|
||||
hovered_index: None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Each frame while `Active`, updates `hovered_index` based on the
|
||||
/// current cursor position. Cheap — just re-runs hit-testing against
|
||||
/// the precomputed anchors. The overlay redraw system reads this index
|
||||
/// to apply the focused tint and scale.
|
||||
fn radial_track_cursor(
|
||||
cursor_override: Option<Res<RadialCursorOverride>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
) {
|
||||
let RightClickRadialState::Active {
|
||||
legal_destinations,
|
||||
hovered_index,
|
||||
..
|
||||
} = state.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
|
||||
return;
|
||||
};
|
||||
let anchors: Vec<Vec2> = legal_destinations.iter().map(|(_, a)| *a).collect();
|
||||
*hovered_index = radial_hovered_index(world, &anchors);
|
||||
}
|
||||
|
||||
/// Handles three exit conditions while `Active`:
|
||||
/// 1. Right-mouse release → confirm if hovering, otherwise cancel.
|
||||
/// 2. `Escape` → cancel.
|
||||
/// 3. Left-mouse press → cancel (keeps the existing drag pipeline clean).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn radial_handle_release_or_cancel(
|
||||
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
) {
|
||||
if !state.is_active() {
|
||||
return;
|
||||
}
|
||||
|
||||
let escape_pressed = keys
|
||||
.as_ref()
|
||||
.is_some_and(|k| k.just_pressed(KeyCode::Escape));
|
||||
let right_released = buttons
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.just_released(MouseButton::Right));
|
||||
let left_pressed = buttons
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.just_pressed(MouseButton::Left));
|
||||
|
||||
if !escape_pressed && !right_released && !left_pressed {
|
||||
return;
|
||||
}
|
||||
|
||||
// On confirm, fire a MoveRequestEvent. On any other exit, just clear.
|
||||
if right_released
|
||||
&& let RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count,
|
||||
legal_destinations,
|
||||
hovered_index: Some(idx),
|
||||
..
|
||||
} = state.as_ref()
|
||||
&& let Some((dest, _)) = legal_destinations.get(*idx)
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: source_pile.clone(),
|
||||
to: dest.clone(),
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
|
||||
*state = RightClickRadialState::Idle;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual overlay — spawns / despawns sprites in step with the state.
|
||||
//
|
||||
// Strategy: on every frame, despawn ALL prior overlay entities and
|
||||
// respawn the current snapshot. Cheap (≤ 11 sprites + a centre dot) and
|
||||
// keeps the overlay always perfectly in sync without component
|
||||
// bookkeeping. Skipped in tests because `MinimalPlugins` does not
|
||||
// register `Sprite` rendering anyway and the state-machine assertions
|
||||
// don't rely on entity existence.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Despawns and respawns the radial overlay sprites every frame the
|
||||
/// state is `Active`; despawns them when the state returns to `Idle`.
|
||||
fn radial_redraw_overlay(
|
||||
state: Res<RightClickRadialState>,
|
||||
mut commands: Commands,
|
||||
existing_icons: Query<Entity, With<RadialIcon>>,
|
||||
existing_centres: Query<Entity, With<RadialCentre>>,
|
||||
) {
|
||||
// Always clear last-frame overlay entities first.
|
||||
for e in &existing_icons {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
for e in &existing_centres {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
|
||||
let RightClickRadialState::Active {
|
||||
legal_destinations,
|
||||
hovered_index,
|
||||
centre,
|
||||
..
|
||||
} = state.as_ref()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Centre dot — small bright marker so the player can see where the
|
||||
// ring is anchored even when the cursor moves.
|
||||
commands.spawn((
|
||||
RadialCentre,
|
||||
Sprite {
|
||||
color: ACCENT_PRIMARY,
|
||||
custom_size: Some(Vec2::splat(8.0)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
|
||||
));
|
||||
|
||||
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
|
||||
let focused = *hovered_index == Some(i);
|
||||
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
|
||||
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
|
||||
// Hovered icon gets a strong yellow rim; resting icons get a
|
||||
// muted purple rim so the focused one reads as the obvious target.
|
||||
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
RadialIcon { index: i },
|
||||
Sprite {
|
||||
color: fill,
|
||||
custom_size: Some(Vec2::splat(RADIAL_ICON_SIZE_PX)),
|
||||
..default()
|
||||
},
|
||||
Transform {
|
||||
translation: Vec3::new(anchor.x, anchor.y, Z_RADIAL_MENU),
|
||||
scale: Vec3::splat(scale),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|p| {
|
||||
// Outline ring — drawn as a slightly larger sprite
|
||||
// behind the fill so it reads as a halo, not a stroke.
|
||||
p.spawn((
|
||||
Sprite {
|
||||
color: outline,
|
||||
custom_size: Some(Vec2::splat(RADIAL_ICON_SIZE_PX + 4.0)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layout::compute_layout;
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::card::{Card as CoreCard, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
|
||||
/// resources / messages it depends on. No window, no camera — the
|
||||
/// `RadialCursorOverride` resource feeds the cursor position.
|
||||
fn radial_test_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<MoveRequestEvent>();
|
||||
app.init_resource::<DragState>();
|
||||
app.init_resource::<ButtonInput<MouseButton>>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<RadialCursorOverride>();
|
||||
app.add_plugins(RadialMenuPlugin);
|
||||
app
|
||||
}
|
||||
|
||||
/// Deterministic single-card board: Ace of Clubs on Tableau(0),
|
||||
/// every other pile empty. The Ace has exactly one legal
|
||||
/// destination — Foundation(0) — under the standard rules
|
||||
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
|
||||
fn ace_only_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
// Wipe everything.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
g.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
// Ace of Clubs on Tableau(0).
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
g
|
||||
}
|
||||
|
||||
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
|
||||
/// must skip it.
|
||||
fn face_down_only_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
g.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::King,
|
||||
face_up: false,
|
||||
});
|
||||
g
|
||||
}
|
||||
|
||||
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
|
||||
app.insert_resource(GameStateResource(state));
|
||||
app.insert_resource(LayoutResource(compute_layout(layout_window)));
|
||||
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
|
||||
}
|
||||
|
||||
fn press(app: &mut App, button: MouseButton) {
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<MouseButton>>()
|
||||
.press(button);
|
||||
}
|
||||
|
||||
fn release(app: &mut App, button: MouseButton) {
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<MouseButton>>()
|
||||
.release(button);
|
||||
}
|
||||
|
||||
fn clear_buttons(app: &mut App) {
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<MouseButton>>()
|
||||
.clear();
|
||||
}
|
||||
|
||||
fn collect_move_events(app: &mut App) -> Vec<MoveRequestEvent> {
|
||||
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
cursor.read(events).cloned().collect()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn radial_anchor_single_destination_above_centre() {
|
||||
let centre = Vec2::new(100.0, 200.0);
|
||||
let pos = radial_anchor_for_index(centre, 1, 0, 80.0);
|
||||
// Single destination → straight above (centre + (0, radius)).
|
||||
assert!((pos.x - 100.0).abs() < 1e-3);
|
||||
assert!((pos.y - 280.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_anchor_two_destinations_first_above_second_below() {
|
||||
let centre = Vec2::ZERO;
|
||||
let radius = 50.0;
|
||||
let p0 = radial_anchor_for_index(centre, 2, 0, radius);
|
||||
let p1 = radial_anchor_for_index(centre, 2, 1, radius);
|
||||
// index 0 is at 12 o'clock; index 1 is the opposite side.
|
||||
assert!(p0.y > p1.y);
|
||||
assert!(p0.x.abs() < 1e-3);
|
||||
assert!(p1.x.abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_anchor_zero_count_returns_centre() {
|
||||
let centre = Vec2::new(7.0, -3.0);
|
||||
assert_eq!(radial_anchor_for_index(centre, 0, 0, 80.0), centre);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_hovered_index_inside_box_returns_index() {
|
||||
let anchors = vec![Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
|
||||
// Cursor squarely inside icon 1's box.
|
||||
assert_eq!(radial_hovered_index(Vec2::new(0.0, 100.0), &anchors), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_hovered_index_outside_returns_none() {
|
||||
let anchors = vec![Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
|
||||
assert_eq!(radial_hovered_index(Vec2::new(500.0, 500.0), &anchors), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_destinations_for_ace_includes_only_first_empty_foundation() {
|
||||
let g = ace_only_state();
|
||||
let card = CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g);
|
||||
// Ace can be placed on every empty foundation. We only need
|
||||
// the count to be ≥ 1 and the source pile to be excluded.
|
||||
assert!(!dests.is_empty(), "Ace must have at least one legal destination");
|
||||
assert!(!dests.contains(&PileType::Tableau(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_destinations_excludes_source_pile() {
|
||||
let g = ace_only_state();
|
||||
let card = CoreCard {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let dests = legal_destinations_for_card(&card, &PileType::Foundation(0), &g);
|
||||
assert!(!dests.contains(&PileType::Foundation(0)));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// System-level tests (state machine + event firing)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing right-click on a face-up card with at least one legal
|
||||
/// destination must transition the state to `Active` carrying the
|
||||
/// expected source / count / legal-destination set.
|
||||
#[test]
|
||||
fn right_click_press_on_face_up_card_opens_radial() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
// Initial state — Idle.
|
||||
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||
|
||||
press(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
|
||||
let state = app.world().resource::<RightClickRadialState>().clone();
|
||||
match state {
|
||||
RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count,
|
||||
cards,
|
||||
legal_destinations,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(source_pile, PileType::Tableau(0));
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(cards, vec![100]);
|
||||
assert!(!legal_destinations.is_empty());
|
||||
assert!(legal_destinations
|
||||
.iter()
|
||||
.any(|(p, _)| matches!(p, PileType::Foundation(_))));
|
||||
}
|
||||
other => panic!("expected Active, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Releasing the right button while the cursor is over a destination
|
||||
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
|
||||
#[test]
|
||||
fn right_click_release_over_destination_fires_move_request() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
press(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
|
||||
// Capture the destination chosen — pull anchor[0] from the state.
|
||||
let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() {
|
||||
RightClickRadialState::Active { legal_destinations, .. } => legal_destinations[0].clone(),
|
||||
_ => panic!("expected Active"),
|
||||
};
|
||||
|
||||
// Move the cursor onto that anchor and release.
|
||||
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(anchor);
|
||||
// Need a track-cursor pass first so hovered_index updates.
|
||||
app.update();
|
||||
// Then release.
|
||||
clear_buttons(&mut app);
|
||||
release(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
|
||||
// Move event must have fired.
|
||||
let events = collect_move_events(&mut app);
|
||||
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected");
|
||||
let evt = &events[0];
|
||||
assert_eq!(evt.from, PileType::Tableau(0));
|
||||
assert_eq!(evt.to, dest_pile);
|
||||
assert_eq!(evt.count, 1);
|
||||
// State must return to Idle.
|
||||
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||
}
|
||||
|
||||
/// Releasing the right button far from any icon must clear state
|
||||
/// without firing any MoveRequestEvent.
|
||||
#[test]
|
||||
fn right_click_release_outside_any_destination_cancels() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
press(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
assert!(app.world().resource::<RightClickRadialState>().is_active());
|
||||
|
||||
// Move cursor far away — well outside every icon's hit-box.
|
||||
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(Vec2::new(10_000.0, 10_000.0));
|
||||
app.update();
|
||||
|
||||
clear_buttons(&mut app);
|
||||
release(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
|
||||
let events = collect_move_events(&mut app);
|
||||
assert!(events.is_empty(), "no MoveRequestEvent on outside-release");
|
||||
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||
}
|
||||
|
||||
/// Pressing Escape while the radial is active must cancel cleanly,
|
||||
/// without firing any MoveRequestEvent.
|
||||
#[test]
|
||||
fn escape_cancels_active_radial() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
press(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
assert!(app.world().resource::<RightClickRadialState>().is_active());
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Escape);
|
||||
app.update();
|
||||
|
||||
let events = collect_move_events(&mut app);
|
||||
assert!(events.is_empty(), "no MoveRequestEvent on Escape cancel");
|
||||
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||
}
|
||||
|
||||
/// Right-clicking on a face-down card must NOT open the radial.
|
||||
#[test]
|
||||
fn right_click_on_face_down_card_does_not_open_radial() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
|
||||
press(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
*app.world().resource::<RightClickRadialState>(),
|
||||
RightClickRadialState::Idle,
|
||||
"face-down cards must not open the radial"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
//! On-screen overlay shown while a recorded [`Replay`] plays back.
|
||||
//!
|
||||
//! The overlay is a thin top-of-window banner with three pieces of UI:
|
||||
//!
|
||||
//! - A "Replay" label on the left so the player knows the surface is
|
||||
//! under playback control rather than live input.
|
||||
//! - A "Move N of M" progress indicator in the centre, recomputed every
|
||||
//! frame the cursor advances.
|
||||
//! - A "Stop" button on the right that aborts playback and returns
|
||||
//! control to the player.
|
||||
//!
|
||||
//! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner
|
||||
//! label swaps to "Replay complete" and stays visible until the playback
|
||||
//! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`]
|
||||
//! a few seconds later, at which point the overlay despawns.
|
||||
//!
|
||||
//! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but
|
||||
//! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets
|
||||
//! the player still open Settings, Pause, and Help during a replay; those
|
||||
//! modals will render on top of the banner as expected.
|
||||
//!
|
||||
//! [`Replay`]: solitaire_data::Replay
|
||||
//! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
|
||||
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, TEXT_PRIMARY, TYPE_BODY, TYPE_HEADLINE, VAL_SPACE_2,
|
||||
VAL_SPACE_4, Z_DROP_OVERLAY,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `bevy::ui` `ZIndex` value for the replay overlay banner.
|
||||
///
|
||||
/// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner
|
||||
/// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally
|
||||
/// **below** modals, but the overlay needs to be above HUD readouts) yet
|
||||
/// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals
|
||||
/// continue to render on top of the overlay during a replay.
|
||||
///
|
||||
/// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an
|
||||
/// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used
|
||||
/// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so
|
||||
/// we materialise a separate constant rather than reuse the `f32` value.
|
||||
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
|
||||
|
||||
/// Total height of the banner in pixels. Thin enough to leave the
|
||||
/// gameplay surface visible underneath, tall enough to comfortably fit
|
||||
/// the headline-sized "Replay" label.
|
||||
const BANNER_HEIGHT: f32 = 48.0;
|
||||
|
||||
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
||||
/// reads as a clear "this is a UI strip" callout while still letting the
|
||||
/// felt show through enough to anchor the banner to the play surface.
|
||||
const BANNER_ALPHA: f32 = 0.92;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the banner's root `Node`. Used by the spawn / despawn /
|
||||
/// progress-update systems to find the overlay.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayRoot;
|
||||
|
||||
/// Marker on the left-hand banner label `Text`. Carries either "Replay"
|
||||
/// (during playback) or "Replay complete" (once finished); the
|
||||
/// completion-text-update system swaps the contents in place.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayBannerText;
|
||||
|
||||
/// Marker on the centre progress `Text`. Updated every frame to reflect
|
||||
/// the current `(cursor, total)` returned by
|
||||
/// [`ReplayPlaybackState::progress`].
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayProgressText;
|
||||
|
||||
/// Marker on the right-hand "Stop" button. Click handler queries for this
|
||||
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
|
||||
/// transition is seen.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayStopButton;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy plugin that registers every system needed to drive the replay
|
||||
/// overlay's lifecycle.
|
||||
///
|
||||
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
|
||||
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
|
||||
/// the resource manually and exercise the overlay in isolation.
|
||||
pub struct ReplayOverlayPlugin;
|
||||
|
||||
impl Plugin for ReplayOverlayPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// The systems are ordered so that, on a single frame:
|
||||
// 1. The state-watcher spawns or despawns the overlay if the
|
||||
// `ReplayPlaybackState` resource changed.
|
||||
// 2. The completion-text update swaps the banner label when the
|
||||
// state is `Completed`.
|
||||
// 3. The progress-text update writes the latest "Move N of M".
|
||||
// 4. The Stop-button click handler reads `Interaction::Pressed`
|
||||
// and calls `stop_replay_playback` (which mutates the state).
|
||||
// Putting Stop last means a click in frame N is observed by
|
||||
// `react_to_state_change` in frame N+1, which then despawns the
|
||||
// overlay in response — a clean state-driven loop.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_state_change,
|
||||
update_banner_label,
|
||||
update_progress_text,
|
||||
handle_stop_button,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
|
||||
/// spawns or despawns the overlay accordingly. Treats the resource as the
|
||||
/// single source of truth — the spawn / despawn decision is derived from
|
||||
/// `is_playing() || is_completed()` rather than tracking previous-state
|
||||
/// transitions explicitly, which keeps the system stateless.
|
||||
fn react_to_state_change(
|
||||
mut commands: Commands,
|
||||
state: Res<ReplayPlaybackState>,
|
||||
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let should_be_visible = state.is_playing() || state.is_completed();
|
||||
let already_spawned = existing.iter().next().is_some();
|
||||
|
||||
if should_be_visible && !already_spawned {
|
||||
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
||||
} else if !should_be_visible && already_spawned {
|
||||
for entity in &existing {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
// The `should_be_visible && already_spawned` branch is a no-op here —
|
||||
// the per-frame text update systems below repaint the banner label
|
||||
// and progress readout in place without a respawn.
|
||||
}
|
||||
|
||||
/// Spawns the banner — a flex-row Node anchored to the top edge of the
|
||||
/// window with three children: the "Replay" / "Replay complete" label,
|
||||
/// the centred progress text, and the right-aligned Stop button.
|
||||
fn spawn_overlay(
|
||||
commands: &mut Commands,
|
||||
font_res: Option<&FontResource>,
|
||||
state: &ReplayPlaybackState,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
|
||||
let banner_label = if state.is_completed() {
|
||||
"Replay complete"
|
||||
} else {
|
||||
"Replay"
|
||||
};
|
||||
let progress_label = format_progress(state);
|
||||
|
||||
let banner_bg = Color::srgba(
|
||||
BG_ELEVATED_HI.to_srgba().red,
|
||||
BG_ELEVATED_HI.to_srgba().green,
|
||||
BG_ELEVATED_HI.to_srgba().blue,
|
||||
BANNER_ALPHA,
|
||||
);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
ReplayOverlayRoot,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(BANNER_HEIGHT),
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(banner_bg),
|
||||
// Pin the banner to its z layer in both the local and the
|
||||
// global stacking context — `GlobalZIndex` matters because
|
||||
// the overlay is a top-level Node (no parent), and Bevy 0.18
|
||||
// has historically had subtle stacking-context drift here.
|
||||
ZIndex(Z_REPLAY_OVERLAY),
|
||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||
))
|
||||
.with_children(|banner| {
|
||||
// Left: "Replay" label in the loud yellow accent so it reads
|
||||
// unmistakably as a non-gameplay surface.
|
||||
banner.spawn((
|
||||
ReplayOverlayBannerText,
|
||||
Text::new(banner_label),
|
||||
TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
// Centre: progress readout — neutral primary text colour so
|
||||
// the eye treats it as data, not a callout.
|
||||
banner.spawn((
|
||||
ReplayOverlayProgressText,
|
||||
Text::new(progress_label),
|
||||
TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
|
||||
// Right: Stop button. Tertiary variant — the action is
|
||||
// available but not the loudest element in the banner; the
|
||||
// "Replay" yellow accent owns that slot. `spawn_modal_button`
|
||||
// gives us hover / press paint and focus rings for free via
|
||||
// the existing `UiModalPlugin` paint system.
|
||||
banner
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|wrap| {
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStopButton,
|
||||
"Stop",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame text updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Overwrites the banner label whenever the resource changes — covers the
|
||||
/// `Playing → Completed` transition by swapping "Replay" for
|
||||
/// "Replay complete" in place without despawning the overlay.
|
||||
fn update_banner_label(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = if state.is_completed() {
|
||||
"Replay complete"
|
||||
} else if state.is_playing() {
|
||||
"Replay"
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
for mut text in &mut q {
|
||||
**text = label.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
|
||||
/// Cheap — early-exits if the resource has not changed since the last
|
||||
/// frame so idle replays don't churn the text mesh.
|
||||
fn update_progress_text(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = format_progress(&state);
|
||||
for mut text in &mut q {
|
||||
**text = label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats the centre progress readout for the given state.
|
||||
/// Exposed at module scope so the spawn path and the per-frame update
|
||||
/// path produce the exact same string.
|
||||
fn format_progress(state: &ReplayPlaybackState) -> String {
|
||||
match state.progress() {
|
||||
Some((cursor, total)) => format!("Move {cursor} of {total}"),
|
||||
None if state.is_completed() => "Replay complete".to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop button handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
|
||||
/// click, calls [`stop_replay_playback`] which resets the state to
|
||||
/// `Inactive`; the next frame's `react_to_state_change` then despawns
|
||||
/// the overlay.
|
||||
fn handle_stop_button(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
|
||||
) {
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
stop_replay_playback(&mut commands, &mut state);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
|
||||
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
|
||||
/// (denominator of the progress indicator), so the move kind is
|
||||
/// irrelevant beyond producing the right count.
|
||||
fn synthetic_replay(move_count: usize) -> Replay {
|
||||
Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
120,
|
||||
1_000,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a test app that has the overlay plugin but **not** the
|
||||
/// playback plugin — tests insert `ReplayPlaybackState` manually so
|
||||
/// they can drive every state transition deterministically.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(ReplayOverlayPlugin);
|
||||
app.init_resource::<ReplayPlaybackState>();
|
||||
app
|
||||
}
|
||||
|
||||
/// Count `ReplayOverlayRoot` entities in the world — the overlay's
|
||||
/// presence/absence is the spawn-test's primary observable.
|
||||
fn overlay_root_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&ReplayOverlayRoot>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Read the current text content of the unique progress-text entity.
|
||||
fn progress_text(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text, With<ReplayOverlayProgressText>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the current text content of the unique banner-label entity.
|
||||
fn banner_text(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text, With<ReplayOverlayBannerText>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set the playback resource without going through the playback core.
|
||||
fn set_state(app: &mut App, state: ReplayPlaybackState) {
|
||||
app.world_mut().insert_resource(state);
|
||||
}
|
||||
|
||||
/// Find the unique `ReplayStopButton` entity for the click-handler
|
||||
/// test. There must be exactly one.
|
||||
fn stop_button_entity(app: &mut App) -> Entity {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<ReplayStopButton>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("Stop button must exist while overlay is spawned")
|
||||
}
|
||||
|
||||
/// Going `Inactive → Playing` spawns exactly one overlay root and
|
||||
/// the banner label reads "Replay".
|
||||
#[test]
|
||||
fn overlay_spawns_when_playback_starts() {
|
||||
let mut app = headless_app();
|
||||
// First update with the default `Inactive` resource — overlay
|
||||
// must not exist yet.
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 0);
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
1,
|
||||
"exactly one ReplayOverlayRoot must spawn on Inactive → Playing",
|
||||
);
|
||||
assert_eq!(banner_text(&mut app), "Replay");
|
||||
}
|
||||
|
||||
/// The progress-text entity reads `"Move {cursor} of {total}"` for a
|
||||
/// well-formed `Playing` state.
|
||||
#[test]
|
||||
fn overlay_progress_text_reflects_cursor() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
|
||||
assert_eq!(progress_text(&mut app), "Move 5 of 10");
|
||||
}
|
||||
|
||||
/// Pressing the Stop button resets the state back to `Inactive` and
|
||||
/// the next frame's `react_to_state_change` despawns the overlay.
|
||||
/// Mirrors the synthetic `Interaction::Pressed` insertion pattern
|
||||
/// used elsewhere in the engine for headless click tests.
|
||||
#[test]
|
||||
fn overlay_stop_button_click_clears_playback() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
|
||||
let stop = stop_button_entity(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(stop)
|
||||
.insert(Interaction::Pressed);
|
||||
// Tick once: the click handler runs late in the frame and resets
|
||||
// the state to `Inactive`.
|
||||
app.update();
|
||||
|
||||
// State must be back to Inactive.
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
assert!(
|
||||
matches!(state, ReplayPlaybackState::Inactive),
|
||||
"Stop click must reset ReplayPlaybackState to Inactive; got {state:?}",
|
||||
);
|
||||
|
||||
// One more tick — `react_to_state_change` sees the resource
|
||||
// change to Inactive and despawns the overlay.
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
0,
|
||||
"overlay must despawn the frame after state returns to Inactive",
|
||||
);
|
||||
}
|
||||
|
||||
/// Manually flipping the resource back to `Inactive` (e.g. via the
|
||||
/// playback core's auto-clear after `Completed`) tears the overlay
|
||||
/// down without any further input.
|
||||
#[test]
|
||||
fn overlay_despawns_when_playback_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(3),
|
||||
cursor: 1,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
|
||||
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
0,
|
||||
"overlay must despawn on Playing → Inactive transition",
|
||||
);
|
||||
}
|
||||
|
||||
/// On `Playing → Completed` the banner label updates in place rather
|
||||
/// than respawning. The overlay must still be present, and the label
|
||||
/// must read "Replay complete".
|
||||
#[test]
|
||||
fn overlay_text_changes_on_completed() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(7),
|
||||
cursor: 7,
|
||||
secs_to_next: 0.0,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(banner_text(&mut app), "Replay");
|
||||
|
||||
set_state(&mut app, ReplayPlaybackState::Completed);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
1,
|
||||
"overlay must remain spawned while in Completed state",
|
||||
);
|
||||
assert_eq!(
|
||||
banner_text(&mut app),
|
||||
"Replay complete",
|
||||
"banner label must swap on Playing → Completed",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
//! In-engine replay playback core.
|
||||
//!
|
||||
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
||||
//! game state is reset to the deal seeded from the replay's `seed` /
|
||||
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
|
||||
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
|
||||
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
|
||||
//! plays back identically to a live game.
|
||||
//!
|
||||
//! ## Public surface
|
||||
//!
|
||||
//! - [`ReplayPlaybackState`] — single source of truth for whether
|
||||
//! playback is live, how far through the move list we've ticked, and
|
||||
//! how long until the next advance.
|
||||
//! - [`start_replay_playback`] — public entry point; the Stats
|
||||
//! "Watch replay" button calls this. Resets the game to the recorded
|
||||
//! deal and transitions the state machine to
|
||||
//! [`ReplayPlaybackState::Playing`].
|
||||
//! - [`stop_replay_playback`] — interrupts playback at any time. Safe to
|
||||
//! call when [`ReplayPlaybackState::Inactive`].
|
||||
//! - [`ReplayPlaybackPlugin`] — registers the resource and the tick /
|
||||
//! linger systems.
|
||||
//!
|
||||
//! ## Coordination note
|
||||
//!
|
||||
//! This module is built in parallel with the Stats-side overlay. The
|
||||
//! resource shape, helper signatures, and plugin marker match the
|
||||
//! contract the overlay agent reads against — see also the docs on the
|
||||
//! enum variants.
|
||||
//!
|
||||
//! ## Recording is paused during playback
|
||||
//!
|
||||
//! Playback fires the same [`MoveRequestEvent`] / [`DrawRequestEvent`]
|
||||
//! the live engine handles. Without intervention, [`RecordingReplay`]
|
||||
//! would re-record those events and a replay would re-record itself
|
||||
//! indefinitely. To prevent that, [`record_replay_skip_during_playback`]
|
||||
//! snapshots the recording's length at the start of playback and
|
||||
//! truncates the buffer back to that length every frame. This keeps
|
||||
//! the recording contract opaque to `game_plugin` — no event-source
|
||||
//! flag is threaded through, no every-callsite gate is added.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
|
||||
/// Default per-move duration during playback, in seconds. Acts as the
|
||||
/// fallback when `SettingsResource` is absent — i.e. in headless test
|
||||
/// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`].
|
||||
/// In production the live value is read from
|
||||
/// [`solitaire_data::Settings::replay_move_interval_secs`] every frame
|
||||
/// so Settings adjustments take effect on the next playback tick.
|
||||
///
|
||||
/// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs`
|
||||
/// (the data crate cannot depend on this engine crate, so the constant
|
||||
/// is duplicated). The
|
||||
/// `settings_replay_move_interval_default_matches_engine_constant`
|
||||
/// test in `solitaire_engine::settings_plugin` enforces equality.
|
||||
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
|
||||
|
||||
/// Helper: returns the live per-move replay interval. Reads
|
||||
/// [`SettingsResource::replay_move_interval_secs`] when the resource is
|
||||
/// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise.
|
||||
/// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot
|
||||
/// busy-loop the playback tick.
|
||||
fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 {
|
||||
let raw = settings
|
||||
.map(|s| s.0.replay_move_interval_secs)
|
||||
.unwrap_or(REPLAY_MOVE_INTERVAL_SECS);
|
||||
raw.max(f32::EPSILON)
|
||||
}
|
||||
|
||||
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
|
||||
/// the auto-clear system transitions it back to
|
||||
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
|
||||
/// display "Replay complete" before dismissing.
|
||||
pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
||||
|
||||
/// Lifecycle state of an in-flight replay playback.
|
||||
///
|
||||
/// The default state is [`Inactive`](Self::Inactive) — no replay is
|
||||
/// running. The overlay (and any other consumer) reads this resource to
|
||||
/// decide whether the "Replay" banner should be visible and what
|
||||
/// progress to display.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// 1. Default state is [`Inactive`](Self::Inactive).
|
||||
/// 2. [`start_replay_playback`] transitions to
|
||||
/// [`Playing`](Self::Playing) and resets the live `GameState` to the
|
||||
/// replay's recorded deal.
|
||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||
/// for each [`ReplayMove`].
|
||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||
/// [`Completed`](Self::Completed). It lingers for
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||
/// [`auto_clear_completed_replay`]) before returning to
|
||||
/// [`Inactive`](Self::Inactive).
|
||||
/// 5. [`stop_replay_playback`] interrupts at any time and forces the
|
||||
/// state back to [`Inactive`](Self::Inactive).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub enum ReplayPlaybackState {
|
||||
/// No replay is being played back. The overlay despawns itself when
|
||||
/// the resource transitions back to this variant.
|
||||
#[default]
|
||||
Inactive,
|
||||
/// A replay is currently being played back. The overlay reads
|
||||
/// `replay.moves.len()` for the denominator of the progress
|
||||
/// indicator and `cursor` for the numerator.
|
||||
Playing {
|
||||
/// The replay being played back. Owned so the state is the
|
||||
/// only place playback metadata lives — no separate resource
|
||||
/// needed.
|
||||
replay: Replay,
|
||||
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
|
||||
cursor: usize,
|
||||
/// Seconds remaining until the next move is dispatched.
|
||||
secs_to_next: f32,
|
||||
},
|
||||
/// The replay finished playing back. The overlay swaps the banner
|
||||
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
||||
/// transitions back to [`Inactive`](Self::Inactive) a few seconds
|
||||
/// later.
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl ReplayPlaybackState {
|
||||
/// Returns `true` when a replay is currently being played back.
|
||||
pub fn is_playing(&self) -> bool {
|
||||
matches!(self, Self::Playing { .. })
|
||||
}
|
||||
|
||||
/// Returns `true` when the replay has finished but the resource has
|
||||
/// not yet been auto-cleared back to [`Self::Inactive`].
|
||||
pub fn is_completed(&self) -> bool {
|
||||
matches!(self, Self::Completed)
|
||||
}
|
||||
|
||||
/// Returns `(cursor, total)` when a replay is in progress so the
|
||||
/// overlay can render `"Move N of M"`. Returns `None` while
|
||||
/// [`Inactive`](Self::Inactive) or [`Completed`](Self::Completed) —
|
||||
/// the replay is consumed when transitioning out of `Playing`, so
|
||||
/// the total is no longer available in `Completed`.
|
||||
pub fn progress(&self) -> Option<(usize, usize)> {
|
||||
match self {
|
||||
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
|
||||
Self::Inactive | Self::Completed => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry point — call from the Stats "Watch replay" button
|
||||
/// handler.
|
||||
///
|
||||
/// Resets the live [`GameStateResource`] to a fresh deal seeded from
|
||||
/// `replay.seed` / `replay.draw_mode` / `replay.mode` (via
|
||||
/// [`Commands::insert_resource`]), then transitions the state machine
|
||||
/// to [`ReplayPlaybackState::Playing`] with `cursor: 0` and
|
||||
/// `secs_to_next: REPLAY_MOVE_INTERVAL_SECS`.
|
||||
///
|
||||
/// `commands` is used to overwrite [`GameStateResource`] in a deferred
|
||||
/// flush — equivalent to what `handle_new_game` does, minus the
|
||||
/// [`crate::events::NewGameRequestEvent`] round-trip and the
|
||||
/// abandon-current-game confirmation modal (which would block playback
|
||||
/// indefinitely). Using `Commands` rather than [`crate::events::NewGameRequestEvent`]
|
||||
/// also sidesteps the fact that `NewGameRequestEvent` has no
|
||||
/// `draw_mode_override` field — `handle_new_game` always reads
|
||||
/// `draw_mode` from `Settings`, which would silently coerce a Draw-1
|
||||
/// replay into a Draw-3 game (or vice versa) when the player's
|
||||
/// settings disagree with the recording.
|
||||
///
|
||||
/// Safe to call from any state — if a replay is already playing it is
|
||||
/// dropped and the new one starts immediately.
|
||||
pub fn start_replay_playback(
|
||||
commands: &mut Commands,
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
replay: Replay,
|
||||
) {
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||
commands.insert_resource(GameStateResource(fresh));
|
||||
|
||||
// Initial `secs_to_next` uses the constant rather than reading
|
||||
// `SettingsResource` because this entry point takes `Commands` /
|
||||
// `ResMut<ReplayPlaybackState>` only. The first-tick latency may
|
||||
// therefore lag the configured interval by up to ~0.45 s on an
|
||||
// unusually short setting; subsequent ticks read the live setting
|
||||
// every frame via [`tick_replay_playback`].
|
||||
**state = ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
cursor: 0,
|
||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||
};
|
||||
}
|
||||
|
||||
/// Aborts an in-flight replay playback and resets
|
||||
/// [`ReplayPlaybackState`] back to [`ReplayPlaybackState::Inactive`].
|
||||
///
|
||||
/// Safe to call from any state — when already
|
||||
/// [`ReplayPlaybackState::Inactive`] it simply re-asserts inactivity.
|
||||
///
|
||||
/// The current [`GameStateResource`] is left as-is: the player sees the
|
||||
/// replay's most-recently-applied state until they start a fresh game
|
||||
/// manually. This avoids forcing an extra deal animation in their face
|
||||
/// the moment they cancel.
|
||||
///
|
||||
/// `commands` is currently unused but accepted to match the
|
||||
/// [`start_replay_playback`] signature — leaves room to hook in
|
||||
/// cleanup (e.g. despawning playback-only overlays) without a future
|
||||
/// API break.
|
||||
pub fn stop_replay_playback(
|
||||
_commands: &mut Commands,
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
) {
|
||||
**state = ReplayPlaybackState::Inactive;
|
||||
}
|
||||
|
||||
/// Tick system. Runs every frame; only does work when
|
||||
/// [`ReplayPlaybackState::is_playing`].
|
||||
///
|
||||
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
||||
/// expires, fires the canonical event for the move at `cursor`,
|
||||
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
||||
/// reaches `replay.moves.len()`, transitions to
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
///
|
||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||
/// (e.g. test-driven 200 ms ticks against a 450 ms interval) still
|
||||
/// fire the right number of events — accumulated debt is paid off
|
||||
/// across as many advances as needed in the same frame. In normal
|
||||
/// gameplay frame deltas are well below `REPLAY_MOVE_INTERVAL_SECS`,
|
||||
/// so the loop runs at most once per frame.
|
||||
fn tick_replay_playback(
|
||||
time: Res<Time>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
let interval = current_move_interval_secs(settings.as_deref());
|
||||
let mut transition_to_completed = false;
|
||||
|
||||
if let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
} = state.as_mut()
|
||||
{
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
*cursor += 1;
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
|
||||
if *cursor >= replay.moves.len() {
|
||||
transition_to_completed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if transition_to_completed {
|
||||
*state = ReplayPlaybackState::Completed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Local timer for the [`ReplayPlaybackState::Completed`] linger.
|
||||
/// Resets to zero whenever the state transitions out of
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
#[derive(Default)]
|
||||
struct CompletionLinger(f32);
|
||||
|
||||
/// Auto-clear system. While [`ReplayPlaybackState::Completed`],
|
||||
/// accumulates time and transitions back to
|
||||
/// [`ReplayPlaybackState::Inactive`] once
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] has elapsed.
|
||||
fn auto_clear_completed_replay(
|
||||
time: Res<Time>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut linger: Local<CompletionLinger>,
|
||||
) {
|
||||
if state.is_completed() {
|
||||
linger.0 += time.delta_secs();
|
||||
if linger.0 >= REPLAY_COMPLETION_LINGER_SECS {
|
||||
*state = ReplayPlaybackState::Inactive;
|
||||
linger.0 = 0.0;
|
||||
}
|
||||
} else {
|
||||
// Reset whenever we're not in Completed so the next completion
|
||||
// measures from zero rather than accumulating across cycles.
|
||||
linger.0 = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Local cache of the recording buffer's length at the start of
|
||||
/// playback. Lets us roll back any growth during playback without
|
||||
/// touching `game_plugin`'s recording call sites.
|
||||
#[derive(Default)]
|
||||
struct RecordingSnapshot {
|
||||
/// `Some(len)` while playback is active. The recording is
|
||||
/// truncated back to this length every frame so playback-driven
|
||||
/// events leak no entries into the recorded move list. `None`
|
||||
/// when not playing — recording behaves normally.
|
||||
snapshot_len: Option<usize>,
|
||||
}
|
||||
|
||||
/// Recording-pause system. While [`ReplayPlaybackState::is_playing`],
|
||||
/// snapshots the recording's length on entry and truncates the
|
||||
/// recording back to that length every frame. This keeps the live
|
||||
/// [`RecordingReplay`] opaque to `game_plugin`'s `handle_move` /
|
||||
/// `handle_draw` — those still push unconditionally; we just wipe the
|
||||
/// playback-driven entries before any other system can read them.
|
||||
///
|
||||
/// Implemented this way because [`RecordingReplay`] is mutated inside
|
||||
/// the [`GameMutation`] system set (the schedule set that owns
|
||||
/// `handle_move` / `handle_draw`). We schedule this system
|
||||
/// `.after(GameMutation)` so the truncation runs each frame *after*
|
||||
/// the unconditional push, removing the same entry the playback tick
|
||||
/// caused.
|
||||
fn record_replay_skip_during_playback(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut recording: ResMut<RecordingReplay>,
|
||||
mut snap: Local<RecordingSnapshot>,
|
||||
) {
|
||||
// Treat `Playing` and `Completed` identically for the purpose of
|
||||
// recording suppression. The tick system's final advance fires
|
||||
// its event in the same frame it transitions to `Completed`; the
|
||||
// event is then consumed by `handle_move` / `handle_draw` either
|
||||
// this frame (race-dependent on system order) or the next. By
|
||||
// suppressing recording growth across both states, we close that
|
||||
// window cleanly: the snapshot survives until the resource is
|
||||
// back to `Inactive` (auto-cleared after
|
||||
// `REPLAY_COMPLETION_LINGER_SECS`).
|
||||
if state.is_playing() || state.is_completed() {
|
||||
let baseline = match snap.snapshot_len {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let n = recording.moves.len();
|
||||
snap.snapshot_len = Some(n);
|
||||
n
|
||||
}
|
||||
};
|
||||
if recording.moves.len() > baseline {
|
||||
recording.moves.truncate(baseline);
|
||||
}
|
||||
} else {
|
||||
// Drop the snapshot when neither playing nor completed so
|
||||
// the next playback cycle re-anchors to whatever the
|
||||
// recording is at that point.
|
||||
snap.snapshot_len = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// On-completion side effect: fire a single [`StateChangedEvent`] when
|
||||
/// playback transitions from `Playing` to `Completed` so any UI that
|
||||
/// listens for state mutations refreshes one final time. Cheap and
|
||||
/// idempotent — `StateChangedEvent` is a one-shot signal.
|
||||
fn fire_state_changed_on_completion(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut last_was_completed: Local<bool>,
|
||||
mut writer: MessageWriter<StateChangedEvent>,
|
||||
) {
|
||||
let now_completed = state.is_completed();
|
||||
if now_completed && !*last_was_completed {
|
||||
writer.write(StateChangedEvent);
|
||||
}
|
||||
*last_was_completed = now_completed;
|
||||
}
|
||||
|
||||
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
|
||||
/// playback ticks, completion linger, and the recording-pause guard.
|
||||
///
|
||||
/// Register this in the main app alongside [`crate::game_plugin::GamePlugin`].
|
||||
/// Tests can install it under [`MinimalPlugins`] to exercise the public
|
||||
/// API without spinning up the full client.
|
||||
pub struct ReplayPlaybackPlugin;
|
||||
|
||||
impl Plugin for ReplayPlaybackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ReplayPlaybackState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
tick_replay_playback,
|
||||
auto_clear_completed_replay,
|
||||
fire_state_changed_on_completion,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
record_replay_skip_during_playback.after(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
/// `ReplayPlaybackPlugin`. `GamePlugin` brings the canonical
|
||||
/// `MoveRequestEvent` / `DrawRequestEvent` registrations along with
|
||||
/// `RecordingReplay` so the recording-pause test can read it.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin::headless())
|
||||
.add_plugins(ReplayPlaybackPlugin);
|
||||
// Disable game-state persistence so tests don't touch the
|
||||
// real ~/.local/share/solitaire_quest/game_state.json.
|
||||
app.insert_resource(crate::game_plugin::GameStatePath(None));
|
||||
app.insert_resource(crate::game_plugin::ReplayPath(None));
|
||||
// Tick once so any startup systems flush before the first
|
||||
// assertion.
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// `Time<Virtual>` clamps each tick to `max_delta` (default 250 ms),
|
||||
/// so we drive 200 ms steps and call `update` enough times to pass
|
||||
/// the requested duration.
|
||||
fn advance_by(app: &mut App, total_secs: f32) {
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(0.2),
|
||||
));
|
||||
let ticks = (total_secs / 0.2).ceil() as usize + 1;
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3-move replay covering both `Move` and `StockClick` variants.
|
||||
/// Seed 12345 is arbitrary — the test asserts on event counts and
|
||||
/// move shapes, not on board positions.
|
||||
fn sample_replay_three_moves() -> Replay {
|
||||
Replay::new(
|
||||
12345,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
500,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Tableau(3),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Scoped helper to invoke `start_replay_playback` from within the
|
||||
/// app's `World` (the public API takes `Commands`, which only
|
||||
/// exists inside systems). We use a one-shot system to obtain the
|
||||
/// `Commands`.
|
||||
fn start_playback(app: &mut App, replay: Replay) {
|
||||
#[derive(Resource)]
|
||||
struct ReplayInbox(Option<Replay>);
|
||||
app.insert_resource(ReplayInbox(Some(replay)));
|
||||
|
||||
fn run(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut inbox: ResMut<ReplayInbox>,
|
||||
) {
|
||||
if let Some(replay) = inbox.0.take() {
|
||||
start_replay_playback(&mut commands, &mut state, replay);
|
||||
}
|
||||
}
|
||||
let id = app.world_mut().register_system(run);
|
||||
app.world_mut()
|
||||
.run_system(id)
|
||||
.expect("one-shot start_playback");
|
||||
}
|
||||
|
||||
fn stop_playback(app: &mut App) {
|
||||
fn run(mut commands: Commands, mut state: ResMut<ReplayPlaybackState>) {
|
||||
stop_replay_playback(&mut commands, &mut state);
|
||||
}
|
||||
let id = app.world_mut().register_system(run);
|
||||
app.world_mut()
|
||||
.run_system(id)
|
||||
.expect("one-shot stop_playback");
|
||||
}
|
||||
|
||||
/// Fresh state must be `Inactive`. After `start_replay_playback`
|
||||
/// the state must be `Playing { cursor: 0, .. }` carrying the
|
||||
/// supplied replay.
|
||||
#[test]
|
||||
fn start_replay_playback_transitions_inactive_to_playing() {
|
||||
let mut app = headless_app();
|
||||
assert!(matches!(
|
||||
*app.world().resource::<ReplayPlaybackState>(),
|
||||
ReplayPlaybackState::Inactive
|
||||
));
|
||||
|
||||
let replay = sample_replay_three_moves();
|
||||
start_playback(&mut app, replay.clone());
|
||||
// Apply the deferred Commands flush.
|
||||
app.update();
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing {
|
||||
cursor,
|
||||
replay: r,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*cursor, 0);
|
||||
assert_eq!(r.seed, replay.seed);
|
||||
assert_eq!(r.moves.len(), 3);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
assert_eq!(state.progress(), Some((0, 3)));
|
||||
}
|
||||
|
||||
/// One full interval (plus a small margin to clear the boundary)
|
||||
/// must advance the cursor by at least one.
|
||||
#[test]
|
||||
fn tick_advances_cursor_after_interval() {
|
||||
let mut app = headless_app();
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
// Drive virtual time forward by one interval.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.05);
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing { cursor, .. } => {
|
||||
assert!(
|
||||
*cursor >= 1,
|
||||
"expected cursor advanced past one move, got {cursor}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Driving past `n * REPLAY_MOVE_INTERVAL_SECS` must produce
|
||||
/// `n` events that match the recorded move kinds. We register a
|
||||
/// pair of accumulator systems that drain `MoveRequestEvent` /
|
||||
/// `DrawRequestEvent` into resources every frame — using a
|
||||
/// detached cursor across many `app.update()` calls is unreliable
|
||||
/// because Bevy's `Messages` double-buffer drops events older
|
||||
/// than two frames.
|
||||
#[test]
|
||||
fn tick_fires_canonical_event_for_each_move() {
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedMoves(Vec<MoveRequestEvent>);
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedDraws(usize);
|
||||
|
||||
fn collect_moves(
|
||||
mut events: MessageReader<MoveRequestEvent>,
|
||||
mut sink: ResMut<CapturedMoves>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
sink.0.push(ev.clone());
|
||||
}
|
||||
}
|
||||
fn collect_draws(
|
||||
mut events: MessageReader<DrawRequestEvent>,
|
||||
mut sink: ResMut<CapturedDraws>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
sink.0 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut app = headless_app();
|
||||
app.init_resource::<CapturedMoves>()
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, (collect_moves, collect_draws));
|
||||
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
// Drive through 3 intervals. Add a small margin to ensure the
|
||||
// last firing isn't sitting exactly on the boundary.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 3.0 + 0.1);
|
||||
|
||||
let captured_moves = app.world().resource::<CapturedMoves>();
|
||||
let captured_draws = app.world().resource::<CapturedDraws>();
|
||||
|
||||
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
|
||||
assert_eq!(
|
||||
captured_draws.0, 2,
|
||||
"expected 2 DrawRequestEvent (two StockClicks)",
|
||||
);
|
||||
assert_eq!(
|
||||
captured_moves.0.len(),
|
||||
1,
|
||||
"expected 1 MoveRequestEvent (the single Move variant)",
|
||||
);
|
||||
let m = &captured_moves.0[0];
|
||||
assert!(matches!(m.from, PileType::Waste));
|
||||
assert!(matches!(m.to, PileType::Tableau(3)));
|
||||
assert_eq!(m.count, 1);
|
||||
}
|
||||
|
||||
/// Driving past one interval on a single-move replay must
|
||||
/// transition to `Completed`.
|
||||
#[test]
|
||||
fn playback_completes_when_cursor_reaches_end() {
|
||||
let mut app = headless_app();
|
||||
let one_move = Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
);
|
||||
start_playback(&mut app, one_move);
|
||||
app.update();
|
||||
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.1);
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
assert!(
|
||||
state.is_completed(),
|
||||
"expected Completed after consuming the only move, got {state:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `stop_replay_playback` must force the state back to `Inactive`
|
||||
/// even mid-playback.
|
||||
#[test]
|
||||
fn stop_replay_playback_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
// Tick once so the state is well and truly `Playing`.
|
||||
advance_by(&mut app, 0.1);
|
||||
assert!(app.world().resource::<ReplayPlaybackState>().is_playing());
|
||||
|
||||
stop_playback(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(matches!(
|
||||
*app.world().resource::<ReplayPlaybackState>(),
|
||||
ReplayPlaybackState::Inactive
|
||||
));
|
||||
}
|
||||
|
||||
/// Recording must remain frozen during playback. Pre-populate the
|
||||
/// recording with one entry, start playback, and assert the
|
||||
/// recording's move list is unchanged after several ticks.
|
||||
#[test]
|
||||
fn recording_paused_during_playback() {
|
||||
let mut app = headless_app();
|
||||
// Pre-populate the recording with one entry that should
|
||||
// survive playback unchanged. Mirrors the situation where the
|
||||
// player partway through a game opens stats and clicks Watch
|
||||
// Replay — their in-flight recording must not get clobbered.
|
||||
{
|
||||
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
rec.moves.push(ReplayMove::StockClick);
|
||||
}
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
let baseline_len = app.world().resource::<RecordingReplay>().moves.len();
|
||||
assert_eq!(
|
||||
baseline_len, 1,
|
||||
"preconditions: recording starts with one entry",
|
||||
);
|
||||
|
||||
// Drive playback through every move in the replay. Each move
|
||||
// would normally append to `RecordingReplay`; the pause
|
||||
// system must clamp the recording back to `baseline_len` on
|
||||
// every frame.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 4.0 + 0.1);
|
||||
|
||||
let after_len = app.world().resource::<RecordingReplay>().moves.len();
|
||||
assert_eq!(
|
||||
after_len, baseline_len,
|
||||
"recording must not grow while playback is active",
|
||||
);
|
||||
}
|
||||
|
||||
/// With `SettingsResource::replay_move_interval_secs` set to 0.10 s
|
||||
/// (well below the 0.45 s default), playback over a fixed
|
||||
/// wall-clock window must dispatch strictly more moves than the
|
||||
/// same fixture would at the 0.45 s default. This is the
|
||||
/// regression check that the tick reads from the live Settings
|
||||
/// value rather than the hardcoded
|
||||
/// [`REPLAY_MOVE_INTERVAL_SECS`] constant.
|
||||
///
|
||||
/// The follow-up assertion exercises the boundary condition: at
|
||||
/// the 0.10 s/move setting, exactly six 0.10 s ticks must yield
|
||||
/// fewer moves than six 0.20 s ticks (because the latter doubles
|
||||
/// the per-update advance and pays off two intervals each tick).
|
||||
#[test]
|
||||
fn replay_playback_tick_uses_settings_interval() {
|
||||
use solitaire_data::Settings;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedDraws(usize);
|
||||
|
||||
fn collect_draws(
|
||||
mut events: MessageReader<DrawRequestEvent>,
|
||||
mut sink: ResMut<CapturedDraws>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
sink.0 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Long replay so the fast cadence has plenty of moves to
|
||||
// chew through and the 0.45 s vs 0.10 s difference is easy
|
||||
// to observe.
|
||||
fn ten_draws_replay() -> Replay {
|
||||
Replay::new(
|
||||
7,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick; 10],
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Run 1: 0.10 s/move (Settings override) ----
|
||||
let mut fast_app = headless_app();
|
||||
fast_app.insert_resource(SettingsResource(Settings {
|
||||
replay_move_interval_secs: 0.10,
|
||||
..Settings::default()
|
||||
}));
|
||||
fast_app
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
|
||||
start_playback(&mut fast_app, ten_draws_replay());
|
||||
fast_app.update();
|
||||
// 1.0 s of virtual time at 0.10 s/move dispatches ~5 moves
|
||||
// after the default 0.45 s startup interval is consumed.
|
||||
advance_by(&mut fast_app, 1.0);
|
||||
let fast_count = fast_app.world().resource::<CapturedDraws>().0;
|
||||
|
||||
// ---- Run 2: 0.45 s/move (default — no SettingsResource) ----
|
||||
let mut slow_app = headless_app();
|
||||
// `tick_replay_playback` falls back to `REPLAY_MOVE_INTERVAL_SECS`
|
||||
// (0.45 s) when `SettingsResource` is absent.
|
||||
slow_app
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
|
||||
start_playback(&mut slow_app, ten_draws_replay());
|
||||
slow_app.update();
|
||||
advance_by(&mut slow_app, 1.0);
|
||||
let slow_count = slow_app.world().resource::<CapturedDraws>().0;
|
||||
|
||||
assert!(
|
||||
fast_count > slow_count,
|
||||
"at 0.10 s/move the tick must dispatch strictly more moves \
|
||||
than at the 0.45 s default over the same wall-clock window: \
|
||||
fast={fast_count}, slow={slow_count}",
|
||||
);
|
||||
|
||||
// ---- Boundary: a 0.05 s/tick cadence over the same window
|
||||
// dispatches NO MORE moves than a 0.10 s/tick cadence, because
|
||||
// 0.05 s < 0.10 s configured interval — the secs_to_next clock
|
||||
// never crosses the threshold inside a single tick. ----
|
||||
//
|
||||
// We don't assert "exactly zero" because the leading update()
|
||||
// after `start_playback` may run before the strategy is
|
||||
// applied (cf. comments on `tick_advances_cursor_after_interval`),
|
||||
// but the count must not exceed what we'd get with one-tick
|
||||
// advances at the same total wall-clock window.
|
||||
fn count_after_window(interval_secs: f32, tick_secs: f32, total_secs: f32) -> usize {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
replay_move_interval_secs: interval_secs,
|
||||
..Settings::default()
|
||||
}));
|
||||
app.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
start_playback(&mut app, ten_draws_replay());
|
||||
app.update();
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(tick_secs),
|
||||
));
|
||||
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
app.world().resource::<CapturedDraws>().0
|
||||
}
|
||||
|
||||
let count_at_05 = count_after_window(0.10, 0.05, 1.0);
|
||||
let count_at_20 = count_after_window(0.10, 0.20, 1.0);
|
||||
assert!(
|
||||
count_at_05 <= count_at_20,
|
||||
"0.05 s ticks (strictly less than the 0.10 s interval) must \
|
||||
dispatch no more moves than 0.20 s ticks over the same \
|
||||
wall-clock window: count_at_05={count_at_05}, count_at_20={count_at_20}",
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,16 +19,17 @@ use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use solitaire_data::{
|
||||
save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress,
|
||||
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};
|
||||
|
||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||
use crate::events::{ManualSyncRequestEvent, SyncCompleteEvent};
|
||||
use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent};
|
||||
use crate::game_plugin::RecordingReplay;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
use crate::stats_plugin::{StatsResource, StatsStoragePath};
|
||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
|
||||
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public resources
|
||||
@@ -56,6 +57,13 @@ pub struct PullTaskResult(pub Option<Result<SyncPayload, SyncError>>);
|
||||
#[derive(Resource, Default)]
|
||||
struct PullTask(Option<Task<Result<SyncPayload, SyncError>>>);
|
||||
|
||||
/// Holds the in-flight winning-replay upload task so the polling
|
||||
/// system can harvest the resulting share URL on the main thread
|
||||
/// without blocking. `None` outside an active upload; `Some(task)`
|
||||
/// from `GameWonEvent` until the response lands.
|
||||
#[derive(Resource, Default)]
|
||||
struct PendingReplayUpload(Option<Task<Result<String, SyncError>>>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin struct
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -93,10 +101,19 @@ impl Plugin for SyncPlugin {
|
||||
.init_resource::<SyncStatusResource>()
|
||||
.init_resource::<PullTaskResult>()
|
||||
.init_resource::<PullTask>()
|
||||
.init_resource::<PendingReplayUpload>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncCompleteEvent>()
|
||||
.add_systems(Startup, start_pull)
|
||||
.add_systems(Update, (poll_pull_result, handle_manual_sync_request))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
poll_pull_result,
|
||||
handle_manual_sync_request,
|
||||
push_replay_on_win,
|
||||
poll_replay_upload_result,
|
||||
),
|
||||
)
|
||||
.add_systems(Last, push_on_exit);
|
||||
}
|
||||
}
|
||||
@@ -263,6 +280,92 @@ fn push_on_exit(
|
||||
}
|
||||
}
|
||||
|
||||
/// Update-schedule system: on each `GameWonEvent` push the just-completed
|
||||
/// replay to the active sync backend so it's available for web playback.
|
||||
///
|
||||
/// Spawned as a fire-and-forget task on `AsyncComputeTaskPool` — the game
|
||||
/// loop never blocks on the network round-trip. Errors are logged but
|
||||
/// never surfaced to the UI; failure to upload is non-fatal because the
|
||||
/// replay is also persisted locally by `game_plugin::record_replay_on_win`,
|
||||
/// so the player can still review it on the next login. `LocalOnlyProvider`'s
|
||||
/// `UnsupportedPlatform` is silently absorbed in the same way the
|
||||
/// `push_on_exit` path handles it.
|
||||
fn push_replay_on_win(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
provider: Res<SyncProviderResource>,
|
||||
game: Res<GameStateResource>,
|
||||
recording: Res<RecordingReplay>,
|
||||
mut pending: ResMut<PendingReplayUpload>,
|
||||
) {
|
||||
for ev in wins.read() {
|
||||
// Empty-recording guard mirrors `record_replay_on_win` —
|
||||
// synthesised win events from XP / streak tests must not trigger
|
||||
// a server upload.
|
||||
if recording.moves.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let replay = Replay::new(
|
||||
game.0.seed,
|
||||
game.0.draw_mode.clone(),
|
||||
game.0.mode,
|
||||
ev.time_seconds,
|
||||
ev.score,
|
||||
Utc::now().date_naive(),
|
||||
recording.moves.clone(),
|
||||
);
|
||||
let provider = provider.0.clone();
|
||||
let task = AsyncComputeTaskPool::get()
|
||||
.spawn(async move { provider.push_replay(&replay).await });
|
||||
// If a previous upload is still in flight, drop it — the most
|
||||
// recent win is the one whose share link the player will care
|
||||
// about. Bevy's `Task` Drop cancels cooperatively.
|
||||
pending.0 = Some(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update-schedule system: harvests the upload task's result on the
|
||||
/// 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 history: ResMut<ReplayHistoryResource>,
|
||||
replay_path: Res<LatestReplayPath>,
|
||||
) {
|
||||
let Some(task) = pending.0.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||
return;
|
||||
};
|
||||
pending.0 = None;
|
||||
let url = match result {
|
||||
Ok(url) => url,
|
||||
Err(SyncError::UnsupportedPlatform) => return,
|
||||
Err(e) => {
|
||||
warn!("replay upload failed: {e}");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -427,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ use solitaire_core::card::{Rank, Suit};
|
||||
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
|
||||
pub use loader::{CardThemeLoader, CardThemeLoaderError};
|
||||
pub use manifest::ThemeManifest;
|
||||
pub use plugin::{set_theme, ActiveTheme, ThemePlugin};
|
||||
pub use plugin::{
|
||||
ensure_theme_thumbnails, set_theme, ActiveTheme, ThemePlugin, ThemeThumbnailCache,
|
||||
ThemeThumbnailPair, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX,
|
||||
};
|
||||
pub use registry::{
|
||||
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
|
||||
};
|
||||
|
||||
@@ -8,24 +8,82 @@
|
||||
//! exposed for tests and for any embedder that wants to load an
|
||||
//! alternative theme manually.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::asset::AssetEvent;
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::math::UVec2;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
|
||||
use crate::assets::DEFAULT_THEME_MANIFEST_URL;
|
||||
use crate::assets::{
|
||||
default_theme_svg_bytes, rasterize_svg, user_theme_dir, DEFAULT_THEME_MANIFEST_URL,
|
||||
};
|
||||
use crate::card_plugin::CardImageSet;
|
||||
use crate::events::StateChangedEvent;
|
||||
|
||||
use super::loader::CardThemeLoader;
|
||||
use super::registry::ThemeRegistry;
|
||||
use super::{CardKey, CardTheme};
|
||||
|
||||
/// Width (logical px) of one Settings → Cosmetic theme-picker
|
||||
/// thumbnail. A 2:3 card aspect at 100×140 keeps each chip a small
|
||||
/// glanceable preview without bloating the picker row.
|
||||
pub const THEME_THUMBNAIL_WIDTH_PX: u32 = 100;
|
||||
/// Height counterpart to [`THEME_THUMBNAIL_WIDTH_PX`].
|
||||
pub const THEME_THUMBNAIL_HEIGHT_PX: u32 = 140;
|
||||
|
||||
/// Resource pointing at the currently-active card theme. Populated on
|
||||
/// startup with the bundled default theme and replaced by [`set_theme`]
|
||||
/// when the player switches.
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct ActiveTheme(pub Handle<CardTheme>);
|
||||
|
||||
/// One pair of preview-sized `Handle<Image>` for the Settings picker:
|
||||
/// the theme's Ace of Spades and its card back.
|
||||
///
|
||||
/// Either handle may be [`Handle::default`] when the underlying SVG
|
||||
/// could not be located (e.g. a user theme that ships only a partial
|
||||
/// set of files). The picker UI treats the default-handle case as
|
||||
/// "render a placeholder swatch instead of an image" so a broken
|
||||
/// theme can never crash the panel.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ThemeThumbnailPair {
|
||||
/// Rasterised `spades_ace.svg` of the theme.
|
||||
pub ace: Handle<Image>,
|
||||
/// Rasterised `back.svg` of the theme.
|
||||
pub back: Handle<Image>,
|
||||
}
|
||||
|
||||
impl ThemeThumbnailPair {
|
||||
/// Returns `true` only when *both* preview slots resolve to a
|
||||
/// non-default handle — a theme with at least one missing SVG is
|
||||
/// considered incomplete and renders the placeholder for the
|
||||
/// missing slot.
|
||||
pub fn is_fully_populated(&self) -> bool {
|
||||
self.ace != Handle::default() && self.back != Handle::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource caching one [`ThemeThumbnailPair`] per registered theme,
|
||||
/// keyed by `ThemeMeta::id`.
|
||||
///
|
||||
/// Populated lazily by [`ensure_theme_thumbnails`] whenever the
|
||||
/// [`ThemeRegistry`] grows or changes. The Settings panel reads from
|
||||
/// this cache by id and falls back to the placeholder rendering path
|
||||
/// when an entry is missing.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ThemeThumbnailCache {
|
||||
pub entries: HashMap<String, ThemeThumbnailPair>,
|
||||
}
|
||||
|
||||
impl ThemeThumbnailCache {
|
||||
/// Returns the cached pair for `theme_id`, if any.
|
||||
pub fn get(&self, theme_id: &str) -> Option<&ThemeThumbnailPair> {
|
||||
self.entries.get(theme_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevy plugin that loads the default theme and keeps `CardImageSet`
|
||||
/// in sync with `Assets<CardTheme>`.
|
||||
///
|
||||
@@ -45,6 +103,7 @@ pub struct ThemePlugin;
|
||||
impl Plugin for ThemePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_asset::<CardTheme>()
|
||||
.init_resource::<ThemeThumbnailCache>()
|
||||
.register_asset_loader(crate::assets::SvgLoader)
|
||||
.register_asset_loader(CardThemeLoader)
|
||||
.add_systems(Startup, load_initial_theme)
|
||||
@@ -53,6 +112,7 @@ impl Plugin for ThemePlugin {
|
||||
(
|
||||
sync_card_image_set_with_active_theme,
|
||||
react_to_settings_theme_change,
|
||||
ensure_theme_thumbnails,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -112,7 +172,7 @@ fn react_to_settings_theme_change(
|
||||
commands.insert_resource(ActiveTheme(handle));
|
||||
}
|
||||
|
||||
/// Replaces every face slot and slot 0 of the back array on
|
||||
/// Replaces every face slot and the active-theme back-handle slot on
|
||||
/// `CardImageSet` whenever the active theme finishes loading or
|
||||
/// changes. Fires `StateChangedEvent` afterwards so the existing
|
||||
/// `card_plugin::sync_cards_on_change` pipeline re-renders every
|
||||
@@ -155,8 +215,16 @@ fn sync_card_image_set_with_active_theme(
|
||||
}
|
||||
|
||||
/// Pure helper that copies the theme's image handles into the
|
||||
/// `[suit][rank]` face matrix and into back slot 0. Split out so it
|
||||
/// can be unit-tested without spinning up a Bevy `App`.
|
||||
/// `[suit][rank]` face matrix and into the dedicated `theme_back`
|
||||
/// slot. Split out so it can be unit-tested without spinning up a
|
||||
/// Bevy `App`.
|
||||
///
|
||||
/// The legacy `backs[0..5]` array is left untouched — those handles
|
||||
/// are the player's `selected_card_back` choices and remain available
|
||||
/// as a fallback when the active theme does not declare a back. The
|
||||
/// face-down render path in `card_plugin::card_sprite` prefers
|
||||
/// `theme_back` when present, so writing here is sufficient to make
|
||||
/// every face-down card pick up the theme's art on the next sync.
|
||||
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
for rank in [
|
||||
@@ -169,7 +237,7 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
|
||||
}
|
||||
}
|
||||
}
|
||||
image_set.backs[0] = theme.back.clone();
|
||||
image_set.theme_back = Some(theme.back.clone());
|
||||
}
|
||||
|
||||
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
|
||||
@@ -223,6 +291,104 @@ pub fn set_theme(
|
||||
handle
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Picker-thumbnail generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Filename of the canonical "preview face" SVG inside a theme — the
|
||||
/// Ace of Spades. Matches `CardKey::manifest_name(Spades, Ace)` so the
|
||||
/// path resolves the same way whether we're reading from disk or from
|
||||
/// the bundled-default lookup table.
|
||||
const PREVIEW_FACE_FILENAME: &str = "spades_ace.svg";
|
||||
|
||||
/// Filename of the back SVG inside a theme.
|
||||
const PREVIEW_BACK_FILENAME: &str = "back.svg";
|
||||
|
||||
/// Resolves the SVG bytes for one preview file (`back.svg` or
|
||||
/// `spades_ace.svg`) belonging to the named theme.
|
||||
///
|
||||
/// - For the bundled `default` theme, reads from the embedded
|
||||
/// `DEFAULT_THEME_SVGS` table via [`default_theme_svg_bytes`]. No
|
||||
/// filesystem I/O.
|
||||
/// - For any user theme, reads from `<user_theme_dir>/<id>/<filename>`.
|
||||
/// Returns `None` for any I/O failure (file missing, permission
|
||||
/// denied, etc.) — the caller treats `None` as "render placeholder".
|
||||
fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8>> {
|
||||
if theme_id == "default" {
|
||||
return default_theme_svg_bytes(filename).map(|b| b.to_vec());
|
||||
}
|
||||
let path = user_theme_dir().join(theme_id).join(filename);
|
||||
std::fs::read(&path).ok()
|
||||
}
|
||||
|
||||
/// Pure helper: rasterises one SVG preview byte slice at the picker's
|
||||
/// thumbnail dimensions, inserts the resulting `Image` into
|
||||
/// `Assets<Image>`, and returns the new handle. Returns
|
||||
/// [`Handle::default`] if rasterisation fails (malformed SVG, etc.) so
|
||||
/// the picker can render a placeholder for broken themes without
|
||||
/// crashing.
|
||||
fn rasterize_preview_to_handle(
|
||||
svg_bytes: &[u8],
|
||||
images: &mut Assets<Image>,
|
||||
) -> Handle<Image> {
|
||||
let target = UVec2::new(THEME_THUMBNAIL_WIDTH_PX, THEME_THUMBNAIL_HEIGHT_PX);
|
||||
match rasterize_svg(svg_bytes, target) {
|
||||
Ok(image) => images.add(image),
|
||||
Err(err) => {
|
||||
warn!("theme thumbnail rasterise failed: {err}");
|
||||
Handle::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a [`ThemeThumbnailPair`] for a single theme. Either handle
|
||||
/// is [`Handle::default`] when the matching SVG could not be located
|
||||
/// or rasterised.
|
||||
fn generate_thumbnail_pair_for(
|
||||
theme_id: &str,
|
||||
images: &mut Assets<Image>,
|
||||
) -> ThemeThumbnailPair {
|
||||
let ace = read_theme_preview_svg_bytes(theme_id, PREVIEW_FACE_FILENAME)
|
||||
.map(|b| rasterize_preview_to_handle(&b, images))
|
||||
.unwrap_or_default();
|
||||
let back = read_theme_preview_svg_bytes(theme_id, PREVIEW_BACK_FILENAME)
|
||||
.map(|b| rasterize_preview_to_handle(&b, images))
|
||||
.unwrap_or_default();
|
||||
ThemeThumbnailPair { ace, back }
|
||||
}
|
||||
|
||||
/// System that generates a [`ThemeThumbnailPair`] for every registered
|
||||
/// theme that doesn't yet have one in [`ThemeThumbnailCache`].
|
||||
///
|
||||
/// Runs each frame but the early-exit check (`already cached?`) keeps
|
||||
/// the steady-state cost to a single hash lookup per theme. Generation
|
||||
/// itself only happens once per theme — the SVGs are rasterised and
|
||||
/// inserted into `Assets<Image>` and the handles cached forever.
|
||||
///
|
||||
/// Lazy-on-first-pass beats Startup-only for two reasons:
|
||||
///
|
||||
/// - The `ThemeRegistry` is built by a different `Startup` system, and
|
||||
/// Bevy doesn't guarantee inter-system Startup ordering without
|
||||
/// explicit `.after()` chaining. Polling each Update tick removes
|
||||
/// the dependency.
|
||||
/// - The future `refresh_registry` path (used after a successful
|
||||
/// theme import in Phase 7) adds entries mid-session — this system
|
||||
/// picks them up automatically without any extra wiring.
|
||||
pub fn ensure_theme_thumbnails(
|
||||
registry: Option<Res<ThemeRegistry>>,
|
||||
mut cache: ResMut<ThemeThumbnailCache>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
) {
|
||||
let Some(registry) = registry else { return };
|
||||
for entry in registry.iter() {
|
||||
if cache.entries.contains_key(&entry.id) {
|
||||
continue;
|
||||
}
|
||||
let pair = generate_thumbnail_pair_for(&entry.id, &mut images);
|
||||
cache.entries.insert(entry.id.clone(), pair);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -251,6 +417,7 @@ mod tests {
|
||||
CardImageSet {
|
||||
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||
backs: std::array::from_fn(|_| Handle::default()),
|
||||
theme_back: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,24 +451,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_theme_overwrites_back_slot_zero() {
|
||||
// Build a theme whose back handle is a freshly-allocated weak
|
||||
// handle — its id will differ from the default-handle id we
|
||||
// started with, proving the back slot was overwritten.
|
||||
fn applying_theme_writes_theme_back_slot_and_leaves_legacy_backs_untouched() {
|
||||
// The active-theme back lives in its own dedicated slot
|
||||
// (`theme_back`) so the legacy `backs[0..5]` PNG fallbacks
|
||||
// remain untouched. This guarantees the player's
|
||||
// `selected_card_back` choice can still be honoured when no
|
||||
// theme is active.
|
||||
let mut image_set = empty_card_image_set();
|
||||
// Snapshot the legacy back ids so we can prove they don't
|
||||
// change when a theme is applied.
|
||||
let legacy_ids_before: [bevy::asset::AssetId<bevy::image::Image>; 5] =
|
||||
std::array::from_fn(|i| image_set.backs[i].id());
|
||||
let theme = empty_theme();
|
||||
let original_back_id = image_set.backs[0].id();
|
||||
assert!(image_set.theme_back.is_none(), "theme_back starts empty");
|
||||
apply_theme_to_card_image_set(&theme, &mut image_set);
|
||||
// Both default handles compare equal to themselves; the test
|
||||
// asserts via id() that whichever handle is in slot 0 came
|
||||
// from the theme — even if both happen to be Handle::default,
|
||||
// the id swap is still observable via the value-equality of
|
||||
// theme.back's id.
|
||||
assert_eq!(image_set.backs[0].id(), theme.back.id());
|
||||
// No assertion about original_back_id — both sides may be the
|
||||
// same default handle id when neither is loaded; the contract
|
||||
// we're checking is "slot 0 now matches theme.back".
|
||||
let _ = original_back_id;
|
||||
// The active-theme back is now populated and matches the theme.
|
||||
let active_back = image_set
|
||||
.theme_back
|
||||
.as_ref()
|
||||
.expect("theme_back populated after apply");
|
||||
assert_eq!(active_back.id(), theme.back.id());
|
||||
// Every legacy back slot is preserved byte-for-byte by id.
|
||||
for (i, before) in legacy_ids_before.iter().enumerate() {
|
||||
assert_eq!(
|
||||
image_set.backs[i].id(),
|
||||
*before,
|
||||
"legacy back slot {i} must not be clobbered by theme apply",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -333,4 +510,120 @@ mod tests {
|
||||
let url2 = format!("themes://{}/theme.ron", "user_uploaded");
|
||||
assert_eq!(url2, "themes://user_uploaded/theme.ron");
|
||||
}
|
||||
|
||||
/// Test 1: the bundled default theme always has embedded SVG bytes
|
||||
/// available, so calling `generate_thumbnail_pair_for("default", …)`
|
||||
/// must produce two non-default `Handle<Image>` slots.
|
||||
#[test]
|
||||
fn theme_thumbnails_generated_for_default_theme() {
|
||||
let mut images = Assets::<Image>::default();
|
||||
let pair = generate_thumbnail_pair_for("default", &mut images);
|
||||
assert!(
|
||||
pair.is_fully_populated(),
|
||||
"default theme must yield both ace + back thumbnail handles"
|
||||
);
|
||||
// And the underlying images must actually exist in the assets
|
||||
// collection — the handles are real, not dangling.
|
||||
assert!(images.get(&pair.ace).is_some(), "ace image must be inserted");
|
||||
assert!(images.get(&pair.back).is_some(), "back image must be inserted");
|
||||
}
|
||||
|
||||
/// Test 2: when a theme is registered but its preview SVGs are not
|
||||
/// available on disk (a broken user-supplied theme), thumbnail
|
||||
/// generation must NOT panic and must leave the missing slots as
|
||||
/// the default handle so the picker UI can render its placeholder.
|
||||
#[test]
|
||||
fn theme_thumbnails_handle_missing_svg_gracefully() {
|
||||
let mut images = Assets::<Image>::default();
|
||||
// A theme id that definitely has no files on disk under the
|
||||
// user_theme_dir (the directory may not even exist on a
|
||||
// fresh test machine). The function reads the filesystem
|
||||
// lazily and silently returns None on I/O failures — no
|
||||
// panic, no rasterise attempt.
|
||||
let pair = generate_thumbnail_pair_for(
|
||||
"this-theme-does-not-exist-on-disk-for-testing",
|
||||
&mut images,
|
||||
);
|
||||
assert_eq!(
|
||||
pair.ace,
|
||||
Handle::default(),
|
||||
"missing ace.svg must yield Handle::default placeholder"
|
||||
);
|
||||
assert_eq!(
|
||||
pair.back,
|
||||
Handle::default(),
|
||||
"missing back.svg must yield Handle::default placeholder"
|
||||
);
|
||||
assert!(
|
||||
!pair.is_fully_populated(),
|
||||
"incomplete pair must report not-fully-populated"
|
||||
);
|
||||
}
|
||||
|
||||
/// `read_theme_preview_svg_bytes` for the default theme always
|
||||
/// returns embedded bytes for the canonical preview pair —
|
||||
/// covering the happy-path branch of the helper.
|
||||
#[test]
|
||||
fn read_default_theme_preview_returns_some_for_canonical_files() {
|
||||
assert!(
|
||||
read_theme_preview_svg_bytes("default", PREVIEW_BACK_FILENAME).is_some(),
|
||||
"default theme back.svg must be embedded"
|
||||
);
|
||||
assert!(
|
||||
read_theme_preview_svg_bytes("default", PREVIEW_FACE_FILENAME).is_some(),
|
||||
"default theme spades_ace.svg must be embedded"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ensure_theme_thumbnails` is idempotent: calling it twice with
|
||||
/// the same registry must not regenerate or replace already-cached
|
||||
/// entries. This guards against the per-frame Update tick churning
|
||||
/// new `Handle<Image>` allocations and growing `Assets<Image>`
|
||||
/// without bound.
|
||||
#[test]
|
||||
fn ensure_theme_thumbnails_caches_after_first_run() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<ThemeThumbnailCache>();
|
||||
app.insert_resource(ThemeRegistry {
|
||||
entries: vec![crate::theme::ThemeEntry {
|
||||
id: "default".into(),
|
||||
display_name: "Default".into(),
|
||||
manifest_url: crate::assets::DEFAULT_THEME_MANIFEST_URL.into(),
|
||||
meta: ThemeMeta {
|
||||
id: "default".into(),
|
||||
name: "Default".into(),
|
||||
author: "x".into(),
|
||||
version: "x".into(),
|
||||
card_aspect: (2, 3),
|
||||
},
|
||||
}],
|
||||
});
|
||||
app.add_systems(Update, ensure_theme_thumbnails);
|
||||
|
||||
// First tick generates the entry.
|
||||
app.update();
|
||||
let first_ace = app
|
||||
.world()
|
||||
.resource::<ThemeThumbnailCache>()
|
||||
.get("default")
|
||||
.map(|p| p.ace.clone())
|
||||
.expect("default theme thumbnail must exist after one tick");
|
||||
|
||||
// Second tick must NOT replace the cached handle.
|
||||
app.update();
|
||||
let second_ace = app
|
||||
.world()
|
||||
.resource::<ThemeThumbnailCache>()
|
||||
.get("default")
|
||||
.map(|p| p.ace.clone())
|
||||
.expect("default theme thumbnail must still exist");
|
||||
|
||||
assert_eq!(
|
||||
first_ace.id(),
|
||||
second_ace.id(),
|
||||
"cached thumbnail handle must be stable across ticks"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,33 @@
|
||||
//! level ≥ `CHALLENGE_UNLOCK_LEVEL`); each win during the session bumps the
|
||||
//! counter and auto-deals a fresh game. When the timer expires the session
|
||||
//! ends and `TimeAttackEndedEvent` fires.
|
||||
//!
|
||||
//! ## Persistence
|
||||
//!
|
||||
//! Classic / Zen / Challenge mid-deals already round-trip through
|
||||
//! `game_state.json` (the file carries `mode: GameMode`, so the deal *and*
|
||||
//! its mode flag both survive a window close). Time Attack additionally
|
||||
//! has session-level state — the 10-minute window remaining and the running
|
||||
//! win counter — that lives in [`TimeAttackResource`], not in `GameState`.
|
||||
//! That extra state is persisted to the sibling file
|
||||
//! `time_attack_session.json` via [`solitaire_data::TimeAttackSession`] so
|
||||
//! closing the window mid-Time-Attack does not lose the session.
|
||||
//!
|
||||
//! The file is written periodically (every ~30 real seconds, mirroring the
|
||||
//! game-state auto-save cadence) and on `AppExit`. It is deleted on session
|
||||
//! end, on a fresh session start, and on quit-to-menu. Load happens once at
|
||||
//! plugin startup; if the persisted window expired during the time the app
|
||||
//! was closed, the file is treated as missing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_data::{
|
||||
delete_time_attack_session_at, load_time_attack_session_from, save_time_attack_session_to,
|
||||
time_attack_session_path, TimeAttackSession,
|
||||
};
|
||||
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
@@ -33,12 +57,52 @@ pub struct TimeAttackEndedEvent {
|
||||
pub wins: u32,
|
||||
}
|
||||
|
||||
/// Real-world seconds between Time Attack session-state auto-saves.
|
||||
///
|
||||
/// Mirrors the game-state auto-save cadence in `game_plugin::AUTO_SAVE_INTERVAL_SECS`
|
||||
/// so a crash loses at most ~30 s of session-timer progress.
|
||||
const TIME_ATTACK_AUTO_SAVE_INTERVAL_SECS: f32 = 30.0;
|
||||
|
||||
/// Persistence path for `time_attack_session.json`. `None` disables I/O
|
||||
/// (used in headless tests so they don't touch the real data dir).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct TimeAttackSessionPath(pub Option<PathBuf>);
|
||||
|
||||
/// Accumulated real-world seconds since the last Time Attack session save.
|
||||
/// Exposed as a `Resource` so tests can pre-seed it past the threshold without
|
||||
/// needing to control `Time::delta_secs()` (mirrors `game_plugin::AutoSaveTimer`).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct TimeAttackAutoSaveTimer(pub f32);
|
||||
|
||||
/// Implements the 10-minute Time Attack mode: counts down the session timer, tracks wins per session, and fires `TimeAttackEndedEvent` when time expires.
|
||||
pub struct TimeAttackPlugin;
|
||||
|
||||
impl TimeAttackPlugin {
|
||||
/// Plugin variant with persistence disabled. Use in headless tests to
|
||||
/// avoid touching the real `time_attack_session.json` on disk.
|
||||
pub fn headless() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for TimeAttackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<TimeAttackResource>()
|
||||
let path = time_attack_session_path();
|
||||
// Restore any saved session that hasn't yet expired in real time.
|
||||
// A missing file or an expired window both yield `None`, in which
|
||||
// case the resource keeps its default (inactive) value.
|
||||
let initial_session = path
|
||||
.as_deref()
|
||||
.and_then(load_time_attack_session_from)
|
||||
.map_or_else(TimeAttackResource::default, |s| TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: s.remaining_secs,
|
||||
wins: s.wins,
|
||||
});
|
||||
|
||||
app.insert_resource(initial_session)
|
||||
.insert_resource(TimeAttackSessionPath(path))
|
||||
.init_resource::<TimeAttackAutoSaveTimer>()
|
||||
.add_message::<TimeAttackEndedEvent>()
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
@@ -49,10 +113,13 @@ impl Plugin for TimeAttackPlugin {
|
||||
handle_start_time_attack_request.before(GameMutation),
|
||||
)
|
||||
.add_systems(Update, advance_time_attack)
|
||||
.add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation));
|
||||
.add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation))
|
||||
.add_systems(Update, auto_save_time_attack_session)
|
||||
.add_systems(Last, save_time_attack_session_on_exit);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_start_time_attack_request(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<StartTimeAttackRequestEvent>,
|
||||
@@ -60,6 +127,8 @@ fn handle_start_time_attack_request(
|
||||
mut session: ResMut<TimeAttackResource>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
path: Option<Res<TimeAttackSessionPath>>,
|
||||
mut auto_save_timer: ResMut<TimeAttackAutoSaveTimer>,
|
||||
) {
|
||||
// Either T or the HUD Modes-popover "Time Attack" row triggers this.
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
@@ -77,6 +146,18 @@ fn handle_start_time_attack_request(
|
||||
remaining_secs: TIME_ATTACK_DURATION_SECS,
|
||||
wins: 0,
|
||||
};
|
||||
// Reset the auto-save accumulator so the first save lands a full
|
||||
// interval from now, not immediately because of an old residual value
|
||||
// left over from a previous session.
|
||||
auto_save_timer.0 = 0.0;
|
||||
// Delete any leftover persisted session file from a prior run so the
|
||||
// fresh window starts at exactly TIME_ATTACK_DURATION_SECS rather than
|
||||
// resuming whatever the disk happened to hold. Failures here are
|
||||
// logged but never fatal.
|
||||
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
|
||||
&& let Err(e) = delete_time_attack_session_at(p) {
|
||||
warn!("time_attack_session: failed to delete stale session: {e}");
|
||||
}
|
||||
new_game.write(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(GameMode::TimeAttack),
|
||||
@@ -89,11 +170,16 @@ fn advance_time_attack(
|
||||
mut session: ResMut<TimeAttackResource>,
|
||||
mut ended: MessageWriter<TimeAttackEndedEvent>,
|
||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||
path: Option<Res<TimeAttackSessionPath>>,
|
||||
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
|
||||
) {
|
||||
if !session.active {
|
||||
return;
|
||||
}
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
// Mirrors `tick_elapsed_time`: pause while the launch / mode-picker
|
||||
// Home modal is up so the countdown doesn't burn while the player
|
||||
// is choosing what to play next.
|
||||
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
session.remaining_secs -= time.delta_secs();
|
||||
@@ -102,6 +188,12 @@ fn advance_time_attack(
|
||||
session.active = false;
|
||||
session.remaining_secs = 0.0;
|
||||
ended.write(TimeAttackEndedEvent { wins });
|
||||
// Session ended naturally — delete the persisted file so the next
|
||||
// launch sees no in-progress session.
|
||||
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
|
||||
&& let Err(e) = delete_time_attack_session_at(p) {
|
||||
warn!("time_attack_session: failed to delete on expiry: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +216,80 @@ fn auto_deal_on_time_attack_win(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current Unix-seconds wall-clock time, falling back to 0 if
|
||||
/// the system time predates the epoch (impossible under any sane clock,
|
||||
/// but the fallback keeps the function infallible).
|
||||
fn current_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Periodically persists the live `TimeAttackResource` to
|
||||
/// `time_attack_session.json` every 30 real-world seconds while a session
|
||||
/// is active. The accumulator uses real-clock delta so it keeps ticking
|
||||
/// even if the in-game timer is paused — the goal is "if the OS kills the
|
||||
/// process now, how much do we lose?" and pause does not change that.
|
||||
fn auto_save_time_attack_session(
|
||||
time: Res<Time>,
|
||||
session: Res<TimeAttackResource>,
|
||||
path: Option<Res<TimeAttackSessionPath>>,
|
||||
mut timer: ResMut<TimeAttackAutoSaveTimer>,
|
||||
) {
|
||||
if !session.active {
|
||||
return;
|
||||
}
|
||||
timer.0 += time.delta_secs();
|
||||
if timer.0 < TIME_ATTACK_AUTO_SAVE_INTERVAL_SECS {
|
||||
return;
|
||||
}
|
||||
timer.0 -= TIME_ATTACK_AUTO_SAVE_INTERVAL_SECS;
|
||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
let payload = TimeAttackSession {
|
||||
remaining_secs: session.remaining_secs,
|
||||
wins: session.wins,
|
||||
saved_at_unix_secs: current_unix_secs(),
|
||||
};
|
||||
if let Err(e) = save_time_attack_session_to(p, &payload) {
|
||||
warn!("time_attack_session: auto-save failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Last-schedule companion to `game_plugin::save_game_state_on_exit`:
|
||||
/// flushes the live session resource to disk on `AppExit` so a graceful
|
||||
/// quit does not lose the timer + win count. If the session is inactive
|
||||
/// the persisted file is deleted instead, leaving a clean slate for the
|
||||
/// next launch.
|
||||
fn save_time_attack_session_on_exit(
|
||||
mut exit_events: MessageReader<AppExit>,
|
||||
session: Res<TimeAttackResource>,
|
||||
path: Res<TimeAttackSessionPath>,
|
||||
) {
|
||||
if exit_events.is_empty() {
|
||||
return;
|
||||
}
|
||||
exit_events.clear();
|
||||
let Some(p) = path.0.as_deref() else { return };
|
||||
|
||||
if !session.active {
|
||||
if let Err(e) = delete_time_attack_session_at(p) {
|
||||
warn!("time_attack_session: failed to delete on exit: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = TimeAttackSession {
|
||||
remaining_secs: session.remaining_secs,
|
||||
wins: session.wins,
|
||||
saved_at_unix_secs: current_unix_secs(),
|
||||
};
|
||||
if let Err(e) = save_time_attack_session_to(p, &payload) {
|
||||
warn!("time_attack_session: failed to save on exit: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -140,6 +306,12 @@ mod tests {
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(TimeAttackPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
// Disable session persistence — tests must not touch the real
|
||||
// ~/.local/share/solitaire_quest/time_attack_session.json.
|
||||
app.insert_resource(TimeAttackSessionPath(None));
|
||||
// The plugin's startup-load hook may have populated TimeAttackResource
|
||||
// from a real on-disk session. Reset it so each test starts inactive.
|
||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource::default();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -302,4 +474,170 @@ mod tests {
|
||||
"TimeAttackEndedEvent must not fire while paused"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Persistence tests — closing the window mid-Time-Attack must not lose
|
||||
// the session timer or the running win count.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn tmp_ta_path(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("engine_test_ta_{name}.json"))
|
||||
}
|
||||
|
||||
/// On `AppExit`, an active session must be flushed to disk so the next
|
||||
/// launch can restore it.
|
||||
#[test]
|
||||
fn exit_persists_active_session() {
|
||||
use solitaire_data::load_time_attack_session_from;
|
||||
|
||||
let path = tmp_ta_path("exit_save");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(TimeAttackSessionPath(Some(path.clone())));
|
||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: 240.0,
|
||||
wins: 4,
|
||||
};
|
||||
|
||||
app.world_mut().write_message(AppExit::Success);
|
||||
app.update();
|
||||
|
||||
// Plugin stamps `saved_at_unix_secs` with the current wall clock,
|
||||
// and we load immediately, so wall-clock elapsed is ~0 and the
|
||||
// restored remaining_secs should match what we wrote within a tiny
|
||||
// epsilon (allowing for the test taking a few seconds to run).
|
||||
let loaded =
|
||||
load_time_attack_session_from(&path).expect("file should exist after exit");
|
||||
assert!(
|
||||
(loaded.remaining_secs - 240.0).abs() < 5.0,
|
||||
"remaining_secs must round-trip within 5 s tolerance, got {}",
|
||||
loaded.remaining_secs,
|
||||
);
|
||||
assert_eq!(loaded.wins, 4, "wins must round-trip");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// On `AppExit` with no active session, any stale persisted file must
|
||||
/// be deleted so the next launch starts clean.
|
||||
#[test]
|
||||
fn exit_clears_persisted_file_when_no_active_session() {
|
||||
let path = tmp_ta_path("exit_clear");
|
||||
// Pre-create a stale file.
|
||||
std::fs::write(&path, b"{\"remaining_secs\":100.0,\"wins\":1,\"saved_at_unix_secs\":0}")
|
||||
.expect("write stale");
|
||||
assert!(path.exists());
|
||||
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(TimeAttackSessionPath(Some(path.clone())));
|
||||
// Default = inactive session.
|
||||
app.world_mut().write_message(AppExit::Success);
|
||||
app.update();
|
||||
|
||||
assert!(!path.exists(), "stale file must be deleted on exit when session is inactive");
|
||||
}
|
||||
|
||||
/// `auto_save_time_attack_session` writes the session once the
|
||||
/// accumulator crosses 30 s while the session is active.
|
||||
#[test]
|
||||
fn auto_save_writes_after_30_seconds() {
|
||||
use solitaire_data::load_time_attack_session_from;
|
||||
|
||||
let path = tmp_ta_path("auto_save_30s");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(TimeAttackSessionPath(Some(path.clone())));
|
||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: 500.0,
|
||||
wins: 2,
|
||||
};
|
||||
// Pre-seed the timer past the threshold so the very next update fires the save.
|
||||
app.insert_resource(TimeAttackAutoSaveTimer(TIME_ATTACK_AUTO_SAVE_INTERVAL_SECS + 0.1));
|
||||
app.update();
|
||||
|
||||
assert!(path.exists(), "auto-save file must exist after timer crosses threshold");
|
||||
let loaded = load_time_attack_session_from(&path).expect("session must load");
|
||||
assert_eq!(loaded.wins, 2);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Auto-save is a no-op when no session is active — we should not be
|
||||
/// littering the user's data dir with empty session files just because
|
||||
/// the app was running.
|
||||
#[test]
|
||||
fn auto_save_is_noop_when_session_inactive() {
|
||||
let path = tmp_ta_path("auto_save_noop");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(TimeAttackSessionPath(Some(path.clone())));
|
||||
// Session stays at default (inactive). Timer is past threshold.
|
||||
app.insert_resource(TimeAttackAutoSaveTimer(TIME_ATTACK_AUTO_SAVE_INTERVAL_SECS + 0.1));
|
||||
app.update();
|
||||
|
||||
assert!(!path.exists(), "auto-save must not fire when session is inactive");
|
||||
}
|
||||
|
||||
/// Starting a fresh session must delete any stale persisted file so a
|
||||
/// player who quit Time Attack mid-window, came back, then started a
|
||||
/// brand-new session begins at exactly TIME_ATTACK_DURATION_SECS.
|
||||
#[test]
|
||||
fn starting_new_session_deletes_stale_persisted_file() {
|
||||
let path = tmp_ta_path("start_clears");
|
||||
// Pre-create a stale file.
|
||||
std::fs::write(&path, b"{\"remaining_secs\":42.0,\"wins\":99,\"saved_at_unix_secs\":0}")
|
||||
.expect("write stale");
|
||||
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(TimeAttackSessionPath(Some(path.clone())));
|
||||
// Player must be at unlock level for the start-handler to act.
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
|
||||
|
||||
press_t(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(!path.exists(), "stale persisted file must be cleared at session start");
|
||||
|
||||
// And the live resource must reflect a fresh session, not the stale data.
|
||||
let session = app.world().resource::<TimeAttackResource>();
|
||||
assert!(session.active);
|
||||
assert_eq!(session.wins, 0, "wins must reset to 0, not the stale 99");
|
||||
assert!(
|
||||
(session.remaining_secs - TIME_ATTACK_DURATION_SECS).abs() < 1.0,
|
||||
"remaining_secs must reset to TIME_ATTACK_DURATION_SECS, not the stale 42; got {}",
|
||||
session.remaining_secs,
|
||||
);
|
||||
}
|
||||
|
||||
/// Natural session expiry (timer reaches 0) must delete the persisted
|
||||
/// file so the next launch does not see an "active" session that has
|
||||
/// already ended.
|
||||
#[test]
|
||||
fn session_expiry_deletes_persisted_file() {
|
||||
let path = tmp_ta_path("expiry_clears");
|
||||
// Pre-create a file that simulates the auto-save's prior write.
|
||||
std::fs::write(&path, b"{\"remaining_secs\":1.0,\"wins\":7,\"saved_at_unix_secs\":0}")
|
||||
.expect("write");
|
||||
assert!(path.exists());
|
||||
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(TimeAttackSessionPath(Some(path.clone())));
|
||||
// Session about to expire on the next update tick.
|
||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: -1.0,
|
||||
wins: 7,
|
||||
};
|
||||
|
||||
app.update();
|
||||
|
||||
assert!(!path.exists(), "persisted file must be deleted on natural expiry");
|
||||
let session = app.world().resource::<TimeAttackResource>();
|
||||
assert!(!session.active);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,13 +41,17 @@
|
||||
//! here no-ops so [`crate::selection_plugin`]'s Tab/Enter
|
||||
//! card-selection still works.
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use bevy::ecs::query::Has;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim};
|
||||
use crate::ui_theme::{FOCUS_RING, RADIUS_MD, Z_FOCUS_RING};
|
||||
use crate::ui_theme::{FOCUS_RING, MOTION_FOCUS_PULSE_SECS, RADIUS_MD, Z_FOCUS_RING};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public component / resource API
|
||||
@@ -117,21 +121,89 @@ impl Plugin for UiFocusPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<FocusedButton>()
|
||||
.add_systems(Startup, spawn_focus_overlay)
|
||||
// Attach + auto-focus run in `PostUpdate` so they see entities
|
||||
// a click-handler in `Update` queued via `Commands` earlier in
|
||||
// the same frame. If they ran in `Update` they'd race the
|
||||
// click handler: there's no ordering edge between an arbitrary
|
||||
// modal-spawning system and the focus chain, so Bevy's
|
||||
// `auto_insert_apply_deferred` pass cannot synchronise them.
|
||||
// Pushing the attach / auto-focus pair into `PostUpdate` puts
|
||||
// the natural schedule-boundary sync point between every
|
||||
// modal spawn and focus arrival — `FocusedButton` is always
|
||||
// populated before the same `app.update()` returns.
|
||||
//
|
||||
// The remaining systems stay in `Update` so they keep
|
||||
// observing input on the frame it occurs. They read
|
||||
// `FocusedButton` written during the *previous* tick's
|
||||
// `PostUpdate`, which is exactly what we want: the very next
|
||||
// user keypress after a modal opens lands on a populated
|
||||
// resource.
|
||||
.add_systems(
|
||||
Update,
|
||||
PostUpdate,
|
||||
(
|
||||
attach_focusable_to_modal_buttons,
|
||||
auto_focus_on_modal_open,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
sync_focus_on_mouse_click,
|
||||
clear_hud_focus_on_unhover,
|
||||
handle_focus_keys,
|
||||
update_focus_overlay,
|
||||
pulse_focus_overlay,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the focus-ring breathing factor for a given elapsed time.
|
||||
///
|
||||
/// Returns a value in `[0.65, 1.0]` following a sin curve over
|
||||
/// [`MOTION_FOCUS_PULSE_SECS`]. Multiply [`FOCUS_RING`]'s native alpha by
|
||||
/// this factor each frame to produce the breathing effect.
|
||||
///
|
||||
/// Pure helper so the curve can be unit-tested without a Bevy app.
|
||||
pub fn focus_ring_pulse_factor(elapsed_secs: f32) -> f32 {
|
||||
let phase = (elapsed_secs * TAU / MOTION_FOCUS_PULSE_SECS).sin();
|
||||
// 0.825 mid-point ± 0.175 amplitude → range [0.65, 1.0]. Multiplicative
|
||||
// factor against FOCUS_RING's static alpha so the brightest tick is
|
||||
// exactly the original colour, not a brighter one.
|
||||
0.825 + 0.175 * phase
|
||||
}
|
||||
|
||||
/// Modulates the focus overlay's border alpha with a slow sin-curve
|
||||
/// breathing pulse so the indicator catches the eye without competing
|
||||
/// with gameplay motion. Skipped under `AnimSpeed::Instant` — the static
|
||||
/// border colour is restored so reduced-motion users see no animation.
|
||||
fn pulse_focus_overlay(
|
||||
time: Res<Time>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
focused: Res<FocusedButton>,
|
||||
mut overlay: Query<&mut BorderColor, With<FocusOverlay>>,
|
||||
) {
|
||||
let Ok(mut border) = overlay.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let instant = settings
|
||||
.as_deref()
|
||||
.is_some_and(|s| matches!(s.0.animation_speed, AnimSpeed::Instant));
|
||||
|
||||
let factor = if instant || focused.0.is_none() {
|
||||
1.0
|
||||
} else {
|
||||
focus_ring_pulse_factor(time.elapsed_secs())
|
||||
};
|
||||
|
||||
let mut colour = FOCUS_RING;
|
||||
colour.set_alpha(FOCUS_RING.alpha() * factor);
|
||||
*border = BorderColor::all(colour);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private marker for the single overlay entity
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -588,6 +660,40 @@ mod tests {
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn focus_ring_pulse_factor_at_zero_is_mid_point() {
|
||||
// sin(0) = 0 → factor = 0.825 (mid of [0.65, 1.0]).
|
||||
let f = focus_ring_pulse_factor(0.0);
|
||||
assert!((f - 0.825).abs() < 1e-5, "factor at t=0 should be 0.825, got {f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_ring_pulse_factor_peaks_at_quarter_period() {
|
||||
// sin(τ/4) = 1 → factor = 1.0.
|
||||
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS / 4.0);
|
||||
assert!((f - 1.0).abs() < 1e-4, "factor at peak should be 1.0, got {f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_ring_pulse_factor_troughs_at_three_quarter_period() {
|
||||
// sin(3τ/4) = -1 → factor = 0.65.
|
||||
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS * 3.0 / 4.0);
|
||||
assert!((f - 0.65).abs() < 1e-4, "factor at trough should be 0.65, got {f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_ring_pulse_factor_stays_in_brightness_range() {
|
||||
// Sweep across two full periods; factor must stay within [0.65, 1.0].
|
||||
for i in 0..200 {
|
||||
let t = i as f32 * MOTION_FOCUS_PULSE_SECS * 0.01;
|
||||
let f = focus_ring_pulse_factor(t);
|
||||
assert!(
|
||||
(0.649..=1.001).contains(&f),
|
||||
"factor at t={t} out of range: {f}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin-marker for the synthetic test modal — `spawn_modal`
|
||||
/// requires a `Component` on the scrim.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -744,6 +850,143 @@ mod tests {
|
||||
assert_eq!(focused, Some(a), "Primary button A should auto-focus");
|
||||
}
|
||||
|
||||
/// One-shot trigger resource consumed by the production-shaped test
|
||||
/// system [`spawn_modal_via_system`]. When set to `true`, the system
|
||||
/// queues a `spawn_modal` call on the next `Update` and clears the
|
||||
/// flag. Mirrors the real production flow where a click-handler
|
||||
/// system queues the modal spawn via `Commands` rather than the
|
||||
/// test fixture using `world.flush()` ahead of time.
|
||||
#[derive(Resource, Default)]
|
||||
struct SpawnModalTrigger(bool);
|
||||
|
||||
/// Production-shaped modal spawner: a regular Bevy `System` that
|
||||
/// reads a trigger flag and queues a 2-button modal via `Commands`.
|
||||
/// Crucially this system has **no** ordering relationship with
|
||||
/// `UiFocusPlugin`'s chain — exactly the situation that surfaces the
|
||||
/// "focus arrives one frame late" bug in production.
|
||||
fn spawn_modal_via_system(
|
||||
mut commands: Commands,
|
||||
mut trigger: ResMut<SpawnModalTrigger>,
|
||||
) {
|
||||
if !trigger.0 {
|
||||
return;
|
||||
}
|
||||
trigger.0 = false;
|
||||
spawn_modal(&mut commands, TestModal, 0, |card| {
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
TestButtonB,
|
||||
"B",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
None,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
TestButtonA,
|
||||
"A",
|
||||
None,
|
||||
ButtonVariant::Primary,
|
||||
None,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Same-frame-focus contract: when a modal is spawned by an
|
||||
/// independent system during the same `Update` as the focus chain,
|
||||
/// `FocusedButton` must be populated with the primary button by the
|
||||
/// time `handle_focus_keys` runs in that **same** update — so a Tab
|
||||
/// pressed in the very next tick advances focus rather than
|
||||
/// landing on "nothing focused → primary".
|
||||
#[test]
|
||||
fn primary_button_is_focused_on_modal_spawn_same_frame() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<SpawnModalTrigger>();
|
||||
// Register the production-shaped spawn system in `Update` with
|
||||
// no chain relationship to `UiFocusPlugin`.
|
||||
app.add_systems(Update, spawn_modal_via_system);
|
||||
// Initial Startup pass.
|
||||
app.update();
|
||||
|
||||
// Trigger the spawn and run exactly ONE update — the same
|
||||
// `Update` cycle that the focus chain runs in. By the end of
|
||||
// this update, `FocusedButton` must already point at the
|
||||
// primary button.
|
||||
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
|
||||
app.update();
|
||||
|
||||
let primary = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<TestButtonA>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("Primary button should exist after the spawn update");
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<FocusedButton>().0,
|
||||
Some(primary),
|
||||
"FocusedButton must be populated with the primary on the same frame the modal spawns"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tab pressed on the very next tick after a modal opens must
|
||||
/// advance focus from the primary to the secondary — not from
|
||||
/// "nothing focused" to the primary. The latter would mean focus
|
||||
/// arrived a frame late and Tab was wasted on first-focus instead
|
||||
/// of advancing.
|
||||
#[test]
|
||||
fn first_tab_after_modal_open_advances_to_secondary() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<SpawnModalTrigger>();
|
||||
app.add_systems(Update, spawn_modal_via_system);
|
||||
app.update();
|
||||
|
||||
// Spawn the modal in update N.
|
||||
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
|
||||
app.update();
|
||||
|
||||
// Press Tab on update N+1. If focus arrived correctly in N,
|
||||
// Tab advances primary → secondary. If focus arrived late,
|
||||
// Tab promotes "no focus" to primary (the bug).
|
||||
let primary = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<TestButtonA>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("primary spawned");
|
||||
let secondary = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<TestButtonB>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("secondary spawned");
|
||||
|
||||
press_key(&mut app, KeyCode::Tab);
|
||||
app.update();
|
||||
|
||||
let focused_after_tab = app.world().resource::<FocusedButton>().0;
|
||||
assert_ne!(
|
||||
focused_after_tab,
|
||||
Some(primary),
|
||||
"first Tab after modal open should advance off the primary, not land on it (focus arrived late)"
|
||||
);
|
||||
assert_eq!(
|
||||
focused_after_tab,
|
||||
Some(secondary),
|
||||
"first Tab from primary should land on the secondary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_advances_focus_in_spawn_order() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
//! ```
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
@@ -74,6 +76,19 @@ pub struct ModalScrim;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalCard;
|
||||
|
||||
/// Marker on a [`ModalScrim`] entity opting that modal into the
|
||||
/// click-outside-to-dismiss behaviour.
|
||||
///
|
||||
/// When attached, [`dismiss_modal_on_scrim_click`] despawns the scrim
|
||||
/// (and its hierarchy) on a left mouse press whose cursor falls on the
|
||||
/// scrim and outside every [`ModalCard`]. Modals with destructive
|
||||
/// actions or unsaved state (Settings, Onboarding, Pause, Forfeit
|
||||
/// confirmation, Confirm New Game, etc.) intentionally do not opt in
|
||||
/// — those require an explicit Cancel / Done / Confirm so an
|
||||
/// accidental scrim click cannot lose work.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct ScrimDismissible;
|
||||
|
||||
/// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalHeader;
|
||||
@@ -474,6 +489,89 @@ pub fn advance_modal_enter(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Click-outside-to-dismiss
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns `true` when the cursor at `cursor_logical` falls inside the
|
||||
/// axis-aligned rectangle described by `centre_logical` (rectangle
|
||||
/// centre, logical pixels) and `size_logical` (full width × height,
|
||||
/// logical pixels).
|
||||
///
|
||||
/// Pure helper extracted from [`dismiss_modal_on_scrim_click`] so the
|
||||
/// hit-test decision can be tested without a real `Window` /
|
||||
/// rendered UI tree.
|
||||
#[inline]
|
||||
fn cursor_is_inside_rect(cursor_logical: Vec2, centre_logical: Vec2, size_logical: Vec2) -> bool {
|
||||
let half = size_logical * 0.5;
|
||||
cursor_logical.x >= centre_logical.x - half.x
|
||||
&& cursor_logical.x <= centre_logical.x + half.x
|
||||
&& cursor_logical.y >= centre_logical.y - half.y
|
||||
&& cursor_logical.y <= centre_logical.y + half.y
|
||||
}
|
||||
|
||||
/// Despawns the topmost [`ScrimDismissible`] modal when the player
|
||||
/// presses the left mouse button while the cursor is over the scrim
|
||||
/// AND outside every [`ModalCard`]. Modals without the marker are
|
||||
/// untouched, and existing dismiss paths (Cancel / Done / Esc /
|
||||
/// dedicated buttons) keep working unchanged.
|
||||
///
|
||||
/// **Topmost-only.** Stacked dismissible modals would otherwise all
|
||||
/// dismiss together on a single click. The system processes at most
|
||||
/// one entity per frame: the first match in the query is taken,
|
||||
/// matching the click-handler convention used elsewhere in the engine.
|
||||
/// Spawn order is the practical tiebreaker — dismissible modals are
|
||||
/// rarely stacked, so picking any one is acceptable.
|
||||
///
|
||||
/// **No same-frame dismissal.** `just_pressed` is true only on the
|
||||
/// frame the button transitions to pressed. The press that *opens* a
|
||||
/// modal happens on one frame; this system fires on a subsequent
|
||||
/// press, so a modal can never be opened and dismissed in a single
|
||||
/// click.
|
||||
///
|
||||
/// `cards`/`scrims` queries read [`UiGlobalTransform`] (window-space
|
||||
/// physical pixels) and [`ComputedNode`] (size in physical pixels);
|
||||
/// both are converted to logical pixels via
|
||||
/// `ComputedNode::inverse_scale_factor` so they can be compared with
|
||||
/// the cursor position from `Window::cursor_position` (logical px).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn dismiss_modal_on_scrim_click(
|
||||
mut commands: Commands,
|
||||
mouse: Option<Res<ButtonInput<MouseButton>>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
|
||||
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
|
||||
) {
|
||||
let Some(mouse) = mouse else { return };
|
||||
if !mouse.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Topmost-only: bail after the first dismissible scrim. Stacked
|
||||
// dismissible modals are not currently a real case, but this guard
|
||||
// keeps the behaviour predictable if they ever arise.
|
||||
let Some(scrim_entity) = scrims.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cursor_over_card = cards.iter().any(|(transform, computed)| {
|
||||
let inv = computed.inverse_scale_factor;
|
||||
let size_logical = computed.size() * inv;
|
||||
let centre_logical = transform.translation * inv;
|
||||
cursor_is_inside_rect(cursor, centre_logical, size_logical)
|
||||
});
|
||||
|
||||
if !cursor_over_card {
|
||||
commands.entity(scrim_entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
|
||||
/// press states are visible without each overlay registering its own
|
||||
/// paint system.
|
||||
@@ -515,6 +613,12 @@ impl Plugin for UiModalPlugin {
|
||||
Update,
|
||||
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(),
|
||||
);
|
||||
// Click-outside-to-dismiss is independent of the open
|
||||
// animation chain — it reads `just_pressed(Left)` and runs
|
||||
// every tick. `just_pressed` is true only on the frame the
|
||||
// button transitions to pressed, so the press that *opens* a
|
||||
// modal cannot dismiss the same modal on the next frame.
|
||||
app.add_systems(Update, dismiss_modal_on_scrim_click);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,5 +772,224 @@ mod tests {
|
||||
Duration::from_secs_f32(secs),
|
||||
));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Click-outside-to-dismiss
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pure-helper hit-test: cursor inside the rectangle returns true.
|
||||
#[test]
|
||||
fn cursor_is_inside_rect_inside_returns_true() {
|
||||
// 100×60 rectangle centred at (200, 150).
|
||||
let centre = Vec2::new(200.0, 150.0);
|
||||
let size = Vec2::new(100.0, 60.0);
|
||||
// Centre + a few corners just inside.
|
||||
assert!(cursor_is_inside_rect(centre, centre, size));
|
||||
assert!(cursor_is_inside_rect(Vec2::new(151.0, 121.0), centre, size));
|
||||
assert!(cursor_is_inside_rect(Vec2::new(249.0, 179.0), centre, size));
|
||||
}
|
||||
|
||||
/// Pure-helper hit-test: cursor outside the rectangle returns false
|
||||
/// on every side.
|
||||
#[test]
|
||||
fn cursor_is_inside_rect_outside_returns_false() {
|
||||
let centre = Vec2::new(200.0, 150.0);
|
||||
let size = Vec2::new(100.0, 60.0);
|
||||
assert!(!cursor_is_inside_rect(Vec2::new(149.0, 150.0), centre, size)); // left
|
||||
assert!(!cursor_is_inside_rect(Vec2::new(251.0, 150.0), centre, size)); // right
|
||||
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 119.0), centre, size)); // above
|
||||
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 181.0), centre, size)); // below
|
||||
}
|
||||
|
||||
/// Builds a headless app capable of running
|
||||
/// `dismiss_modal_on_scrim_click`: registers the plugin, primes the
|
||||
/// `ButtonInput<MouseButton>` resource that `MinimalPlugins`
|
||||
/// doesn't provide, and spawns a synthetic `PrimaryWindow`.
|
||||
fn dismiss_test_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
|
||||
app.init_resource::<ButtonInput<MouseButton>>();
|
||||
// Synthetic primary window. `MinimalPlugins` doesn't ship
|
||||
// `WindowPlugin`, so spawning the entity by hand is fine —
|
||||
// `dismiss_modal_on_scrim_click` only reads `cursor_position`
|
||||
// off it, not any platform-backed state.
|
||||
app.world_mut().spawn((
|
||||
Window {
|
||||
resolution: bevy::window::WindowResolution::new(800, 600),
|
||||
..default()
|
||||
},
|
||||
PrimaryWindow,
|
||||
));
|
||||
app
|
||||
}
|
||||
|
||||
/// Marker for synthetic-modal tests below.
|
||||
#[derive(Component, Debug)]
|
||||
struct DismissTestModal;
|
||||
|
||||
/// Spawns a synthetic scrim + card pair pre-populated with
|
||||
/// `ComputedNode` + `UiGlobalTransform` so the dismiss system has
|
||||
/// real geometry to hit-test against without running the full UI
|
||||
/// layout pipeline. `card_centre` and `card_size` are in physical
|
||||
/// pixels (matching `ComputedNode.size`); the synthetic
|
||||
/// `inverse_scale_factor` is 1.0 so logical == physical.
|
||||
fn spawn_synthetic_modal(
|
||||
app: &mut App,
|
||||
dismissible: bool,
|
||||
card_centre: Vec2,
|
||||
card_size: Vec2,
|
||||
) -> Entity {
|
||||
let world = app.world_mut();
|
||||
let mut scrim = world.spawn((DismissTestModal, ModalScrim));
|
||||
if dismissible {
|
||||
scrim.insert(ScrimDismissible);
|
||||
}
|
||||
let scrim_entity = scrim.id();
|
||||
let card_entity = world
|
||||
.spawn((
|
||||
ModalCard,
|
||||
{
|
||||
let mut node = ComputedNode {
|
||||
stack_index: 0,
|
||||
size: card_size,
|
||||
content_size: card_size,
|
||||
scrollbar_size: Vec2::ZERO,
|
||||
scroll_position: Vec2::ZERO,
|
||||
outline_width: 0.0,
|
||||
outline_offset: 0.0,
|
||||
unrounded_size: card_size,
|
||||
border: bevy::sprite::BorderRect::default(),
|
||||
border_radius: bevy::ui::ResolvedBorderRadius::default(),
|
||||
padding: bevy::sprite::BorderRect::default(),
|
||||
inverse_scale_factor: 1.0,
|
||||
};
|
||||
// `is_empty` guard inside Bevy treats zero-size
|
||||
// nodes as inert; we always pass a non-zero size.
|
||||
node.size = card_size;
|
||||
node
|
||||
},
|
||||
UiGlobalTransform::from_translation(card_centre),
|
||||
))
|
||||
.id();
|
||||
// Parent the card to the scrim so a `commands.entity(scrim).despawn()`
|
||||
// also takes the card down — matching the real `spawn_modal` hierarchy.
|
||||
world.entity_mut(scrim_entity).add_child(card_entity);
|
||||
scrim_entity
|
||||
}
|
||||
|
||||
/// Sets the synthetic primary window's cursor position (logical px,
|
||||
/// since we use `inverse_scale_factor = 1.0` everywhere in tests).
|
||||
fn set_cursor(app: &mut App, position: Option<Vec2>) {
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query_filtered::<&mut Window, With<PrimaryWindow>>();
|
||||
let mut window = q.single_mut(world).expect("primary window");
|
||||
window.set_cursor_position(position);
|
||||
}
|
||||
|
||||
/// Drives a fresh `just_pressed(Left)` for the next `app.update()`.
|
||||
/// `MinimalPlugins` doesn't run the input clear pass, so we mark
|
||||
/// the clear by hand on the resource between presses.
|
||||
fn press_left_mouse(app: &mut App) {
|
||||
let mut input = app
|
||||
.world_mut()
|
||||
.resource_mut::<ButtonInput<MouseButton>>();
|
||||
input.clear();
|
||||
input.press(MouseButton::Left);
|
||||
}
|
||||
|
||||
/// Click outside the card on a dismissible modal despawns it.
|
||||
#[test]
|
||||
fn dismissible_scrim_despawns_on_scrim_click_outside_card() {
|
||||
let mut app = dismiss_test_app();
|
||||
let scrim = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ true,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
// Cursor far outside the card — top-left corner of the window.
|
||||
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
|
||||
press_left_mouse(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().get_entity(scrim).is_err(),
|
||||
"dismissible scrim should be despawned on a scrim-area click"
|
||||
);
|
||||
}
|
||||
|
||||
/// Click *inside* the card area must NOT dismiss the modal — the
|
||||
/// player intends to interact with the card content.
|
||||
#[test]
|
||||
fn dismissible_scrim_does_not_despawn_on_card_click() {
|
||||
let mut app = dismiss_test_app();
|
||||
let scrim = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ true,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
// Cursor at the card centre — definitely inside.
|
||||
set_cursor(&mut app, Some(Vec2::new(400.0, 300.0)));
|
||||
press_left_mouse(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().get_entity(scrim).is_ok(),
|
||||
"click inside the card must not dismiss the modal"
|
||||
);
|
||||
}
|
||||
|
||||
/// Modals without `ScrimDismissible` ignore scrim clicks entirely.
|
||||
/// Settings, Onboarding, Pause, etc. rely on this opt-out.
|
||||
#[test]
|
||||
fn non_dismissible_scrim_does_not_despawn_on_scrim_click() {
|
||||
let mut app = dismiss_test_app();
|
||||
let scrim = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ false,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
|
||||
press_left_mouse(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().get_entity(scrim).is_ok(),
|
||||
"non-dismissible scrim must survive a scrim-area click"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stacked dismissible modals: one click despawns at most one
|
||||
/// modal per frame (the one the query yields first). The other
|
||||
/// stays put until the next press.
|
||||
#[test]
|
||||
fn stacked_modals_dismiss_at_most_one_per_click() {
|
||||
let mut app = dismiss_test_app();
|
||||
let a = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ true,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
let b = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ true,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
// Cursor outside both cards.
|
||||
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
|
||||
press_left_mouse(&mut app);
|
||||
app.update();
|
||||
|
||||
let a_alive = app.world().get_entity(a).is_ok();
|
||||
let b_alive = app.world().get_entity(b).is_ok();
|
||||
assert!(
|
||||
a_alive ^ b_alive,
|
||||
"exactly one of the two stacked dismissible modals should remain"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -333,6 +333,11 @@ pub const MOTION_SHAKE_SECS: f32 = 0.25;
|
||||
/// Shake angular frequency in rad/s.
|
||||
pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
|
||||
|
||||
/// Duration of the smooth return tween when a drag is rejected by an
|
||||
/// invalid drop target. Short enough to feel snappy but long enough to
|
||||
/// read as motion rather than a teleport.
|
||||
pub const MOTION_DRAG_REJECT_SECS: f32 = 0.15;
|
||||
|
||||
/// Card flip — half-time per phase (squash + grow). 100 ms each =
|
||||
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
|
||||
/// feel without 3D rendering.
|
||||
@@ -356,6 +361,14 @@ pub const MOTION_CASCADE_STAGGER_SECS: f32 = 0.06;
|
||||
/// (overshoot) plus ±15° Z-rotation. 500 ms.
|
||||
pub const MOTION_CASCADE_SLIDE_SECS: f32 = 0.50;
|
||||
|
||||
/// Per-line stagger between score-breakdown rows during the win modal
|
||||
/// reveal animation, in seconds.
|
||||
pub const MOTION_SCORE_BREAKDOWN_STAGGER_SECS: f32 = 0.15;
|
||||
|
||||
/// Per-line fade-in duration during the win modal score reveal, in
|
||||
/// seconds.
|
||||
pub const MOTION_SCORE_BREAKDOWN_FADE_SECS: f32 = 0.12;
|
||||
|
||||
/// Screen shake on win — wider and longer than the old 0.6 s / 8 px.
|
||||
/// 800 ms.
|
||||
pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80;
|
||||
@@ -379,10 +392,51 @@ pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
|
||||
/// readout 1.0 → 1.1 → 1.0. 250 ms.
|
||||
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
|
||||
|
||||
/// Foundation-completion flourish — when a King lands on a foundation
|
||||
/// pile (Ace → King, 13 cards), briefly scale the King card 1.0 →
|
||||
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] → 1.0 and tint the matching
|
||||
/// `PileMarker` gold. 400 ms.
|
||||
pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4;
|
||||
|
||||
/// Peak scale magnification reached at the midpoint of the
|
||||
/// foundation-completion flourish. The triangular curve climbs from
|
||||
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`.
|
||||
pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15;
|
||||
|
||||
/// Total duration of the streak-milestone flourish on the HUD score
|
||||
/// readout, in seconds. Mirrors the foundation flourish in feel — a
|
||||
/// brief celebratory pulse that does not block subsequent gameplay.
|
||||
pub const MOTION_STREAK_FLOURISH_SECS: f32 = 0.6;
|
||||
|
||||
/// Peak scale magnification reached at the midpoint of the streak
|
||||
/// flourish (1.0 → this → 1.0). Larger than the foundation flourish
|
||||
/// peak so the lifetime-streak celebration reads as a bigger deal than
|
||||
/// the per-suit completion.
|
||||
pub const STREAK_FLOURISH_PEAK_SCALE: f32 = 1.20;
|
||||
|
||||
/// Win-streak counts that trigger the flourish. The flourish fires
|
||||
/// only when the streak crosses a threshold from below — never at
|
||||
/// every win past the highest threshold. Static for now; could become
|
||||
/// a `Settings`-tunable list later if play-testing surfaces it.
|
||||
pub const STREAK_MILESTONES: &[u32] = &[3, 5, 10];
|
||||
|
||||
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
||||
/// 400 ms.
|
||||
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
||||
|
||||
/// Period of the focus-ring breathing pulse, in seconds.
|
||||
///
|
||||
/// The keyboard focus ring's alpha is modulated by a sin-curve over this
|
||||
/// interval so the indicator gently "breathes" instead of presenting as
|
||||
/// a flat outline. 1.4 s reads as a calm heartbeat — slow enough that
|
||||
/// the motion is in the player's peripheral vision rather than competing
|
||||
/// for attention, fast enough that a focus change still draws the eye.
|
||||
/// Not run through [`scaled_duration`]: the pulse is an accessibility
|
||||
/// affordance, not gameplay motion. `AnimSpeed::Instant` is honoured at
|
||||
/// the system level by skipping the pulse entirely (see
|
||||
/// `pulse_focus_overlay` in `ui_focus`).
|
||||
pub const MOTION_FOCUS_PULSE_SECS: f32 = 1.4;
|
||||
|
||||
/// Hover delay before a tooltip appears, in seconds. Long enough that
|
||||
/// players gliding the cursor across the HUD don't see flicker; short
|
||||
/// enough that "stop and read" feels responsive. Not run through
|
||||
|
||||
@@ -34,6 +34,7 @@ use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_theme::{
|
||||
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
|
||||
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
|
||||
@@ -137,6 +138,23 @@ struct TooltipText;
|
||||
/// target's own border.
|
||||
const TOOLTIP_GAP_PX: f32 = 4.0;
|
||||
|
||||
/// Pure helper: returns `true` once `elapsed_secs` has met or exceeded
|
||||
/// the player-configured `delay_secs`, so the tooltip should be revealed.
|
||||
///
|
||||
/// Treating "elapsed >= delay" as the show condition (rather than
|
||||
/// strictly greater than) is what makes a `delay_secs == 0.0` setting
|
||||
/// behave as advertised: on the very first tick after hover starts,
|
||||
/// `elapsed_secs` is `0.0` and the tooltip appears immediately. With a
|
||||
/// strict `>` the zero-delay case would still wait one tick.
|
||||
///
|
||||
/// Extracted so the comparison can be unit-tested without spinning up
|
||||
/// a Bevy `App` — `Time<Virtual>` clamps each tick to 250 ms under
|
||||
/// `MinimalPlugins`, which makes precise sub-second timing assertions
|
||||
/// awkward.
|
||||
pub(crate) fn tooltip_should_show(elapsed_secs: f32, delay_secs: f32) -> bool {
|
||||
elapsed_secs >= delay_secs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -257,6 +275,7 @@ fn track_tooltip_hover(
|
||||
fn show_or_hide_tooltip(
|
||||
time: Res<Time>,
|
||||
state: Res<TooltipState>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
|
||||
tooltip_text_only: Query<&Tooltip>,
|
||||
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
|
||||
@@ -280,9 +299,15 @@ fn show_or_hide_tooltip(
|
||||
return;
|
||||
};
|
||||
|
||||
// Player-configurable dwell delay; falls back to the design-token
|
||||
// default when `SettingsResource` is absent (test harnesses running
|
||||
// `UiTooltipPlugin` under `MinimalPlugins` without `SettingsPlugin`).
|
||||
let delay_secs = settings
|
||||
.as_ref()
|
||||
.map(|s| s.0.tooltip_delay_secs)
|
||||
.unwrap_or(MOTION_TOOLTIP_DELAY_SECS);
|
||||
let elapsed = time.elapsed().saturating_sub(started_at);
|
||||
let delay = Duration::from_secs_f32(MOTION_TOOLTIP_DELAY_SECS);
|
||||
if elapsed < delay {
|
||||
if !tooltip_should_show(elapsed.as_secs_f32(), delay_secs) {
|
||||
hide(&mut visibility);
|
||||
return;
|
||||
}
|
||||
@@ -550,4 +575,30 @@ mod tests {
|
||||
"overlay text must update to the new hovered entity's Tooltip string"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 5: `tooltip_should_show` is the pure helper that the system
|
||||
/// uses to gate the reveal — exercising it directly avoids the
|
||||
/// `Time<Virtual>` 250 ms clamp that makes precise sub-second
|
||||
/// timing assertions in `MinimalPlugins` fiddly. The four cases
|
||||
/// below cover the boundary semantics:
|
||||
///
|
||||
/// * `delay = 0.0` ("Instant") must show on the first tick.
|
||||
/// * `elapsed < delay` must NOT show.
|
||||
/// * `elapsed == delay` must show (boundary inclusive).
|
||||
/// * `elapsed > delay` must show.
|
||||
#[test]
|
||||
fn tooltip_should_show_respects_delay() {
|
||||
// delay == 0 ("Instant"): any elapsed (including zero) shows.
|
||||
assert!(tooltip_should_show(0.0, 0.0), "instant delay must show on first tick");
|
||||
assert!(tooltip_should_show(0.5, 0.0));
|
||||
|
||||
// Standard non-zero delay.
|
||||
assert!(!tooltip_should_show(0.4, 0.5), "elapsed < delay must hide");
|
||||
assert!(tooltip_should_show(0.5, 0.5), "elapsed == delay must show (boundary)");
|
||||
assert!(tooltip_should_show(0.6, 0.5), "elapsed > delay must show");
|
||||
|
||||
// Larger delay (max-end of the slider).
|
||||
assert!(!tooltip_should_show(1.0, 1.5));
|
||||
assert!(tooltip_should_show(1.5, 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::scoring::compute_time_bonus;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::achievement_plugin::display_name_for;
|
||||
use crate::events::{
|
||||
@@ -23,10 +25,11 @@ use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_WIN_SHAKE_AMPLITUDE,
|
||||
MOTION_WIN_SHAKE_SECS, RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
|
||||
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2,
|
||||
VAL_SPACE_3, Z_WIN_CASCADE,
|
||||
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
|
||||
RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||
Z_WIN_CASCADE,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,6 +76,15 @@ pub struct WinSummaryPending {
|
||||
/// human-readable level number that was just completed (e.g. `Some(3)`
|
||||
/// means "Challenge 3"). `None` for non-Challenge modes.
|
||||
pub challenge_level: Option<u32>,
|
||||
/// Number of undos used during the winning game. Captured from
|
||||
/// `GameStateResource` at the moment `GameWonEvent` fires so the
|
||||
/// score-breakdown reveal can decide whether to award the no-undo
|
||||
/// bonus row.
|
||||
pub undo_count: u32,
|
||||
/// Game mode of the winning game. Captured at win time so the
|
||||
/// score-breakdown reveal can format the mode-multiplier row
|
||||
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||
pub mode: GameMode,
|
||||
}
|
||||
|
||||
/// Builds a human-readable XP breakdown string for the win modal.
|
||||
@@ -161,6 +173,37 @@ enum WinSummaryButton {
|
||||
PlayAgain,
|
||||
}
|
||||
|
||||
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||
///
|
||||
/// Each row carries a stagger delay (seconds until the row should
|
||||
/// become visible) plus a fade-in timer that lerps the row's text
|
||||
/// alpha from `0.0 → 1.0` over [`MOTION_SCORE_BREAKDOWN_FADE_SECS`].
|
||||
/// Rows are spawned with `Visibility::Hidden`; the reveal system
|
||||
/// flips them to `Visibility::Inherited` once `delay_secs` elapses
|
||||
/// and then drives the per-text alpha lerp until the row reaches
|
||||
/// full opacity.
|
||||
///
|
||||
/// When `AnimSpeed::Instant` is active the row is spawned with
|
||||
/// `delay_secs = 0.0`, `fade_duration_secs = 0.0`, and visibility
|
||||
/// already set to `Inherited` so the reveal happens on frame 1.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct ScoreBreakdownRow {
|
||||
/// Seconds remaining until this row first becomes visible.
|
||||
/// Counts down to 0 in `reveal_score_breakdown`. Zero or negative
|
||||
/// means "show immediately".
|
||||
pub delay_secs: f32,
|
||||
/// Seconds elapsed since this row became visible. Drives the
|
||||
/// alpha lerp on the row's child `Text` nodes.
|
||||
pub fade_elapsed_secs: f32,
|
||||
/// Total fade-in duration. Zero means "no fade — appear at full
|
||||
/// opacity in one frame".
|
||||
pub fade_duration_secs: f32,
|
||||
/// `true` once the row's `Visibility` has been promoted from
|
||||
/// `Hidden` to `Inherited`. Prevents re-running the visibility
|
||||
/// switch every frame after the row first reveals.
|
||||
pub revealed: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -192,7 +235,9 @@ impl Plugin for WinSummaryPlugin {
|
||||
collect_session_achievements,
|
||||
spawn_win_summary_after_delay,
|
||||
handle_win_summary_buttons,
|
||||
handle_win_summary_keyboard,
|
||||
apply_screen_shake,
|
||||
reveal_score_breakdown,
|
||||
)
|
||||
.after(GameMutation),
|
||||
);
|
||||
@@ -217,6 +262,170 @@ pub fn format_win_time(seconds: u64) -> String {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
|
||||
/// Score amount awarded as a "no-undo" bonus in the win modal when the
|
||||
/// player completes the game without using undo. Mirrors the XP-side
|
||||
/// no-undo bonus so the score and XP breakdowns reinforce each other,
|
||||
/// and stays a `pub const` so tests can assert against it without
|
||||
/// re-typing the literal.
|
||||
pub const SCORE_NO_UNDO_BONUS: i32 = 25;
|
||||
|
||||
/// Decomposed view of the player's final score, displayed in the win
|
||||
/// modal as a sequence of fade-in rows.
|
||||
///
|
||||
/// The fields mirror the row layout described in the win-modal
|
||||
/// reveal:
|
||||
///
|
||||
/// ```text
|
||||
/// Base score {base}
|
||||
/// Time bonus ({m:ss}) +{time_bonus}
|
||||
/// No-undo bonus +{no_undo_bonus}
|
||||
/// Mode multiplier ({mode} ×N) ×{multiplier}
|
||||
/// ─────────────────────────────────
|
||||
/// Total {total}
|
||||
/// ```
|
||||
///
|
||||
/// Components that do not apply to the current win are zeroed out:
|
||||
/// `time_bonus = 0` when the player took longer than the time-bonus
|
||||
/// curve produces a positive result, `no_undo_bonus = 0` when undo
|
||||
/// was used, and `multiplier = 1.0` outside Zen mode. The renderer
|
||||
/// uses these zero markers to skip rows the player would not benefit
|
||||
/// from seeing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ScoreBreakdown {
|
||||
/// Running game score before the win-time bonuses are applied.
|
||||
/// Equal to `pending.score`, which is `GameState::score` at the
|
||||
/// moment of `GameWonEvent`.
|
||||
pub base: i32,
|
||||
/// Time-bonus component — `compute_time_bonus(time_seconds)`.
|
||||
/// Zero when `time_seconds == 0` or when the formula yields zero.
|
||||
pub time_bonus: i32,
|
||||
/// Score awarded for completing the win without using undo.
|
||||
/// Zero when `undo_count > 0`.
|
||||
pub no_undo_bonus: i32,
|
||||
/// Multiplier applied to `(base + time_bonus + no_undo_bonus)` to
|
||||
/// produce the final total. `0.0` for Zen mode (which never
|
||||
/// scores), `1.0` otherwise.
|
||||
pub multiplier: f32,
|
||||
/// Game mode the win occurred in. Used by the renderer to format
|
||||
/// the multiplier row label, e.g. `"Mode multiplier (Zen ×0)"`.
|
||||
pub mode: GameMode,
|
||||
/// Elapsed game time in seconds, used to format the time-bonus
|
||||
/// row label as `m:ss`.
|
||||
pub time_seconds: u64,
|
||||
}
|
||||
|
||||
impl ScoreBreakdown {
|
||||
/// Builds a breakdown for the given win, applying the player's
|
||||
/// **cosmetic** time-bonus multiplier (`Settings::time_bonus_multiplier`)
|
||||
/// to the raw `compute_time_bonus` result before storing it on the
|
||||
/// breakdown.
|
||||
///
|
||||
/// `base` is the running game score (`pending.score`); `time_seconds`,
|
||||
/// `undo_count`, and `mode` come from the captured `WinSummaryPending`;
|
||||
/// `time_bonus_multiplier` comes from `SettingsResource`. All score
|
||||
/// arithmetic is saturating to keep the breakdown safe even for
|
||||
/// pathologically high scores.
|
||||
///
|
||||
/// The multiplier is **purely visual** — it changes what the player
|
||||
/// sees in the win modal but does **not** affect achievement
|
||||
/// thresholds, leaderboard submissions, or `StatsSnapshot` totals,
|
||||
/// which all use the raw, unmultiplied scoring values.
|
||||
pub fn compute(
|
||||
base: i32,
|
||||
time_seconds: u64,
|
||||
undo_count: u32,
|
||||
mode: GameMode,
|
||||
time_bonus_multiplier: f32,
|
||||
) -> Self {
|
||||
let raw_bonus = compute_time_bonus(time_seconds);
|
||||
// Apply the cosmetic multiplier and round back to an integer so
|
||||
// the breakdown total stays a whole-number score.
|
||||
let scaled = (raw_bonus as f32 * time_bonus_multiplier).round();
|
||||
// Clamp into i32 range defensively — `raw_bonus` is already
|
||||
// bounded by `compute_time_bonus`, but a multiplier of 2.0 on
|
||||
// an i32::MAX-adjacent bonus could still overflow the cast.
|
||||
let time_bonus = if scaled.is_nan() {
|
||||
0
|
||||
} else {
|
||||
scaled.clamp(i32::MIN as f32, i32::MAX as f32) as i32
|
||||
};
|
||||
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
||||
let multiplier = match mode {
|
||||
GameMode::Zen => 0.0,
|
||||
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack => 1.0,
|
||||
};
|
||||
Self {
|
||||
base,
|
||||
time_bonus,
|
||||
no_undo_bonus,
|
||||
multiplier,
|
||||
mode,
|
||||
time_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
/// Final total displayed on the breakdown's bottom row, rounded
|
||||
/// half-to-even (Rust's default `as i32` cast truncates toward
|
||||
/// zero, which is fine for a non-fractional multiplier set).
|
||||
pub fn total(&self) -> i32 {
|
||||
let pre_mult = self
|
||||
.base
|
||||
.saturating_add(self.time_bonus)
|
||||
.saturating_add(self.no_undo_bonus);
|
||||
((pre_mult as f32) * self.multiplier) as i32
|
||||
}
|
||||
|
||||
/// Whether the no-undo bonus row should be rendered. Skipped when
|
||||
/// the player used undo (bonus is zero) so the modal does not
|
||||
/// show a "+0" line that adds nothing.
|
||||
pub fn shows_no_undo_row(&self) -> bool {
|
||||
self.no_undo_bonus > 0
|
||||
}
|
||||
|
||||
/// Whether the time-bonus row should be rendered. Skipped when
|
||||
/// the bonus is zero (e.g. `time_seconds == 0`).
|
||||
pub fn shows_time_bonus_row(&self) -> bool {
|
||||
self.time_bonus > 0
|
||||
}
|
||||
|
||||
/// Whether the mode-multiplier row should be rendered. Skipped
|
||||
/// for `multiplier == 1.0` so Classic/Challenge/TimeAttack wins
|
||||
/// do not show a redundant "×1.0" line.
|
||||
pub fn shows_multiplier_row(&self) -> bool {
|
||||
(self.multiplier - 1.0).abs() > f32::EPSILON
|
||||
}
|
||||
|
||||
/// Total number of rows the breakdown will spawn, counting the
|
||||
/// always-present `Base score` and `Total` rows plus the
|
||||
/// separator. Used by tests to assert spawn counts deterministically.
|
||||
pub fn row_count(&self) -> usize {
|
||||
let mut n = 1; // base
|
||||
if self.shows_time_bonus_row() {
|
||||
n += 1;
|
||||
}
|
||||
if self.shows_no_undo_row() {
|
||||
n += 1;
|
||||
}
|
||||
if self.shows_multiplier_row() {
|
||||
n += 1;
|
||||
}
|
||||
n += 1; // separator
|
||||
n += 1; // total
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable display name for a game mode. Used as the prefix in
|
||||
/// the mode-multiplier row, e.g. `"Mode multiplier (Zen ×0)"`.
|
||||
fn mode_display_name(mode: GameMode) -> &'static str {
|
||||
match mode {
|
||||
GameMode::Classic => "Classic",
|
||||
GameMode::Zen => "Zen",
|
||||
GameMode::Challenge => "Challenge",
|
||||
GameMode::TimeAttack => "Time Attack",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -267,6 +476,8 @@ fn cache_win_data(
|
||||
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
||||
pending.new_record = is_new_record;
|
||||
pending.challenge_level = challenge_level;
|
||||
pending.undo_count = game.0.undo_count;
|
||||
pending.mode = game.0.mode;
|
||||
|
||||
if is_new_record {
|
||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||
@@ -365,7 +576,26 @@ fn spawn_win_summary_after_delay(
|
||||
pending.xp = pending.xp.saturating_add(ev.amount);
|
||||
}
|
||||
let challenge_level = pending.challenge_level;
|
||||
spawn_overlay(&mut commands, &pending, &session, challenge_level);
|
||||
// Re-derive AnimSpeed here — the `speed` binding above
|
||||
// only lives inside the `for _ in won.read()` loop.
|
||||
let anim_speed = settings
|
||||
.as_ref()
|
||||
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
|
||||
// The cosmetic time-bonus multiplier is also pulled
|
||||
// here — defaults to 1.0 (no change) when settings are
|
||||
// absent (tests under MinimalPlugins without
|
||||
// SettingsPlugin).
|
||||
let time_bonus_multiplier = settings
|
||||
.as_ref()
|
||||
.map_or(1.0_f32, |s| s.0.time_bonus_multiplier);
|
||||
spawn_overlay(
|
||||
&mut commands,
|
||||
&pending,
|
||||
&session,
|
||||
challenge_level,
|
||||
anim_speed,
|
||||
time_bonus_multiplier,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -395,6 +625,31 @@ fn handle_win_summary_buttons(
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard accelerator for the win summary's "Play Again" button.
|
||||
/// Enter / Return collapses the win modal and starts a fresh deal —
|
||||
/// the same path the click handler takes — so a keyboard-only player
|
||||
/// can dismiss the post-win celebration without reaching for the mouse.
|
||||
fn handle_win_summary_keyboard(
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut commands: Commands,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
if overlays.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(keys) = keys else {
|
||||
return;
|
||||
};
|
||||
if !keys.just_pressed(KeyCode::Enter) {
|
||||
return;
|
||||
}
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
new_game.write(NewGameRequestEvent::default());
|
||||
}
|
||||
|
||||
/// Applies a decaying sinusoidal offset to the main `Camera2d` each frame
|
||||
/// while `ScreenShakeResource::remaining > 0`.
|
||||
///
|
||||
@@ -439,12 +694,32 @@ fn apply_screen_shake(
|
||||
///
|
||||
/// `challenge_level` is `Some(N)` when the win was a Challenge-mode completion;
|
||||
/// a "Challenge N complete!" annotation is added to the modal header in that case.
|
||||
///
|
||||
/// `anim_speed` controls the score-breakdown reveal: under
|
||||
/// `AnimSpeed::Instant`, every breakdown row is spawned visible and at
|
||||
/// full opacity (no stagger, no fade); otherwise rows are spawned
|
||||
/// hidden and the [`reveal_score_breakdown`] system fades them in over
|
||||
/// roughly one second.
|
||||
///
|
||||
/// `time_bonus_multiplier` is the player's cosmetic
|
||||
/// `Settings::time_bonus_multiplier` and is folded into the time-bonus
|
||||
/// row of the score breakdown only — it does **not** alter any stored
|
||||
/// score or achievement-unlock evaluation.
|
||||
fn spawn_overlay(
|
||||
commands: &mut Commands,
|
||||
pending: &WinSummaryPending,
|
||||
session: &SessionAchievements,
|
||||
challenge_level: Option<u32>,
|
||||
anim_speed: AnimSpeed,
|
||||
time_bonus_multiplier: f32,
|
||||
) {
|
||||
let breakdown = ScoreBreakdown::compute(
|
||||
pending.score,
|
||||
pending.time_seconds,
|
||||
pending.undo_count,
|
||||
pending.mode,
|
||||
time_bonus_multiplier,
|
||||
);
|
||||
commands
|
||||
.spawn((
|
||||
WinSummaryOverlay,
|
||||
@@ -502,12 +777,9 @@ fn spawn_overlay(
|
||||
));
|
||||
}
|
||||
|
||||
// Score
|
||||
card.spawn((
|
||||
Text::new(format!("Score: {}", pending.score)),
|
||||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
// Score breakdown reveal — replaces the previous single
|
||||
// "Score:" line with a per-component multi-row layout.
|
||||
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||||
|
||||
// Time
|
||||
card.spawn((
|
||||
@@ -552,8 +824,11 @@ fn spawn_overlay(
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
// Append the Enter / Return glyph so keyboard players see
|
||||
// the accelerator on the button itself — mirrors the
|
||||
// chip-style hints on every modal button helper.
|
||||
b.spawn((
|
||||
Text::new("Play Again"),
|
||||
Text::new("Play Again \u{21B5}"),
|
||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||
TextColor(BG_BASE),
|
||||
));
|
||||
@@ -597,6 +872,220 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the score-breakdown rows inside the win-modal card.
|
||||
///
|
||||
/// Rows are appended in this order — only the first and last two are
|
||||
/// always present, the middle three depend on `breakdown`:
|
||||
///
|
||||
/// 1. `Base score` — value column = `breakdown.base`.
|
||||
/// 2. `Time bonus (m:ss)` — only when `breakdown.shows_time_bonus_row()`.
|
||||
/// 3. `No-undo bonus` — only when `breakdown.shows_no_undo_row()`.
|
||||
/// 4. `Mode multiplier (Mode-name ×N)` — only when
|
||||
/// `breakdown.shows_multiplier_row()`.
|
||||
/// 5. Separator (em-dashes).
|
||||
/// 6. `Total` — value column = `breakdown.total()`.
|
||||
///
|
||||
/// Every row is spawned with a [`ScoreBreakdownRow`] component carrying
|
||||
/// a per-row stagger delay calculated from
|
||||
/// [`MOTION_SCORE_BREAKDOWN_STAGGER_SECS`]. Under `AnimSpeed::Instant`,
|
||||
/// stagger and fade are both zero so the breakdown appears in one frame.
|
||||
fn spawn_score_breakdown(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
breakdown: &ScoreBreakdown,
|
||||
anim_speed: AnimSpeed,
|
||||
) {
|
||||
let stagger = scaled_duration(MOTION_SCORE_BREAKDOWN_STAGGER_SECS, anim_speed);
|
||||
let fade = scaled_duration(MOTION_SCORE_BREAKDOWN_FADE_SECS, anim_speed);
|
||||
let mut row_index: u32 = 0;
|
||||
|
||||
// 1. Base score — always shown.
|
||||
spawn_breakdown_row(
|
||||
card,
|
||||
"Base score",
|
||||
format!("{}", breakdown.base),
|
||||
ACCENT_PRIMARY,
|
||||
anim_speed,
|
||||
stagger * row_index as f32,
|
||||
fade,
|
||||
);
|
||||
row_index += 1;
|
||||
|
||||
// 2. Time bonus.
|
||||
if breakdown.shows_time_bonus_row() {
|
||||
spawn_breakdown_row(
|
||||
card,
|
||||
&format!("Time bonus ({})", format_win_time(breakdown.time_seconds)),
|
||||
format!("+{}", breakdown.time_bonus),
|
||||
STATE_SUCCESS,
|
||||
anim_speed,
|
||||
stagger * row_index as f32,
|
||||
fade,
|
||||
);
|
||||
row_index += 1;
|
||||
}
|
||||
|
||||
// 3. No-undo bonus.
|
||||
if breakdown.shows_no_undo_row() {
|
||||
spawn_breakdown_row(
|
||||
card,
|
||||
"No-undo bonus",
|
||||
format!("+{}", breakdown.no_undo_bonus),
|
||||
STATE_SUCCESS,
|
||||
anim_speed,
|
||||
stagger * row_index as f32,
|
||||
fade,
|
||||
);
|
||||
row_index += 1;
|
||||
}
|
||||
|
||||
// 4. Mode multiplier (only when not 1.0).
|
||||
if breakdown.shows_multiplier_row() {
|
||||
let mode_name = mode_display_name(breakdown.mode);
|
||||
spawn_breakdown_row(
|
||||
card,
|
||||
&format!("Mode multiplier ({mode_name} ×{:.1})", breakdown.multiplier),
|
||||
format!("×{:.1}", breakdown.multiplier),
|
||||
STATE_INFO,
|
||||
anim_speed,
|
||||
stagger * row_index as f32,
|
||||
fade,
|
||||
);
|
||||
row_index += 1;
|
||||
}
|
||||
|
||||
// 5. Separator — em-dashes spanning the visual width.
|
||||
spawn_breakdown_row(
|
||||
card,
|
||||
"─────────────────",
|
||||
"─────".to_string(),
|
||||
TEXT_SECONDARY,
|
||||
anim_speed,
|
||||
stagger * row_index as f32,
|
||||
fade,
|
||||
);
|
||||
row_index += 1;
|
||||
|
||||
// 6. Total — emphasised in primary accent.
|
||||
spawn_breakdown_row(
|
||||
card,
|
||||
"Total",
|
||||
format!("{}", breakdown.total()),
|
||||
ACCENT_PRIMARY,
|
||||
anim_speed,
|
||||
stagger * row_index as f32,
|
||||
fade,
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawns one row of the score breakdown — a flex-row `Node` with two
|
||||
/// `Text` children (label left, value right). The row is tagged with
|
||||
/// [`ScoreBreakdownRow`] and starts hidden when `anim_speed` is anything
|
||||
/// other than [`AnimSpeed::Instant`]; the [`reveal_score_breakdown`]
|
||||
/// system flips it visible after `delay_secs` and fades in the text
|
||||
/// over `fade_duration_secs`.
|
||||
fn spawn_breakdown_row(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
value: String,
|
||||
value_color: Color,
|
||||
anim_speed: AnimSpeed,
|
||||
delay_secs: f32,
|
||||
fade_duration_secs: f32,
|
||||
) {
|
||||
// Under Instant, every row is visible immediately at full opacity.
|
||||
let instant = matches!(anim_speed, AnimSpeed::Instant);
|
||||
let initial_visibility = if instant {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
let initial_alpha = if instant { 1.0 } else { 0.0 };
|
||||
|
||||
let label_color_with_alpha = TEXT_PRIMARY.with_alpha(initial_alpha);
|
||||
let value_color_with_alpha = value_color.with_alpha(initial_alpha);
|
||||
|
||||
card.spawn((
|
||||
ScoreBreakdownRow {
|
||||
delay_secs,
|
||||
fade_elapsed_secs: 0.0,
|
||||
fade_duration_secs,
|
||||
revealed: instant,
|
||||
},
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
min_width: Val::Px(280.0),
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
initial_visibility,
|
||||
))
|
||||
.with_children(|row| {
|
||||
// Label — left-aligned.
|
||||
row.spawn((
|
||||
Text::new(label.to_string()),
|
||||
TextFont { font_size: TYPE_BODY, ..default() },
|
||||
TextColor(label_color_with_alpha),
|
||||
));
|
||||
// Value — right-aligned via the parent's JustifyContent::SpaceBetween.
|
||||
row.spawn((
|
||||
Text::new(value),
|
||||
TextFont { font_size: TYPE_BODY, ..default() },
|
||||
TextColor(value_color_with_alpha),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Reveal system — ticks each [`ScoreBreakdownRow`] down toward zero
|
||||
/// and fades its child `Text` alpha from 0 → 1 over the row's
|
||||
/// `fade_duration_secs` once `delay_secs` elapses.
|
||||
///
|
||||
/// The system is non-blocking: the Play Again button is interactable
|
||||
/// from the moment the modal spawns; the breakdown reveal just plays
|
||||
/// out underneath. Rows that have already reached full opacity are
|
||||
/// skipped via the `revealed` flag plus an early
|
||||
/// `fade_elapsed >= fade_duration` short-circuit on the alpha lerp.
|
||||
pub fn reveal_score_breakdown(
|
||||
time: Res<Time>,
|
||||
mut rows: Query<(&mut ScoreBreakdownRow, &mut Visibility, Option<&Children>)>,
|
||||
mut texts: Query<&mut TextColor>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (mut row, mut visibility, children) in &mut rows {
|
||||
if !row.revealed {
|
||||
row.delay_secs -= dt;
|
||||
if row.delay_secs <= 0.0 {
|
||||
*visibility = Visibility::Inherited;
|
||||
row.revealed = true;
|
||||
} else {
|
||||
continue; // still hidden, no fade work yet
|
||||
}
|
||||
}
|
||||
// Row is revealed — drive the fade-in until it's fully opaque.
|
||||
let fade_done = row.fade_elapsed_secs >= row.fade_duration_secs;
|
||||
if !fade_done {
|
||||
row.fade_elapsed_secs += dt;
|
||||
}
|
||||
let t = if row.fade_duration_secs <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
(row.fade_elapsed_secs / row.fade_duration_secs).clamp(0.0, 1.0)
|
||||
};
|
||||
let target_alpha = if fade_done { 1.0 } else { t };
|
||||
if let Some(children) = children {
|
||||
for child in children.iter() {
|
||||
if let Ok(mut tc) = texts.get_mut(child) {
|
||||
let c = tc.0;
|
||||
if (c.alpha() - target_alpha).abs() > f32::EPSILON {
|
||||
tc.0 = c.with_alpha(target_alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -662,6 +1151,8 @@ mod tests {
|
||||
assert!(p.xp_detail.is_empty());
|
||||
assert!(!p.new_record);
|
||||
assert!(p.challenge_level.is_none());
|
||||
assert_eq!(p.undo_count, 0);
|
||||
assert_eq!(p.mode, GameMode::Classic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -941,4 +1432,254 @@ mod tests {
|
||||
"challenge_level must be None for non-Challenge wins"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Score-breakdown tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `cache_win_data` captures both `undo_count` and `mode` from the
|
||||
/// `GameStateResource` at the moment of `GameWonEvent`. The breakdown
|
||||
/// reveal needs both fields to format the no-undo-bonus and
|
||||
/// mode-multiplier rows.
|
||||
#[test]
|
||||
fn cache_win_data_captures_undo_count_and_mode() {
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
|
||||
let mut app = make_app();
|
||||
// Set up a Zen-mode game with 2 undos used.
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
|
||||
game.0.undo_count = 2;
|
||||
}
|
||||
|
||||
app.world_mut()
|
||||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert_eq!(pending.undo_count, 2);
|
||||
assert_eq!(pending.mode, GameMode::Zen);
|
||||
}
|
||||
|
||||
/// `ScoreBreakdown::compute` produces the expected per-component
|
||||
/// values for a non-trivial Classic-mode win. Time-bonus is the
|
||||
/// canonical `compute_time_bonus(120) = 5833` (700_000 / 120) and
|
||||
/// the no-undo bonus fires because `undo_count == 0`.
|
||||
#[test]
|
||||
fn score_breakdown_compute_produces_expected_components() {
|
||||
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||||
assert_eq!(bd.base, 3200);
|
||||
assert_eq!(bd.time_bonus, 5833); // 700_000 / 120
|
||||
assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS);
|
||||
assert!((bd.multiplier - 1.0).abs() < f32::EPSILON);
|
||||
// Classic ×1.0 → multiplier row is suppressed.
|
||||
assert!(!bd.shows_multiplier_row());
|
||||
// Total == base + time_bonus + no_undo_bonus.
|
||||
assert_eq!(bd.total(), 3200 + 5833 + SCORE_NO_UNDO_BONUS);
|
||||
}
|
||||
|
||||
/// Zen-mode wins produce a zero multiplier — the breakdown shows
|
||||
/// the multiplier row and the total collapses to zero regardless
|
||||
/// of the other components.
|
||||
#[test]
|
||||
fn score_breakdown_zen_mode_zeros_total() {
|
||||
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen, 1.0);
|
||||
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
|
||||
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
|
||||
assert_eq!(bd.total(), 0);
|
||||
}
|
||||
|
||||
/// When the player used undo, the `no_undo_bonus` is zero and the
|
||||
/// row is suppressed.
|
||||
#[test]
|
||||
fn score_breakdown_skips_no_undo_row_when_undo_was_used() {
|
||||
let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic, 1.0);
|
||||
assert_eq!(bd.no_undo_bonus, 0);
|
||||
assert!(!bd.shows_no_undo_row());
|
||||
}
|
||||
|
||||
/// At `time_seconds == 0` the time-bonus formula yields 0; the row
|
||||
/// is suppressed.
|
||||
#[test]
|
||||
fn score_breakdown_skips_time_bonus_row_when_zero() {
|
||||
let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic, 1.0);
|
||||
assert_eq!(bd.time_bonus, 0);
|
||||
assert!(!bd.shows_time_bonus_row());
|
||||
}
|
||||
|
||||
/// `row_count()` reports the number of rows the renderer will
|
||||
/// spawn. A non-trivial Classic win with both bonuses produces:
|
||||
/// base + time + no-undo + separator + total = 5 rows (no
|
||||
/// multiplier row, ×1.0 is suppressed).
|
||||
#[test]
|
||||
fn win_modal_score_breakdown_spawns_one_row_per_component() {
|
||||
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||||
assert_eq!(
|
||||
bd.row_count(),
|
||||
5,
|
||||
"Classic with both bonuses: base + time + no-undo + sep + total"
|
||||
);
|
||||
|
||||
// Zen with both bonuses ALSO shows the multiplier row.
|
||||
let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen, 1.0);
|
||||
assert_eq!(
|
||||
zen.row_count(),
|
||||
6,
|
||||
"Zen with both bonuses: base + time + no-undo + multiplier + sep + total"
|
||||
);
|
||||
}
|
||||
|
||||
/// When `no_undo_bonus == 0`, the row count drops by one.
|
||||
#[test]
|
||||
fn win_modal_score_breakdown_skips_zero_bonus_rows() {
|
||||
let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||||
let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic, 1.0);
|
||||
assert_eq!(
|
||||
bd_with.row_count() - 1,
|
||||
bd_without.row_count(),
|
||||
"removing the no-undo bonus must remove exactly one row"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cosmetic time-bonus multiplier from `Settings::time_bonus_multiplier`
|
||||
/// scales the displayed `time_bonus` row by the factor, rounded to
|
||||
/// the nearest integer. A `0.5` multiplier halves the canonical
|
||||
/// `compute_time_bonus(120) = 5833` to `2917` (5833 × 0.5 = 2916.5,
|
||||
/// round-half-to-even via `.round()` lands on 2917 in IEEE-754).
|
||||
#[test]
|
||||
fn score_breakdown_applies_time_bonus_multiplier() {
|
||||
let raw = compute_time_bonus(120);
|
||||
assert_eq!(raw, 5833, "sanity-check raw bonus before testing the multiplier");
|
||||
|
||||
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 0.5);
|
||||
let expected = ((raw as f32) * 0.5).round() as i32;
|
||||
assert_eq!(
|
||||
bd.time_bonus, expected,
|
||||
"time_bonus row must reflect raw_bonus × multiplier (rounded)"
|
||||
);
|
||||
// The row is still shown — value is 2917, not zero.
|
||||
assert!(bd.shows_time_bonus_row());
|
||||
}
|
||||
|
||||
/// At `multiplier == 0.0` ("Off"), the time-bonus row collapses to
|
||||
/// zero and is suppressed by the renderer (same path as a zero
|
||||
/// elapsed time).
|
||||
#[test]
|
||||
fn score_breakdown_off_multiplier_zeros_time_bonus() {
|
||||
let bd = ScoreBreakdown::compute(100, 120, 0, GameMode::Classic, 0.0);
|
||||
assert_eq!(
|
||||
bd.time_bonus, 0,
|
||||
"0.0 multiplier must zero out the displayed time bonus"
|
||||
);
|
||||
assert!(
|
||||
!bd.shows_time_bonus_row(),
|
||||
"with time_bonus = 0 the row must be suppressed by the renderer"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `2.0` multiplier doubles the displayed bonus — exercises the
|
||||
/// upper end of the slider range.
|
||||
#[test]
|
||||
fn score_breakdown_double_multiplier_doubles_time_bonus() {
|
||||
let raw = compute_time_bonus(120);
|
||||
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 2.0);
|
||||
let expected = ((raw as f32) * 2.0).round() as i32;
|
||||
assert_eq!(bd.time_bonus, expected);
|
||||
}
|
||||
|
||||
/// Pure helper test: the reveal logic uses delta-time to count
|
||||
/// down `delay_secs`; at `t = 0` only the first row is "revealed",
|
||||
/// and after one stagger interval the second row reveals as well.
|
||||
/// We exercise the system directly on a hand-built world rather
|
||||
/// than going through the full modal-spawn path so the test is
|
||||
/// independent of `Time` resource quirks.
|
||||
#[test]
|
||||
fn score_breakdown_reveal_advances_visibility_per_stagger() {
|
||||
use bevy::time::TimePlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>());
|
||||
app.init_resource::<Time>();
|
||||
app.add_systems(Update, reveal_score_breakdown);
|
||||
|
||||
// Spawn three rows with delays of 0.0, 0.15, and 0.30 s.
|
||||
let stagger = MOTION_SCORE_BREAKDOWN_STAGGER_SECS;
|
||||
let fade = MOTION_SCORE_BREAKDOWN_FADE_SECS;
|
||||
let row0 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
ScoreBreakdownRow {
|
||||
delay_secs: 0.0,
|
||||
fade_elapsed_secs: 0.0,
|
||||
fade_duration_secs: fade,
|
||||
revealed: false,
|
||||
},
|
||||
Visibility::Hidden,
|
||||
))
|
||||
.id();
|
||||
let row1 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
ScoreBreakdownRow {
|
||||
delay_secs: stagger,
|
||||
fade_elapsed_secs: 0.0,
|
||||
fade_duration_secs: fade,
|
||||
revealed: false,
|
||||
},
|
||||
Visibility::Hidden,
|
||||
))
|
||||
.id();
|
||||
let row2 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
ScoreBreakdownRow {
|
||||
delay_secs: stagger * 2.0,
|
||||
fade_elapsed_secs: 0.0,
|
||||
fade_duration_secs: fade,
|
||||
revealed: false,
|
||||
},
|
||||
Visibility::Hidden,
|
||||
))
|
||||
.id();
|
||||
|
||||
// Frame 1: `time.delta` is 0 (first frame), so only row0
|
||||
// (delay = 0) should reveal.
|
||||
app.update();
|
||||
assert!(app.world().entity(row0).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(!app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
|
||||
// Advance time by one stagger interval — row1 should reveal.
|
||||
{
|
||||
let mut time = app.world_mut().resource_mut::<Time>();
|
||||
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||||
}
|
||||
app.update();
|
||||
assert!(app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
|
||||
// Advance again — row2 should reveal.
|
||||
{
|
||||
let mut time = app.world_mut().resource_mut::<Time>();
|
||||
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||||
}
|
||||
app.update();
|
||||
assert!(app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||
}
|
||||
|
||||
/// Under `AnimSpeed::Instant`, breakdown rows must spawn already
|
||||
/// revealed and at full opacity — there should be no stagger
|
||||
/// reveal animation at all.
|
||||
#[test]
|
||||
fn score_breakdown_instant_speed_skips_stagger() {
|
||||
// Helper: simulate what `spawn_breakdown_row` constructs by
|
||||
// checking the `instant` branch behaviour. Specifically: under
|
||||
// Instant, scaled_duration → 0.0, so the row's stagger and
|
||||
// fade are both zero.
|
||||
let stagger = scaled_duration(MOTION_SCORE_BREAKDOWN_STAGGER_SECS, AnimSpeed::Instant);
|
||||
let fade = scaled_duration(MOTION_SCORE_BREAKDOWN_FADE_SECS, AnimSpeed::Instant);
|
||||
assert_eq!(stagger, 0.0);
|
||||
assert_eq!(fade, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ sqlx = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
bcrypt = { workspace = true }
|
||||
tower_governor = { workspace = true }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Migration 002: winning-replay storage
|
||||
--
|
||||
-- One row per winning replay uploaded via POST /api/replays. The replay
|
||||
-- itself is stored as the canonical JSON the desktop client wrote — it
|
||||
-- already carries a schema_version field, so the server doesn't need to
|
||||
-- shape-validate the payload beyond ensuring it parses as JSON.
|
||||
--
|
||||
-- The handful of denormalised columns (final_score, time_seconds,
|
||||
-- recorded_at) are projected out of the JSON at insert time so list
|
||||
-- endpoints (e.g. recent / per-user / leaderboard-style sorts) can be
|
||||
-- served via a covering query without touching every row's blob.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS replays (
|
||||
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
seed INTEGER NOT NULL, -- replay's deal seed
|
||||
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
|
||||
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
|
||||
time_seconds INTEGER NOT NULL, -- duration of the win
|
||||
final_score INTEGER NOT NULL, -- final score at the win
|
||||
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
|
||||
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
|
||||
replay_json TEXT NOT NULL -- full Replay serialisation
|
||||
);
|
||||
|
||||
-- Recent-replays list endpoint sorts by received_at DESC; the index
|
||||
-- keeps that scan cheap on a populated table.
|
||||
CREATE INDEX IF NOT EXISTS replays_received_at_idx
|
||||
ON replays(received_at DESC);
|
||||
|
||||
-- Lookups by user (e.g. "my replays" view) are common too.
|
||||
CREATE INDEX IF NOT EXISTS replays_user_id_idx
|
||||
ON replays(user_id);
|
||||
@@ -31,6 +31,10 @@ pub enum AppError {
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
/// The requested resource does not exist.
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// A database error occurred.
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
@@ -56,6 +60,7 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::Database(e) => {
|
||||
tracing::error!("database error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||
|
||||
@@ -9,11 +9,13 @@ pub mod challenge;
|
||||
pub mod error;
|
||||
pub mod leaderboard;
|
||||
pub mod middleware;
|
||||
pub mod replays;
|
||||
pub mod sync;
|
||||
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware as axum_middleware,
|
||||
response::Html,
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
@@ -24,6 +26,7 @@ use tower_governor::{
|
||||
key_extractor::SmartIpKeyExtractor,
|
||||
GovernorLayer,
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
/// Shared application state injected into every Axum handler via [`axum::extract::State`].
|
||||
///
|
||||
@@ -64,6 +67,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/api/sync/pull", get(sync::pull))
|
||||
.route("/api/sync/push", post(sync::push))
|
||||
.route("/api/replays", post(replays::upload))
|
||||
.route("/api/leaderboard", get(leaderboard::get_leaderboard))
|
||||
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
||||
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||
@@ -98,12 +102,27 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
// Public endpoints (no auth, no rate limit beyond defaults).
|
||||
let public = Router::new()
|
||||
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||
.route("/api/replays/recent", get(replays::recent))
|
||||
.route("/api/replays/{id}", get(replays::get_by_id))
|
||||
.route("/health", get(health));
|
||||
|
||||
// Replay web UI: a single HTML page served at `/replays/:id` plus a
|
||||
// ServeDir for the static assets (`web/index.html`, `web/replay.css`,
|
||||
// and the wasm-bindgen-generated `web/pkg/`). The HTML page is the
|
||||
// same regardless of `:id` — it reads the path from `location` in JS
|
||||
// and fetches the replay JSON from `/api/replays/:id`.
|
||||
let web = Router::new()
|
||||
.route(
|
||||
"/replays/{id}",
|
||||
get(|| async { Html(include_str!("../web/index.html")) }),
|
||||
)
|
||||
.nest_service("/web", ServeDir::new("solitaire_server/web"));
|
||||
|
||||
Router::new()
|
||||
.merge(protected)
|
||||
.merge(auth_routes)
|
||||
.merge(public)
|
||||
.merge(web)
|
||||
// Reject request bodies larger than 1 MB.
|
||||
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||
.with_state(state)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Winning-replay storage and retrieval.
|
||||
//!
|
||||
//! `POST /api/replays` — upload a winning replay (auth required).
|
||||
//! `GET /api/replays/recent` — list the N most-recent replays across users.
|
||||
//! `GET /api/replays/:id` — fetch a single replay's full JSON.
|
||||
//!
|
||||
//! The replay payload itself is opaque to the server — the desktop client
|
||||
//! generates a `solitaire_data::Replay` and the web playback re-executes
|
||||
//! the same atomic input list against a fresh `GameState`. The server
|
||||
//! just persists, indexes, and serves the JSON; it does not validate the
|
||||
//! semantics of the move list.
|
||||
//!
|
||||
//! Three columns are projected out of the replay JSON at insert time
|
||||
//! (`final_score`, `time_seconds`, `recorded_at`) so list endpoints can
|
||||
//! be served without scanning every blob.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Subset of `Replay` fields the server needs to project out of the
|
||||
/// uploaded JSON to populate the denormalised columns. Mirrors the
|
||||
/// fields on `solitaire_data::Replay`; we don't depend on
|
||||
/// `solitaire_data` here because the server crate must not pull in
|
||||
/// the desktop client's transitive dependencies.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayHeader {
|
||||
seed: i64,
|
||||
draw_mode: String,
|
||||
mode: String,
|
||||
time_seconds: i64,
|
||||
final_score: i64,
|
||||
recorded_at: String,
|
||||
}
|
||||
|
||||
/// Successful upload acknowledgement. The server-minted `id` is what
|
||||
/// the client / web UI uses to link to `/replays/<id>`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReplayUploadResponse {
|
||||
/// UUID v4 minted server-side at insert time.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// One row in the recent-replays list. Just the projection columns —
|
||||
/// the full move list lives behind `GET /api/replays/:id`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReplaySummary {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub seed: i64,
|
||||
pub draw_mode: String,
|
||||
pub mode: String,
|
||||
pub time_seconds: i64,
|
||||
pub final_score: i64,
|
||||
pub recorded_at: String,
|
||||
pub received_at: String,
|
||||
}
|
||||
|
||||
/// `GET /api/replays/recent?limit=N` — bound the result set so a
|
||||
/// long-tail history doesn't ship megabytes per request.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecentQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/replays` — accept a winning replay JSON, persist it,
|
||||
/// return the server-minted `id`. Auth required (the upload is
|
||||
/// attributed to the authenticated user).
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Result<Json<ReplayUploadResponse>, AppError> {
|
||||
// Project the header fields the SQL columns need. The full payload
|
||||
// is stored verbatim — schema_version sits inside it and the
|
||||
// playback path is what enforces compatibility.
|
||||
let header: ReplayHeader = serde_json::from_value(payload.clone())
|
||||
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let received_at = Utc::now().to_rfc3339();
|
||||
let replay_json = serde_json::to_string(&payload)?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO replays (
|
||||
id, user_id, seed, draw_mode, mode, time_seconds, final_score,
|
||||
recorded_at, received_at, replay_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
id,
|
||||
user.user_id,
|
||||
header.seed,
|
||||
header.draw_mode,
|
||||
header.mode,
|
||||
header.time_seconds,
|
||||
header.final_score,
|
||||
header.recorded_at,
|
||||
received_at,
|
||||
replay_json,
|
||||
)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ReplayUploadResponse { id }))
|
||||
}
|
||||
|
||||
/// `GET /api/replays/recent` — list the N most-recent replays across
|
||||
/// every user, newest first. Auth not required so the web UI can show
|
||||
/// a public "latest wins" feed without a logged-in client.
|
||||
pub async fn recent(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<RecentQuery>,
|
||||
) -> Result<Json<Vec<ReplaySummary>>, AppError> {
|
||||
// 50 is a sane upper bound so a `?limit=999999` request can't make
|
||||
// the server allocate megabytes. 20 is the default for a quick feed.
|
||||
let limit = q.limit.unwrap_or(20).min(50) as i64;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT
|
||||
r.id AS "id!: String",
|
||||
u.username AS "username!: String",
|
||||
r.seed AS "seed!: i64",
|
||||
r.draw_mode AS "draw_mode!: String",
|
||||
r.mode AS "mode!: String",
|
||||
r.time_seconds AS "time_seconds!: i64",
|
||||
r.final_score AS "final_score!: i64",
|
||||
r.recorded_at AS "recorded_at!: String",
|
||||
r.received_at AS "received_at!: String"
|
||||
FROM replays r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
ORDER BY r.received_at DESC
|
||||
LIMIT ?"#,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
rows.into_iter()
|
||||
.map(|r| ReplaySummary {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
seed: r.seed,
|
||||
draw_mode: r.draw_mode,
|
||||
mode: r.mode,
|
||||
time_seconds: r.time_seconds,
|
||||
final_score: r.final_score,
|
||||
recorded_at: r.recorded_at,
|
||||
received_at: r.received_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /api/replays/:id` — return the full replay JSON the desktop
|
||||
/// client uploaded. Public; the web UI fetches this directly.
|
||||
///
|
||||
/// The server does not validate or transform the payload — what was
|
||||
/// stored is what's returned. Schema-version compatibility is the
|
||||
/// responsibility of the playback side (web UI), matching the
|
||||
/// `schema_version` gate the desktop loader uses.
|
||||
pub async fn get_by_id(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT replay_json FROM replays WHERE id = ?",
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let replay_json = row
|
||||
.ok_or_else(|| AppError::NotFound("replay not found".into()))?
|
||||
.replay_json;
|
||||
let value: serde_json::Value = serde_json::from_str(&replay_json)?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
@@ -1447,3 +1447,150 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
|
||||
"11th request must be rate-limited with 429"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay endpoints
|
||||
//
|
||||
// End-to-end coverage for the upload → fetch → render path that powers
|
||||
// the web replay viewer. Each test boots the full router against an
|
||||
// in-memory SQLite, registers a user, and exercises one of the three
|
||||
// replay endpoints. The schema-correctness tests (storage round-trip,
|
||||
// version gate, atomic write) live in `solitaire_data::replay`; here we
|
||||
// only verify the HTTP transport + database layer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal v2 replay JSON the upload endpoint will accept.
|
||||
///
|
||||
/// Uses the same field shape `solitaire_data::Replay` produces — kept
|
||||
/// in sync by hand because the server crate intentionally does not
|
||||
/// depend on `solitaire_data` (which carries dirs/keyring/reqwest).
|
||||
fn sample_replay_payload(seed: u64, score: i32) -> Value {
|
||||
serde_json::json!({
|
||||
"schema_version": 2,
|
||||
"seed": seed,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 134,
|
||||
"final_score": score,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": [
|
||||
"StockClick",
|
||||
{ "Move": { "from": "Waste", "to": { "Tableau": 3 }, "count": 1 } }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// Round-trip: register → upload → fetch → assert the payload returned
|
||||
/// by `GET /api/replays/:id` matches what was uploaded byte-for-byte.
|
||||
/// This is the canonical "the web viewer can play back what the
|
||||
/// desktop client uploaded" test.
|
||||
#[tokio::test]
|
||||
async fn replay_upload_then_fetch_round_trips_payload() {
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
let (token, _) = register_user(app.clone(), "replay_round_trip_user", "p4ssword!").await;
|
||||
|
||||
let payload = sample_replay_payload(7654, 4321);
|
||||
let resp = post_authed(app.clone(), "/api/replays", &token, payload.clone()).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK, "upload must return 200");
|
||||
let id = body_json(resp).await["id"]
|
||||
.as_str()
|
||||
.expect("upload response missing `id`")
|
||||
.to_string();
|
||||
assert!(uuid::Uuid::parse_str(&id).is_ok(), "id must be a UUID");
|
||||
|
||||
// Fetch is public — no auth required, exercising the path the
|
||||
// logged-out web viewer takes.
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("/api/replays/{id}"))
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::empty())
|
||||
.expect("fetch request");
|
||||
let resp = app.clone().oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::OK, "fetch must return 200");
|
||||
let fetched = body_json(resp).await;
|
||||
assert_eq!(
|
||||
fetched, payload,
|
||||
"fetched payload must match what was uploaded byte-for-byte",
|
||||
);
|
||||
}
|
||||
|
||||
/// `GET /api/replays/:id` for an id that was never uploaded must
|
||||
/// return 404 (not 500). Exercises the `AppError::NotFound` mapping
|
||||
/// added in the server commit.
|
||||
#[tokio::test]
|
||||
async fn replay_fetch_unknown_id_returns_404() {
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/replays/nonexistent-id-1234")
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::empty())
|
||||
.expect("fetch request");
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Two uploads, then `GET /api/replays/recent` — the more recent
|
||||
/// upload must come first and the response must include the
|
||||
/// uploader's username (joined from the `users` table).
|
||||
#[tokio::test]
|
||||
async fn replay_recent_lists_newest_first_with_username() {
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
let (token, _) = register_user(app.clone(), "replay_recent_user", "p4ssword!").await;
|
||||
|
||||
let _ = post_authed(app.clone(), "/api/replays", &token, sample_replay_payload(1, 100)).await;
|
||||
let _ = post_authed(app.clone(), "/api/replays", &token, sample_replay_payload(2, 200)).await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/replays/recent")
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::empty())
|
||||
.expect("recent request");
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let entries = body_json(resp).await;
|
||||
let array = entries.as_array().expect("recent should return an array");
|
||||
assert!(array.len() >= 2, "two uploads should yield two list entries");
|
||||
// Newer upload (seed = 2) must appear before older one (seed = 1).
|
||||
let seeds: Vec<i64> = array
|
||||
.iter()
|
||||
.map(|e| e["seed"].as_i64().expect("seed should be an integer"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seeds, [2, 1],
|
||||
"received_at DESC: most recent upload first",
|
||||
);
|
||||
assert_eq!(
|
||||
array[0]["username"].as_str(),
|
||||
Some("replay_recent_user"),
|
||||
"username must be joined into the response",
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/replays` without an `Authorization` header must return
|
||||
/// 401, not silently insert as an anonymous user.
|
||||
#[tokio::test]
|
||||
async fn replay_upload_without_auth_returns_401() {
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
let resp = post_json(app, "/api/replays", sample_replay_payload(99, 50)).await;
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
/// `POST /api/replays` with a malformed body (missing fields the
|
||||
/// header projector needs) must return 400, not 500.
|
||||
#[tokio::test]
|
||||
async fn replay_upload_malformed_body_returns_400() {
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
let (token, _) = register_user(app.clone(), "replay_bad_body_user", "p4ssword!").await;
|
||||
let bad = serde_json::json!({ "schema_version": 2, "missing_required_fields": true });
|
||||
let resp = post_authed(app, "/api/replays", &token, bad).await;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Solitaire Quest — Replay</title>
|
||||
<link rel="stylesheet" href="/web/replay.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Solitaire Quest <span class="muted">— Replay</span></h1>
|
||||
<div id="caption" class="muted">Loading…</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="board"></section>
|
||||
|
||||
<section id="controls">
|
||||
<button id="btn-prev" disabled>⏮ Restart</button>
|
||||
<button id="btn-play">▶ Play</button>
|
||||
<button id="btn-step">⏭ Step</button>
|
||||
<span id="progress" class="muted">step 0 / 0</span>
|
||||
</section>
|
||||
|
||||
<section id="status" class="muted">
|
||||
<span id="score">Score 0</span>
|
||||
<span id="moves">Moves 0</span>
|
||||
<span id="result"></span>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/web/replay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Browser-side replay state machine. Owns a live `GameState` and the
|
||||
* replay's move list; each `step()` applies the next move.
|
||||
*/
|
||||
export class ReplayPlayer {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
ReplayPlayerFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_replayplayer_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Returns `true` once every move has been applied.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
is_finished() {
|
||||
const ret = wasm.replayplayer_is_finished(this.__wbg_ptr);
|
||||
return ret !== 0;
|
||||
}
|
||||
/**
|
||||
* Construct from a raw replay JSON string.
|
||||
* @param {string} replay_json
|
||||
*/
|
||||
constructor(replay_json) {
|
||||
const ptr0 = passStringToWasm0(replay_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.replayplayer_new(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
this.__wbg_ptr = ret[0];
|
||||
ReplayPlayerFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
||||
* @returns {any}
|
||||
*/
|
||||
state() {
|
||||
const ret = wasm.replayplayer_state(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Apply the next move; returns the post-step snapshot, or `null`
|
||||
* once the move list is exhausted.
|
||||
* @returns {any}
|
||||
*/
|
||||
step() {
|
||||
const ret = wasm.replayplayer_step(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* 0-indexed position of the next move to apply.
|
||||
* @returns {number}
|
||||
*/
|
||||
step_idx() {
|
||||
const ret = wasm.replayplayer_step_idx(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
/**
|
||||
* Total number of moves the replay contains.
|
||||
* @returns {number}
|
||||
*/
|
||||
total_steps() {
|
||||
const ret = wasm.replayplayer_total_steps(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg_Error_3639a60ed15f87e7: function(arg0, arg1) {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
},
|
||||
__wbg_new_227d7c05414eb861: function() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_2fad8ca02fd00684: function() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_3baa8d9866155c79: function() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
},
|
||||
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
|
||||
arg0[arg1] = arg2;
|
||||
},
|
||||
__wbg_set_f614f6a0608d1d1d: function(arg0, arg1, arg2) {
|
||||
arg0[arg1 >>> 0] = arg2;
|
||||
},
|
||||
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0) {
|
||||
// Cast intrinsic for `F64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0) {
|
||||
// Cast intrinsic for `U64 -> Externref`.
|
||||
const ret = BigInt.asUintN(64, arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./solitaire_wasm_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
return decodeText(ptr >>> 0, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasmInstance, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasmInstance = instance;
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('solitaire_wasm_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
Binary file not shown.
@@ -0,0 +1,183 @@
|
||||
/* Solitaire Quest replay viewer — palette mirrors the desktop client's
|
||||
midnight-purple Balatro tone (BG_BASE = #1A0F2E) and the dark felt
|
||||
from the engine's TABLE_COLOUR. */
|
||||
|
||||
:root {
|
||||
--bg: #0f0a1f;
|
||||
--felt: #0f4c30;
|
||||
--panel: #1a0f2e;
|
||||
--panel-hi: #2d1b69;
|
||||
--text: #f5f0ff;
|
||||
--text-muted: #b5a8d5;
|
||||
--accent: #ffd23f;
|
||||
--red: #cc3344;
|
||||
--black: #1a0f2e;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #ccc;
|
||||
--card-w: 80px;
|
||||
--card-h: 112px;
|
||||
--gap: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.muted { color: var(--text-muted); }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Board: a positioning context for both the dashed empty-pile slots
|
||||
and the absolutely-positioned card sprites. Width matches the
|
||||
7-column grid (7*card-w + 6 inter-column gaps), height covers the
|
||||
top row plus a worst-case 13-card tableau fan. Cards live as
|
||||
siblings of the slot placeholders so they can move between piles
|
||||
without ever changing parent — the transform-based `transition`
|
||||
then animates the flight. */
|
||||
#board {
|
||||
position: relative;
|
||||
background: var(--felt);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: calc(7 * var(--card-w) + 6 * var(--gap));
|
||||
/* Top row + a generous fan budget (12 fan steps + the card's
|
||||
own height) so a king-down-to-ace column never overflows. */
|
||||
height: calc(var(--card-h) + 32px + var(--card-h) + 12 * 28px);
|
||||
}
|
||||
|
||||
/* Empty-pile slot placeholders are absolutely positioned at the same
|
||||
coordinates the renderer uses for cards, so they line up perfectly
|
||||
when the pile is empty. */
|
||||
.slot {
|
||||
position: absolute;
|
||||
width: var(--card-w);
|
||||
height: var(--card-h);
|
||||
border: 2px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: absolute;
|
||||
/* `top: 0; left: 0` plus a per-card `transform: translate(...)`
|
||||
gives us a single transformed property to animate. Using
|
||||
`transform` (rather than `top` / `left`) lets the browser run
|
||||
the animation on the compositor — smooth even on the
|
||||
low-spec laptops the player tests on. */
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--card-w);
|
||||
height: var(--card-h);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
padding: 4px 6px;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 200ms ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.card.face-down {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
#482f97 0,
|
||||
#482f97 6px,
|
||||
#2d1b69 6px,
|
||||
#2d1b69 12px
|
||||
);
|
||||
color: transparent;
|
||||
border-color: #4a3a8a;
|
||||
}
|
||||
|
||||
.card .corner {
|
||||
position: absolute;
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card .corner.top { top: 4px; left: 6px; }
|
||||
.card .corner.bottom { bottom: 4px; right: 6px; transform: rotate(180deg); }
|
||||
|
||||
.card.red { color: var(--red); }
|
||||
.card.black { color: var(--black); }
|
||||
|
||||
.card .center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
#controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#controls button {
|
||||
background: var(--panel-hi);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#controls button:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
#controls button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#status {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#status #result.win {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Solitaire Quest replay viewer.
|
||||
//
|
||||
// Pulls the replay JSON from `/api/replays/:id`, hands it to the
|
||||
// `solitaire_wasm` ReplayPlayer (which owns a real solitaire_core
|
||||
// `GameState` compiled to WebAssembly), and renders each step's pile
|
||||
// snapshot as plain HTML cards. The WASM module is the single source
|
||||
// of truth for the rules engine — we don't re-implement Klondike in JS.
|
||||
//
|
||||
// Card flight animation: each card's DOM element persists across
|
||||
// re-renders, keyed by `card.id`. `render()` updates each card's
|
||||
// `transform: translate(...)` to its new (pile, index) coordinates;
|
||||
// the CSS `transition` on `transform` animates the flight. Cards that
|
||||
// disappear from the snapshot fade and remove; new cards fade in at
|
||||
// their target position.
|
||||
|
||||
import init, { ReplayPlayer } from "/web/pkg/solitaire_wasm.js";
|
||||
|
||||
const STEP_INTERVAL_MS = 600;
|
||||
const FAN_OFFSET_PX = 28;
|
||||
const CARD_W = 80;
|
||||
const CARD_H = 112;
|
||||
const GAP = 12;
|
||||
|
||||
// Pile origin (top-left of the slot, in board-relative pixels).
|
||||
// Top row: stock at column 0, waste at column 1, foundations at 3-6.
|
||||
// Bottom row: tableau columns 0-6.
|
||||
const TOP_ROW_Y = 0;
|
||||
const TABLEAU_ROW_Y = CARD_H + 32;
|
||||
const colX = (col) => col * (CARD_W + GAP);
|
||||
|
||||
const PILE_ORIGIN = {
|
||||
stock: { x: colX(0), y: TOP_ROW_Y },
|
||||
waste: { x: colX(1), y: TOP_ROW_Y },
|
||||
"foundation-0": { x: colX(3), y: TOP_ROW_Y },
|
||||
"foundation-1": { x: colX(4), y: TOP_ROW_Y },
|
||||
"foundation-2": { x: colX(5), y: TOP_ROW_Y },
|
||||
"foundation-3": { x: colX(6), y: TOP_ROW_Y },
|
||||
"tableau-0": { x: colX(0), y: TABLEAU_ROW_Y },
|
||||
"tableau-1": { x: colX(1), y: TABLEAU_ROW_Y },
|
||||
"tableau-2": { x: colX(2), y: TABLEAU_ROW_Y },
|
||||
"tableau-3": { x: colX(3), y: TABLEAU_ROW_Y },
|
||||
"tableau-4": { x: colX(4), y: TABLEAU_ROW_Y },
|
||||
"tableau-5": { x: colX(5), y: TABLEAU_ROW_Y },
|
||||
"tableau-6": { x: colX(6), y: TABLEAU_ROW_Y },
|
||||
};
|
||||
|
||||
const SUIT_GLYPHS = {
|
||||
clubs: "♣",
|
||||
diamonds: "♦",
|
||||
hearts: "♥",
|
||||
spades: "♠",
|
||||
};
|
||||
const RED_SUITS = new Set(["diamonds", "hearts"]);
|
||||
const RANK_LABELS = ["", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
|
||||
|
||||
const board = document.getElementById("board");
|
||||
const captionEl = document.getElementById("caption");
|
||||
const progressEl = document.getElementById("progress");
|
||||
const scoreEl = document.getElementById("score");
|
||||
const movesEl = document.getElementById("moves");
|
||||
const resultEl = document.getElementById("result");
|
||||
const btnPlay = document.getElementById("btn-play");
|
||||
const btnStep = document.getElementById("btn-step");
|
||||
const btnPrev = document.getElementById("btn-prev");
|
||||
|
||||
let player = null;
|
||||
let replayJson = null;
|
||||
let playInterval = null;
|
||||
|
||||
// Persistent map: card.id → DOM element. Reused across renders so the
|
||||
// browser interpolates the `transform` change rather than rebuilding
|
||||
// nodes every step.
|
||||
const cardEls = new Map();
|
||||
|
||||
async function bootstrap() {
|
||||
const id = window.location.pathname.split("/").pop();
|
||||
if (!id) {
|
||||
captionEl.textContent = "No replay id in URL.";
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`/api/replays/${id}`);
|
||||
} catch (e) {
|
||||
captionEl.textContent = `Network error: ${e}`;
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
captionEl.textContent = `Server returned ${response.status}.`;
|
||||
return;
|
||||
}
|
||||
const replay = await response.json();
|
||||
replayJson = JSON.stringify(replay);
|
||||
|
||||
captionEl.textContent =
|
||||
`Seed ${replay.seed} · ${replay.draw_mode} · ${replay.mode} ` +
|
||||
`· ${formatDuration(replay.time_seconds)} win on ${replay.recorded_at} ` +
|
||||
`· final score ${replay.final_score}`;
|
||||
|
||||
spawnEmptySlots();
|
||||
await init();
|
||||
resetPlayer();
|
||||
}
|
||||
|
||||
/// Spawn the dashed empty-pile placeholders once. They never move and
|
||||
/// never get keyed to card ids, so they're outside the cardEls map.
|
||||
function spawnEmptySlots() {
|
||||
Object.entries(PILE_ORIGIN).forEach(([name, { x, y }]) => {
|
||||
const slot = document.createElement("div");
|
||||
slot.className = `slot slot-${name}`;
|
||||
slot.style.transform = `translate(${x}px, ${y}px)`;
|
||||
board.appendChild(slot);
|
||||
});
|
||||
}
|
||||
|
||||
function resetPlayer() {
|
||||
if (playInterval) {
|
||||
clearInterval(playInterval);
|
||||
playInterval = null;
|
||||
btnPlay.textContent = "▶ Play";
|
||||
}
|
||||
player = new ReplayPlayer(replayJson);
|
||||
btnPrev.disabled = true;
|
||||
btnStep.disabled = false;
|
||||
btnPlay.disabled = false;
|
||||
render(player.state());
|
||||
}
|
||||
|
||||
function step() {
|
||||
const snap = player.step();
|
||||
if (snap === null) {
|
||||
finish();
|
||||
return null;
|
||||
}
|
||||
btnPrev.disabled = false;
|
||||
render(snap);
|
||||
return snap;
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (playInterval) {
|
||||
clearInterval(playInterval);
|
||||
playInterval = null;
|
||||
}
|
||||
btnPlay.textContent = "▶ Play";
|
||||
btnPlay.disabled = true;
|
||||
btnStep.disabled = true;
|
||||
}
|
||||
|
||||
/// Apply `snap` to the persistent card-element map.
|
||||
///
|
||||
/// Phase 1: collect every card present in this snapshot, computing its
|
||||
/// target board-relative (x, y) from its pile + index.
|
||||
/// Phase 2: for each card, find or create its DOM element and update
|
||||
/// its visual state + transform. Persistent elements interpolate via
|
||||
/// CSS transition; freshly-created ones fade in.
|
||||
/// Phase 3: any card present in `cardEls` but absent from `snap` (rare
|
||||
/// but happens during stat resets) fades out and is removed.
|
||||
function render(snap) {
|
||||
if (!snap) return;
|
||||
|
||||
const targets = new Map(); // card.id → { card, x, y }
|
||||
|
||||
function placePile(name, cards, fan) {
|
||||
const origin = PILE_ORIGIN[name];
|
||||
cards.forEach((card, idx) => {
|
||||
const yOffset = fan ? idx * FAN_OFFSET_PX : 0;
|
||||
targets.set(card.id, {
|
||||
card,
|
||||
x: origin.x,
|
||||
y: origin.y + yOffset,
|
||||
z: idx,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
placePile("stock", snap.stock, false);
|
||||
placePile("waste", snap.waste, false);
|
||||
snap.foundations.forEach((cards, idx) =>
|
||||
placePile(`foundation-${idx}`, cards, false));
|
||||
snap.tableaus.forEach((cards, idx) =>
|
||||
placePile(`tableau-${idx}`, cards, true));
|
||||
|
||||
// Apply or create.
|
||||
targets.forEach(({ card, x, y, z }) => {
|
||||
let el = cardEls.get(card.id);
|
||||
if (!el) {
|
||||
el = createCardElement(card);
|
||||
// Spawn off-screen with opacity 0 so the entry transition
|
||||
// fades in at the destination rather than popping.
|
||||
el.style.transform = `translate(${x}px, ${y}px)`;
|
||||
el.style.opacity = "0";
|
||||
board.appendChild(el);
|
||||
cardEls.set(card.id, el);
|
||||
// Force the browser to commit the off-screen frame before
|
||||
// we set the visible state, so the transition runs.
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = "1";
|
||||
});
|
||||
} else {
|
||||
updateCardElement(el, card);
|
||||
el.style.transform = `translate(${x}px, ${y}px)`;
|
||||
}
|
||||
el.style.zIndex = String(z + 1);
|
||||
});
|
||||
|
||||
// Drop any cards no longer in play (e.g. on player reset).
|
||||
cardEls.forEach((el, id) => {
|
||||
if (!targets.has(id)) {
|
||||
el.style.opacity = "0";
|
||||
// Remove after the fade transition completes.
|
||||
setTimeout(() => {
|
||||
el.remove();
|
||||
cardEls.delete(id);
|
||||
}, 220);
|
||||
}
|
||||
});
|
||||
|
||||
progressEl.textContent = `step ${snap.step_idx} / ${snap.total_steps}`;
|
||||
scoreEl.textContent = `Score ${snap.score}`;
|
||||
movesEl.textContent = `Moves ${snap.move_count}`;
|
||||
if (snap.is_won) {
|
||||
resultEl.textContent = "✨ Won";
|
||||
resultEl.classList.add("win");
|
||||
} else {
|
||||
resultEl.textContent = "";
|
||||
resultEl.classList.remove("win");
|
||||
}
|
||||
}
|
||||
|
||||
function createCardElement(card) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "card";
|
||||
el.dataset.cardId = String(card.id);
|
||||
populateCardFace(el, card);
|
||||
return el;
|
||||
}
|
||||
|
||||
/// Cheap "is this still the same visual state" check. Face-up cards
|
||||
/// only need a re-paint if their face_up flag flipped (rank/suit are
|
||||
/// immutable per id), so we can skip rebuilding the inner DOM for the
|
||||
/// 99% case where only the transform changed.
|
||||
function updateCardElement(el, card) {
|
||||
const wasFaceDown = el.classList.contains("face-down");
|
||||
const isFaceDown = !card.face_up;
|
||||
if (wasFaceDown !== isFaceDown) {
|
||||
el.replaceChildren();
|
||||
el.classList.remove("red", "black", "face-down");
|
||||
populateCardFace(el, card);
|
||||
}
|
||||
}
|
||||
|
||||
function populateCardFace(el, card) {
|
||||
if (!card.face_up) {
|
||||
el.classList.add("face-down");
|
||||
return;
|
||||
}
|
||||
el.classList.add(RED_SUITS.has(card.suit) ? "red" : "black");
|
||||
const label = RANK_LABELS[card.rank] || "?";
|
||||
const glyph = SUIT_GLYPHS[card.suit] || "?";
|
||||
|
||||
const top = document.createElement("span");
|
||||
top.className = "corner top";
|
||||
top.textContent = `${label}\n${glyph}`;
|
||||
el.appendChild(top);
|
||||
|
||||
const center = document.createElement("span");
|
||||
center.className = "center";
|
||||
center.textContent = glyph;
|
||||
el.appendChild(center);
|
||||
|
||||
const bottom = document.createElement("span");
|
||||
bottom.className = "corner bottom";
|
||||
bottom.textContent = `${label}\n${glyph}`;
|
||||
el.appendChild(bottom);
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
btnStep.addEventListener("click", () => {
|
||||
if (player) step();
|
||||
});
|
||||
|
||||
btnPlay.addEventListener("click", () => {
|
||||
if (!player) return;
|
||||
if (playInterval) {
|
||||
clearInterval(playInterval);
|
||||
playInterval = null;
|
||||
btnPlay.textContent = "▶ Play";
|
||||
return;
|
||||
}
|
||||
btnPlay.textContent = "⏸ Pause";
|
||||
playInterval = setInterval(() => {
|
||||
const snap = step();
|
||||
if (snap === null) finish();
|
||||
}, STEP_INTERVAL_MS);
|
||||
});
|
||||
|
||||
btnPrev.addEventListener("click", () => {
|
||||
if (!replayJson) return;
|
||||
// Drop every existing card so the next render fades them all in
|
||||
// at the freshly-dealt positions. Without this, cards from the
|
||||
// current state would slide to wherever the new deal puts them
|
||||
// — confusing since the deal is supposed to look like a fresh
|
||||
// start, not a continuation.
|
||||
cardEls.forEach((el) => el.remove());
|
||||
cardEls.clear();
|
||||
resetPlayer();
|
||||
});
|
||||
|
||||
bootstrap();
|
||||
+256
-2
@@ -3,10 +3,10 @@
|
||||
//! All functions are free of I/O and side effects — safe to call from any
|
||||
//! context including unit tests and the Bevy main thread.
|
||||
|
||||
use chrono::Utc;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
|
||||
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
|
||||
use crate::progress::level_for_xp;
|
||||
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
||||
|
||||
/// Merge two [`SyncPayload`]s into a single authoritative result.
|
||||
///
|
||||
@@ -109,10 +109,45 @@ fn merge_stats(
|
||||
best_single_score: local.best_single_score.max(remote.best_single_score),
|
||||
draw_one_wins: local.draw_one_wins.max(remote.draw_one_wins),
|
||||
draw_three_wins: local.draw_three_wins.max(remote.draw_three_wins),
|
||||
// Per-mode bests. Bests take max; fastest times take a *zero-aware*
|
||||
// min — see [`min_ignore_zero`] for the rationale (0 means "no win
|
||||
// yet" for these fields, unlike the lifetime `fastest_win_seconds`
|
||||
// which uses `u64::MAX` as its sentinel).
|
||||
classic_best_score: local.classic_best_score.max(remote.classic_best_score),
|
||||
classic_fastest_win_seconds: min_ignore_zero(
|
||||
local.classic_fastest_win_seconds,
|
||||
remote.classic_fastest_win_seconds,
|
||||
),
|
||||
zen_best_score: local.zen_best_score.max(remote.zen_best_score),
|
||||
zen_fastest_win_seconds: min_ignore_zero(
|
||||
local.zen_fastest_win_seconds,
|
||||
remote.zen_fastest_win_seconds,
|
||||
),
|
||||
challenge_best_score: local.challenge_best_score.max(remote.challenge_best_score),
|
||||
challenge_fastest_win_seconds: min_ignore_zero(
|
||||
local.challenge_fastest_win_seconds,
|
||||
remote.challenge_fastest_win_seconds,
|
||||
),
|
||||
last_modified: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-aware minimum: returns the smaller of `a` and `b`, but treats `0` as
|
||||
/// "no value recorded yet" so `min_ignore_zero(0, x) == x`.
|
||||
///
|
||||
/// The lifetime `fastest_win_seconds` field uses `u64::MAX` as its "no wins"
|
||||
/// sentinel (see [`StatsSnapshot::default`]) and so a plain `min` works for
|
||||
/// it. The per-mode `*_fastest_win_seconds` fields, on the other hand, are
|
||||
/// `#[serde(default)]` — and `u64`'s default is 0, not `u64::MAX`. Using a
|
||||
/// straight `min` would therefore wrongly resolve "one side has a real time,
|
||||
/// the other has no win" to 0. This helper preserves the real time instead.
|
||||
fn min_ignore_zero(a: u64, b: u64) -> u64 {
|
||||
match (a, b) {
|
||||
(0, x) | (x, 0) => x,
|
||||
_ => a.min(b),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Achievements
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -240,6 +275,22 @@ fn merge_progress(
|
||||
// Challenge index: take the higher (further ahead in challenge progression).
|
||||
let challenge_index = local.challenge_index.max(remote.challenge_index);
|
||||
|
||||
// Daily-challenge history: union the two ordered lists into a sorted,
|
||||
// deduplicated, capped Vec so completions made on either device survive.
|
||||
let daily_challenge_history = union_naive_dates(
|
||||
&local.daily_challenge_history,
|
||||
&remote.daily_challenge_history,
|
||||
);
|
||||
|
||||
// Longest streak ever: simple max — never regresses.
|
||||
let daily_challenge_longest_streak = local
|
||||
.daily_challenge_longest_streak
|
||||
.max(remote.daily_challenge_longest_streak)
|
||||
// Also defend against an old payload whose `longest_streak` was
|
||||
// never written but whose current `daily_challenge_streak` exceeds
|
||||
// the recorded longest — keep them coherent post-merge.
|
||||
.max(daily_challenge_streak);
|
||||
|
||||
PlayerProgress {
|
||||
total_xp,
|
||||
level: level_for_xp(total_xp),
|
||||
@@ -250,6 +301,8 @@ fn merge_progress(
|
||||
unlocked_card_backs,
|
||||
unlocked_backgrounds,
|
||||
challenge_index,
|
||||
daily_challenge_history,
|
||||
daily_challenge_longest_streak,
|
||||
last_modified: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -261,6 +314,20 @@ fn union_usize_vecs(a: &[usize], b: &[usize]) -> Vec<usize> {
|
||||
set.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Returns the sorted union of two `NaiveDate` slices with duplicates
|
||||
/// removed and the result capped at [`DAILY_CHALLENGE_HISTORY_CAP`]
|
||||
/// entries (oldest dates trimmed first).
|
||||
fn union_naive_dates(a: &[NaiveDate], b: &[NaiveDate]) -> Vec<NaiveDate> {
|
||||
use std::collections::BTreeSet;
|
||||
let set: BTreeSet<NaiveDate> = a.iter().chain(b.iter()).copied().collect();
|
||||
let mut v: Vec<NaiveDate> = set.into_iter().collect();
|
||||
if v.len() > DAILY_CHALLENGE_HISTORY_CAP {
|
||||
let excess = v.len() - DAILY_CHALLENGE_HISTORY_CAP;
|
||||
v.drain(0..excess);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -753,4 +820,191 @@ mod tests {
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.fastest_win_seconds, 300);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Daily-challenge history + longest-streak merge
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn nd(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_unions_daily_challenge_history() {
|
||||
// Local and remote have disjoint completion dates; the merged
|
||||
// history must contain all of them, sorted ascending, with no
|
||||
// duplicates and within the cap.
|
||||
let mut local = default_payload();
|
||||
local.progress.daily_challenge_history =
|
||||
vec![nd(2026, 4, 20), nd(2026, 4, 22), nd(2026, 4, 24)];
|
||||
let mut remote = default_payload();
|
||||
remote.progress.daily_challenge_history =
|
||||
vec![nd(2026, 4, 21), nd(2026, 4, 22), nd(2026, 4, 25)];
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(
|
||||
merged.progress.daily_challenge_history,
|
||||
vec![
|
||||
nd(2026, 4, 20),
|
||||
nd(2026, 4, 21),
|
||||
nd(2026, 4, 22),
|
||||
nd(2026, 4, 24),
|
||||
nd(2026, 4, 25),
|
||||
],
|
||||
"history union must be sorted, deduplicated, and contain every date from either side"
|
||||
);
|
||||
assert!(
|
||||
merged.progress.daily_challenge_history.len() <= DAILY_CHALLENGE_HISTORY_CAP,
|
||||
"merged history must respect the 365-entry cap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_caps_daily_challenge_history_at_max() {
|
||||
// Construct a local history that already has CAP entries and a
|
||||
// remote history that adds 50 fresher entries — the merge must
|
||||
// drop the oldest 50 so the cap is preserved.
|
||||
let start = nd(2024, 1, 1);
|
||||
let local_dates: Vec<NaiveDate> = (0..DAILY_CHALLENGE_HISTORY_CAP as i64)
|
||||
.map(|i| start + chrono::Duration::days(i))
|
||||
.collect();
|
||||
let remote_dates: Vec<NaiveDate> = (DAILY_CHALLENGE_HISTORY_CAP as i64
|
||||
..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50)
|
||||
.map(|i| start + chrono::Duration::days(i))
|
||||
.collect();
|
||||
|
||||
let mut local = default_payload();
|
||||
local.progress.daily_challenge_history = local_dates.clone();
|
||||
let mut remote = default_payload();
|
||||
remote.progress.daily_challenge_history = remote_dates.clone();
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(
|
||||
merged.progress.daily_challenge_history.len(),
|
||||
DAILY_CHALLENGE_HISTORY_CAP,
|
||||
"merged history must be capped at DAILY_CHALLENGE_HISTORY_CAP"
|
||||
);
|
||||
// The oldest 50 entries should have been evicted; oldest retained
|
||||
// is therefore start + 50 days.
|
||||
assert_eq!(
|
||||
merged.progress.daily_challenge_history.first().copied(),
|
||||
Some(start + chrono::Duration::days(50))
|
||||
);
|
||||
// Most recent retained is the last remote date.
|
||||
assert_eq!(
|
||||
merged.progress.daily_challenge_history.last().copied(),
|
||||
remote_dates.last().copied()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_takes_max_longest_streak() {
|
||||
let mut local = default_payload();
|
||||
local.progress.daily_challenge_longest_streak = 4;
|
||||
let mut remote = default_payload();
|
||||
remote.progress.daily_challenge_longest_streak = 9;
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(
|
||||
merged.progress.daily_challenge_longest_streak, 9,
|
||||
"longest streak must be the max across both sides"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-mode bests merge
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn merge_per_mode_best_takes_max() {
|
||||
// Classic best score: 1000 vs 2000 → 2000. Mirror behaviour for
|
||||
// `best_single_score` so per-mode follows the same rule.
|
||||
let mut local = default_payload();
|
||||
local.stats.classic_best_score = 1000;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.classic_best_score = 2000;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.classic_best_score, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_per_mode_best_takes_max_for_zen_and_challenge() {
|
||||
let mut local = default_payload();
|
||||
local.stats.zen_best_score = 800;
|
||||
local.stats.challenge_best_score = 5000;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.zen_best_score = 1500;
|
||||
remote.stats.challenge_best_score = 3000;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.zen_best_score, 1500);
|
||||
assert_eq!(merged.stats.challenge_best_score, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_per_mode_fastest_ignores_zero() {
|
||||
// Local has no Zen win (zen_fastest = 0); remote has 180s.
|
||||
// Straight min(0, 180) would return 0 — wrong. The merge must
|
||||
// preserve the real time.
|
||||
let local = default_payload();
|
||||
let mut remote = default_payload();
|
||||
remote.stats.zen_fastest_win_seconds = 180;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.zen_fastest_win_seconds, 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_per_mode_fastest_takes_min_when_both_present() {
|
||||
// When both sides have real times, the merge takes the smaller —
|
||||
// mirroring the lifetime `fastest_win_seconds` behaviour.
|
||||
let mut local = default_payload();
|
||||
local.stats.classic_fastest_win_seconds = 240;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.classic_fastest_win_seconds = 120;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.classic_fastest_win_seconds, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_per_mode_fastest_both_zero_stays_zero() {
|
||||
// Neither side has a win — the field must remain 0 rather than
|
||||
// accidentally becoming non-zero.
|
||||
let local = default_payload();
|
||||
let remote = default_payload();
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.classic_fastest_win_seconds, 0);
|
||||
assert_eq!(merged.stats.zen_fastest_win_seconds, 0);
|
||||
assert_eq!(merged.stats.challenge_fastest_win_seconds, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_per_mode_fastest_local_real_remote_zero() {
|
||||
// Symmetric to `merge_per_mode_fastest_ignores_zero`: local has the
|
||||
// real time, remote is the zero-side. The merge must keep local's
|
||||
// value rather than flooring to 0.
|
||||
let mut local = default_payload();
|
||||
local.stats.challenge_fastest_win_seconds = 300;
|
||||
let remote = default_payload();
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.challenge_fastest_win_seconds, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_longest_streak_never_below_current_streak() {
|
||||
// If a payload's `daily_challenge_longest_streak` was never written
|
||||
// (legacy file) but its `daily_challenge_streak` is non-zero, the
|
||||
// merged longest must reflect at least the current streak so the
|
||||
// two values stay coherent.
|
||||
let mut local = default_payload();
|
||||
local.progress.daily_challenge_streak = 7;
|
||||
local.progress.daily_challenge_longest_streak = 0; // legacy
|
||||
let remote = default_payload();
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert!(
|
||||
merged.progress.daily_challenge_longest_streak >= 7,
|
||||
"longest streak must be at least as large as the merged current streak"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ pub fn level_for_xp(xp: u64) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of dates retained in [`PlayerProgress::daily_challenge_history`].
|
||||
///
|
||||
/// Bounds the per-player file size across years of play. ~365 entries is
|
||||
/// roughly a year of daily completions, far more than the 14-day window the
|
||||
/// in-game calendar surfaces.
|
||||
pub const DAILY_CHALLENGE_HISTORY_CAP: usize = 365;
|
||||
|
||||
/// Persisted player progression state.
|
||||
///
|
||||
/// Mutation helpers such as `add_xp`, `record_daily_completion`, etc. are
|
||||
@@ -45,6 +52,14 @@ pub struct PlayerProgress {
|
||||
/// Index of the next Challenge-mode seed to serve to this player.
|
||||
#[serde(default)]
|
||||
pub challenge_index: u32,
|
||||
/// All dates the player has completed the daily challenge, in
|
||||
/// chronological ascending order. Bounded to the most recent 365
|
||||
/// entries so file size stays bounded across years of play.
|
||||
#[serde(default)]
|
||||
pub daily_challenge_history: Vec<NaiveDate>,
|
||||
/// Longest daily-challenge streak ever achieved on this profile.
|
||||
#[serde(default)]
|
||||
pub daily_challenge_longest_streak: u32,
|
||||
/// Wall-clock time of the last modification (used for conflict detection).
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
@@ -61,6 +76,8 @@ impl Default for PlayerProgress {
|
||||
unlocked_card_backs: vec![0],
|
||||
unlocked_backgrounds: vec![0],
|
||||
challenge_index: 0,
|
||||
daily_challenge_history: Vec::new(),
|
||||
daily_challenge_longest_streak: 0,
|
||||
last_modified: DateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
@@ -114,6 +131,12 @@ impl PlayerProgress {
|
||||
/// - Completion the day after the previous: streak increments.
|
||||
/// - Same day as the previous: no-op (idempotent).
|
||||
///
|
||||
/// On every fresh completion, `date` is appended to
|
||||
/// `daily_challenge_history` (kept sorted ascending and capped at
|
||||
/// [`DAILY_CHALLENGE_HISTORY_CAP`] entries) and
|
||||
/// `daily_challenge_longest_streak` is bumped if the current streak
|
||||
/// exceeds it.
|
||||
///
|
||||
/// Returns `true` if this call recorded a fresh completion.
|
||||
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
|
||||
match self.daily_challenge_last_completed {
|
||||
@@ -126,6 +149,19 @@ impl PlayerProgress {
|
||||
}
|
||||
}
|
||||
self.daily_challenge_last_completed = Some(date);
|
||||
// Append to history (defensive against duplicates and out-of-order
|
||||
// dates so a hand-edited or merged file can't corrupt the order).
|
||||
if !self.daily_challenge_history.contains(&date) {
|
||||
self.daily_challenge_history.push(date);
|
||||
self.daily_challenge_history.sort();
|
||||
if self.daily_challenge_history.len() > DAILY_CHALLENGE_HISTORY_CAP {
|
||||
let excess = self.daily_challenge_history.len() - DAILY_CHALLENGE_HISTORY_CAP;
|
||||
self.daily_challenge_history.drain(0..excess);
|
||||
}
|
||||
}
|
||||
if self.daily_challenge_streak > self.daily_challenge_longest_streak {
|
||||
self.daily_challenge_longest_streak = self.daily_challenge_streak;
|
||||
}
|
||||
self.last_modified = Utc::now();
|
||||
true
|
||||
}
|
||||
@@ -320,4 +356,85 @@ mod tests {
|
||||
p.record_daily_completion(date(2026, 4, 22)); // skip the 21st
|
||||
assert_eq!(p.daily_challenge_streak, 1, "gap must reset streak");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// record_daily_completion — history + longest-streak side effects
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_appends_to_history_in_chronological_order() {
|
||||
let mut p = PlayerProgress::default();
|
||||
assert!(p.daily_challenge_history.is_empty());
|
||||
p.record_daily_completion(date(2026, 4, 20));
|
||||
p.record_daily_completion(date(2026, 4, 21));
|
||||
p.record_daily_completion(date(2026, 4, 22));
|
||||
assert_eq!(
|
||||
p.daily_challenge_history,
|
||||
vec![
|
||||
date(2026, 4, 20),
|
||||
date(2026, 4, 21),
|
||||
date(2026, 4, 22),
|
||||
],
|
||||
"history should hold all three completions in ascending order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_same_day_does_not_duplicate_history() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.record_daily_completion(date(2026, 4, 20));
|
||||
p.record_daily_completion(date(2026, 4, 20));
|
||||
assert_eq!(
|
||||
p.daily_challenge_history,
|
||||
vec![date(2026, 4, 20)],
|
||||
"same-day completion is a no-op and must not duplicate history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_updates_longest_streak() {
|
||||
let mut p = PlayerProgress::default();
|
||||
// Three-day streak: longest jumps from 0 → 3.
|
||||
p.record_daily_completion(date(2026, 4, 20));
|
||||
p.record_daily_completion(date(2026, 4, 21));
|
||||
p.record_daily_completion(date(2026, 4, 22));
|
||||
assert_eq!(p.daily_challenge_streak, 3);
|
||||
assert_eq!(p.daily_challenge_longest_streak, 3);
|
||||
|
||||
// Gap resets the current streak — longest must NOT regress.
|
||||
p.record_daily_completion(date(2026, 4, 25));
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
assert_eq!(
|
||||
p.daily_challenge_longest_streak, 3,
|
||||
"longest_streak must never regress after a gap"
|
||||
);
|
||||
|
||||
// Two-day streak — still below longest, so longest stays at 3.
|
||||
p.record_daily_completion(date(2026, 4, 26));
|
||||
assert_eq!(p.daily_challenge_streak, 2);
|
||||
assert_eq!(p.daily_challenge_longest_streak, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_challenge_history_is_capped_at_max() {
|
||||
// Push DAILY_CHALLENGE_HISTORY_CAP + 5 consecutive days; the
|
||||
// earliest five must be evicted and the most recent CAP retained.
|
||||
let mut p = PlayerProgress::default();
|
||||
let start = date(2024, 1, 1);
|
||||
let total = DAILY_CHALLENGE_HISTORY_CAP + 5;
|
||||
for offset in 0..total {
|
||||
p.record_daily_completion(start + Duration::days(offset as i64));
|
||||
}
|
||||
assert_eq!(p.daily_challenge_history.len(), DAILY_CHALLENGE_HISTORY_CAP);
|
||||
// Oldest retained is `start + 5` (we dropped the first 5).
|
||||
assert_eq!(
|
||||
p.daily_challenge_history.first().copied(),
|
||||
Some(start + Duration::days(5))
|
||||
);
|
||||
// Newest retained is the last date pushed.
|
||||
assert_eq!(
|
||||
p.daily_challenge_history.last().copied(),
|
||||
Some(start + Duration::days(total as i64 - 1))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,56 @@ pub struct StatsSnapshot {
|
||||
pub draw_one_wins: u32,
|
||||
/// Wins achieved in Draw-Three mode.
|
||||
pub draw_three_wins: u32,
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Per-mode bests
|
||||
//
|
||||
// These mirror `best_single_score` / `fastest_win_seconds` but
|
||||
// narrowed to one [`solitaire_core::game_state::GameMode`]. They are
|
||||
// additive: lifetime totals continue to track across all modes, and
|
||||
// legacy `stats.json` files load to 0 for every new field via
|
||||
// `#[serde(default)]`.
|
||||
//
|
||||
// Time-Attack and Daily-Challenge are intentionally absent here:
|
||||
// - Time Attack has its own session-level scoring (count of wins
|
||||
// inside a 10-minute window); a per-game best wouldn't compose.
|
||||
// - Daily Challenge uses Classic scoring rules and so already
|
||||
// contributes to `classic_*` here.
|
||||
//
|
||||
// Sentinel for `*_fastest_win_seconds` is `0` (not `u64::MAX`),
|
||||
// because legacy files deserialise unknown fields to the type's
|
||||
// `Default::default()` — and `u64::default()` is 0. The merge logic
|
||||
// and the UI must therefore treat 0 as "no win recorded yet".
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Best single score achieved in Classic mode (Draw-One or Draw-Three).
|
||||
/// 0 means "no Classic win yet".
|
||||
#[serde(default)]
|
||||
pub classic_best_score: u32,
|
||||
|
||||
/// Fastest Classic-mode win time, in seconds. 0 means "no Classic win yet".
|
||||
#[serde(default)]
|
||||
pub classic_fastest_win_seconds: u64,
|
||||
|
||||
/// Best single score achieved in Zen mode. Zen has no time pressure but
|
||||
/// scoring is still on, so players who care about it still play for a high.
|
||||
/// 0 means "no Zen win yet".
|
||||
#[serde(default)]
|
||||
pub zen_best_score: u32,
|
||||
|
||||
/// Fastest Zen-mode win time, in seconds. 0 means "no Zen win yet".
|
||||
#[serde(default)]
|
||||
pub zen_fastest_win_seconds: u64,
|
||||
|
||||
/// Best single score achieved in Challenge mode (the hardest mode — separate
|
||||
/// leaderboard). 0 means "no Challenge win yet".
|
||||
#[serde(default)]
|
||||
pub challenge_best_score: u32,
|
||||
|
||||
/// Fastest Challenge-mode win time, in seconds. 0 means "no Challenge win yet".
|
||||
#[serde(default)]
|
||||
pub challenge_fastest_win_seconds: u64,
|
||||
|
||||
/// Wall-clock time of the last modification (used for conflict detection).
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
@@ -51,6 +101,12 @@ impl Default for StatsSnapshot {
|
||||
best_single_score: 0,
|
||||
draw_one_wins: 0,
|
||||
draw_three_wins: 0,
|
||||
classic_best_score: 0,
|
||||
classic_fastest_win_seconds: 0,
|
||||
zen_best_score: 0,
|
||||
zen_fastest_win_seconds: 0,
|
||||
challenge_best_score: 0,
|
||||
challenge_fastest_win_seconds: 0,
|
||||
last_modified: DateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
@@ -147,4 +203,20 @@ mod tests {
|
||||
assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon");
|
||||
assert_eq!(s.win_streak_current, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_mode_fields_default_to_zero() {
|
||||
// The new per-mode fields must default to 0 — both in the explicit
|
||||
// `Default` impl and (because of `#[serde(default)]`) for any
|
||||
// legacy payload that omits them. The legacy-JSON deserialise
|
||||
// round-trip lives in `solitaire_data::stats` where `serde_json`
|
||||
// is in scope.
|
||||
let s = StatsSnapshot::default();
|
||||
assert_eq!(s.classic_best_score, 0);
|
||||
assert_eq!(s.classic_fastest_win_seconds, 0);
|
||||
assert_eq!(s.zen_best_score, 0);
|
||||
assert_eq!(s.zen_fastest_win_seconds, 0);
|
||||
assert_eq!(s.challenge_best_score, 0);
|
||||
assert_eq!(s.challenge_fastest_win_seconds, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "solitaire_wasm"
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
solitaire_core = { path = "../solitaire_core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
# `getrandom` is pulled in transitively via `rand` (used by
|
||||
# `solitaire_core::Deck::shuffle`). On `wasm32-unknown-unknown` it
|
||||
# needs an explicit JS-backend feature, otherwise the build aborts
|
||||
# with a "wasm32-unknown-unknown is not a supported target" error.
|
||||
# Pinning here forces the feature on without us having to pollute
|
||||
# `solitaire_core`'s deps with wasm-only flags.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
@@ -0,0 +1,272 @@
|
||||
//! WebAssembly bindings for browser-side replay playback.
|
||||
//!
|
||||
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
|
||||
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
|
||||
//! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each
|
||||
//! step applies one [`ReplayMove`] to the underlying `GameState` and
|
||||
//! returns the resulting pile snapshot as JSON for the JS layer to
|
||||
//! render.
|
||||
//!
|
||||
//! The state machine is the same Rust [`solitaire_core::GameState`]
|
||||
//! the desktop client uses, so the two implementations cannot drift —
|
||||
//! same seed + same input list = same pile state at every step,
|
||||
//! regardless of which platform replays the game.
|
||||
//!
|
||||
//! The crate intentionally does **not** depend on `solitaire_data`
|
||||
//! (which pulls `dirs`, `keyring`, `reqwest`, and other non-wasm
|
||||
//! crates) — instead it defines a minimal `Replay` mirror with the
|
||||
//! same serde shape as `solitaire_data::Replay`. The JSON wire format
|
||||
//! is the contract.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
|
||||
/// player inputs, post-StockClick refinement). Only the JSON shape
|
||||
/// matters for cross-crate compatibility.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReplayMove {
|
||||
Move {
|
||||
from: PileType,
|
||||
to: PileType,
|
||||
count: usize,
|
||||
},
|
||||
StockClick,
|
||||
}
|
||||
|
||||
/// Mirrors `solitaire_data::Replay` v2.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Replay {
|
||||
#[serde(default)]
|
||||
pub schema_version: u32,
|
||||
pub seed: u64,
|
||||
pub draw_mode: DrawMode,
|
||||
pub mode: GameMode,
|
||||
pub time_seconds: u64,
|
||||
pub final_score: i32,
|
||||
pub recorded_at: NaiveDate,
|
||||
pub moves: Vec<ReplayMove>,
|
||||
}
|
||||
|
||||
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StateSnapshot {
|
||||
pub step_idx: usize,
|
||||
pub total_steps: usize,
|
||||
pub score: i32,
|
||||
pub move_count: u32,
|
||||
pub is_won: bool,
|
||||
pub stock: Vec<CardSnapshot>,
|
||||
pub waste: Vec<CardSnapshot>,
|
||||
/// Length 4 — one per foundation slot, in slot order (0..=3). The
|
||||
/// claimed suit (if any) is the bottom card's suit.
|
||||
pub foundations: [Vec<CardSnapshot>; 4],
|
||||
/// Length 7 — one per tableau column (0..=6).
|
||||
pub tableaus: [Vec<CardSnapshot>; 7],
|
||||
}
|
||||
|
||||
/// One card, projected for the JS card renderer. `face_up = false`
|
||||
/// means the card back is drawn; in that case `suit` and `rank` are
|
||||
/// still set (so the renderer doesn't need separate "unknown" data),
|
||||
/// just hidden visually.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct CardSnapshot {
|
||||
pub id: u32,
|
||||
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
|
||||
pub suit: &'static str,
|
||||
/// 1-13, where 1 is Ace and 13 is King.
|
||||
pub rank: u8,
|
||||
pub face_up: bool,
|
||||
}
|
||||
|
||||
impl From<&solitaire_core::card::Card> for CardSnapshot {
|
||||
fn from(c: &solitaire_core::card::Card) -> Self {
|
||||
Self {
|
||||
id: c.id,
|
||||
suit: match c.suit {
|
||||
Suit::Clubs => "clubs",
|
||||
Suit::Diamonds => "diamonds",
|
||||
Suit::Hearts => "hearts",
|
||||
Suit::Spades => "spades",
|
||||
},
|
||||
rank: c.rank.value(),
|
||||
face_up: c.face_up,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser-side replay state machine. Owns a live `GameState` and the
|
||||
/// replay's move list; each `step()` applies the next move.
|
||||
#[wasm_bindgen]
|
||||
pub struct ReplayPlayer {
|
||||
game: GameState,
|
||||
moves: Vec<ReplayMove>,
|
||||
step_idx: usize,
|
||||
}
|
||||
|
||||
// Native-callable methods. Used by both the wasm-bindgen interface
|
||||
// below and by unit tests, which can't go through `serde_wasm_bindgen`
|
||||
// (it panics on non-wasm targets).
|
||||
impl ReplayPlayer {
|
||||
/// Construct from a raw replay JSON string. Returns the parsing
|
||||
/// error as a `String` so the wasm-bindgen wrapper can convert
|
||||
/// it to a `JsValue` and tests can assert on it directly.
|
||||
pub fn from_json(replay_json: &str) -> Result<Self, String> {
|
||||
let replay: Replay =
|
||||
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
||||
let game =
|
||||
GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||
Ok(Self {
|
||||
game,
|
||||
moves: replay.moves,
|
||||
step_idx: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the next move. Returns `None` once the list is exhausted.
|
||||
pub fn step_native(&mut self) -> Option<StateSnapshot> {
|
||||
if self.step_idx >= self.moves.len() {
|
||||
return None;
|
||||
}
|
||||
let mv = self.moves[self.step_idx].clone();
|
||||
let _ = match mv {
|
||||
ReplayMove::Move { from, to, count } => self.game.move_cards(from, to, count),
|
||||
ReplayMove::StockClick => self.game.draw(),
|
||||
};
|
||||
self.step_idx += 1;
|
||||
Some(self.snapshot())
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> StateSnapshot {
|
||||
let pile_cards = |t: PileType| -> Vec<CardSnapshot> {
|
||||
self.game
|
||||
.piles
|
||||
.get(&t)
|
||||
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let foundations: [Vec<CardSnapshot>; 4] = [
|
||||
pile_cards(PileType::Foundation(0)),
|
||||
pile_cards(PileType::Foundation(1)),
|
||||
pile_cards(PileType::Foundation(2)),
|
||||
pile_cards(PileType::Foundation(3)),
|
||||
];
|
||||
let tableaus: [Vec<CardSnapshot>; 7] = [
|
||||
pile_cards(PileType::Tableau(0)),
|
||||
pile_cards(PileType::Tableau(1)),
|
||||
pile_cards(PileType::Tableau(2)),
|
||||
pile_cards(PileType::Tableau(3)),
|
||||
pile_cards(PileType::Tableau(4)),
|
||||
pile_cards(PileType::Tableau(5)),
|
||||
pile_cards(PileType::Tableau(6)),
|
||||
];
|
||||
StateSnapshot {
|
||||
step_idx: self.step_idx,
|
||||
total_steps: self.moves.len(),
|
||||
score: self.game.score,
|
||||
move_count: self.game.move_count,
|
||||
is_won: self.game.is_won,
|
||||
stock: pile_cards(PileType::Stock),
|
||||
waste: pile_cards(PileType::Waste),
|
||||
foundations,
|
||||
tableaus,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JS-facing surface. Thin wrapper around the native API: serialises
|
||||
// `StateSnapshot` to `JsValue` via `serde_wasm_bindgen` and converts
|
||||
// `String` errors to `JsValue` strings. Native unit tests bypass this
|
||||
// layer because `serde_wasm_bindgen::to_value` panics off-target.
|
||||
#[wasm_bindgen]
|
||||
impl ReplayPlayer {
|
||||
/// Construct from a raw replay JSON string.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(replay_json: &str) -> Result<ReplayPlayer, JsValue> {
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
console_error_panic_hook::set_once();
|
||||
Self::from_json(replay_json).map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
||||
pub fn state(&self) -> JsValue {
|
||||
serde_wasm_bindgen::to_value(&self.snapshot()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
/// Apply the next move; returns the post-step snapshot, or `null`
|
||||
/// once the move list is exhausted.
|
||||
pub fn step(&mut self) -> JsValue {
|
||||
match self.step_native() {
|
||||
Some(snap) => serde_wasm_bindgen::to_value(&snap).unwrap_or(JsValue::NULL),
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of moves the replay contains.
|
||||
pub fn total_steps(&self) -> usize {
|
||||
self.moves.len()
|
||||
}
|
||||
|
||||
/// 0-indexed position of the next move to apply.
|
||||
pub fn step_idx(&self) -> usize {
|
||||
self.step_idx
|
||||
}
|
||||
|
||||
/// Returns `true` once every move has been applied.
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.step_idx >= self.moves.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_replay_json() -> String {
|
||||
// Minimal v2 replay: seed 42, two stock clicks. Real winning
|
||||
// replays will have many more moves; for the test we just
|
||||
// verify deserialization + step() advances correctly.
|
||||
r#"{
|
||||
"schema_version": 2,
|
||||
"seed": 42,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": ["StockClick", "StockClick"]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Constructing from a valid v2 replay JSON must succeed and
|
||||
/// initialise step_idx to 0.
|
||||
#[test]
|
||||
fn new_initialises_step_idx_zero() {
|
||||
let player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
|
||||
assert_eq!(player.step_idx, 0);
|
||||
assert_eq!(player.moves.len(), 2);
|
||||
}
|
||||
|
||||
/// Each step advances the index; once exhausted, step_native returns None.
|
||||
#[test]
|
||||
fn steps_advance_then_terminate() {
|
||||
let mut player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
|
||||
assert!(player.step_native().is_some());
|
||||
assert_eq!(player.step_idx, 1);
|
||||
assert!(player.step_native().is_some());
|
||||
assert_eq!(player.step_idx, 2);
|
||||
assert!(player.step_native().is_none(), "no further steps");
|
||||
}
|
||||
|
||||
/// Malformed JSON returns an error rather than panicking.
|
||||
#[test]
|
||||
fn invalid_json_returns_error() {
|
||||
let result = ReplayPlayer::from_json("not valid json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user