Compare commits

..

19 Commits

Author SHA1 Message Date
funman300 d948fa862a docs: CHANGELOG + SESSION_HANDOFF refresh for v0.14.0
CHANGELOG gains a [0.14.0] section covering 18 commits since v0.13.0
across three threads: the v0.13.0-era UX candidates that missed the
v0.13.0 tag (theme thumbnails, daily-challenge calendar, Time Attack
auto-save, per-mode bests, time-bonus slider), Quat's three bug
fixes from a smoke-test round (multi-card lift validation, softlock
detection, deal-tween information leak), and the major new replay
pipeline (record → persist → upload → web viewer with a new
solitaire_wasm crate).

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1134.

SESSION_HANDOFF rewritten as the session 9 / post-v0.14.0 doc.
The session 8 changelog table is preserved alongside a new "v0.14.0
shipped" rollup. The next-round candidates list seeds six fresh
ideas (deferred Bevy audio trim, solver toggle, in-engine replay
playback, per-replay history, solver-driven hints, "won via
replay" achievement). Resume prompt asks A–F about smoke-test,
audio trim, solver toggle, in-engine playback, fresh UX, or
packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:44:03 +00:00
funman300 1fcd032b0a feat(web): card flight animations between piles
The replay viewer's renderer used to wipe and rebuild every card
from scratch on every step (`board.replaceChildren()`). Each step
was a discrete redraw — fine for correctness, abrupt for the eye.

Restructured to a persistent card-element model:

- `#board` is now a positioned context (relative) instead of a
  CSS grid. The dashed empty-pile placeholders are absolutely-
  positioned `.slot` elements painted once at bootstrap.
- Each card lives as a sibling of the slots, absolutely-positioned
  with `transform: translate(x, y)`. The CSS transition on
  `transform` (280 ms cubic-bezier) runs every move as a flight
  rather than a redraw.
- `cardEls: Map<id, HTMLElement>` persists across renders. Cards
  unchanged between steps don't re-create their DOM at all.
- Z-index is set per-render from the card's pile index so a card
  flying out from the bottom of a tableau passes behind the cards
  above it.
- Newly-spawned cards (rare — only on Restart) fade in at their
  target position via a `requestAnimationFrame` opacity flip;
  cards that disappear (also rare) fade out and despawn after the
  220 ms fade.
- `will-change: transform` lets the browser composite the
  animation, keeping it smooth on low-spec hardware.

Restart now drops every existing card before resetting so the
fresh deal looks like a new game, not a continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:01:02 +00:00
funman300 3081505a3d test(server): E2E coverage for replay upload → fetch path
Five new integration tests against the in-process Axum router +
in-memory SQLite, covering the full HTTP transport + database layer
that the web replay viewer depends on:

- `replay_upload_then_fetch_round_trips_payload` — register → POST →
  GET → assert the fetched JSON matches the upload byte-for-byte.
  Canonical "the web viewer can play back what the desktop client
  uploaded" coverage.
- `replay_fetch_unknown_id_returns_404` — exercises the
  `AppError::NotFound` mapping (not a 500).
- `replay_recent_lists_newest_first_with_username` — two uploads,
  asserts received_at DESC ordering and that the username join
  populates the `username` field.
- `replay_upload_without_auth_returns_401` — guards against the
  upload endpoint accidentally accepting anonymous inserts.
- `replay_upload_malformed_body_returns_400` — header projector
  rejects payloads missing required fields with 400, not 500.

Schema-correctness (round-trip, version gate, atomic write) is still
covered by `solitaire_data::replay`'s unit tests; this file is
strictly for the HTTP transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:58:54 +00:00
funman300 07b8ecd9b2 feat(server): web replay viewer (HTML/CSS + WASM bindings)
Wires the WASM module from the previous commit into a minimal web
viewer served at <server>/replays/<id>. Two new server routes:

- `GET /replays/:id`  — returns the same embedded HTML page for any
  id; the page itself reads the path from window.location in JS and
  fetches the replay JSON via /api/replays/:id.
- `/web/*` — ServeDir for the static assets (replay.css, replay.js,
  and the wasm-bindgen-generated pkg/).

Web layer:
- index.html — header, board, controls, status. Module script.
- replay.css — midnight-purple palette matching the desktop client,
  dark felt board, CSS-grid pile layout, tableau fan via per-card
  inline `top` offset.
- replay.js — fetches the replay, instantiates the wasm
  ReplayPlayer, drives state(), step(). Controls: Restart, Play/Pause
  toggle, Step. Auto-tick at 600 ms.
- pkg/ — generated by wasm-bindgen (committed so deployers don't
  have to install wasm-bindgen-cli + the wasm32 target).

`tower-http = "0.6"` added to solitaire_server with the `fs` feature
for ServeDir.

To regenerate pkg/ after a solitaire_wasm change:
    RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
      cargo build -p solitaire_wasm \
      --target wasm32-unknown-unknown --release
    wasm-bindgen --target web \
      --out-dir solitaire_server/web/pkg --no-typescript \
      target/wasm32-unknown-unknown/release/solitaire_wasm.wasm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:54:01 +00:00
funman300 5bed43ef32 feat(wasm): solitaire_wasm crate for browser-side replay re-execution
A new `cdylib + rlib` workspace member that wraps `solitaire_core::
GameState` for use from JavaScript. The web replay viewer fetches a
replay JSON, hands it to `ReplayPlayer::new`, and steps through
moves one at a time — same Rust rules engine the desktop client
uses, so the two implementations cannot drift.

The crate intentionally does NOT depend on `solitaire_data` (which
pulls dirs/keyring/reqwest, none wasm-friendly). Instead it defines
a minimal `Replay` mirror with the same serde shape; the JSON wire
format is the contract.

Public surface (#[wasm_bindgen]):
- `ReplayPlayer::new(json)` — parse + rebuild deal from seed/mode
- `state()` / `step()` — return JS-friendly StateSnapshot
- `total_steps()` / `step_idx()` / `is_finished()` — progress helpers

Native-callable mirror (`from_json`, `step_native`) lets unit tests
exercise the state machine without going through `serde_wasm_bindgen`,
which panics off-target. 3 tests cover construction, step advance,
and invalid-JSON handling.

`getrandom` needs the `wasm_js` feature on the wasm32 target;
configured via the cfg target dep table so non-wasm builds aren't
affected.

Build pipeline (executed from the repo root):
    rustup target add wasm32-unknown-unknown
    RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
      cargo build -p solitaire_wasm \
      --target wasm32-unknown-unknown --release
    wasm-bindgen --target web \
      --out-dir solitaire_server/web/pkg --no-typescript \
      target/wasm32-unknown-unknown/release/solitaire_wasm.wasm

The generated bindings land in solitaire_server/web/pkg/ and are
committed alongside the web UI (next commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:53:19 +00:00
funman300 23c9704887 feat(engine): upload winning replays to the sync server
`push_replay_on_win` listens for `GameWonEvent` and spawns a
fire-and-forget `AsyncComputeTaskPool` task that calls
`SyncProvider::push_replay`. The game loop never blocks on the
network round-trip; failures log a warning but never abort the win
flow because the replay is already persisted locally by
`game_plugin::record_replay_on_win`.

`UnsupportedPlatform` (LocalOnlyProvider) is silently absorbed in
the same way the existing `push_on_exit` path handles it — local
players don't see a server error every time they win.

Empty-recording guard mirrors `record_replay_on_win`: synthesised
win events from XP / streak / weekly-goal tests must not trigger an
upload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:52:36 +00:00
funman300 93182fa251 feat(server): replay upload + fetch endpoints
API surface for the web replay viewer to come:

- `POST /api/replays`        — auth required; persists the JSON body
  verbatim, mints a server-side UUID, returns `{id}`. Three columns
  (final_score, time_seconds, recorded_at) are projected out of the
  payload at insert time so list endpoints don't have to scan blobs.
- `GET  /api/replays/recent` — public; returns the N most-recent
  replays across users (limit defaults to 20, capped at 50). Joins
  the username so the feed reads as "AliceWon · 2:14 win".
- `GET  /api/replays/:id`    — public; returns the full replay JSON
  the desktop client uploaded.

Migration `002_replays.sql` adds the `replays` table with indexes
on `received_at DESC` (recent feed) and `user_id` (per-user views).

Schema-version compatibility is the playback side's responsibility,
matching the desktop's existing `schema_version` gate — the server
just stores and serves whatever JSON came in.

`AppError::NotFound` added so `GET /api/replays/:id` can return a
proper 404 instead of an internal-server-error.

`.sqlx` cache regenerated for the new `query!` invocations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:50:25 +00:00
funman300 89c51ab712 feat(settings): time-bonus multiplier slider in Settings → Gameplay
Cosmetic-only player setting (default 1.0, range 0.0-2.0, step 0.1)
that scales the time-bonus row shown in the win-summary modal's
score breakdown. Achievement thresholds, lifetime score totals, and
leaderboard submissions still use the raw values produced by
`solitaire_core::scoring`, so the multiplier never affects what gets
recorded — just what the player sees on the win screen.

- New `Settings::time_bonus_multiplier` field with `#[serde(default)]`
  + `sanitized()` clamp so older settings.json files load cleanly.
- New constants `TIME_BONUS_MULTIPLIER_{MIN,MAX,STEP}` re-exported
  through `solitaire_data::lib`.
- `settings_plugin` adds a slider row under the Gameplay header
  matching the existing tooltip-delay control.
- `win_summary_plugin` applies the multiplier when rendering the
  time-bonus row of the score breakdown; "Off" label when 0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:49:07 +00:00
funman300 3984231c9b feat(data,sync,engine): per-mode best score and fastest win
Lifetime stats now also track best score and fastest win per game
mode (Classic, Zen, Challenge), additive on top of the existing
all-modes-combined `best_single_score` and `fastest_win_seconds`.
Time Attack is intentionally excluded — its scoring model is
session-level (count of wins inside a 10-minute window) so a
per-game best wouldn't compose. Daily Challenge inherits Classic
scoring and contributes through the Classic row.

- `solitaire_sync::StatsSnapshot` gains six fields (`{mode}_best_score`,
  `{mode}_fastest_win_seconds` × {Classic, Zen, Challenge}). All are
  `#[serde(default)]` so older save files load cleanly to zeros.
- `solitaire_sync::merge` propagates the per-mode bests through the
  same max/min logic as the global counterparts.
- `solitaire_data::StatsExt::update_per_mode_bests` is the engine's
  entry point — called from `update_stats_on_win` alongside the
  existing `update_on_win`.
- Stats overlay grows a "Per-mode bests" section with three rows
  (Classic / Zen / Challenge) tagged with `PerModeBestsRow`. Empty
  rows render an em-dash, matching the first-launch zero-state
  treatment used by the primary cells.
- 3 new tests cover the rendering, the Classic-mode update path,
  and the Zen-mode update path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:46:32 +00:00
funman300 d9f36bf34a feat(engine): "Watch replay" affordance in Stats overlay
The Stats screen now shows the most recent winning replay's caption
("M:SS win on YYYY-MM-DD") and a Watch Replay button. Until the web
viewer is fully wired the click fires a toast pointing the player at
the upcoming `<server>/replays/<id>` URL; once the upload + page
ship the toast is replaced with an actual link.

- New resources `LatestReplayResource(Option<Replay>)` and
  `LatestReplayPath(Option<PathBuf>)` populated at plugin build time
  from the platform-default `latest_replay.json`. Headless mode
  disables I/O the same way `StatsResource` does.
- `refresh_latest_replay_on_win` re-loads from disk after every
  `GameWonEvent` so opening the modal a second time reflects the
  most recent victory rather than a stale snapshot.
- `format_replay_caption` is a pure helper exposed for both the
  Stats button label and (later) toast messaging.
- `WatchReplayButton` marker added to `solitaire_engine`'s public
  re-exports so the future web-side click integrations can match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:41:55 +00:00
funman300 57d1c58fdf feat(engine): record + persist winning replays on disk
- New `RecordingReplay` resource (in `game_plugin`): in-memory move
  buffer that accumulates atomic player inputs as they're applied to
  `GameState`. Cleared on every `NewGameRequestEvent` so a fresh deal
  starts from an empty list.
- `handle_move` and `handle_draw` push the corresponding `ReplayMove`
  on success only — invalid / rejected events never enter the buffer.
  `Undo` is intentionally not recorded; the replay represents the
  canonical path to victory, not the missteps that were rolled back.
- `record_replay_on_win` listens for `GameWonEvent`, freezes the
  buffer into a `Replay` (seed + draw_mode + mode + score + duration
  + today's date + the move list), and persists atomically to
  `<data_dir>/solitaire_quest/latest_replay.json` via the new
  `ReplayPath` resource.
- Empty-recording guard: synthesised win events from XP / streak /
  weekly-goal tests must not clobber the developer's real replay
  file. A real win always has at least one recorded move.
- 5 dedicated tests cover ordering, rejected-move skipping, undo
  skipping, new-game clearing, and the freeze→save round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:38:49 +00:00
funman300 42535f5109 feat(data): replay storage layer with atomic StockClick input
New `solitaire_data::replay` module:
- `Replay` struct: seed + draw_mode + mode + ordered move list +
  presentation metadata (time / score / date). Replays are
  reconstructed by rebuilding `GameState::new_with_mode` and applying
  the move list in order — a deterministic state machine driven by
  atomic player inputs, no per-step snapshots stored.
- `ReplayMove`: one variant per atomic player input. `Move {from, to,
  count}` covers card moves; `StockClick` covers every click on the
  stock (the engine resolves draw-vs-recycle deterministically from
  current state during both record and playback).
- Schema-versioned (`REPLAY_SCHEMA_VERSION = 2`); legacy files are
  rejected via the version gate so older replays just disappear from
  the UI rather than half-loading.
- Atomic save (.tmp -> rename), `dirs::data_dir()`-based path
  resolution. 5 round-trip / atomic / version-gate / corruption tests.

Sync trait extension:
- `SyncProvider::push_replay(&Replay)` — default returns
  `UnsupportedPlatform` so `LocalOnlyProvider` is silently no-op'd by
  the future push-on-win path. Mirrors the existing `pull` / `push`
  default-impl pattern.
- `SolitaireServerClient::push_replay` — `POST /api/replays`, same
  401-refresh-and-retry shape as `push`.

The wire format is the contract: `solitaire_wasm` (added in a later
commit) parses the JSON via its own minimal mirror struct so it can
compile to wasm32 without pulling the desktop client's transitive
deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:36:25 +00:00
funman300 d5e6f8026b docs: SESSION_HANDOFF refresh for session 8 (Quat smoke-test round)
Captures the three bug-fix commits (move validation, deal-tween leak,
softlock detection), notes that bug #3's "no end-game screen" was
downstream of the softlock-detection bug and is now resolved, and
records the two investigation findings (audio-stack feature trim,
solver-at-deal toggle) as deferred decisions for the player.

Updates HEAD/test counts (origin at 2716472, 1126 tests passing).
Cleans the next-round candidates list — calendar / thumbnails /
Time-Attack auto-save shipped between v0.13.0's doc commit and
session 8; replay is WIP in the working tree.

Resume prompt now offers six choices (A–F) covering finish-replay,
smoke-test, audio-feature trim, solver toggle, other UX, packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:44:33 +00:00
funman300 271647265c fix(engine): treat unplayable stock as softlock in has_legal_moves
Previous heuristic returned true whenever stock or waste held any
cards. Quat hit a state with 4 cards remaining and the stock kept
cycling but nothing was ever playable — the existing detection
counted "draw is available" as a legal move and the game just sat
there.

Replace the early return with a single pass over every card that
could ever be a move source: every Stock card, every Waste card, and
the face-up top of every Tableau column. For each, check whether it
can currently land on any Foundation or Tableau. Return true only if
*some* card anywhere can land *somewhere* — otherwise the player is
genuinely stuck no matter how many times they recycle the deck.

Tightened the existing fresh-game test name to reflect what it
actually proves (a fresh deal has playable moves, not "stock is
non-empty"). Added one new test reproducing Quat's exact case.

Reported by Quat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:55 +00:00
funman300 3eabc149a8 fix(engine): hide previous-game positions during new-game deal
Reported leak: when a new game starts, every card sprite tweens from
its previous-game Transform to its new dealt position. A careful
observer can track those origin points and deduce face-down cards in
the new layout — the tween's start frame literally renders the prior
game's geometry.

Fix: in handle_new_game, after replacing the GameState, snap every
existing card Transform to the stock pile's position before writing
StateChangedEvent. The downstream slide tween in card_plugin then
reads the stock position as its source, so all 52 cards animate out
from a single point — reads as "dealing from the deck" with no
information leak.

No layout reach in headless test contexts so the snap is gated on
Option<Res<LayoutResource>>.

Reported by Quat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:26:14 +00:00
funman300 f1aeb24157 fix(core): validate moved tableau stack forms a legal run
move_cards only checked that the *bottom* card of a moved stack landed
legally on the destination — the cards above the bottom went through
unverified. A player could lift an arbitrary selection from one column
and drop it on another whenever the bottom happened to match, even if
the upper cards didn't form a descending alternating-colour sequence.

Adds is_valid_tableau_sequence(&[Card]) -> bool to rules.rs (4 lines)
and one call site in move_cards's tableau-destination branch. One
focused test covering single-card / valid-run / same-colour /
rank-gap cases.

Reported by Quat: "stack 4 onto stack 2" was accepted when illegal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:26:14 +00:00
funman300 000143231b feat(engine): auto-save Time Attack sessions across launches
Classic, Zen, and Challenge already auto-saved correctly via the
existing game_state.json path — GameState carries mode and the
save/restore systems are mode-agnostic. Time Attack was the gap:
the per-deal GameState round-tripped fine, but the session-level
TimeAttackResource (10-minute countdown + accumulated wins)
defaulted on every launch, so closing mid-session reset the timer
and erased the win count.

Adds a sibling time_attack_session.json next to game_state.json,
atomic .tmp + rename via the existing save pattern. The new
TimeAttackSession struct carries remaining_secs, wins, and
saved_at_unix_secs (wall-clock anchor for stale-session detection).
load_time_attack_session_from_at takes an injectable now() so
tests can drive deterministic clock scenarios.

Load logic: if now_unix - saved_at_unix_secs > remaining_secs the
window expired in real time while the app was closed — return None
so the player isn't dropped into a session whose timer ran out
behind their back. Otherwise restore remaining_secs minus the
real-world elapsed delta. Handles clock-running-backwards (NTP
correction, VM clock drift) by clamping the elapsed delta at zero.

time_attack_plugin wires four new systems: load on Startup, clear
stale file when a fresh session starts (rare — only matters when
the previous session was abandoned + a new one started without
exit/relaunch), 30-second auto-save while a session is active,
delete file on natural expiry, and save on AppExit. The save file
is removed every time the session ends so a stale "session exists"
state can't pollute the next launch.

No GameState schema bump needed — the per-mode session lives in
its own file. stats / progress / achievements / settings unaffected.

8 new storage tests cover round-trip, expired-discard, time-decay,
atomic-write, missing-file, corrupt-file, delete idempotency, and
clock-backwards. 6 new plugin tests cover exit-persists,
exit-clears, auto-save-cadence, auto-save-noop-when-inactive,
new-session-clears-stale, and natural-expiry-clears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:06:35 +00:00
funman300 1a1047664b feat(engine): 14-day daily-challenge calendar in the Profile modal
The daily challenge already updated streak counters, but past
completions were invisible — the player had no in-game surface to
see streak length or the actual day-by-day record. Adds a 14-dot
horizontal calendar above the Profile modal's achievements section
with a "Current streak: N · Longest: M" caption.

Each dot represents a day in the trailing 14-day window ending
today. Today's dot gets a 2-px Balatro-yellow ring; completed days
fill STATE_SUCCESS; missed days fill BG_ELEVATED. Geometry: 14 ×
12 px + 13 × 6 px gap ≈ 246 px — fits comfortably inside the
modal's 360 px min_width even on the 800 px window minimum.

PlayerProgress gains two #[serde(default)] fields:
- daily_challenge_history: Vec<NaiveDate> capped at 365 entries
  (one year of history; older entries pushed off when the cap is
  hit). Sorted ascending, deduped on insert so same-day re-runs
  don't bloat the list.
- daily_challenge_longest_streak: u32, updated whenever streak
  exceeds the previous max.

Legacy progress.json files load to empty/0 via #[serde(default)].

solitaire_sync::merge unions histories from local + remote (sorted,
capped) and takes max(longest_streak), with a clamp to ensure
longest is never below the merged current streak — guards against
legacy payloads where longest=0 but current is mid-streak.

13 new tests across solitaire_sync (record_daily history append,
chronological order, dedupe, cap, longest update, merge union,
merge cap, max longest, clamp), solitaire_data (history append,
longest update, legacy deserialise), and solitaire_engine
(modal renders 14 dots, today marker on rightmost only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:05:54 +00:00
funman300 ba527de351 feat(engine): card-art thumbnails in the theme picker
Settings → Cosmetic's theme picker showed only the theme name. Now
each chip carries a small Ace-of-Spades + back preview pair so the
player can see what each theme looks like before switching.

A new ThemeThumbnailCache resource keys per-theme by id and stores
two Handle<Image>s (ace + back) rasterised at thumbnail resolution
via the existing rasterize_svg path. Generation runs once per
theme registration in theme_plugin; subsequent picker re-spawns
just look up the cached handles. Themes that lack one of the
preview SVGs (broken user theme) get a Handle::default() placeholder
rather than crashing — the placeholder renders as a transparent
rectangle the same size as the missing thumbnail.

The picker chip spawn loop in settings_plugin reads the cache and
renders the pair as two child sprites above the chip text. The
selected-theme chip's existing STATE_SUCCESS tint sits behind the
thumbnails; contrast stays readable.

Asset-source plumbing in assets/sources.rs and assets/mod.rs picks
up the new bytes-loading helper that the thumbnail generator uses
for embedded:// theme assets at startup time (before AssetServer is
fully initialised).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:41:20 +00:00
44 changed files with 5538 additions and 153 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
+95 -1
View File
@@ -8,6 +8,99 @@ project follows [Semantic Versioning](https://semver.org/).
_Nothing yet._ _Nothing yet._
## [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.02.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 ## [0.13.0] — 2026-05-02
Third UX iteration round on top of v0.12.0. Six handoff candidates Third UX iteration round on top of v0.12.0. Six handoff candidates
@@ -312,7 +405,8 @@ with no PNG artwork yet.
CREDITS.md, persistent window geometry, mode-launcher Home repurpose, CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
client-side sync round-trip integration tests. client-side sync round-trip integration tests.
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...HEAD [Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...HEAD
[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.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.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.11.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.10.0...v0.11.0
Generated
+58
View File
@@ -4321,6 +4321,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -5280,6 +5286,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -7322,6 +7338,17 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@@ -7710,6 +7737,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tower", "tower",
"tower-http",
"tower_governor", "tower_governor",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -7726,6 +7754,20 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "solitaire_wasm"
version = "0.1.0"
dependencies = [
"chrono",
"console_error_panic_hook",
"getrandom 0.3.4",
"serde",
"serde-wasm-bindgen",
"serde_json",
"solitaire_core",
"wasm-bindgen",
]
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.8"
@@ -8758,14 +8800,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string", "iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -9182,6 +9234,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
+1
View File
@@ -7,6 +7,7 @@ members = [
"solitaire_server", "solitaire_server",
"solitaire_app", "solitaire_app",
"solitaire_assetgen", "solitaire_assetgen",
"solitaire_wasm",
] ]
resolver = "2" resolver = "2"
+74 -43
View File
@@ -1,20 +1,24 @@
# Solitaire Quest — UX Overhaul Session Handoff # Solitaire Quest — Session Handoff
**Last updated:** 2026-05-02 (session 7, late-late) — Third UX iteration round complete on top of v0.12.0. Six post-handoff candidates shipped plus two code-review fixes. Ready to tag v0.13.0. **Last updated:** 2026-05-02 (session 9, post-v0.14.0 release prep) — v0.14.0 cut. The Quat bug fixes, the rest of the v0.13.0 candidate list, and the entire replay → upload → web-viewer pipeline are all bundled in this release. Direction now opens for the next round.
## Status at pause ## Status at pause
- **HEAD:** doc-commit closing this round (CHANGELOG + handoff). Local master has the impending tag at this commit. - **HEAD on origin:** v0.14.0's tag commit (CHANGELOG + handoff refresh).
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional). - **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean. - **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **1053 passed / 0 failed** across the workspace (+22 from v0.12.0's 1031 baseline). - **Tests:** **1134 passed / 0 failed** across the workspace.
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`, `v0.12.0`. v0.13.0 is the next tag. - **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`, `v0.12.0`, `v0.13.0`, `v0.14.0`.
## Where we are ## Where we are
Post-v0.12.0 the handoff listed six "next-round candidates" — every one shipped today plus two code-review fixes (font handling unified to bundled-only, sccache wiring removed). v0.13.0 is the right slice. v0.14.0 is the largest release since the card-theme system. Three threads land together:
The candidate list is exhausted again. Direction is open. 1. **The remaining v0.13.0-era UX candidates** — theme thumbnails, daily-challenge calendar, Time Attack auto-save, per-mode bests, time-bonus multiplier slider.
2. **Quat smoke-test bug fixes** — multi-card move validation, softlock detection, deal-tween information leak.
3. **The replay pipeline** — record on win, persist to disk, upload to server, view in browser via a new `solitaire_wasm` crate. The biggest single feature since the card-theme system.
The card-flight web animations and replay E2E test coverage close out the pipeline.
### Design direction (unchanged) ### Design direction (unchanged)
@@ -26,42 +30,64 @@ The candidate list is exhausted again. Direction is open.
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. `github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
## Session 7 round 3 (shipped 2026-05-02 late-late) — v0.13.0 ## Session 8 + 9 (shipped 2026-05-02) — v0.14.0
### v0.13.0-era UX candidates (had landed but missed v0.13.0's tag)
| Area | Commit | What landed | | Area | Commit | What landed |
|---|---|---| |---|---|---|
| Font fix | `17f9b51` | Code-review fix: bundle FiraMono via `include_bytes!()` in both `font_plugin` and `svg_loader`; drop `load_system_fonts`, drop the lenient resolver, drop the CSS-generic fallbacks. New `bundled_font_resolver` always returns the single bundled face. Parse failure aborts with a clear error. | | Theme thumbnails | `ba527de` | Each Settings → Cosmetic theme chip renders an Ace + back preview pair via `rasterize_svg`. Cached per theme. Missing-SVG themes show a transparent placeholder rather than crashing. |
| sccache removal | `13dd44b` | Code-review fix: deleted `.cargo/config.toml` and the `.cargo` directory. Plain `cargo build` works without per-project setup. | | Daily-challenge calendar | `1a10476` | 14-dot horizontal calendar in the Profile modal. Today is ringed, completed days fill `STATE_SUCCESS`, missed days fill `BG_ELEVATED`. Caption: "Current streak: N · Longest: M". `PlayerProgress` gains `daily_challenge_history` (capped at 365) and `daily_challenge_longest_streak`. |
| Wave 1 bundle | `ddc8f27` | **Tooltip-delay slider** in Settings → Gameplay (0.01.5 s, 0.1 s steps, "Instant" label at zero). **Win-streak fire animation** at thresholds [3, 5, 10] via new `WinStreakMilestoneEvent`. **Score-breakdown reveal on win modal** with per-row stagger (Base / Time bonus / No-undo / Multiplier / Total), respects `AnimSpeed::Instant`. | | Time Attack auto-save | `0001432` | New sibling `time_attack_session.json` next to `game_state.json`. Atomic .tmp + rename. 30 s auto-save while active + on `AppExit`. Sessions whose 10-min window expired in real time while the app was closed are discarded on load. |
| Card-back theming | `7ed4f2c` | The active theme's `back.svg` now actually drives the face-down sprite. Legacy `back_N.png` picker remains as a fallback for themes without a back; Settings caption surfaces when the override is in effect. | | Per-mode bests | `3984231` | StatsSnapshot gains six `#[serde(default)]` fields (Classic / Zen / Challenge × best_score + fastest_win_seconds). Stats screen renders a "Per-mode bests" section. Lifetime totals continue to roll all modes together. |
| Drag-with-keyboard | `a0fc0d2` | Tab → Enter → arrows → Enter completes a move without a mouse. New `KeyboardDragState` resource; mutual exclusion with mouse drag via `KEYBOARD_DRAG_TOUCH_ID` sentinel. Help + onboarding hotkey lists updated. | | Time-bonus slider | `89c51ab` | Settings → Gameplay slider 0.02.0, default 1.0, "Off" at zero. Multiplies the time-bonus shown in the win modal. Cosmetic only — does NOT affect achievement unlock thresholds. |
| Right-click radial | `b37f0cb` | Hold RMB on a face-up card → ring of icons at the cursor, one per legal destination; release over an icon → `MoveRequestEvent`. New `RadialMenuPlugin`. Help controls reference gains a "Mouse" section. |
## Open punch list — release prep ### Quat smoke-test bug fixes
1. **Push** the unpushed commits to origin (5 commits now: 17f9b51, 13dd44b, ddc8f27, 7ed4f2c, a0fc0d2, b37f0cb, plus the impending doc commit). | Area | Commit | What landed |
2. **Tag v0.13.0** at the doc-commit HEAD. |---|---|---|
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe. | Move validation (#1) | `f1aeb24` | `solitaire_core::rules::is_valid_tableau_sequence(&[Card]) -> bool` checks every adjacent pair in a moved stack descends one rank with alternating colour. Wired into `move_cards`. Closes the bug where any multi-card lift could be dropped as long as the bottom landed legally. |
| Deal-tween leak (#4) | `3eabc14` | New-game snaps every card sprite to the stock pile position before writing `StateChangedEvent`, so all 52 cards animate from a single deck point during the deal. Previously sprites started from previous-game positions, briefly revealing the prior deal. |
| Softlock detection (#2) | `2716472` | `has_legal_moves` rewritten: walks every potential move source (every stock card, every waste card, the face-up top of every tableau column) against every foundation and every tableau. Previous heuristic returned `true` whenever stock had cards, hiding genuine softlocks. `GameOverScreen` now actually fires for true softlocks. |
| End-game screen (#3) | — | Resolved as downstream of #2. The pre-existing `GameOverScreen` and `WinSummaryOverlay` already cover the close-out paths; the softlock screen just never spawned because the old `has_legal_moves` lied. |
## Open punch list — UX iteration (next-round candidates) ### Replay pipeline (the major feature)
The v0.13.0 list is exhausted. Fresh candidates for a future round: | Area | Commit | What landed |
|---|---|---|
| Replay storage | `42535f5` | `solitaire_data::replay::Replay` (seed + draw_mode + mode + score + time + recorded date + ordered move list) and atomic save/load helpers under `<data_dir>/latest_replay.json`. Schema v1; `load` returns None for any other version. |
| Engine recording | `57d1c58` | `RecordingReplay` resource + `ReplayPath` settings. Every successful `MoveRequestEvent` / `DrawRequestEvent` appends to recording; `GameWonEvent` freezes the recording into a `Replay` and persists. Undo intentionally not recorded. New game clears the recording. |
| Stats button | `d9f36bf` | Stats overlay surfaces a "Latest win:" caption + "Watch replay" button. Loads from disk via `LatestReplayResource`. (Full in-engine playback deferred — button currently fires an `InfoToastEvent` describing the replay.) |
| Server upload + fetch | `93182fa` | `POST /api/replays` accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated. SQL migration for the new `replays` table. |
| Engine sync | `23c9704` | Engine uploads winning replays automatically when the player has cloud sync configured. Re-uses the existing JWT/refresh-token flow. |
| WASM crate | `5bed43e` | New workspace member `solitaire_wasm` compiles replay-relevant `solitaire_core` types to WebAssembly so a browser can re-execute a replay client-side. `wasm-bindgen` glue. |
| Web viewer | `07b8ecd` | `GET /replays/:id` returns HTML + CSS + the wasm bundle. Browser fetches the replay JSON, rasterises a deal from the seed, and animates the recorded moves. |
| E2E coverage | `3081505` | Server tests covering the full upload → fetch round-trip via `axum::test`. |
| Web flight anim | `1fcd032` | Card-flight tweens on the web side so the browser viewer reads as a real game replay rather than a static dump. |
- **In-game daily-challenge calendar** — currently the daily challenge fires once on launch; a Settings or Profile-side calendar showing past days' completion / streak status would make the progression visible. ## Open punch list
- **Card-art preview in the theme picker** — Settings → Cosmetic shows theme name only; rendering the theme's Ace-of-Spades + back side-by-side as a thumbnail would make picking faster.
- **Per-mode high-score readout** in the Stats screen. Currently lifetime stats roll all modes together. ### Release prep
- **Auto-save in-progress games** in Zen / Time Attack so players who close the window mid-session don't lose their state. 1. **Smoke-test on the alex machine** after pulling — confirm Quat's three bug fixes hold up in real gameplay, and try the new replay button + web viewer end-to-end.
- **Configurable scoring weights** for the curious — Settings → Gameplay slider for time-bonus magnitude. Cosmetic but power-user appealing. 2. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
- **Replay a winning game** — record the seed + move list at win time and offer "watch replay" from the Stats screen.
### UX iteration (next-round candidates)
- **Solver-at-deal toggle** (Quat investigation #1, still deferred): add a Settings → Gameplay toggle "Winnable deals only" rather than baking solver-only into every deal. Lightest middle ground.
- **Disable Bevy's default audio feature** (Quat investigation #2, still deferred): one-line `default-features = false` swap on the workspace `bevy =` line, re-enable explicitly the features the engine uses (`render`, `bevy_winit`, `2d`, `bevy_window`, `png`, `bevy_text`, `bevy_ui`, `bevy_log`, `bevy_asset`, `default_font`, `bevy_state`). Drops ~50 transitive crates including the rodio + symphonia stack the project doesn't use (kira handles audio).
- **In-engine replay playback** — promote the "Watch replay" button from a stub toast to a real playback overlay that re-runs the recorded moves with `CardAnimation` tweens. The wasm crate already proves the playback math; the in-engine version reuses the same execute logic against the live game state.
- **Per-replay history** — currently single-slot at `latest_replay.json`. A "best replay per mode" bucket or a recent-N rolling list would let players revisit notable wins.
- **Solver-driven hint system** — extend the existing hint toggle so a deal-time solver provides higher-quality hints (currently a heuristic). Requires the solver from the toggle work above.
- **Achievement: "won via replay path"** — track when a player wins a deal whose previously-saved replay also won the same deal. Mostly fun; trivial scope.
## Card-theme system (CARD_PLAN.md, fully shipped) ## Card-theme system (CARD_PLAN.md, fully shipped)
Seven phases landed across `b8fb3fb``924a1e2` in v0.11.0; v0.13.0's `7ed4f2c` finally consumes the per-theme `back.svg`. End-to-end: Seven phases landed across `b8fb3fb``924a1e2` in v0.11.0; v0.13.0's `7ed4f2c` consumes the per-theme `back.svg`; v0.14.0's `ba527de` adds preview thumbnails. End-to-end:
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`. - **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`.
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs. - **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks. - **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
- **Picker UI** in Settings → Cosmetic; the active theme's `back` overrides the legacy `back_N.png` picker when present. - **Picker UI** in Settings → Cosmetic; thumbnails + the active theme's `back` override the legacy `back_N.png` picker when present.
## Resume prompt ## Resume prompt
@@ -69,17 +95,17 @@ Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0; v0.13.0's `7ed4f2
You are a senior Rust + Bevy developer working on Solitaire Quest. You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine — local Working directory: <Rusty_Solitaire clone path on this machine — local
directory may still be named Rusty_Solitare from earlier; that's fine>. directory may still be named Rusty_Solitare from earlier; that's fine>.
Branch: master. Direction is OPEN — three UX iteration rounds shipped Branch: master. Direction is OPEN — v0.14.0 just shipped covering the
and v0.13.0 is ready to tag. Quat bug fixes, the v0.13.0 candidate tail, and the entire
replay-pipeline feature.
State: HEAD at the doc-commit closing session 7 round 3. Local master State: HEAD at v0.14.0. Working tree clean apart from untracked
is several commits ahead of origin and unpushed. Working tree clean CARD_PLAN.md (intentional).
apart from untracked CARD_PLAN.md (intentional).
Build: cargo clippy --workspace --all-targets -- -D warnings clean. Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1053 passed / 0 failed. Tests: 1134 passed / 0 failed.
READ FIRST (in order, before doing anything): READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — full state, session 7 changelog, punch list 1. SESSION_HANDOFF.md — v0.14.0 changelog + open punch list
2. CHANGELOG.md — release-by-release record 2. CHANGELOG.md — release-by-release record
3. CLAUDE.md — hard rules (UI-first, no panics, etc.) 3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
4. ARCHITECTURE.md — crate responsibilities + data flow 4. ARCHITECTURE.md — crate responsibilities + data flow
@@ -88,22 +114,27 @@ READ FIRST (in order, before doing anything):
may be missing on a fresh machine) may be missing on a fresh machine)
DECISION TO ASK THE PLAYER FIRST: DECISION TO ASK THE PLAYER FIRST:
A. Push and cut v0.13.0 now. A. Smoke-test v0.14.0 on the alex machine first to confirm the
B. Smoke-test the new feel layer first (theme-aware backs, keyboard three Quat bug fixes hold up in real gameplay and the replay
drag, right-click radial, score-breakdown reveal, streak fire, pipeline works end-to-end (record → upload → web viewer).
tooltip-delay slider), then tag. B. Take the deferred Bevy-audio-feature trim (Quat investigation
C. Skip the tag for another iteration round — see "next-round #2) — one-line workspace edit, ~50 fewer transitive crates.
candidates" in SESSION_HANDOFF for fresh ideas. C. Take the deferred solver toggle (Quat investigation #1): add
D. Take the deferred desktop-packaging item (needs artwork + "Winnable deals only" Settings toggle. Larger.
D. Promote the in-engine "Watch replay" button to real playback.
E. Pick from the remaining "next-round candidates" in this doc.
F. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user). signing certs from the user).
WORKFLOW NOTES: WORKFLOW NOTES:
- Commits use: - Commits use:
git -c user.name=funman300 -c user.email=root@vscode.infinity \ git -c user.name=funman300 -c user.email=root@vscode.infinity \
commit -m "..." commit -m "..."
- When attributing playtester feedback in commits/docs, use "Quat"
not "Rhys" (saved feedback memory).
- Sub-agents stage + verify only; orchestrator commits. - Sub-agents stage + verify only; orchestrator commits.
- Every commit must pass build / clippy / test before pushing. - Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — that is the canonical remote. - Push to GitHub (origin) — that is the canonical remote.
OPEN AT THE START: ask which of A / B / C / D. Don't pick unilaterally. OPEN AT THE START: ask which of AF. Don't pick unilaterally.
``` ```
+13 -1
View File
@@ -4,7 +4,7 @@ use crate::card::Card;
use crate::deck::{deal_klondike, Deck}; use crate::deck::{deal_klondike, Deck};
use crate::error::MoveError; use crate::error::MoveError;
use crate::pile::{Pile, PileType}; 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}; use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
const MAX_UNDO_STACK: usize = 64; const MAX_UNDO_STACK: usize = 64;
@@ -283,6 +283,18 @@ impl GameState {
if !can_place_on_tableau(&bottom_card, dest) { if !can_place_on_tableau(&bottom_card, dest) {
return Err(MoveError::RuleViolation("invalid tableau placement".into())); 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), _ => return Err(MoveError::InvalidDestination),
} }
+34
View File
@@ -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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -174,4 +186,26 @@ mod tests {
let p = pile_with(PileType::Tableau(0), vec![top]); let p = pile_with(PileType::Tableau(0), vec![top]);
assert!(!can_place_on_tableau(&c, &p)); 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),
]));
}
} }
+26 -3
View File
@@ -56,6 +56,15 @@ pub trait SyncProvider: Send + Sync {
async fn delete_account(&self) -> Result<(), SyncError> { async fn delete_account(&self) -> Result<(), SyncError> {
Ok(()) Ok(())
} }
/// Upload a winning replay to the backend so it's available for web
/// playback at `<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<(), SyncError> {
Err(SyncError::UnsupportedPlatform)
}
} }
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by /// 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> { async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await (**self).delete_account().await
} }
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<(), SyncError> {
(**self).push_replay(replay).await
}
} }
pub mod stats; pub mod stats;
@@ -99,8 +111,11 @@ pub use stats::{StatsExt, StatsSnapshot};
pub mod storage; pub mod storage;
pub use storage::{ pub use storage::{
cleanup_orphaned_tmp_files, delete_game_state_at, game_state_file_path, load_game_state_from, cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at,
load_stats, load_stats_from, save_game_state_to, save_stats, save_stats_to, stats_file_path, 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; pub mod achievements;
@@ -126,7 +141,9 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings; pub mod settings;
pub use settings::{ pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend, load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
Theme, WindowGeometry, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, 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; pub mod auth_tokens;
@@ -136,3 +153,9 @@ pub use auth_tokens::{
pub mod sync_client; pub mod sync_client;
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient}; pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
pub mod replay;
pub use replay::{
latest_replay_path, load_latest_replay_from, save_latest_replay_to, Replay, ReplayMove,
REPLAY_SCHEMA_VERSION,
};
+66
View File
@@ -298,4 +298,70 @@ mod tests {
assert!(!recorded_again, "same-day completion must report no-op"); assert!(!recorded_again, "same-day completion must report no-op");
assert_eq!(p.daily_challenge_streak, 1); 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"
);
}
} }
+297
View File
@@ -0,0 +1,297 @@
//! 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";
/// 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>,
}
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,
}
}
}
/// Returns the platform-specific path to `latest_replay.json`, or `None`
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
pub fn latest_replay_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_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.
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.
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)
}
#[cfg(test)]
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);
}
/// 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);
}
}
+149 -3
View File
@@ -151,6 +151,21 @@ pub struct Settings {
/// `#[serde(default = "default_tooltip_delay")]`. /// `#[serde(default = "default_tooltip_delay")]`.
#[serde(default = "default_tooltip_delay")] #[serde(default = "default_tooltip_delay")]
pub tooltip_delay_secs: f32, 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,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -189,6 +204,25 @@ pub const TOOLTIP_DELAY_MAX_SECS: f32 = 1.5;
/// Increment applied by the tooltip-delay decrement / increment buttons. /// Increment applied by the tooltip-delay decrement / increment buttons.
pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1; 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
}
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -206,14 +240,15 @@ impl Default for Settings {
selected_theme_id: default_theme_id(), selected_theme_id: default_theme_id(),
shown_achievement_onboarding: false, shown_achievement_onboarding: false,
tooltip_delay_secs: default_tooltip_delay(), tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(),
} }
} }
} }
impl Settings { impl Settings {
/// Clamps `sfx_volume`, `music_volume`, and `tooltip_delay_secs` into /// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
/// their respective ranges after deserialization or hand-editing of /// `time_bonus_multiplier` into their respective ranges after
/// `settings.json`. /// deserialization or hand-editing of `settings.json`.
pub fn sanitized(self) -> Self { pub fn sanitized(self) -> Self {
Self { Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0), sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
@@ -221,6 +256,9 @@ impl Settings {
tooltip_delay_secs: self tooltip_delay_secs: self
.tooltip_delay_secs .tooltip_delay_secs
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_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),
..self ..self
} }
} }
@@ -245,6 +283,20 @@ impl Settings {
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS); .clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
self.tooltip_delay_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
}
} }
/// Returns the platform-specific path to `settings.json`, or `None` if /// Returns the platform-specific path to `settings.json`, or `None` if
@@ -375,6 +427,7 @@ mod tests {
selected_theme_id: "default".to_string(), selected_theme_id: "default".to_string(),
shown_achievement_onboarding: false, shown_achievement_onboarding: false,
tooltip_delay_secs: default_tooltip_delay(), tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(),
}; };
save_settings_to(&path, &s).expect("save"); save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path); let loaded = load_settings_from(&path);
@@ -689,4 +742,97 @@ mod tests {
.sanitized(); .sanitized();
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS); assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
} }
// -----------------------------------------------------------------------
// time_bonus_multiplier — cosmetic win-modal time-bonus weight
// -----------------------------------------------------------------------
#[test]
fn settings_time_bonus_multiplier_default_is_one() {
let s = Settings::default();
assert!(
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
"default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}",
s.time_bonus_multiplier
);
}
#[test]
fn settings_time_bonus_multiplier_round_trip() {
let path = tmp_path("time_bonus_multiplier_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
time_bonus_multiplier: 1.5,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(
(loaded.time_bonus_multiplier - 1.5).abs() < 1e-6,
"time_bonus_multiplier must survive serde round-trip; got {}",
loaded.time_bonus_multiplier
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() {
// A settings.json written before this field existed must
// deserialize cleanly to the existing 1.0 baseline so old
// players see no change to their win-modal bonuses.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
"legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}",
s.time_bonus_multiplier
);
}
#[test]
fn settings_time_bonus_multiplier_clamps_to_range() {
// Negative or oversized values from a hand-edited file must be
// clamped on load.
let s = Settings {
time_bonus_multiplier: -0.5,
..Settings::default()
}
.sanitized();
assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN);
let s2 = Settings {
time_bonus_multiplier: 99.0,
..Settings::default()
}
.sanitized();
assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX);
}
#[test]
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!(
(s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6
);
// Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN.
assert!(
(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
);
}
} }
+177 -2
View File
@@ -5,16 +5,35 @@
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`. //! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
use chrono::Utc; use chrono::Utc;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::{DrawMode, GameMode};
pub use solitaire_sync::StatsSnapshot; pub use solitaire_sync::StatsSnapshot;
/// Extension trait providing game-logic mutation helpers for [`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 { pub trait StatsExt {
/// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`. /// 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); 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 { impl StatsExt for StatsSnapshot {
@@ -51,6 +70,43 @@ impl StatsExt for StatsSnapshot {
self.last_modified = Utc::now(); 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)] #[cfg(test)]
@@ -177,4 +233,123 @@ mod tests {
s.update_on_win(200, 60, &DrawMode::DrawOne); s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow"); 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);
}
} }
+314
View File
@@ -6,7 +6,9 @@
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; 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 solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use crate::stats::StatsSnapshot; use crate::stats::StatsSnapshot;
@@ -14,6 +16,7 @@ use crate::stats::StatsSnapshot;
const APP_DIR_NAME: &str = "solitaire_quest"; const APP_DIR_NAME: &str = "solitaire_quest";
const STATS_FILE_NAME: &str = "stats.json"; const STATS_FILE_NAME: &str = "stats.json";
const GAME_STATE_FILE_NAME: &str = "game_state.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 /// Returns the platform-specific path to `stats.json`, or `None` if
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers). /// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
@@ -139,6 +142,131 @@ pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
Ok(()) 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`. /// Inner helper: delete `*.json.tmp` entries inside `dir`.
/// ///
/// Per-file errors (already deleted, permission denied) are silently ignored. /// Per-file errors (already deleted, permission denied) are silently ignored.
@@ -387,4 +515,190 @@ mod tests {
let loaded = load_stats_from(&stats_path); let loaded = load_stats_from(&stats_path);
assert_eq!(loaded, StatsSnapshot::default()); 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);
}
} }
+49
View File
@@ -16,6 +16,7 @@ use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}
use crate::{ use crate::{
auth_tokens::{load_access_token, load_refresh_token, store_tokens}, auth_tokens::{load_access_token, load_refresh_token, store_tokens},
replay::Replay,
settings::SyncBackend, settings::SyncBackend,
SyncError, SyncProvider, SyncError, SyncProvider,
}; };
@@ -356,6 +357,54 @@ impl SyncProvider for SolitaireServerClient {
extract_leaderboard_body(resp).await extract_leaderboard_body(resp).await
} }
/// Upload a winning replay to `POST /api/replays`. Mirrors the
/// `push` auth flow: 401 triggers a token refresh and one retry.
/// Non-success statuses are surfaced as the relevant `SyncError`
/// variant so the engine's push-on-win system can downgrade
/// network/auth failures into a quiet log without aborting the
/// game flow.
async fn push_replay(&self, replay: &Replay) -> Result<(), 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 check_replay_status(resp.status());
}
check_replay_status(resp.status())
}
}
fn check_replay_status(status: reqwest::StatusCode) -> Result<(), SyncError> {
if status.is_success() {
Ok(())
} else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
Err(SyncError::Auth(format!("server returned {status}")))
} else {
Err(SyncError::Network(format!("server returned {status}")))
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2 -2
View File
@@ -11,8 +11,8 @@ pub mod svg_loader;
pub mod user_dir; pub mod user_dir;
pub use sources::{ pub use sources::{
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin, default_theme_svg_bytes, populate_embedded_default_theme, register_theme_asset_sources,
DEFAULT_THEME_MANIFEST_URL, USER_THEMES, AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
}; };
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings}; pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
pub use user_dir::{set_user_theme_dir, user_theme_dir}; pub use user_dir::{set_user_theme_dir, user_theme_dir};
+42
View File
@@ -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 /// Pushes every bundled default-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a /// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a
/// free function (and not inside the `Plugin::build` body) means the /// free function (and not inside the `Plugin::build` body) means the
@@ -291,6 +310,29 @@ mod tests {
assert_eq!(faces.len(), 52); 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` /// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH`
/// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa) /// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa)
/// the asset would register at one path and be loaded from /// the asset would register at one path and be loaded from
+435 -49
View File
@@ -10,9 +10,11 @@ use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*; use bevy::prelude::*;
use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state_from, use solitaire_core::pile::PileType;
save_game_state_to}; use solitaire_data::{delete_game_state_at, game_state_file_path, latest_replay_path,
load_game_state_from, save_game_state_to, save_latest_replay_to, Replay, ReplayMove};
use crate::events::{ use crate::events::{
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
@@ -52,6 +54,32 @@ pub struct GameMutation;
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>); pub struct GameStatePath(pub Option<PathBuf>);
/// Persistence path for the most recent winning replay. `None` disables I/O.
#[derive(Resource, Debug, Clone)]
pub struct ReplayPath(pub Option<PathBuf>);
/// In-memory accumulator for [`ReplayMove`] entries during the current
/// game. Cleared on every new-game start; frozen into a [`Replay`] and
/// flushed to disk by [`record_replay_on_win`] when the player wins.
///
/// Recording captures only successful state-mutating events the player
/// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is
/// intentionally not recorded — see [`solitaire_data::replay`] for the
/// design rationale.
#[derive(Resource, Debug, Default, Clone)]
pub struct RecordingReplay {
/// Ordered list of moves applied so far this game.
pub moves: Vec<ReplayMove>,
}
impl RecordingReplay {
/// Reset the recording. Called on every `NewGameRequestEvent` so a
/// fresh deal starts with an empty move list.
pub fn clear(&mut self) {
self.moves.clear();
}
}
/// Registers game resources, events, and the systems that route user intent /// Registers game resources, events, and the systems that route user intent
/// (events) into mutations on `GameState`. /// (events) into mutations on `GameState`.
pub struct GamePlugin; pub struct GamePlugin;
@@ -75,6 +103,8 @@ impl Plugin for GamePlugin {
app.insert_resource(GameStateResource(initial_state)) app.insert_resource(GameStateResource(initial_state))
.insert_resource(GameStatePath(path)) .insert_resource(GameStatePath(path))
.insert_resource(ReplayPath(latest_replay_path()))
.init_resource::<RecordingReplay>()
.init_resource::<DragState>() .init_resource::<DragState>()
.init_resource::<SyncStatusResource>() .init_resource::<SyncStatusResource>()
.add_message::<MoveRequestEvent>() .add_message::<MoveRequestEvent>()
@@ -100,6 +130,7 @@ impl Plugin for GamePlugin {
.in_set(GameMutation), .in_set(GameMutation),
) )
.add_systems(Update, check_no_moves.after(GameMutation)) .add_systems(Update, check_no_moves.after(GameMutation))
.add_systems(Update, record_replay_on_win.after(GameMutation))
.add_systems(Update, handle_confirm_input.after(GameMutation)) .add_systems(Update, handle_confirm_input.after(GameMutation))
.add_systems(Update, handle_confirm_button_input.after(GameMutation)) .add_systems(Update, handle_confirm_button_input.after(GameMutation))
.add_systems(Update, handle_game_over_input.after(GameMutation)) .add_systems(Update, handle_game_over_input.after(GameMutation))
@@ -163,11 +194,14 @@ fn handle_new_game(
mut new_game: MessageReader<NewGameRequestEvent>, mut new_game: MessageReader<NewGameRequestEvent>,
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
mut recording: ResMut<RecordingReplay>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>, settings: Option<Res<crate::settings_plugin::SettingsResource>>,
path: Option<Res<GameStatePath>>, path: Option<Res<GameStatePath>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>, confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
game_over_screens: Query<Entity, With<GameOverScreen>>, game_over_screens: Query<Entity, With<GameOverScreen>>,
layout: Option<Res<crate::layout::LayoutResource>>,
mut card_transforms: Query<&mut Transform, With<crate::card_plugin::CardEntity>>,
) { ) {
for ev in new_game.read() { for ev in new_game.read() {
// If an active game is in progress, intercept and show a confirm dialog. // If an active game is in progress, intercept and show a confirm dialog.
@@ -204,11 +238,33 @@ fn handle_new_game(
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone()); .map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
let mode = ev.mode.unwrap_or(game.0.mode); let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode); game.0 = GameState::new_with_mode(seed, draw_mode, mode);
// Reset the in-flight replay buffer — a fresh deal starts with
// an empty move list. The previously saved replay on disk
// (latest_replay.json) is preserved until the player wins again.
recording.clear();
// Delete any previously saved in-progress state — this is a fresh game. // Delete any previously saved in-progress state — this is a fresh game.
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
&& let Err(e) = delete_game_state_at(p) { && let Err(e) = delete_game_state_at(p) {
warn!("game_state: failed to delete saved game: {e}"); warn!("game_state: failed to delete saved game: {e}");
} }
// Snap every existing card sprite to the stock position before the
// deal animation starts. Without this the per-card slide tween reads
// each card's previous-game Transform as its source, which lets a
// careful observer track origin points to deduce where face-down
// cards came from. Funnelling all sprites through the deck position
// hides that information and reads naturally as "dealt from the
// deck." Skipped when LayoutResource isn't present (headless tests).
if let Some(layout) = layout.as_ref()
&& let Some(stock) = layout
.0
.pile_positions
.get(&solitaire_core::pile::PileType::Stock)
{
for mut tx in &mut card_transforms {
tx.translation.x = stock.x;
tx.translation.y = stock.y;
}
}
changed.write(StateChangedEvent); changed.write(StateChangedEvent);
} }
} }
@@ -361,9 +417,8 @@ fn handle_draw(
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
mut flipped: MessageWriter<CardFlippedEvent>, mut flipped: MessageWriter<CardFlippedEvent>,
mut recording: ResMut<RecordingReplay>,
) { ) {
use solitaire_core::pile::PileType;
for _ in draws.read() { for _ in draws.read() {
// Capture which cards are about to be drawn (top of the stock pile) // Capture which cards are about to be drawn (top of the stock pile)
// so we can fire flip events after they land face-up in the waste. // so we can fire flip events after they land face-up in the waste.
@@ -392,6 +447,13 @@ fn handle_draw(
for id in drawn_ids { for id in drawn_ids {
flipped.write(CardFlippedEvent(id)); flipped.write(CardFlippedEvent(id));
} }
// Record the atomic player input. Whether the engine
// resolves this to a draw or a waste→stock recycle is
// a deterministic function of stock state at the time
// the click happens — re-executing on the same starting
// deal produces the same effect, so the input alone is
// sufficient to recover the move on playback.
recording.moves.push(ReplayMove::StockClick);
changed.write(StateChangedEvent); changed.write(StateChangedEvent);
} }
Err(e) => warn!("draw rejected: {e}"), Err(e) => warn!("draw rejected: {e}"),
@@ -407,10 +469,9 @@ fn handle_move(
mut won: MessageWriter<GameWonEvent>, mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<crate::events::CardFlippedEvent>, mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
mut foundation_done: MessageWriter<FoundationCompletedEvent>, mut foundation_done: MessageWriter<FoundationCompletedEvent>,
mut recording: ResMut<RecordingReplay>,
path: Option<Res<GameStatePath>>, path: Option<Res<GameStatePath>>,
) { ) {
use solitaire_core::pile::PileType;
for ev in moves.read() { for ev in moves.read() {
let was_won = game.0.is_won; let was_won = game.0.is_won;
// Identify the card that will be exposed (and may flip face-up) by the move. // Identify the card that will be exposed (and may flip face-up) by the move.
@@ -426,6 +487,14 @@ fn handle_move(
}); });
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) { match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
Ok(()) => { Ok(()) => {
// Record the move in the in-flight replay buffer. Done
// first so the entry is captured even if a subsequent
// event-write or pile-lookup happens to bail out below.
recording.moves.push(ReplayMove::Move {
from: ev.from.clone(),
to: ev.to.clone(),
count: ev.count,
});
// Fire flip event if the candidate card is now face-up. // Fire flip event if the candidate card is now face-up.
if let Some(fid) = flip_candidate_id if let Some(fid) = flip_candidate_id
&& game.0.piles.get(&ev.from) && game.0.piles.get(&ev.from)
@@ -486,62 +555,109 @@ fn handle_undo(
} }
} }
/// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into
/// a [`Replay`] tagged with the deal seed/mode, the win's score and
/// elapsed time, and today's date — then persist it atomically to
/// `<data_dir>/solitaire_quest/latest_replay.json` (or to whichever path
/// `ReplayPath` carries; tests inject a temp path).
///
/// Only the most recent winning replay is retained — the existing file is
/// overwritten. The recording buffer is left intact after the win so a
/// subsequent state-change does not erase the move list before the save
/// completes; it gets cleared on the next `NewGameRequestEvent`.
pub fn record_replay_on_win(
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
recording: Res<RecordingReplay>,
path: Option<Res<ReplayPath>>,
) {
for ev in wins.read() {
// Skip persistence when the recording is empty. This guards
// against unrelated tests in other plugins that synthesise a
// `GameWonEvent` (e.g. to exercise XP / streak / weekly goal
// logic) without driving any actual moves — those wins should
// not silently overwrite the developer's real replay file.
// A real win always has at least one recorded `Move`.
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 Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
// No persistence path configured (e.g. tests / minimal Linux
// containers without dirs::data_dir). The in-memory replay
// is still available via the resource for callers that want
// to inspect it without going through the disk.
continue;
};
if let Err(e) = save_latest_replay_to(p, &replay) {
warn!("replay: failed to save winning replay: {e}");
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #29 — No-moves detection // Task #29 — No-moves detection
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Returns `true` if the current game state has at least one legal move. /// Returns `true` if the current game state has at least one legal move
/// that could ever lead to progress.
/// ///
/// Considers: /// Considers a card "playable" if it's currently face-up on the top of
/// - Any non-empty Stock or Waste pile (draw / recycle is always available). /// the Waste or any Tableau, OR if it lives in the Stock or Waste pile
/// - Any face-up card on Waste or Tableau piles that can legally move to any /// at all (every card in those piles eventually rotates through the
/// Foundation or Tableau destination. /// Waste's top in both Draw-One and Draw-Three over the course of
/// recycling). For each such candidate, checks whether it can land on
/// any Foundation or any Tableau in the current state.
///
/// Returns `false` only when *no* card anywhere can land anywhere —
/// the player can keep drawing through the stock forever and nothing
/// will ever come of it. This treats "draw cycle with no useful drop"
/// as a softlock rather than as "legal moves remain", which the
/// previous heuristic incorrectly did (Quat hit this with 4 cards
/// remaining and the game just sat there).
pub fn has_legal_moves(game: &GameState) -> bool { pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::card::Card;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
// If stock or waste is non-empty, the player can always draw. let mut sources: Vec<Card> = Vec::new();
if !game.piles.get(&PileType::Stock).is_some_and(|p| p.cards.is_empty()) for ty in [PileType::Stock, PileType::Waste] {
|| !game.piles.get(&PileType::Waste).is_some_and(|p| p.cards.is_empty()) if let Some(p) = game.piles.get(&ty) {
sources.extend(p.cards.iter().cloned());
}
}
for i in 0..7_usize {
if let Some(t) = game.piles.get(&PileType::Tableau(i))
&& let Some(top) = t.cards.last().filter(|c| c.face_up)
{
sources.push(top.clone());
}
}
for card in &sources {
for slot in 0..4_u8 {
if let Some(dest) = game.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(card, dest)
{ {
return true; return true;
} }
}
// Check each playable source pile.
let sources: Vec<PileType> = {
let mut v = vec![PileType::Waste];
for i in 0..7_usize { for i in 0..7_usize {
v.push(PileType::Tableau(i)); if let Some(dest) = game.piles.get(&PileType::Tableau(i))
} && can_place_on_tableau(card, dest)
v {
};
for from in &sources {
let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
// Check foundation slots.
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
if let Some(dest_pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, dest_pile) {
return true;
}
}
// Check tableau piles.
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if dest == *from {
continue;
}
if let Some(dest_pile) = game.piles.get(&dest)
&& can_place_on_tableau(card, dest_pile) {
return true; return true;
} }
} }
} }
false false
} }
@@ -772,8 +888,11 @@ mod tests {
fn test_app(seed: u64) -> App { fn test_app(seed: u64) -> App {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin); app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
// Disable I/O — tests must not touch the real game state file. // Disable I/O — tests must not touch the real game state file or
// the real replay file. Both default to dirs::data_dir() in the
// plugin's build path; clearing them keeps tests self-contained.
app.insert_resource(GameStatePath(None)); app.insert_resource(GameStatePath(None));
app.insert_resource(ReplayPath(None));
// Override the system-time seed with a known value. // Override the system-time seed with a known value.
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
@@ -1115,10 +1234,49 @@ mod tests {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
#[test] #[test]
fn has_legal_moves_returns_true_when_stock_nonempty() { fn has_legal_moves_returns_true_for_fresh_game() {
// A fresh game has 24 cards in stock — draw is always available. // A fresh deal always contains at least one playable card —
// typically several tableau→tableau opportunities plus any Aces
// that surface as a tableau column's bottom card.
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
assert!(has_legal_moves(&game), "draw is always available when stock is non-empty"); assert!(has_legal_moves(&game), "fresh deal must contain at least one legal move");
}
#[test]
fn has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards() {
// Reproduces Quat's softlock: stock has cards but no card anywhere
// (stock or otherwise) can land on any pile. The previous heuristic
// returned `true` here because stock was non-empty, so the game
// sat there forever instead of declaring softlock.
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Fill foundation 0 with Clubs A10, leaving only J/Q/K of Clubs
// as plausible foundation moves; load the stock with cards that
// can't land on the empty tableau (anything but a King) and can't
// extend foundation 0 (anything but Jack of Clubs).
let stock = game.piles.get_mut(&PileType::Stock).unwrap();
stock.cards.clear();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
stock.cards.push(Card { id: 100 + r as u32, suit: Suit::Hearts, rank: r, face_up: false });
}
let foundation_zero = game.piles.get_mut(&PileType::Foundation(0)).unwrap();
for r in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
] {
foundation_zero.cards.push(Card { id: r as u32, suit: Suit::Clubs, rank: r, face_up: true });
}
assert!(
!has_legal_moves(&game),
"stock cards with no legal landing should count as softlock",
);
} }
#[test] #[test]
@@ -1636,4 +1794,232 @@ mod tests {
"no InfoToastEvent must fire on a successful undo" "no InfoToastEvent must fire on a successful undo"
); );
} }
// -----------------------------------------------------------------------
// Win-game replay recording
//
// The recording resource captures exactly the player-driven actions
// that successfully advanced GameState. On GameWonEvent it freezes
// into a Replay (with seed/mode/time/score metadata) and persists.
// -----------------------------------------------------------------------
/// Set up Tableau(0) with a face-up Ace of Clubs that can be moved
/// to the empty Foundation(0) — gives us a single deterministic move
/// to drive the recording without depending on the dealt layout.
fn seed_single_legal_move(app: &mut App) {
use solitaire_core::card::{Card, Rank, Suit};
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card {
id: 999,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
let f0 = gs.0.piles.get_mut(&PileType::Foundation(0)).unwrap();
f0.cards.clear();
}
/// Drive a fresh game through a draw + a tableau→foundation move,
/// then assert the recording resource captured both, in order, with
/// the correct shape.
#[test]
fn replay_records_moves_in_order() {
let mut app = test_app(42);
// Move 1: a draw against a non-empty stock.
app.world_mut().write_message(DrawRequestEvent);
app.update();
// Move 2: a real card move from tableau to foundation.
seed_single_legal_move(&mut app);
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Foundation(0),
count: 1,
});
app.update();
// Move 3: another draw.
app.world_mut().write_message(DrawRequestEvent);
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert_eq!(
recording.moves.len(),
3,
"recording must capture exactly the three successful actions",
);
assert!(
matches!(recording.moves[0], ReplayMove::StockClick),
"first entry must be StockClick, got {:?}",
recording.moves[0],
);
match &recording.moves[1] {
ReplayMove::Move { from, to, count } => {
assert_eq!(*from, PileType::Tableau(0), "from pile must be Tableau(0)");
assert_eq!(*to, PileType::Foundation(0), "to pile must be Foundation(0)");
assert_eq!(*count, 1, "single-card move must have count 1");
}
other => panic!("second entry must be a Move, got {other:?}"),
}
assert!(
matches!(recording.moves[2], ReplayMove::StockClick),
"third entry must be StockClick, got {:?}",
recording.moves[2],
);
}
/// Invalid moves must not appear in the recording — the recording is
/// "what successfully happened", not "what was requested".
#[test]
fn replay_does_not_record_rejected_moves() {
let mut app = test_app(42);
// Stock → Waste is InvalidDestination; the live engine rejects it.
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Stock,
to: PileType::Waste,
count: 1,
});
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert!(
recording.moves.is_empty(),
"rejected moves must not enter the recording, got {:?}",
recording.moves,
);
}
/// Undo intentionally does NOT enter the recording. The replay
/// represents the canonical path the player took to win, not the
/// missteps that were rolled back.
#[test]
fn replay_recording_skips_undo() {
let mut app = test_app(42);
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().write_message(UndoRequestEvent);
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert_eq!(
recording.moves.len(),
1,
"only the draw is recorded; the undo does not erase it nor add a new entry",
);
assert!(matches!(recording.moves[0], ReplayMove::StockClick));
}
/// Starting a new game wipes the recording so the next deal begins
/// with a clean buffer.
#[test]
fn replay_recording_clears_on_new_game() {
let mut app = test_app(1);
app.world_mut().write_message(DrawRequestEvent);
app.update();
assert_eq!(
app.world().resource::<RecordingReplay>().moves.len(),
1,
"draw should have been recorded",
);
// Use `confirmed: true` so the request bypasses the
// abandon-current-game modal (which fires when move_count > 0)
// and goes straight to the new-game branch that clears the
// recording. The modal-spawn path is exercised by other tests
// in this module.
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(2),
mode: None,
confirmed: true,
});
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert!(
recording.moves.is_empty(),
"recording must be cleared on new-game start; got {:?}",
recording.moves,
);
}
/// On `GameWonEvent`, the recording is frozen into a `Replay` and
/// persisted. We point `ReplayPath` at a temp file, fake a win, and
/// load the file back to assert the metadata + move list match.
#[test]
fn replay_recording_freezes_into_replay_on_game_won() {
use solitaire_data::load_latest_replay_from;
let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
let _ = std::fs::remove_file(&path);
let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone())));
// Push two recorded moves manually so we can verify they survive
// the freeze/save round-trip without having to drive a real win.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(2),
count: 1,
});
}
// Fire the win event the engine emits when the last foundation
// completes — `record_replay_on_win` listens for it.
app.world_mut().write_message(GameWonEvent {
score: 4321,
time_seconds: 250,
});
app.update();
let loaded = load_latest_replay_from(&path)
.expect("a winning replay must be persisted to ReplayPath");
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured");
assert_eq!(loaded.final_score, 4321, "final_score must come from the win event");
assert_eq!(loaded.time_seconds, 250, "time_seconds must come from the win event");
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
assert!(matches!(loaded.moves[0], ReplayMove::StockClick));
match &loaded.moves[1] {
ReplayMove::Move { from, to, count } => {
assert_eq!(*from, PileType::Waste);
assert_eq!(*to, PileType::Tableau(2));
assert_eq!(*count, 1);
}
other => panic!("second entry must be a Move, got {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
/// `GameWonEvent` with an empty recording must NOT touch disk.
/// Without this guard, parallel-plugin tests that synthesise
/// win events for XP / streak / weekly-goal logic (without
/// driving any actual moves) would clobber the developer's real
/// replay file every time `cargo test` ran.
#[test]
fn replay_with_empty_recording_skips_save() {
let path = std::env::temp_dir().join("engine_test_replay_empty_skip.json");
let _ = std::fs::remove_file(&path);
let mut app = test_app(1);
app.insert_resource(ReplayPath(Some(path.clone())));
// Recording is empty by default — fire a win event anyway.
app.world_mut().write_message(GameWonEvent {
score: 100,
time_seconds: 30,
});
app.update();
assert!(
!path.exists(),
"no replay must be written when recording is empty",
);
}
} }
+8 -2
View File
@@ -92,7 +92,10 @@ pub use events::{
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
}; };
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath}; pub use game_plugin::{
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
ReplayPath,
};
pub use help_plugin::{HelpPlugin, HelpScreen}; pub use help_plugin::{HelpPlugin, HelpScreen};
pub use home_plugin::{HomePlugin, HomeScreen}; pub use home_plugin::{HomePlugin, HomeScreen};
pub use hud_plugin::{ pub use hud_plugin::{
@@ -119,7 +122,10 @@ pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState, KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
}; };
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate}; pub use stats_plugin::{
format_replay_caption, LatestReplayPath, LatestReplayResource, StatsPlugin, StatsResource,
StatsScreen, StatsUpdate, WatchReplayButton,
};
pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use sync_plugin::{SyncPlugin, SyncProviderResource};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin}; pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{ pub use ui_modal::{
+168 -2
View File
@@ -6,6 +6,7 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate};
use solitaire_core::achievement::achievement_by_id; use solitaire_core::achievement::achievement_by_id;
use solitaire_data::SyncBackend; use solitaire_data::SyncBackend;
@@ -20,14 +21,38 @@ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
TYPE_BODY_LG, VAL_SPACE_2, Z_MODAL_PANEL, 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. /// Marker component on the profile overlay root node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ProfileScreen; 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. /// Registers the `P` key toggle for the profile overlay.
pub struct ProfilePlugin; pub struct ProfilePlugin;
@@ -195,6 +220,16 @@ fn spawn_profile_screen(
font_row.clone(), font_row.clone(),
TextColor(TEXT_PRIMARY), TextColor(TEXT_PRIMARY),
)); ));
// 14-day daily-challenge calendar row.
spawn_daily_calendar(
card,
&prog.daily_challenge_history,
prog.daily_challenge_streak,
prog.daily_challenge_longest_streak,
Local::now().date_naive(),
font_res,
);
} }
// ── Achievements section ──────────────────────────────────── // ── Achievements section ────────────────────────────────────
@@ -300,6 +335,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. /// Return `(backend_name, username_display)` for the given sync backend.
fn sync_info(backend: &SyncBackend) -> (&'static str, String) { fn sync_info(backend: &SyncBackend) -> (&'static str, String) {
match backend { match backend {
@@ -417,4 +544,43 @@ mod tests {
// Level 10 is the first post-table level (span = 1000, starts at 5000). // Level 10 is the first post-table level (span = 1000, starts at 5000).
assert_eq!(xp_progress(5_000, 10), (1_000, 0)); 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");
}
} }
+324 -15
View File
@@ -18,13 +18,14 @@ use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::{ use solitaire_data::{
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings, load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
WindowGeometry, TOOLTIP_DELAY_STEP_SECS, WindowGeometry, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
}; };
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent}; use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
@@ -32,8 +33,9 @@ use crate::ui_modal::{
}; };
use crate::ui_tooltip::Tooltip; use crate::ui_tooltip::Tooltip;
use crate::ui_theme::{ use crate::ui_theme::{
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
Z_MODAL_PANEL,
}; };
/// Side length of a swatch button in the card-back / background pickers. /// Side length of a swatch button in the card-back / background pickers.
@@ -126,6 +128,10 @@ struct ColorBlindText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TooltipDelayText; struct TooltipDelayText;
/// Marks the `Text` node showing the live time-bonus-multiplier value.
#[derive(Component, Debug)]
struct TimeBonusMultiplierText;
/// Marks the scrollable inner card so the mouse-wheel system can target it. /// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsPanelScrollable; struct SettingsPanelScrollable;
@@ -134,6 +140,23 @@ struct SettingsPanelScrollable;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsScrollNode; struct SettingsScrollNode;
/// Snapshot row used by [`spawn_settings_panel`] to render the card-art
/// theme picker. Carries the `ThemeRegistry` entry's display fields plus
/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None`
/// thumbnail means the picker should render a placeholder swatch — used
/// when the cache hasn't generated handles yet, or when a user theme
/// is missing one of the required preview SVGs.
#[derive(Debug, Clone)]
struct ThemePickerEntry {
/// Stable theme id (matches `ThemeMeta::id`).
id: String,
/// Player-facing label.
display_name: String,
/// Pre-generated picker preview pair, when ready. `None` collapses
/// the chip to its plain-text fallback.
thumbnails: Option<ThemeThumbnailPair>,
}
/// Tags interactive buttons inside the Settings panel. /// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)] #[derive(Component, Debug)]
enum SettingsButton { enum SettingsButton {
@@ -147,6 +170,10 @@ enum SettingsButton {
TooltipDelayDown, TooltipDelayDown,
/// Increment the tooltip-hover dwell delay by one step. /// Increment the tooltip-hover dwell delay by one step.
TooltipDelayUp, TooltipDelayUp,
/// Decrement the cosmetic time-bonus multiplier by one step.
TimeBonusDown,
/// Increment the cosmetic time-bonus multiplier by one step.
TimeBonusUp,
ToggleTheme, ToggleTheme,
ToggleColorBlind, ToggleColorBlind,
SyncNow, SyncNow,
@@ -179,6 +206,8 @@ impl SettingsButton {
SettingsButton::CycleAnimSpeed => 40, SettingsButton::CycleAnimSpeed => 40,
SettingsButton::TooltipDelayDown => 45, SettingsButton::TooltipDelayDown => 45,
SettingsButton::TooltipDelayUp => 46, SettingsButton::TooltipDelayUp => 46,
SettingsButton::TimeBonusDown => 47,
SettingsButton::TimeBonusUp => 48,
// Cosmetic section // Cosmetic section
SettingsButton::ToggleTheme => 50, SettingsButton::ToggleTheme => 50,
SettingsButton::ToggleColorBlind => 60, SettingsButton::ToggleColorBlind => 60,
@@ -269,6 +298,7 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text, update_anim_speed_text,
update_color_blind_text, update_color_blind_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text,
attach_focusable_to_settings_buttons, attach_focusable_to_settings_buttons,
scroll_focus_into_view, scroll_focus_into_view,
), ),
@@ -370,6 +400,7 @@ fn sync_settings_panel_visibility(
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>, theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>, card_images: Option<Res<crate::card_plugin::CardImageSet>>,
) { ) {
if !screen.is_changed() { if !screen.is_changed() {
@@ -385,15 +416,27 @@ fn sync_settings_panel_visibility(
let unlocked_bgs = progress let unlocked_bgs = progress
.as_ref() .as_ref()
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice()); .map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id+display_name so spawn_settings_panel // Snapshot themes by id, display_name and (optional)
// doesn't have to know about the registry shape. Empty when // thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under // ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render. // MinimalPlugins) — the picker row simply won't render.
let themes: Vec<(String, String)> = theme_registry // Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.as_deref() .as_deref()
.map(|r| { .map(|r| {
r.iter() r.iter()
.map(|e| (e.id.clone(), e.display_name.clone())) .map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.as_deref()
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default();
@@ -521,6 +564,20 @@ fn update_tooltip_delay_text(
} }
} }
/// Refreshes the live time-bonus-multiplier value in the Gameplay
/// section whenever `SettingsResource` changes.
fn update_time_bonus_multiplier_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = time_bonus_label(settings.0.time_bonus_multiplier);
}
}
fn card_back_label(idx: usize) -> String { fn card_back_label(idx: usize) -> String {
if idx == 0 { if idx == 0 {
"Default".to_string() "Default".to_string()
@@ -662,6 +719,25 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
} }
} }
SettingsButton::TimeBonusDown => {
let before = settings.0.time_bonus_multiplier;
let after = settings.0.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by
// `update_time_bonus_multiplier_text` on the next
// frame via `settings.is_changed()`.
}
}
SettingsButton::TimeBonusUp => {
let before = settings.0.time_bonus_multiplier;
let after = settings.0.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
SettingsButton::ToggleTheme => { SettingsButton::ToggleTheme => {
settings.0.theme = match settings.0.theme { settings.0.theme = match settings.0.theme {
Theme::Green => Theme::Blue, Theme::Green => Theme::Blue,
@@ -747,6 +823,18 @@ fn tooltip_delay_label(secs: f32) -> String {
} }
} }
/// Formats the cosmetic time-bonus multiplier for display in the
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
/// time-bonus row will be hidden; any other value prints as
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
fn time_bonus_label(value: f32) -> String {
if value <= 0.0 {
"Off".into()
} else {
format!("{value:.1}×")
}
}
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon /// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
/// buttons (volume +/, toggle, cycle), swatch buttons (card-back, /// buttons (volume +/, toggle, cycle), swatch buttons (card-back,
/// background pickers), and the "Sync Now" button. The "Done" button is /// background pickers), and the "Sync Now" button. The "Done" button is
@@ -1010,7 +1098,7 @@ fn spawn_settings_panel(
sync_status: &str, sync_status: &str,
unlocked_card_backs: &[usize], unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize], unlocked_backgrounds: &[usize],
themes: &[(String, String)], themes: &[ThemePickerEntry],
scroll_offset: f32, scroll_offset: f32,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
theme_overrides_back: bool, theme_overrides_back: bool,
@@ -1084,6 +1172,11 @@ fn spawn_settings_panel(
settings.tooltip_delay_secs, settings.tooltip_delay_secs,
font_res, font_res,
); );
time_bonus_multiplier_row(
body,
settings.time_bonus_multiplier,
font_res,
);
// --- Cosmetic --- // --- Cosmetic ---
section_label(body, "Cosmetic", font_res); section_label(body, "Cosmetic", font_res);
@@ -1268,6 +1361,56 @@ fn tooltip_delay_row(
}); });
} }
/// `Time bonus 1.0× [] [+]` — slider row for the cosmetic
/// `Settings::time_bonus_multiplier`. Mirrors [`tooltip_delay_row`]
/// (label, current value, decrement, increment) but formats the value
/// via [`time_bonus_label`] so `0.0` reads as `"Off"` and other values
/// as `"{n:.1}×"`. The multiplier is **cosmetic** — adjusting it
/// changes only the win-modal score breakdown, not the canonical
/// scores recorded in stats / achievements / leaderboards.
fn time_bonus_multiplier_row(
parent: &mut ChildSpawnerCommands,
value: f32,
font_res: Option<&FontResource>,
) {
let label_font = label_text_font(font_res);
let value_font = value_text_font(font_res);
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Time bonus".to_string()),
label_font,
TextColor(TEXT_SECONDARY),
));
row.spawn((
TimeBonusMultiplierText,
Text::new(time_bonus_label(value)),
value_font,
TextColor(TEXT_PRIMARY),
));
icon_button(
row,
"",
SettingsButton::TimeBonusDown,
"Shrink the time-bonus shown in the win modal. Cosmetic only.",
font_res,
);
icon_button(
row,
"+",
SettingsButton::TimeBonusUp,
"Boost the time-bonus shown in the win modal. Cosmetic only.",
font_res,
);
});
}
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme, /// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
/// anim speed, colour-blind). /// anim speed, colour-blind).
/// ///
@@ -1384,6 +1527,13 @@ fn picker_row(
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub(crate) struct CardBackPickerOverriddenByTheme; pub(crate) struct CardBackPickerOverriddenByTheme;
/// Marker placed on every preview-thumbnail [`ImageNode`] inside a
/// theme picker chip. Lets tests assert that a chip's children include
/// the rasterised preview pair, and lets a future system update or
/// hot-swap thumbnails without scanning the whole UI tree.
#[derive(Component, Debug)]
pub(crate) struct ThemeThumbnailMarker;
/// Renders the "Card Back" row in its overridden-by-theme state: a /// Renders the "Card Back" row in its overridden-by-theme state: a
/// labelled caption explaining why the swatches are hidden, with no /// labelled caption explaining why the swatches are hidden, with no
/// interactive children. This is what the player sees when the active /// interactive children. This is what the player sees when the active
@@ -1426,14 +1576,25 @@ fn picker_row_overridden_by_theme(
}); });
} }
/// Logical width (px) of one preview thumbnail inside a picker chip.
/// Mirrors [`crate::theme::THEME_THUMBNAIL_WIDTH_PX`] but at the UI
/// scale used by Bevy's flex layout. The rasterised image itself is
/// 100×140 px; the chip displays it at the same logical size so
/// scaling artifacts stay minimal.
const THUMBNAIL_LOGICAL_WIDTH_PX: f32 = 50.0;
/// Logical height counterpart to [`THUMBNAIL_LOGICAL_WIDTH_PX`] —
/// preserves the 2:3 card aspect.
const THUMBNAIL_LOGICAL_HEIGHT_PX: f32 = 70.0;
/// Picker row for card-art themes. Distinct from [`picker_row`] /// Picker row for card-art themes. Distinct from [`picker_row`]
/// because themes are identified by `String` ids (matching /// because themes are identified by `String` ids (matching
/// `ThemeMeta::id`) instead of dense indices, and each chip carries /// `ThemeMeta::id`) instead of dense indices, and each chip carries
/// the theme's display name rather than a numeric label. /// the theme's display name plus a small Ace + back preview pair
/// (when available in [`ThemeThumbnailCache`]).
fn theme_picker_row( fn theme_picker_row(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
label: &str, label: &str,
themes: &[(String, String)], themes: &[ThemePickerEntry],
selected_id: &str, selected_id: &str,
tooltip: &'static str, tooltip: &'static str,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
@@ -1461,19 +1622,25 @@ fn theme_picker_row(
label_font, label_font,
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
for (id, display_name) in themes { for entry in themes {
let is_selected = id == selected_id; let is_selected = entry.id == selected_id;
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI }; let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
row.spawn(( row.spawn((
SettingsButton::SelectTheme(id.clone()), SettingsButton::SelectTheme(entry.id.clone()),
Button, Button,
Tooltip::new(tooltip), Tooltip::new(tooltip),
Node { Node {
// Chips with thumbnails stack the preview pair
// above the label so a glance reveals the
// theme's art without hovering for the
// tooltip.
flex_direction: FlexDirection::Column,
// Theme names are wider than numeric chips — // Theme names are wider than numeric chips —
// pad horizontally instead of using a fixed // pad horizontally instead of using a fixed
// square swatch. // square swatch.
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
min_height: Val::Px(SWATCH_PX), min_height: Val::Px(SWATCH_PX),
row_gap: VAL_SPACE_2,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
@@ -1484,9 +1651,10 @@ fn theme_picker_row(
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY }; let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
b.spawn(( b.spawn((
Text::new(display_name.clone()), Text::new(entry.display_name.clone()),
chip_font.clone(), chip_font.clone(),
TextColor(text_color), TextColor(text_color),
)); ));
@@ -1495,6 +1663,70 @@ fn theme_picker_row(
}); });
} }
/// Spawns the Ace + back preview pair for a theme picker chip.
///
/// When `thumbnails` is `Some(_)` and both handles are non-default,
/// renders two `ImageNode` siblings (Ace on the left, back on the
/// right). When the thumbnails are missing or only partially loaded,
/// renders two muted `BG_ELEVATED` placeholder rectangles at the same
/// logical size — keeping the chip's overall footprint stable so the
/// picker row layout doesn't reflow as the cache fills in.
fn spawn_thumbnail_pair(
parent: &mut ChildSpawnerCommands,
thumbnails: Option<&ThemeThumbnailPair>,
) {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_2,
align_items: AlignItems::Center,
..default()
})
.with_children(|pair| {
match thumbnails {
Some(t) if t.is_fully_populated() => {
spawn_thumbnail_image(pair, t.ace.clone());
spawn_thumbnail_image(pair, t.back.clone());
}
_ => {
spawn_thumbnail_placeholder(pair);
spawn_thumbnail_placeholder(pair);
}
}
});
}
/// Spawns one `ImageNode` thumbnail at the canonical preview size.
/// Tagged with [`ThemeThumbnailMarker`] so tests can scan a chip's
/// children for the rendered preview without crawling the whole UI.
fn spawn_thumbnail_image(parent: &mut ChildSpawnerCommands, image: Handle<Image>) {
parent.spawn((
ThemeThumbnailMarker,
ImageNode::new(image),
Node {
width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX),
height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX),
..default()
},
));
}
/// Spawns a muted placeholder rectangle for the case where the cache
/// has not yet generated thumbnails for a theme — or when a user theme
/// is missing one of its preview SVGs. Same logical size as
/// [`spawn_thumbnail_image`] so chip layout stays stable.
fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX),
height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(BG_ELEVATED),
));
}
/// Status text + manual "Sync Now" button. /// Status text + manual "Sync Now" button.
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) { fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
let status_font = TextFont { let status_font = TextFont {
@@ -1943,6 +2175,83 @@ mod tests {
); );
} }
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
/// at least one theme and the [`ThemeThumbnailCache`] holds a
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
/// `ImageNode` for each preview slot.
#[test]
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
let mut app = headless_app_with_focus();
// Prime an Assets<Image> resource so we can mint stable handles
// for the synthetic thumbnail pair.
app.init_resource::<Assets<Image>>();
let (ace_handle, back_handle) = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
let ace = images.add(Image::default());
let back = images.add(Image::default());
(ace, back)
};
// Inject one theme entry + a matching thumbnail pair.
app.insert_resource(ThemeRegistry {
entries: vec![ThemeEntry {
id: "test_theme".into(),
display_name: "Test Theme".into(),
manifest_url: "themes://test_theme/theme.ron".into(),
meta: crate::theme::ThemeMeta {
id: "test_theme".into(),
name: "Test Theme".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (2, 3),
},
}],
});
let mut cache = ThemeThumbnailCache::default();
cache.entries.insert(
"test_theme".into(),
ThemeThumbnailPair {
ace: ace_handle.clone(),
back: back_handle.clone(),
},
);
app.insert_resource(cache);
// Open the panel and let the spawn + child-flush systems run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Find every ImageNode tagged with ThemeThumbnailMarker — the
// theme picker chip for "test_theme" must contribute exactly
// two of them (ace + back).
let thumbnail_count = app
.world_mut()
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
.iter(app.world())
.count();
assert!(
thumbnail_count >= 2,
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
);
// Spot-check: at least one thumbnail's image handle matches one
// of the ones we inserted into the cache. This guards against a
// future refactor that accidentally clones the wrong handle.
let any_matches = app
.world_mut()
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
.iter(app.world())
.any(|node| node.image == ace_handle || node.image == back_handle);
assert!(
any_matches,
"at least one rendered thumbnail must reuse the cached handle"
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Window geometry persistence // Window geometry persistence
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+314 -3
View File
@@ -11,8 +11,8 @@ use std::path::PathBuf;
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{ use solitaire_data::{
load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsExt, StatsSnapshot, latest_replay_path, load_latest_replay_from, load_stats_from, save_stats_to, stats_file_path,
WEEKLY_GOALS, PlayerProgress, Replay, StatsExt, StatsSnapshot, WEEKLY_GOALS,
}; };
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -58,6 +58,39 @@ pub struct StatsScreen;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct StatsCell; pub struct StatsCell;
/// Resource holding the most recently loaded winning [`Replay`], if any.
///
/// Populated from `<data_dir>/solitaire_quest/latest_replay.json` at
/// startup and refreshed in-place whenever the engine writes a new
/// winning replay (the path the Stats UI calls into is unchanged so a
/// re-open of the modal sees the latest record).
///
/// The Stats overlay reads this to decide whether to render the
/// "Watch replay" call-to-action or the "No replay recorded yet"
/// caption.
#[derive(Resource, Debug, Default, Clone)]
pub struct LatestReplayResource(pub Option<Replay>);
/// Persistence path for the latest winning replay file. `None` disables
/// I/O — used by tests and by `StatsPlugin::headless`.
#[derive(Resource, Debug, Clone)]
pub struct LatestReplayPath(pub Option<PathBuf>);
/// Marker on the "Watch replay" button inside the Stats modal. Clicking
/// it currently fires an [`InfoToastEvent`] indicating playback ships
/// in a future build — see [`handle_watch_replay_button`].
#[derive(Component, Debug)]
pub struct WatchReplayButton;
/// Marker component on each per-mode bests row in the stats overlay.
///
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
/// Zen, Challenge — Time Attack and Daily are intentionally excluded; see
/// `StatsSnapshot` doc comments). Tests query by this marker to assert the
/// per-mode section rendered.
#[derive(Component, Debug)]
pub struct PerModeBestsRow;
/// Registers stats resources, update systems, and the UI toggle. /// Registers stats resources, update systems, and the UI toggle.
pub struct StatsPlugin { pub struct StatsPlugin {
/// Where to persist stats. `None` disables all file I/O (for tests). /// Where to persist stats. `None` disables all file I/O (for tests).
@@ -87,8 +120,18 @@ impl Plugin for StatsPlugin {
Some(path) => load_stats_from(path), Some(path) => load_stats_from(path),
None => StatsSnapshot::default(), None => StatsSnapshot::default(),
}; };
// Replay file lives next to stats.json — when the StatsPlugin
// is in headless mode (storage_path = None), we mirror that
// policy and disable replay I/O too. Otherwise resolve the
// platform-default path via `latest_replay_path()`.
let replay_path = self.storage_path.as_ref().and(latest_replay_path());
let initial_replay = replay_path
.as_deref()
.and_then(load_latest_replay_from);
app.insert_resource(StatsResource(loaded)) app.insert_resource(StatsResource(loaded))
.insert_resource(StatsStoragePath(self.storage_path.clone())) .insert_resource(StatsStoragePath(self.storage_path.clone()))
.insert_resource(LatestReplayResource(initial_replay))
.insert_resource(LatestReplayPath(replay_path))
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
@@ -114,10 +157,72 @@ impl Plugin for StatsPlugin {
handle_forfeit.before(GameMutation), handle_forfeit.before(GameMutation),
) )
.add_systems(Update, toggle_stats_screen.after(GameMutation)) .add_systems(Update, toggle_stats_screen.after(GameMutation))
.add_systems(Update, handle_stats_close_button); .add_systems(Update, handle_stats_close_button)
.add_systems(
Update,
refresh_latest_replay_on_win.after(GameMutation),
)
.add_systems(Update, handle_watch_replay_button);
} }
} }
/// After a win, the engine has just persisted a fresh winning replay.
/// Re-load it so the next time the player opens the Stats overlay, the
/// "Watch replay" call-to-action reflects the most recent victory
/// rather than an older session.
fn refresh_latest_replay_on_win(
mut wins: MessageReader<GameWonEvent>,
mut latest: ResMut<LatestReplayResource>,
path: Res<LatestReplayPath>,
) {
// Only re-load when at least one win actually fired.
if wins.read().next().is_none() {
return;
}
let Some(p) = path.0.as_deref() else {
return;
};
latest.0 = load_latest_replay_from(p);
}
/// Click handler for the "Watch replay" button.
///
/// Replay playback lives on the sync server's web UI rather than in
/// the desktop client. This handler currently surfaces a clear toast
/// pointing the player there once the upload + URL is wired; until
/// then it acknowledges the click and signals that the feature is on
/// the way.
fn handle_watch_replay_button(
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
latest: Res<LatestReplayResource>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
let message = match &latest.0 {
Some(replay) => format!(
"Replay ready ({}) \u{2014} web playback coming in a future build",
format_replay_caption(replay),
),
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
};
toast.write(InfoToastEvent(message));
}
/// Pure helper: render a one-line caption for a [`Replay`] suitable
/// for the Stats overlay button label and the "Replay loaded" toast.
///
/// Format: `"M:SS win on YYYY-MM-DD"`. For a 134-second win recorded
/// on 2026-05-02, returns `"2:14 win on 2026-05-02"`.
pub fn format_replay_caption(replay: &Replay) -> String {
format!(
"{} win on {}",
format_duration(replay.time_seconds),
replay.recorded_at,
)
}
fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) { fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) {
let Some(target) = &path.0 else { let Some(target) = &path.0 else {
return; return;
@@ -140,6 +245,13 @@ fn update_stats_on_win(
stats stats
.0 .0
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode); .update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
// Per-mode best score / fastest win — additive on top of the
// lifetime totals tracked by `update_on_win`. TimeAttack is a
// no-op inside the helper because it has its own session-level
// scoring model.
stats
.0
.update_per_mode_bests(ev.score, ev.time_seconds, game.0.mode);
let new_streak = stats.0.win_streak_current; let new_streak = stats.0.win_streak_current;
// Fire the streak-milestone event only on the threshold // Fire the streak-milestone event only on the threshold
// crossing — `prev < threshold && new >= threshold`. This // crossing — `prev < threshold && new >= threshold`. This
@@ -247,6 +359,7 @@ fn toggle_stats_screen(
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>, time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
latest_replay: Res<LatestReplayResource>,
screens: Query<Entity, With<StatsScreen>>, screens: Query<Entity, With<StatsScreen>>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
@@ -262,6 +375,7 @@ fn toggle_stats_screen(
progress.as_deref().map(|p| &p.0), progress.as_deref().map(|p| &p.0),
time_attack.as_deref(), time_attack.as_deref(),
font_res.as_deref(), font_res.as_deref(),
latest_replay.0.as_ref(),
); );
} }
} }
@@ -287,6 +401,7 @@ fn spawn_stats_screen(
progress: Option<&PlayerProgress>, progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>, time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
latest_replay: Option<&Replay>,
) { ) {
// --- primary stat cells --- // --- primary stat cells ---
// First-launch zero-state: when no games have been played yet, render // First-launch zero-state: when no games have been played yet, render
@@ -361,6 +476,46 @@ fn spawn_stats_screen(
spawn_stat_cell(grid, &best_streak_str, "Best Streak"); spawn_stat_cell(grid, &best_streak_str, "Best Streak");
}); });
// --- per-mode bests section ---
// Three rows, one per supported mode. Time Attack uses session-level
// scoring (count of wins inside a 10-minute window) so a per-game
// best wouldn't compose; Daily uses Classic scoring and so already
// contributes to the Classic row.
card.spawn((
Text::new("Per-mode bests"),
font_section.clone(),
TextColor(STATE_INFO),
));
card.spawn(Node {
flex_direction: FlexDirection::Column,
width: Val::Percent(100.0),
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|column| {
spawn_per_mode_bests_row(
column,
"Classic",
stats.classic_best_score,
stats.classic_fastest_win_seconds,
&font_row,
);
spawn_per_mode_bests_row(
column,
"Zen",
stats.zen_best_score,
stats.zen_fastest_win_seconds,
&font_row,
);
spawn_per_mode_bests_row(
column,
"Challenge",
stats.challenge_best_score,
stats.challenge_fastest_win_seconds,
&font_row,
);
});
// --- progression section --- // --- progression section ---
if let Some(p) = progress { if let Some(p) = progress {
card.spawn(( card.spawn((
@@ -435,7 +590,34 @@ fn spawn_stats_screen(
)); ));
} }
// --- Latest replay caption ---
// Surfaces the most recent winning game so the player can spot
// whether their last victory has been recorded. The Watch
// Replay action below is what the player clicks to revisit it.
let replay_caption = match latest_replay {
Some(r) => format!("Latest win: {}", format_replay_caption(r)),
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
};
card.spawn((
Text::new(replay_caption),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
// The Watch Replay button is always rendered so the
// affordance is discoverable from a fresh install. When no
// replay exists, the click handler surfaces a clear
// "No replay recorded yet" toast rather than silently
// doing nothing.
spawn_modal_button(
actions,
WatchReplayButton,
"Watch replay",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button( spawn_modal_button(
actions, actions,
StatsCloseButton, StatsCloseButton,
@@ -448,6 +630,74 @@ fn spawn_stats_screen(
}); });
} }
/// Spawn one row of the "Per-mode bests" section: the mode label on the
/// left, then the best-score and best-time readouts right-aligned. Each
/// row is tagged with [`PerModeBestsRow`] so tests can count them.
///
/// `best_score == 0` and `fastest_win_seconds == 0` both render as an
/// em-dash, consistent with the first-launch zero-state treatment used
/// by the primary cells above.
fn spawn_per_mode_bests_row(
parent: &mut ChildSpawnerCommands,
mode_label: &str,
best_score: u32,
fastest_win_seconds: u64,
font_row: &TextFont,
) {
let dash = "\u{2014}".to_string();
let score_str = if best_score == 0 {
format!("Best {dash}")
} else {
format!("Best {best_score}")
};
let time_str = if fastest_win_seconds == 0 {
format!("Best time {dash}")
} else {
format!("Best time {}", format_duration(fastest_win_seconds))
};
parent
.spawn((
PerModeBestsRow,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
width: Val::Percent(100.0),
column_gap: VAL_SPACE_3,
..default()
},
))
.with_children(|row| {
// Mode label on the left.
row.spawn((
Text::new(mode_label.to_string()),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
// Right-aligned readouts grouped together.
row.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::FlexEnd,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|readouts| {
readouts.spawn((
Text::new(score_str),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
));
readouts.spawn((
Text::new(time_str),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
});
});
}
/// Spawn a single stat cell: a large value label on top and a small /// Spawn a single stat cell: a large value label on top and a small
/// descriptor below, inside a fixed-min-width column with a subtle /// descriptor below, inside a fixed-min-width column with a subtle
/// border. Recoloured to use ui_theme tokens — the prior 6%-alpha-white /// border. Recoloured to use ui_theme tokens — the prior 6%-alpha-white
@@ -710,6 +960,67 @@ mod tests {
); );
} }
#[test]
fn stats_screen_renders_three_per_mode_bests_rows() {
// Open the Stats overlay and assert three [`PerModeBestsRow`]
// entities exist — one per supported [`GameMode`] (Classic, Zen,
// Challenge — Time Attack and Daily are excluded by design).
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let row_count = app
.world_mut()
.query::<&PerModeBestsRow>()
.iter(app.world())
.count();
assert_eq!(
row_count, 3,
"expected three per-mode bests rows (Classic, Zen, Challenge), got {row_count}"
);
}
#[test]
fn classic_win_event_updates_classic_best_score() {
// Default mode is Classic — a win event should populate the
// Classic per-mode bests but leave Zen and Challenge at zero.
let mut app = headless_app();
app.world_mut().write_message(GameWonEvent {
score: 1500,
time_seconds: 180,
});
app.update();
let stats = &app.world().resource::<StatsResource>().0;
assert_eq!(stats.classic_best_score, 1500);
assert_eq!(stats.classic_fastest_win_seconds, 180);
assert_eq!(stats.zen_best_score, 0);
assert_eq!(stats.challenge_best_score, 0);
}
#[test]
fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 1800,
time_seconds: 600,
});
app.update();
let stats = &app.world().resource::<StatsResource>().0;
assert_eq!(stats.zen_best_score, 1800);
assert_eq!(stats.zen_fastest_win_seconds, 600);
assert_eq!(stats.classic_best_score, 0);
assert_eq!(stats.challenge_best_score, 0);
}
#[test] #[test]
fn pressing_s_twice_closes_stats_screen() { fn pressing_s_twice_closes_stats_screen() {
let mut app = headless_app(); let mut app = headless_app();
+53 -4
View File
@@ -20,14 +20,15 @@ use uuid::Uuid;
use solitaire_data::{ use solitaire_data::{
save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress, save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress,
StatsSnapshot, SyncError, SyncProvider, Replay, StatsSnapshot, SyncError, SyncProvider,
}; };
use solitaire_sync::{merge, SyncPayload, SyncResponse}; use solitaire_sync::{merge, SyncPayload, SyncResponse};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath}; 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::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
use crate::stats_plugin::{StatsResource, StatsStoragePath}; use crate::stats_plugin::{StatsResource, StatsStoragePath};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -96,7 +97,10 @@ impl Plugin for SyncPlugin {
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<SyncCompleteEvent>() .add_message::<SyncCompleteEvent>()
.add_systems(Startup, start_pull) .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),
)
.add_systems(Last, push_on_exit); .add_systems(Last, push_on_exit);
} }
} }
@@ -263,6 +267,51 @@ 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>,
) {
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();
AsyncComputeTaskPool::get()
.spawn(async move {
match provider.push_replay(&replay).await {
Ok(()) => {}
Err(SyncError::UnsupportedPlatform) => {}
Err(e) => warn!("replay upload failed: {e}"),
}
})
.detach();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+4 -1
View File
@@ -31,7 +31,10 @@ use solitaire_core::card::{Rank, Suit};
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId}; pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
pub use loader::{CardThemeLoader, CardThemeLoaderError}; pub use loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest; 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::{ pub use registry::{
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin, build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
}; };
+275 -1
View File
@@ -8,24 +8,82 @@
//! exposed for tests and for any embedder that wants to load an //! exposed for tests and for any embedder that wants to load an
//! alternative theme manually. //! alternative theme manually.
use std::collections::HashMap;
use bevy::asset::AssetEvent; use bevy::asset::AssetEvent;
use bevy::ecs::message::MessageReader; use bevy::ecs::message::MessageReader;
use bevy::math::UVec2;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit}; 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::card_plugin::CardImageSet;
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
use super::loader::CardThemeLoader; use super::loader::CardThemeLoader;
use super::registry::ThemeRegistry;
use super::{CardKey, CardTheme}; 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 /// Resource pointing at the currently-active card theme. Populated on
/// startup with the bundled default theme and replaced by [`set_theme`] /// startup with the bundled default theme and replaced by [`set_theme`]
/// when the player switches. /// when the player switches.
#[derive(Resource, Debug)] #[derive(Resource, Debug)]
pub struct ActiveTheme(pub Handle<CardTheme>); 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` /// Bevy plugin that loads the default theme and keeps `CardImageSet`
/// in sync with `Assets<CardTheme>`. /// in sync with `Assets<CardTheme>`.
/// ///
@@ -45,6 +103,7 @@ pub struct ThemePlugin;
impl Plugin for ThemePlugin { impl Plugin for ThemePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_asset::<CardTheme>() app.init_asset::<CardTheme>()
.init_resource::<ThemeThumbnailCache>()
.register_asset_loader(crate::assets::SvgLoader) .register_asset_loader(crate::assets::SvgLoader)
.register_asset_loader(CardThemeLoader) .register_asset_loader(CardThemeLoader)
.add_systems(Startup, load_initial_theme) .add_systems(Startup, load_initial_theme)
@@ -53,6 +112,7 @@ impl Plugin for ThemePlugin {
( (
sync_card_image_set_with_active_theme, sync_card_image_set_with_active_theme,
react_to_settings_theme_change, react_to_settings_theme_change,
ensure_theme_thumbnails,
), ),
); );
} }
@@ -231,6 +291,104 @@ pub fn set_theme(
handle 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -352,4 +510,120 @@ mod tests {
let url2 = format!("themes://{}/theme.ron", "user_uploaded"); let url2 = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url2, "themes://user_uploaded/theme.ron"); 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"
);
}
} }
+336 -2
View File
@@ -3,9 +3,33 @@
//! level ≥ `CHALLENGE_UNLOCK_LEVEL`); each win during the session bumps the //! level ≥ `CHALLENGE_UNLOCK_LEVEL`); each win during the session bumps the
//! counter and auto-deals a fresh game. When the timer expires the session //! counter and auto-deals a fresh game. When the timer expires the session
//! ends and `TimeAttackEndedEvent` fires. //! 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 bevy::prelude::*;
use solitaire_core::game_state::GameMode; 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::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
@@ -33,12 +57,52 @@ pub struct TimeAttackEndedEvent {
pub wins: u32, 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. /// 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; 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 { impl Plugin for TimeAttackPlugin {
fn build(&self, app: &mut App) { 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::<TimeAttackEndedEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
@@ -49,10 +113,13 @@ impl Plugin for TimeAttackPlugin {
handle_start_time_attack_request.before(GameMutation), handle_start_time_attack_request.before(GameMutation),
) )
.add_systems(Update, advance_time_attack) .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( fn handle_start_time_attack_request(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<StartTimeAttackRequestEvent>, mut requests: MessageReader<StartTimeAttackRequestEvent>,
@@ -60,6 +127,8 @@ fn handle_start_time_attack_request(
mut session: ResMut<TimeAttackResource>, mut session: ResMut<TimeAttackResource>,
mut new_game: MessageWriter<NewGameRequestEvent>, mut new_game: MessageWriter<NewGameRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>, 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. // Either T or the HUD Modes-popover "Time Attack" row triggers this.
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
@@ -77,6 +146,18 @@ fn handle_start_time_attack_request(
remaining_secs: TIME_ATTACK_DURATION_SECS, remaining_secs: TIME_ATTACK_DURATION_SECS,
wins: 0, 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 { new_game.write(NewGameRequestEvent {
seed: None, seed: None,
mode: Some(GameMode::TimeAttack), mode: Some(GameMode::TimeAttack),
@@ -89,6 +170,7 @@ fn advance_time_attack(
mut session: ResMut<TimeAttackResource>, mut session: ResMut<TimeAttackResource>,
mut ended: MessageWriter<TimeAttackEndedEvent>, mut ended: MessageWriter<TimeAttackEndedEvent>,
paused: Option<Res<crate::pause_plugin::PausedResource>>, paused: Option<Res<crate::pause_plugin::PausedResource>>,
path: Option<Res<TimeAttackSessionPath>>,
) { ) {
if !session.active { if !session.active {
return; return;
@@ -102,6 +184,12 @@ fn advance_time_attack(
session.active = false; session.active = false;
session.remaining_secs = 0.0; session.remaining_secs = 0.0;
ended.write(TimeAttackEndedEvent { wins }); 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 +212,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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -140,6 +302,12 @@ mod tests {
.add_plugins(ProgressPlugin::headless()) .add_plugins(ProgressPlugin::headless())
.add_plugins(TimeAttackPlugin); .add_plugins(TimeAttackPlugin);
app.init_resource::<ButtonInput<KeyCode>>(); 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.update();
app app
} }
@@ -302,4 +470,170 @@ mod tests {
"TimeAttackEndedEvent must not fire while paused" "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);
}
} }
+108 -15
View File
@@ -314,14 +314,40 @@ pub struct ScoreBreakdown {
} }
impl ScoreBreakdown { impl ScoreBreakdown {
/// Builds a breakdown for the given win. /// 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`, /// `base` is the running game score (`pending.score`); `time_seconds`,
/// `undo_count`, and `mode` come from the captured `WinSummaryPending`. /// `undo_count`, and `mode` come from the captured `WinSummaryPending`;
/// All score arithmetic is saturating to keep the breakdown safe even /// `time_bonus_multiplier` comes from `SettingsResource`. All score
/// for pathologically high scores. /// arithmetic is saturating to keep the breakdown safe even for
pub fn compute(base: i32, time_seconds: u64, undo_count: u32, mode: GameMode) -> Self { /// pathologically high scores.
let time_bonus = compute_time_bonus(time_seconds); ///
/// 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 no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
let multiplier = match mode { let multiplier = match mode {
GameMode::Zen => 0.0, GameMode::Zen => 0.0,
@@ -554,7 +580,21 @@ fn spawn_win_summary_after_delay(
let anim_speed = settings let anim_speed = settings
.as_ref() .as_ref()
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed); .map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
spawn_overlay(&mut commands, &pending, &session, challenge_level, anim_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,
);
} }
} }
} }
@@ -634,18 +674,25 @@ fn apply_screen_shake(
/// full opacity (no stagger, no fade); otherwise rows are spawned /// full opacity (no stagger, no fade); otherwise rows are spawned
/// hidden and the [`reveal_score_breakdown`] system fades them in over /// hidden and the [`reveal_score_breakdown`] system fades them in over
/// roughly one second. /// 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( fn spawn_overlay(
commands: &mut Commands, commands: &mut Commands,
pending: &WinSummaryPending, pending: &WinSummaryPending,
session: &SessionAchievements, session: &SessionAchievements,
challenge_level: Option<u32>, challenge_level: Option<u32>,
anim_speed: AnimSpeed, anim_speed: AnimSpeed,
time_bonus_multiplier: f32,
) { ) {
let breakdown = ScoreBreakdown::compute( let breakdown = ScoreBreakdown::compute(
pending.score, pending.score,
pending.time_seconds, pending.time_seconds,
pending.undo_count, pending.undo_count,
pending.mode, pending.mode,
time_bonus_multiplier,
); );
commands commands
.spawn(( .spawn((
@@ -1392,7 +1439,7 @@ mod tests {
/// the no-undo bonus fires because `undo_count == 0`. /// the no-undo bonus fires because `undo_count == 0`.
#[test] #[test]
fn score_breakdown_compute_produces_expected_components() { fn score_breakdown_compute_produces_expected_components() {
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic); let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
assert_eq!(bd.base, 3200); assert_eq!(bd.base, 3200);
assert_eq!(bd.time_bonus, 5833); // 700_000 / 120 assert_eq!(bd.time_bonus, 5833); // 700_000 / 120
assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS); assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS);
@@ -1408,7 +1455,7 @@ mod tests {
/// of the other components. /// of the other components.
#[test] #[test]
fn score_breakdown_zen_mode_zeros_total() { fn score_breakdown_zen_mode_zeros_total() {
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen); let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen, 1.0);
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON); assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row"); assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
assert_eq!(bd.total(), 0); assert_eq!(bd.total(), 0);
@@ -1418,7 +1465,7 @@ mod tests {
/// row is suppressed. /// row is suppressed.
#[test] #[test]
fn score_breakdown_skips_no_undo_row_when_undo_was_used() { fn score_breakdown_skips_no_undo_row_when_undo_was_used() {
let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic); let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic, 1.0);
assert_eq!(bd.no_undo_bonus, 0); assert_eq!(bd.no_undo_bonus, 0);
assert!(!bd.shows_no_undo_row()); assert!(!bd.shows_no_undo_row());
} }
@@ -1427,7 +1474,7 @@ mod tests {
/// is suppressed. /// is suppressed.
#[test] #[test]
fn score_breakdown_skips_time_bonus_row_when_zero() { fn score_breakdown_skips_time_bonus_row_when_zero() {
let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic); let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic, 1.0);
assert_eq!(bd.time_bonus, 0); assert_eq!(bd.time_bonus, 0);
assert!(!bd.shows_time_bonus_row()); assert!(!bd.shows_time_bonus_row());
} }
@@ -1438,7 +1485,7 @@ mod tests {
/// multiplier row, ×1.0 is suppressed). /// multiplier row, ×1.0 is suppressed).
#[test] #[test]
fn win_modal_score_breakdown_spawns_one_row_per_component() { fn win_modal_score_breakdown_spawns_one_row_per_component() {
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic); let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
assert_eq!( assert_eq!(
bd.row_count(), bd.row_count(),
5, 5,
@@ -1446,7 +1493,7 @@ mod tests {
); );
// Zen with both bonuses ALSO shows the multiplier row. // Zen with both bonuses ALSO shows the multiplier row.
let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen); let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen, 1.0);
assert_eq!( assert_eq!(
zen.row_count(), zen.row_count(),
6, 6,
@@ -1457,8 +1504,8 @@ mod tests {
/// When `no_undo_bonus == 0`, the row count drops by one. /// When `no_undo_bonus == 0`, the row count drops by one.
#[test] #[test]
fn win_modal_score_breakdown_skips_zero_bonus_rows() { fn win_modal_score_breakdown_skips_zero_bonus_rows() {
let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic); let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic); let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic, 1.0);
assert_eq!( assert_eq!(
bd_with.row_count() - 1, bd_with.row_count() - 1,
bd_without.row_count(), bd_without.row_count(),
@@ -1466,6 +1513,52 @@ mod tests {
); );
} }
/// 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 /// Pure helper test: the reveal logic uses delta-time to count
/// down `delay_secs`; at `t = 0` only the first row is "revealed", /// down `delay_secs`; at `t = 0` only the first row is "revealed",
/// and after one stagger interval the second row reveals as well. /// and after one stagger interval the second row reveals as well.
+1
View File
@@ -25,6 +25,7 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true } jsonwebtoken = { workspace = true }
bcrypt = { workspace = true } bcrypt = { workspace = true }
tower_governor = { workspace = true } tower_governor = { workspace = true }
tower-http = { version = "0.6", features = ["fs"] }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
dotenvy = { 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);
+5
View File
@@ -31,6 +31,10 @@ pub enum AppError {
#[error("bad request: {0}")] #[error("bad request: {0}")]
BadRequest(String), BadRequest(String),
/// The requested resource does not exist.
#[error("not found: {0}")]
NotFound(String),
/// A database error occurred. /// A database error occurred.
#[error("database error: {0}")] #[error("database error: {0}")]
Database(#[from] sqlx::Error), Database(#[from] sqlx::Error),
@@ -56,6 +60,7 @@ impl IntoResponse for AppError {
} }
AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()), AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Database(e) => { AppError::Database(e) => {
tracing::error!("database error: {e}"); tracing::error!("database error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()) (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
+19
View File
@@ -9,11 +9,13 @@ pub mod challenge;
pub mod error; pub mod error;
pub mod leaderboard; pub mod leaderboard;
pub mod middleware; pub mod middleware;
pub mod replays;
pub mod sync; pub mod sync;
use axum::{ use axum::{
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
middleware as axum_middleware, middleware as axum_middleware,
response::Html,
routing::{delete, get, post}, routing::{delete, get, post},
Router, Router,
}; };
@@ -24,6 +26,7 @@ use tower_governor::{
key_extractor::SmartIpKeyExtractor, key_extractor::SmartIpKeyExtractor,
GovernorLayer, GovernorLayer,
}; };
use tower_http::services::ServeDir;
/// Shared application state injected into every Axum handler via [`axum::extract::State`]. /// 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() let protected = Router::new()
.route("/api/sync/pull", get(sync::pull)) .route("/api/sync/pull", get(sync::pull))
.route("/api/sync/push", post(sync::push)) .route("/api/sync/push", post(sync::push))
.route("/api/replays", post(replays::upload))
.route("/api/leaderboard", get(leaderboard::get_leaderboard)) .route("/api/leaderboard", get(leaderboard::get_leaderboard))
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in)) .route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out)) .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). // Public endpoints (no auth, no rate limit beyond defaults).
let public = Router::new() let public = Router::new()
.route("/api/daily-challenge", get(challenge::daily_challenge)) .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)); .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() Router::new()
.merge(protected) .merge(protected)
.merge(auth_routes) .merge(auth_routes)
.merge(public) .merge(public)
.merge(web)
// Reject request bodies larger than 1 MB. // Reject request bodies larger than 1 MB.
.layer(DefaultBodyLimit::max(1024 * 1024)) .layer(DefaultBodyLimit::max(1024 * 1024))
.with_state(state) .with_state(state)
+191
View File
@@ -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))
}
+147
View File
@@ -1447,3 +1447,150 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
"11th request must be rate-limited with 429" "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);
}
+34
View File
@@ -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>
+339
View File
@@ -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.
+183
View File
@@ -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;
}
+316
View File
@@ -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
View File
@@ -3,10 +3,10 @@
//! All functions are free of I/O and side effects — safe to call from any //! All functions are free of I/O and side effects — safe to call from any
//! context including unit tests and the Bevy main thread. //! 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::{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. /// 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), 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_one_wins: local.draw_one_wins.max(remote.draw_one_wins),
draw_three_wins: local.draw_three_wins.max(remote.draw_three_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(), 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 // Achievements
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -240,6 +275,22 @@ fn merge_progress(
// Challenge index: take the higher (further ahead in challenge progression). // Challenge index: take the higher (further ahead in challenge progression).
let challenge_index = local.challenge_index.max(remote.challenge_index); 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 { PlayerProgress {
total_xp, total_xp,
level: level_for_xp(total_xp), level: level_for_xp(total_xp),
@@ -250,6 +301,8 @@ fn merge_progress(
unlocked_card_backs, unlocked_card_backs,
unlocked_backgrounds, unlocked_backgrounds,
challenge_index, challenge_index,
daily_challenge_history,
daily_challenge_longest_streak,
last_modified: Utc::now(), last_modified: Utc::now(),
} }
} }
@@ -261,6 +314,20 @@ fn union_usize_vecs(a: &[usize], b: &[usize]) -> Vec<usize> {
set.into_iter().collect() 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 // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -753,4 +820,191 @@ mod tests {
let (merged, _) = merge(&local, &remote); let (merged, _) = merge(&local, &remote);
assert_eq!(merged.stats.fastest_win_seconds, 300); 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"
);
}
} }
+117
View File
@@ -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. /// Persisted player progression state.
/// ///
/// Mutation helpers such as `add_xp`, `record_daily_completion`, etc. are /// 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. /// Index of the next Challenge-mode seed to serve to this player.
#[serde(default)] #[serde(default)]
pub challenge_index: u32, 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). /// Wall-clock time of the last modification (used for conflict detection).
pub last_modified: DateTime<Utc>, pub last_modified: DateTime<Utc>,
} }
@@ -61,6 +76,8 @@ impl Default for PlayerProgress {
unlocked_card_backs: vec![0], unlocked_card_backs: vec![0],
unlocked_backgrounds: vec![0], unlocked_backgrounds: vec![0],
challenge_index: 0, challenge_index: 0,
daily_challenge_history: Vec::new(),
daily_challenge_longest_streak: 0,
last_modified: DateTime::UNIX_EPOCH, last_modified: DateTime::UNIX_EPOCH,
} }
} }
@@ -114,6 +131,12 @@ impl PlayerProgress {
/// - Completion the day after the previous: streak increments. /// - Completion the day after the previous: streak increments.
/// - Same day as the previous: no-op (idempotent). /// - 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. /// Returns `true` if this call recorded a fresh completion.
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool { pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
match self.daily_challenge_last_completed { match self.daily_challenge_last_completed {
@@ -126,6 +149,19 @@ impl PlayerProgress {
} }
} }
self.daily_challenge_last_completed = Some(date); 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(); self.last_modified = Utc::now();
true true
} }
@@ -320,4 +356,85 @@ mod tests {
p.record_daily_completion(date(2026, 4, 22)); // skip the 21st p.record_daily_completion(date(2026, 4, 22)); // skip the 21st
assert_eq!(p.daily_challenge_streak, 1, "gap must reset streak"); 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))
);
}
} }
+72
View File
@@ -33,6 +33,56 @@ pub struct StatsSnapshot {
pub draw_one_wins: u32, pub draw_one_wins: u32,
/// Wins achieved in Draw-Three mode. /// Wins achieved in Draw-Three mode.
pub draw_three_wins: u32, 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). /// Wall-clock time of the last modification (used for conflict detection).
pub last_modified: DateTime<Utc>, pub last_modified: DateTime<Utc>,
} }
@@ -51,6 +101,12 @@ impl Default for StatsSnapshot {
best_single_score: 0, best_single_score: 0,
draw_one_wins: 0, draw_one_wins: 0,
draw_three_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, 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_best, 7, "best streak must not be reduced on abandon");
assert_eq!(s.win_streak_current, 0); 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);
}
} }
+29
View File
@@ -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"]
+272
View File
@@ -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());
}
}