The post-win UI was firing TWO celebration surfaces on every
GameWonEvent:
- animation_plugin::handle_win_cascade spawned a 4-second toast:
"You Win! Score: {score} Time: {m}:{ss}"
- win_summary_plugin spawned the proper "You Won!" modal with
score breakdown, time bonus, achievements unlocked, XP earned,
and a Play Again button
Both rendered on top of each other — in screenshots the toast
banner was partially clipped behind the modal card, peeking out
on either side. The toast predates the WinSummary modal; the
modal carries strictly more information so the toast is dead
weight.
handle_win_cascade keeps the cards-fly-off animation
(MotionCurve::Expressive cascade with per-card rotation drift) —
that's the visual celebration, distinct from the textual
celebration the modal owns. The system still gates on the same
GameWonEvent message reader; it just doesn't write a toast
afterward. WIN_TOAST_SECS const removed (no remaining callers).
Workspace: 1172 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The pixel-art card theme generated via Claude Design (53 PNGs at
256x384, ~340 KB total) now ships embedded in the binary alongside
the existing default SVG theme. Players see the new theme in the
picker out of the box without needing to drop files into
~/.local/share/solitaire_quest/themes/.
solitaire_engine/assets/themes/rusty-pixel/:
- 53 PNGs (52 face cards + 1 back) at 256x384
- theme.ron declaring meta.id = "rusty-pixel",
card_aspect = (2, 3), pixel_art = true
assets/sources.rs:
- New constants RUSTY_PIXEL_THEME_MANIFEST_URL,
RUSTY_PIXEL_THEME_MANIFEST_PATH,
RUSTY_PIXEL_THEME_MANIFEST_BYTES.
- New embed_rusty_pixel_png! macro mirroring embed_default_svg!.
- New RUSTY_PIXEL_THEME_PNGS table — 53 entries, one per file.
- New rusty_pixel_theme_png_bytes(filename) lookup helper
mirroring default_theme_svg_bytes for the thumbnail cache.
- New populate_embedded_rusty_pixel_theme(app) registers the
manifest + every PNG into Bevy's EmbeddedAssetRegistry.
- AssetSourcesPlugin::build now calls both populate functions
so the picker has both themes loadable from the binary alone.
theme/registry.rs:
- New rusty_pixel_entry() returns the bundled metadata.
- build_registry now inserts default + rusty-pixel ahead of the
user-dir scan, and filters user themes whose id collides with
a bundled built-in. Bundled wins on collision because it's
guaranteed complete; the user's overriding copy may be partial
or stale.
- Updated existing tests for the new len()=2-instead-of-1 baseline.
- New test user_theme_id_collision_with_bundled_is_dropped pins
the dedup contract.
theme/plugin.rs:
- load_initial_theme + react_to_settings_theme_change now both
consult a new manifest_url_for(theme_id) helper that routes
bundled built-ins through embedded:// and unknown ids through
themes://. Drops the previous hard-coded "default →
DEFAULT_THEME_MANIFEST_URL else themes://" branch.
- read_theme_preview_bytes also checks the rusty-pixel embed
table before falling through to the user-dir filesystem read,
so the picker chip's thumbnail works on a fresh install where
the user-dir doesn't exist.
Workspace: 1172 passing tests / 0 failing, was 1171 (+1 net from
the new collision test). cargo clippy --workspace --all-targets
-- -D warnings clean. Binary grows by ~340 KB (the 53 bundled
PNGs).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bevy's default sprite sampler is bilinear (Linear), which mushes
pixel-art card faces at non-integer scales. The rusty-pixel theme
ships 256x384 source PNGs that get displayed at ~150-200px wide on
typical desktop windows — an aggressive downscale where bilinear
visibly blurs the pixel grid.
Globally flipping ImagePlugin to default_nearest() would also affect
the SVG-rasterised default theme, where bilinear's smoothing is
actually desired (the SVG rasteriser produces a high-res 512x768
pixmap that the GPU has to downscale at draw time).
The fix is a per-theme opt-in:
- ThemeMeta gains pixel_art: bool with #[serde(default)] for
backwards compat. Older manifests load with `false`, preserving
SVG-default behaviour.
- sync_card_image_set_with_active_theme inspects theme.meta.pixel_art
after a theme finishes loading. When true, walks every face +
back Handle<Image> in the active CardTheme and rewrites its
sampler to ImageSampler::Descriptor(ImageSamplerDescriptor::nearest()).
The Modified asset event triggers a GPU re-upload with the new
sampler descriptor.
- The 12 ThemeMeta struct literals across the engine
(settings_plugin, card_plugin, theme/{plugin,mod,manifest,
importer,registry}) all gain `pixel_art: false` to match the
new field.
The deployed rusty-pixel theme.ron at
~/.local/share/solitaire_quest/themes/rusty-pixel/ now sets
pixel_art: true, so the player's switch-to-pixel-art chip flips to
nearest sampling on the spot.
Workspace: 1171 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The theme picker chip's thumbnail loader hardcoded `.svg`
filenames (`spades_ace.svg`, `back.svg`) — a holdover from when
every shipped theme was vector-art. Raster-art user themes (e.g.
the v0.19 pixel-art theme generated via Claude Design and dropped
into ~/.local/share/solitaire_quest/themes/rusty-pixel/) had real
PNGs in their directory but the picker rendered placeholders
because it never tried the PNG sibling.
The fix is scoped to the thumbnail-cache pipeline. In-game card
rendering already worked via Bevy's standard PNG asset loader on
manifest-declared face/back paths — only the picker's small
preview chip was affected.
Changes in solitaire_engine/src/theme/plugin.rs:
- PREVIEW_FACE_FILENAME / PREVIEW_BACK_FILENAME (with embedded
`.svg` suffix) replaced by PREVIEW_FACE_BASENAME /
PREVIEW_BACK_BASENAME ("spades_ace" / "back"). The function
appends the extension itself.
- read_theme_preview_svg_bytes -> read_theme_preview_bytes
returns ThemePreviewBytes::{Svg, Png}. For "default" the
embedded table stays SVG-only. For user themes the function
tries `<basename>.svg` first (matching the bundled
convention) and falls back to `<basename>.png` second.
- rasterize_preview_to_handle gains a Png branch that calls a
new decode_png_for_thumbnail helper (Bevy's
Image::from_buffer with ImageType::Format(ImageFormat::Png)).
PNGs decode at native dimensions; the picker chip's UI
layout scales them at draw time. SVGs continue to rasterise
at the fixed 100x140 thumbnail size as before.
- generate_thumbnail_pair_for is unchanged in shape; just
threads the new enum through.
Tests:
- read_default_theme_preview_returns_some_for_canonical_files
updated to match the new function signature and assert on
the Svg variant explicitly.
- New png_only_user_theme_generates_real_thumbnails creates a
temp theme dir, writes a 2x3 PNG (encoded at runtime via the
`image` dev-dep so the bytes are guaranteed valid), and
asserts both ace + back yield non-default Handle<Image>.
Cleans up the temp dir afterward.
solitaire_engine/Cargo.toml: image = "0.25" added as a
dev-dependency for the test's runtime PNG encoding. Already a
transitive Bevy dep so the build graph is unchanged.
Workspace: 1171 passing tests / 0 failing, was 1170 (+1 new).
cargo clippy --workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
auto_save_writes_after_30_seconds intermittently failed under
heavy parallel cargo-test load. Two contributing factors, both
fixable in test fixtures alone:
1. GamePlugin::build() reads dirs::data_dir()/.../game_state.json
before per-test resource overrides apply. If a real
game_state.json exists on the dev machine, it's loaded into
PendingRestoredGame, and auto_save_game_state's pending guard
(`pending.0.is_some()`) silently skips the save. test_app now
resets PendingRestoredGame(None) after plugin build so the
production save state can't leak into per-test world state.
2. Time::delta_secs() on the first MinimalPlugins frame can be
0.0 (nominal) or, under cargo-test parallelism, large enough
to consume the 0.1 s pre-seeded margin past the threshold.
The test now re-arms AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS +
1.0) every iteration in a 16-frame bounded loop, breaking
the moment the file appears. Robust against first-frame Time
variance with no behaviour-contract change.
No production-code change. Verified: 3 back-to-back single-test
runs all pass. Full workspace test suite: 1170 passing / 0 failing.
cargo clippy --workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The v0.18.0 share-link affordance lived in an in-memory
LastSharedReplayUrl resource that was wiped on quit; the player had
to re-open Stats and re-share within the same session of the win.
The Stats overlay's Prev/Next selector also surfaced older replays
that had no share link at all even when those wins had been
uploaded successfully.
This bundles the URL with the replay it belongs to:
- Replay (solitaire_data) gains share_url: Option<String> with
#[serde(default)]. No REPLAY_SCHEMA_VERSION bump — older
replays.json files load unchanged with share_url == None on
every entry. Replay::new() defaults the field to None.
- poll_replay_upload_result (sync_plugin) writes the resolved URL
into ReplayHistoryResource::0.replays[0].share_url and persists
the updated history via save_replay_history_to. The
cancel-on-replace contract in push_replay_on_win guarantees
replays[0] is the win whose URL the task is carrying — at most
one upload is ever in flight, and it's always the most recent
win.
- handle_copy_share_link_button (stats_plugin) reads from
history.0.replays[selected.0].share_url instead of
LastSharedReplayUrl, so the Prev/Next selector's currently-
displayed replay drives the clipboard contents. Each historical
win keeps its own URL.
- LastSharedReplayUrl resource removed entirely — its only role
was bridging the upload-poll system to the Copy button, and
that channel is now the share_url field on the replay record.
Tests:
- solitaire_data: replay_loads_when_share_url_field_is_absent
pins backwards-compat — a pre-v0.19.0 Replay JSON without the
field deserialises with share_url == None.
- solitaire_engine sync_plugin: upload_result_writes_share_url_into_replay_and_persists
drives a pre-resolved AsyncComputeTaskPool task into
PendingReplayUpload, pumps update() until the poll system
resolves it, and asserts both the in-memory replays[0]
carries the URL and a fresh load_replay_history_from(path)
picks it up.
Workspace: 1170 passing tests / 0 failing, was 1168 (+2 net).
cargo clippy --workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the last solver-on-main-thread hot path. The synchronous
v0.17.0 hint flow called solitaire_core::solver::try_solve_from_state
inline on every H press; median latency was ~2 ms but pathological
positions hit the SolverConfig::default() cap at ~120 ms — a visible
input stall on the same frame the player presses H.
Mirrors the d489e7a PendingNewGameSeed pattern. New module
pending_hint.rs holds:
- PendingHintTask resource carrying an Option<HintTask> with
handle: Task<HintTaskOutput> plus move_count_at_spawn for
staleness detection.
- HintTaskOutput enum: SolverMove { from, to } when the verdict
is Winnable + a first_move; NeedsHeuristic when the solver
returns Unwinnable or Inconclusive.
- poll_pending_hint_task system: polls the task each frame and
surfaces the result via the now-public emit_hint_visuals (or
runs find_heuristic_hint on the live state for the
NeedsHeuristic branch). Discards the result when
GameState.move_count has advanced past move_count_at_spawn.
- drop_pending_hint_on_state_change system: any
StateChangedEvent drops the in-flight task. Cooperatively
cancels via Bevy's Task Drop at the next await point.
- PendingHintTask::spawn implements cancel-on-replace — a fresh
H press while a previous task is in flight overwrites the
handle, dropping the prior task.
input_plugin changes:
- handle_keyboard_hint becomes a thin spawn point. Snapshots
the live state, asks the solver via PendingHintTask::spawn,
returns. No card-entity query, no event writers for the
hint visual / toast — the polling system owns those.
- emit_hint_visuals promoted to pub so pending_hint can call it.
- find_heuristic_hint extracted as a pub helper for the
NeedsHeuristic poll path.
- InputPlugin registers PendingHintTask + the two new systems.
drop-on-state-change is chained .before() poll so a move
applied this frame cancels any in-flight task before its
result can be surfaced.
Tests:
- input_plugin: pressing_h_spawns_pending_hint_task (1) — pins
the H-key wiring at one-frame granularity.
- pending_hint: winnable_solver_emits_hint_after_async_completes,
state_change_drops_in_flight_task,
second_spawn_drops_first_in_flight_task (3) — drives the
AsyncComputeTaskPool with a wall-clock-bounded loop mirroring
the winnable_seed_search_* template.
- Removed two now-stale synchronous tests
(hint_uses_solver_when_winnable,
hint_falls_back_to_heuristic_when_solver_inconclusive) — the
behaviours they pinned now live in pending_hint::tests at the
correct layer.
Workspace: 1168 passing tests / 0 failing, was 1166 (net +2:
removed 2 stale, added 4 new). cargo clippy --workspace
--all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The HUD's elapsed-time counter ticked from the moment the default
Classic deal landed at startup, even though the auto-show Home
picker was still up — so the player saw "0:11" before they had
chosen a mode. Time Attack had the same issue when M was pressed
mid-session: the 10-minute countdown burned while the player browsed
modes.
`tick_elapsed_time` and `advance_time_attack` now also gate on the
absence of `HomeScreen`, mirroring their existing `PausedResource`
check. The Home modal already covers input via its scrim, so this
purely freezes the timer without coupling to the pause-overlay
ownership of `PausedResource`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking the new Home header chip opens Profile on top of Home.
Pressing Esc then closed Home (because handle_home_cancel_button
fired on Esc with no awareness of layered modals) and left Profile
orphaned over the game — the player had to press P afterwards just
to dismiss what they meant to dismiss in the first place.
Two changes restore the standard "Esc closes the topmost modal"
contract:
- profile_plugin: split P/button (toggle) from Esc (close-only).
Esc only fires when Profile is currently open.
- home_plugin: handle_home_cancel_button now skips its Esc branch
when any other ModalScrim exists, deferring to whichever modal
is on top. Click on the explicit Cancel button is unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
triangles inconsistently)
Quat: ▶ (U+25B6) rendered as tofu even though ▲ (U+25B2) from the
same Geometric Shapes block works. FiraMono evidently ships the
up/down triangles but not the left/right siblings.
Swapped to U+2192 (RIGHTWARDS ARROW) from the Arrows block, which
is part of every dev-oriented monospace font's core coverage. Reads
as "go / fast-forward" for the timed mode and is visually distinct
from the other 4 tile glyphs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bundled face is FiraMono-Medium (assets/fonts/main.ttf), and its
glyph table covers card suits (U+2660-2666) plus basic Geometric
Shapes (U+25xx) but not Dingbats / Misc Symbols. The previous round
of "BMP fallbacks" still picked from blocks FiraMono doesn't cover,
so 4 of 5 tiles continued to render as tofu.
Re-picked from ranges FiraMono actually has:
- Daily: U+25C6 (BLACK DIAMOND)
- Zen: U+25CB (WHITE CIRCLE) — Zen enso
- Challenge: U+25B2 (BLACK UP-POINTING TRIANGLE) — climbing
- TimeAttack: U+25B6 (BLACK RIGHT-POINTING TRIANGLE) — play / FF
- Classic keeps U+2663 (BLACK CLUB SUIT)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions Quat caught in screenshot review of the picture-tile
rework:
1. Tofu boxes for 4 of 5 tiles. The earlier emoji picks (calendar,
cherry-blossom, lightning, stopwatch) live in Unicode planes that
most Linux desktop fonts don't cover, so they rendered as
missing-glyph rectangles. Swapped to BMP / Dingbats codepoints
that the system-default font fallback always has:
- Daily: \u{2605} (BLACK STAR)
- Zen: \u{2740} (WHITE FLORETTE)
- Challenge: \u{2726} (BLACK FOUR-POINTED STAR)
- TimeAttack: \u{231A} (WATCH, Misc Symbols / Unicode 1.1)
Classic keeps its club (\u{2663}) — already rendered correctly.
2. Cancel button pushed off the bottom of the viewport. The 3-row
tile grid alone is ~540 px; on the 800x600 minimum window the
modal exceeded the screen. Wrapped chips + draw row + grid in a
`HomeScrollable` Node with `max_height: 70vh` and `Overflow::scroll_y()`,
adding a `scroll_home_panel` system to drive `ScrollPosition` from
`MouseWheel`. Mirrors the existing Settings / Leaderboard /
Achievements scrollable pattern. Cancel sits outside the scroll
so it's always reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B step 2 of the MSSC-inspired Home rework. Mode cards become a
wrapping 2-up grid with a centred Unicode-glyph centrepiece per tile,
standing in for real per-mode artwork until that lands.
- HomeMode::glyph() returns the placeholder codepoint for each mode:
♣ Classic, calendar Daily, cherry-blossom Zen, lightning Challenge,
stopwatch TimeAttack. Cherry-blossom is used over lotus-position
because the latter renders inconsistently across desktop fonts.
- The mode-card loop is wrapped in a FlexWrap::Wrap row container.
Tiles set `width: 48%` + `min_height: 180px`; the 5-mode grid
wraps to a third row of one tile, mirroring the half-cell asymmetry
in MSSC's screenshot.
- The glyph paints in ACCENT_PRIMARY when the mode is unlocked and
TEXT_DISABLED when locked, so the gate reads at a glance.
- When real art lands, swap the Text node for an Image node — the
rest of the tile layout, focus order, click handling, and chip
rendering are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B step 1 of the MSSC-inspired Home rework — surfaces today's
daily-challenge metadata on the Daily card so the picker reads as
"there's something fresh waiting" rather than a generic mode label.
- Date line "Today, May 6" pulled from DailyChallengeResource. Reads
in STATE_INFO blue while the run is still open.
- Server-fetched goal (when SyncPlugin is wired) appears underneath
as "Goal: Win in under 5 minutes", matching the toast that already
fires when the player presses C.
- Once the player has recorded today's completion, the date flips
to "Today, May 6 \u{2022} Done" in ACCENT_PRIMARY so the picker
reads as a reward state rather than a TODO.
Headless tests omit DailyChallengePlugin, so HomeContext.daily_today
defaults to None and the card falls back to its baseline layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A of the Microsoft-Solitaire-Collection-inspired launch picker
rework. Three additive changes inside the Home modal, no core / asset
work:
- Player-stats header strip showing Level / XP / Lifetime Score using
a compact formatter (1.2M / 12.3K / 1,234). The whole strip is a
Button — click fires ToggleProfileRequestEvent so Profile opens on
top of Home; closing it returns to the picker.
- Draw-mode chip row above the mode cards lets the player flip
Draw 1 / Draw 3 from the picker itself rather than diving into
Settings. Active chip uses ACCENT_PRIMARY background; the click
persists settings.json and respawns the modal so the active state
repaints cleanly.
- Per-mode score/streak chip on each card — "Best 12,345" for
Classic / Zen / Challenge, "Streak N" for Daily. Hidden on a 0
best so a fresh profile doesn't read "Best 0" everywhere.
`HomeContext` bundle pulls live data from ProgressResource /
StatsResource / SettingsResource with safe defaults so headless
tests under MinimalPlugins still build cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolving the Welcome-back / Restore prompt (either Continue or New
game) cleared `PendingRestoredGame` and despawned the modal, but the
launch-time Home auto-show then fired the next frame and stacked
itself over the player's chosen path — clicking "New game" would deal
a fresh game AND immediately pop the mode picker on top.
`LaunchHomeShown` becomes pub so `handle_restore_prompt` can flip it
to `true` after either resolution; `M` still re-opens the picker on
demand. Headless tests already pre-set the flag to true via
`HomePlugin::headless()`, so they're unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Home and Restore-prompt previously ignored Esc, which after the last
fix meant Esc just did nothing on those screens. Now both honor the
"Esc closes the modal" convention every other modal already follows.
- Home: Esc behaves like the Cancel button — despawns the modal so
the player keeps the underlying default deal.
- Restore: Esc maps to Continue rather than New Game; a reflexive
dismiss press preserves the saved game, matching how the primary
action already advertises the Enter accelerator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A single Esc press while the Confirm New Game / Restore / Home /
Onboarding / Settings modals were open would both close the modal
(via its own input handler) and spawn the Pause overlay on top in
the same frame, dumping the player on a screen they didn't ask for.
toggle_pause now skips when any non-Pause `ModalScrim` is in the
world. The HUD-button path is gated too — clicking Pause while
another modal is up is almost always an accident.
The four modal queries are bundled into a `PauseModalQueries`
SystemParam to stay under Bevy's 16-parameter cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-win modal's "Play Again" was click-only — keyboard-only
players had to reach for the mouse to leave the celebration screen,
and the button advertised no accelerator the way every other modal
button does.
- handle_win_summary_keyboard reads Enter while WinSummaryOverlay is
in the world; despawns the overlay and writes the same
NewGameRequestEvent the click handler takes.
- The button label gains a trailing return-key glyph so the keyboard
path is discoverable on first sight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Home (mode picker) was only reachable via M during gameplay, so
players who hadn't discovered the hotkey never saw the Daily / Zen /
Challenge / Time Attack entry points after the splash cleared.
- HomePlugin gains an `auto_show_on_launch` flag (default true) and a
matching `headless()` test constructor that disables it.
- spawn_home_on_launch flips a one-shot LaunchHomeShown flag once the
splash has cleared, gated on RestorePromptScreen / PendingRestoredGame
so the Welcome-back flow still takes precedence on machines with a
saved game.
- App entry uses HomePlugin::default(); both headless test fixtures
switch to HomePlugin::headless() so per-test worlds start clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a first N press during an active game showed a "Press N
again" toast and started a 3-second countdown — a UI-first violation
since the only continuation was another keystroke. The HUD New Game
button already routed through `ConfirmNewGameScreen` with real Cancel
/ New game buttons; this change makes keyboard N do the same.
- handle_keyboard_core fires NewGameRequestEvent::default() directly;
handle_new_game's existing active-game check spawns the modal.
- Shift+N keeps the keyboard power-user bypass (confirmed: true).
- N is suppressed while the confirm modal or restore prompt is open
so those modals' own input handlers can process N (cancel /
start-new-game) without us re-firing the same frame they close.
- KeyboardConfirmState, NEW_GAME_CONFIRM_WINDOW, NewGameConfirmEvent,
and the "Press N again" toast handler are removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Leaderboard empty state: replace single muted line with a two-tier
"Be the first on the leaderboard." headline + body invite.
- Achievements panel: surface a first-launch hint above the grid until
the player unlocks anything, so the greyed-out rows aren't context-free.
- Volume hotkeys ([/]): emit an InfoToastEvent with the new percentage so
off-panel adjustments give visible feedback (previously silent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quat: opening Modes / Menu showed a solid dark-purple block in the
top-right with no readable content. Cause: the auto-fade system on
the top-level action bar was fading the popover rows too — they
share the `ActionButton` marker so `paint_action_buttons` can still
paint hover/press, but `apply_action_fade` matched the same marker
and dropped their alpha to whatever the cursor-position-based
fade happened to be (typically 0 because the cursor was inside the
opened popover, well below the top reveal zone). The popover
container stayed at full opacity (its background is `BG_ELEVATED`,
not driven by the fade), so what the player saw was the empty
rounded box with no labels.
Fix: new `PopoverRow` marker on the rows in `spawn_modes_popover`
and `spawn_menu_popover` (both share the same row-spawn shape).
`apply_action_fade` excludes `PopoverRow` via `Without<PopoverRow>`.
Hover / press paint still applies — the popover rows just opt out
of the cursor-position auto-fade since they only render when the
player has explicitly opened the dropdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quat reported the volume UI overlapped with adjacent UI elements in
the Settings panel. The five slider/toggle row helpers
(volume_row × 2, tooltip_delay_row, time_bonus_multiplier_row,
replay_move_interval_row, toggle_row) all used the same flex pattern:
Node {
flex_direction: Row,
align_items: Center,
column_gap: VAL_SPACE_2,
}
with no width constraint and no justify_content. Result: every
child packed against the left edge with 8 px gaps. As the value text
varied in width (e.g. "0.80" → "1.00", or "Instant" vs "1.5 s") the
+/− buttons shifted sideways frame to frame, and on narrow windows
the row's natural width could exceed the modal interior, pushing
elements past the right edge or visually merging with neighbours.
Restructured all five helpers to a label-spacer-cluster layout:
[Label] [Value] [-] [+]
└────── flex-grow=1 ──────┘ └─ cluster ─┘
with `width: Val::Percent(100.0)` on the row so it spans the body
width. The flex-grow spacer absorbs all slack horizontal space; the
controls cluster (value + buttons) sits flush against the right
edge regardless of value-text length. Existing tests still pass —
no behaviour change, just stable layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quat: replay sharing as the next punch-list item.
End-to-end:
1. Player wins a game on a server-backed sync backend.
2. `sync_plugin::push_replay_on_win` spawns the upload task on
`AsyncComputeTaskPool` and stores the handle in the new
`PendingReplayUpload` resource. The previous in-flight task (if
any) is dropped — the most recent win is the one whose share link
the player will care about.
3. `poll_replay_upload_result` harvests the task on the main thread
each frame; on success writes `<server>/replays/<id>` to
`LastSharedReplayUrl`. `UnsupportedPlatform` (LocalOnlyProvider)
is silently absorbed; real network/auth errors warn-log.
4. The Stats overlay's action bar gains a "Copy share link" button.
Click writes `LastSharedReplayUrl` to the OS clipboard via
`arboard` and surfaces a "Copied: <url>" toast.
Trait change: `SyncProvider::push_replay` now returns `Result<String,
SyncError>` (the share URL) instead of `Result<(), SyncError>`. The
default (`UnsupportedPlatform`) is unchanged for non-server backends;
`SolitaireServerClient` parses the response body's `id` field and
composes `<base_url>/replays/<id>`. Both call paths (initial + 401
retry) go through the new `share_url_from_response` helper so the
parse logic isn't duplicated.
New deps:
- `arboard` (~10 KB, cross-platform clipboard) added to workspace +
`solitaire_engine`. `default-features = false` keeps the X11/Wayland
binary-feature deps off the dependency graph; arboard handles the
fallback. Approved per the ASK BEFORE rule.
Persistence: the URL is in-memory only — the player must share within
the session of the win. A future revision can persist it alongside
the replay history file if cross-session sharing is needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the current deal's (seed, draw_mode, mode) triple matches an
entry in the rolling ReplayHistory, the HUD's tier-2 context row
now shows "✓ Won before" in the success-green colour. Cleared when
the active game itself is won (the on-screen victory cue is enough)
and on fresh deals the player hasn't beaten before.
The indicator answers a question the rolling-history feature
implicitly raised: when a new game starts on a seed the player has
already conquered, surface that fact so they know they can try for
a faster / higher-scoring win on the same layout. Seed re-rolls in
"Winnable deals only" + system-time seeds make this a natural pace
for the indicator to fire — usually empty, occasionally lit.
Implementation: new `HudWonPreviously` marker spawned in tier-2
alongside Mode / Challenge / DrawCycle. Driven by a separate
`update_won_previously` system rather than threading the marker
through `update_hud`'s ten-way query disambiguation. Reads the
existing `ReplayHistoryResource` from `stats_plugin`; gracefully
no-ops in headless tests that don't load StatsPlugin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quat reported the restore prompt didn't appear and noticed their
save file ended up with move_count 0 — diagnosed as a destructive
overwrite. The flow:
1. Player exits with moves; game_state.json has move_count > 0.
2. Player relaunches. Plugin build sees moves > 0, holds the saved
game in `PendingRestoredGame`, seeds `GameStateResource` with a
fresh deal so the board doesn't show the half-played game until
the player picks Continue.
3. The restore prompt should appear. (Why it didn't on Quat's run
is still TBD — needs a fresh test.)
4. Player exits. `save_game_state_on_exit` writes
`GameStateResource` (the fresh-deal placeholder) to disk,
overwriting the meaningful saved game with move_count 0.
Both `save_game_state_on_exit` and `auto_save_game_state` now check
`PendingRestoredGame`: if it still holds an unanswered saved game,
they save THAT (or skip entirely in the auto-save path). The real
saved game on disk is preserved across launches no matter how many
times the player exits without answering the prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the engine silently restored any saved in-progress game
from `game_state.json` on startup. Players who launched expecting a
fresh deal got dropped back into a half-played game with no signal
that a save had been picked up; players who wanted to continue had
no clear acknowledgement either way.
Now: when launching with a saved game that has at least one move
and isn't already won, the engine holds the saved state in a new
`PendingRestoredGame` resource and seeds `GameStateResource` with
a fresh deal. Once the splash overlay finishes, a modal appears:
Welcome back
You have an in-progress game. Continue where you left off, or
start a new one?
[New game] [Continue]
- Continue (Enter / C / click) — swaps the saved game into
`GameStateResource` and fires `StateChangedEvent`. Card sprites
resync to the restored layout.
- New game (N / click) — drops the saved state, fires
`NewGameRequestEvent { confirmed: true }`. The existing
`handle_new_game` flow then deletes `game_state.json` and deals.
Save files with `move_count == 0` (a fresh deal that was never
played) skip the prompt and load directly — there's nothing
meaningful to "continue" there. Won games skip too (the existing
flow already deletes their save file on win).
The spawn system gates on `SplashRoot` being absent so the modal
doesn't pop up over the brand splash on first launch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Winnable deals only" used to call `choose_winnable_seed` inline on
the main thread inside `handle_new_game`. Each rejected attempt costs
~120 ms (`SolverConfig::default()` budget); the loop caps at
`SOLVER_DEAL_RETRY_CAP` = 50, so a pathological run could stall the
UI for ~6 s on a New Game click. Quat flagged this as the highest-
impact UX regression left in the engine.
Reorganised so the solver runs on `AsyncComputeTaskPool`:
- New `PendingNewGameSeed` resource holds an `Option<PendingSeedTask>`
carrying the in-flight `Task<u64>` plus the request's `mode` and
`confirmed` flags so the polling system can replay them on a
synthetic `NewGameRequestEvent` once the task resolves.
- `handle_new_game` now writes to that resource (and `continue`s)
for the winnable-only / Classic / random-seed branch, instead of
calling `choose_winnable_seed` synchronously.
- `poll_pending_new_game_seed` runs `.before(GameMutation)` so the
synthetic event lands in the same frame's `handle_new_game` —
the player sees no extra-frame visual lag once the solver
completes.
- Cancel-on-replace: when a fresh `NewGameRequestEvent` arrives
while a previous task is in flight, `pending_seed.inner = None`
drops the old task (Bevy's `Task` Drop cancels cooperatively at
the next await point) before processing the new request.
Two tests:
- `winnable_seed_search_runs_async_and_completes_eventually` —
spawns the task, drives `app.update()` in a wall-clock-bounded
loop with `std::thread::yield_now()` so the shared
`AsyncComputeTaskPool` gets a chance to schedule between polls.
- `winnable_seed_search_drops_in_flight_task_on_new_request` —
fires a winnable-only request, then before the task can complete
fires an explicit-seed request that bypasses the solver entirely.
Asserts the explicit seed wins, verifying the cancel-on-replace
contract.
Existing solver tests pass unchanged: explicit-seed paths skip the
new branch and run synchronously like before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The replay overlay's per-move tick rate has been hardcoded at
REPLAY_MOVE_INTERVAL_SECS = 0.45 s/move since the in-engine
playback shipped. Power users want to scrub faster through older
wins. Adds a Settings slider that tunes the interval 0.10–1.00 s in
0.05 s steps; default 0.45 s preserves existing feel.
Settings.replay_move_interval_secs uses #[serde(default)] so legacy
files load to 0.45. sanitized() clamps out-of-range values.
tick_replay_playback now reads SettingsResource per frame and falls
back to the constant when the resource is absent (test fixtures).
The slider takes effect on the very next playback tick — no need to
restart playback.
Mirrors the existing tooltip-delay slider exactly: SettingsButton::
ReplayMoveIntervalUp/Down variants, the same `slider_row` pattern,
the same per-tick repaint system shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The H-key hint now asks the v0.15.0 Klondike solver for the actual
best first move from the current game state instead of the existing
heuristic. The heuristic stays as the fallback path so hints still
work when the solver bails Inconclusive on the player's budget.
solitaire_core::solver gains a path-recording variant. The internal
DFS already enumerated moves on each frame; recording the root_move
on the stack frame is +16 bytes and one unwrap_or per expansion —
the new-game retry loop sees no measurable slowdown.
New public API (additive — try_solve unchanged):
pub struct SolverMove { source, dest, count }
pub struct SolveOutcome { result: SolverResult, first_move: Option<SolverMove> }
pub fn try_solve_with_first_move(seed, draw_mode, &cfg) -> SolveOutcome
pub fn try_solve_from_state(&GameState, &cfg) -> SolveOutcome
The internal solver-move enum was renamed InternalMove so the public
SolverMove can use engine-friendly (source, dest, count) types
instead of the compact internal form.
Engine wiring: handle_keyboard_hint calls try_solve_from_state on
the live GameStateResource. On Winnable + first_move, the hint
surfaces that exact move (no cycling — a single, optimal hint).
Unwinnable or Inconclusive falls through to the existing all_hints
cycling heuristic so hints remain useful in deals the solver gives
up on.
A new HintSolverConfig resource lets tests inject tight budgets to
force the fallback path; production uses SolverConfig::default()
and median solve time stays at 2 ms per H press.
Six new tests pin the contract: 4 in solitaire_core (Winnable
returns first_move, Unwinnable returns None, deterministic, seed
and state forms agree); 2 in solitaire_engine (hint uses solver
when Winnable, falls back to heuristic when Inconclusive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to a54201e. The previous commit added ScrimDismissible to
Stats, Achievements, and Help; this one extends the same one-line
opt-in to the remaining three read-only modals so the click-outside-
to-close gesture is consistent across every informational surface.
Each modal now has the same shape: capture the scrim from
spawn_modal, attach ScrimDismissible after the build closure
returns. Three lines per file plus the import; no behaviour change
to the modal content itself.
Settings, Onboarding, Pause, Forfeit confirm, ConfirmNewGame, and
the win/game-over modals continue to opt OUT — all carry unsaved
or destructive state where an accidental scrim click would lose
work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a ScrimDismissible marker to ui_modal that opts a modal into
the standard "click outside the card to close" gesture. The new
dismiss_modal_on_scrim_click system fires on a left-mouse press
whose cursor falls on the scrim and outside every ModalCard, then
despawns the topmost dismissible scrim — Bevy's hierarchy despawn
cascades to the card and its children.
Marker design is opt-in per modal so destructive / state-mutating
modals (Settings saves on close, Onboarding requires explicit
acknowledgement, Pause / Forfeit / ConfirmNewGame need confirmed
intent) don't lose work to an accidental scrim click. Three
read-only modals opt in this round:
- Stats — informational; press S or click outside to dismiss.
- Achievements — read-only list.
- Help — keyboard reference.
Profile, Leaderboard, and Home will opt in the same way in a
follow-up; they were left out to keep this commit's scope tight.
The hit-test path uses each ModalCard's UiGlobalTransform +
ComputedNode bounding box so stacked modals close cleanly: the
topmost dismissible scrim is the only candidate per click. Tests
spawn synthetic ComputedNodes (with bevy::sprite::BorderRect for
the resolved-border slots Bevy's UI module re-exports) so the
geometry hit-tests deterministically without running the full UI
layout pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously when a click-handler in Update spawned a modal,
attach_focusable_to_modal_buttons and auto_focus_on_modal_open ran
in the same Update — but with no ordering edge to the click handler
the deferred Commands wouldn't materialise in time, so attach saw
no entities, FocusedButton stayed empty, and the very next Tab/Enter
press wasted itself moving focus from None to the primary instead
of activating it.
Moves attach_focusable_to_modal_buttons + auto_focus_on_modal_open
from Update to PostUpdate. The schedule boundary itself supplies
the sync point: every modal spawned anywhere in Update is
materialised before PostUpdate runs, attach can find the new
ModalButtons, and FocusedButton is populated before app.update()
returns. handle_focus_keys stays in Update so it observes input on
the frame it occurs, reading FocusedButton written by the previous
tick's PostUpdate.
Two new tests pin the contract:
- primary_button_is_focused_on_modal_spawn_same_frame uses a
production-shaped spawner system (no chain edge to UiFocusPlugin)
and asserts FocusedButton.0 is Some after a single update —
fails without the fix, passes with it.
- first_tab_after_modal_open_advances_to_secondary guards against a
regression where focus arrives but the very first Tab moves from
None to primary instead of from primary to secondary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the cursor stayed the default arrow over every clickable
UI element (modal buttons, HUD action bar, mode-launcher cards,
settings toggles). Adds the standard "this is clickable" hand
affordance: while not dragging a card, hovering any entity with
Interaction::Hovered (or Pressed — keeps the pointer through a
click-and-hold) sets the window cursor to SystemCursorIcon::Pointer.
The new branch sits between the existing drag handlers in
update_cursor_icon: Grabbing wins when actively dragging, then
Pointer when a button is hovered, then Grab when a draggable card
is hovered, then Default. Card-drag affordance unchanged.
A pure pick_cursor_icon(is_dragging, any_button_hovered,
any_card_hovered) helper makes the priority logic unit-testable
without standing up a full Window + Camera fixture; four new tests
pin every branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoke-test report: the Achievements list isn't scrollable. With 19
achievements the panel overflows the modal at the 800x600 minimum
window and the bottom rows are clipped. The same problem applies to
several other modals whose content has grown over the v0.13–v0.15
rounds.
Mirrors the existing SettingsPanelScrollable pattern from
settings_plugin: each modal's body Node gets Overflow::scroll_y()
plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the
leaderboard's variable-length ranking section), a marker component
so the scroll system can find it, and a sibling system that routes
MouseWheel events into the body's ScrollPosition.
Five modals fixed:
- Achievements: 19 rows clearly overflow; AchievementsScrollable +
scroll_achievements_panel.
- Help: ~28 reference rows overflow at 800x600; HelpScrollable +
scroll_help_panel.
- Stats: 8-cell primary grid + per-mode bests + progression +
weekly goals + unlocks + Time Attack readout + replay caption is
enough content to overflow once the player has any progress;
StatsScrollable + scroll_stats_panel.
- Profile: Sync + Progression + 14-day calendar + up to 18
unlocked achievements + Stats summary overflows once a few
achievements unlock; ProfileScrollable + scroll_profile_panel.
- Leaderboard: 10-row cap is at the edge of overflow on 800x600
with long display names; LeaderboardScrollable +
scroll_leaderboard_panel (max_height = 50vh — the ranking section
is the only variable-length part).
Home modal NOT scrolled — five mode cards plus a Cancel button
were sized to fit at 800x600 by design and adding scroll there
would clutter the launcher.
Five new tests pin the contract: each modal's body has the
scrollable marker, a non-default max_height, and Overflow::scroll_y.
Defer-list (small UX nits surfaced during the sweep, not fixed
here):
- Modal close-on-click-outside is missing across the board; would
need Interaction on ModalScrim in ui_modal.
- ModalButton hover doesn't set a pointer cursor.
- Tab focus on modal open is initialised on the next frame instead
of the same frame; first Tab press selects rather than focus
already being on the primary.
These are bigger touches than the scroll fix and don't fit a
30-LOC budget; surfacing for a follow-up round.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Quat investigation #1. Today some Klondike deals are
unwinnable from the start and the player has no signal that the
deal they were given is solvable. A new Settings → Gameplay toggle
"Winnable deals only" (default off) makes the engine retry seeds
at deal-time until the solver returns Winnable, up to a cap.
Solver
solitaire_core::solver is a hand-rolled iterative-DFS solver with
memoisation on a 64-bit canonical state hash. Move enumeration is
priority-ordered: foundation moves first (zero choice when an Ace
or rank-up exists), inter-tableau moves second, waste-to-tableau
third, stock-draw last. The draw is skipped when the cycle counter
shows we've recirculated the entire stock without progress —
Klondike's deterministic stock cycle means further draws can't
unlock anything new.
Two budget knobs (move_budget = 100k, state_budget = 200k by
default) cap pathological cases at Inconclusive; the caller treats
Inconclusive as "winnable" so the player isn't penalised for the
solver giving up. Median solve time is 2 ms; pathological
inconclusives top out near 120 ms.
Switched from recursive to iterative DFS after a real-deal solve
overflowed Rust's default 8 MB thread stack. Behaviour identical;
the change is invisible to callers.
Pure logic — solitaire_core has no Bevy or I/O. Same input always
yields the same SolverResult.
Settings
Settings.winnable_deals_only is a #[serde(default)] bool; legacy
files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry
loop. The Settings → Gameplay toggle reads as "Winnable deals only"
with a "(may take a moment when on)" caption.
Engine integration
handle_new_game's seed-selection path now branches on the toggle.
When on AND mode is Classic AND no specific seed was requested
(daily challenges, replays, and explicit-seed requests bypass the
solver), choose_winnable_seed walks seed N, N+1, N+2, … calling
try_solve until it finds Winnable or Inconclusive. If the cap is
hit without a verdict, the latest tried seed is used so the player
always gets a deal rather than spinning forever.
19 new tests (11 solver, 3 settings, 5 engine including the
choose_winnable_seed unit). Two ignored bench/scan helpers
(solver_bench, find_unwinnable) for ad-hoc profiling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 19th achievement: "Cinephile — Watch a saved replay all the
way through." Unlocks the first time ReplayPlaybackState transitions
Playing → Completed (i.e. the move list runs out without the player
pressing Stop). Discoverability nudge for the replay feature itself.
The achievement uses the existing event-driven unlock pattern
(condition closure returns false; an unlock system fires
AchievementUnlockedEvent on the right state transition) rather than
the standard condition-evaluation path, mirroring how other
non-stat-driven achievements work.
The unlock system distinguishes natural completion from Stop-button
abort by watching for the specific Playing → Completed transition;
Stop transitions Playing → Inactive directly without going through
Completed, so it doesn't fire the achievement. Already-unlocked
state is checked via AchievementsResource so the achievement can't
double-fire on subsequent replays.
README's "18 Achievements" → "19 Achievements". ARCHITECTURE.md §11
gains a Cinephile entry alongside the existing 18.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes replay storage from a single overwriting slot at
latest_replay.json to a rolling list of the most recent 8 wins at
replays.json so the player can revisit a memorable game even after
winning more recently.
Storage layer
solitaire_data::replay gains ReplayHistory (schema_version=1, Vec<Replay>
capped at REPLAY_HISTORY_CAP = 8) plus save_replay_history_to,
load_replay_history_from, append_replay_to_history, and
replay_history_path. append_replay_to_history inserts at the front,
drops the oldest when the cap is hit, and persists atomically via
the existing .tmp + rename pattern. The legacy single-slot helpers
are #[deprecated] but kept for one release as a migration safety
net via the new migrate_legacy_latest_replay helper.
Engine integration
game_plugin's record_replay_on_win now appends to the history
instead of overwriting latest_replay.json. On Startup, if a legacy
latest_replay.json exists but replays.json doesn't, the migration
helper seeds the new file from the legacy entry — so the player's
last v0.14.0 replay carries forward.
Stats UI
LatestReplayResource → ReplayHistoryResource holding the full
history. New SelectedReplayIndex resource (default 0 = most
recent) drives a Prev / Next / "Replay N / M" selector at the top
of the Stats overlay. ReplayPrevButton, ReplayNextButton, and
ReplaySelectorCaption marker components let the repaint system
update the caption as the selection changes. The Watch button
launches the selected replay rather than always the most recent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes the Stats overlay's Watch Replay button from a stub
InfoToastEvent ("playback coming in a future build") to actually
starting in-engine playback via the new
replay_playback::start_replay_playback API. Pressing the button
when a replay exists resets the game to the recorded deal and the
ReplayOverlayPlugin's banner takes over.
The handler reads ReplayPlaybackState via Option<ResMut<...>> so
headless test fixtures that don't register ReplayPlaybackPlugin
keep compiling — they fall back to a descriptive "Replay ready"
toast. The "no replay yet" branch still surfaces the existing
"win a game first" toast.
Plugin registration in solitaire_app/src/main.rs picks up
ReplayPlaybackPlugin and ReplayOverlayPlugin alongside the existing
StatsPlugin. They run in any order — the playback plugin owns the
state resource, the overlay plugin spawns/despawns based on state
changes, and Stats's button handler dispatches into the playback
plugin's API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visible UI for the in-engine replay playback that just landed: a
thin top banner anchored to the window edge while
ReplayPlaybackState is Playing or Completed, surfacing the player's
current position in the move list and a way to abort.
Layout: full-width banner ~48 px tall with three children — a
"Replay" label in ACCENT_PRIMARY left-aligned, "Move N of M"
progress text centred, and a Tertiary Stop button right-aligned via
the existing spawn_modal_button helper so it gets focus rings and
hover/press states for free.
Z_REPLAY_OVERLAY = Z_DROP_OVERLAY + 5 (= 55) sits above HUD but
well below modal scrim (≥200), so Settings, Pause, and Help still
render on top of the overlay during a replay — the player can
adjust audio or pause mid-playback.
State-driven: the spawn system reacts to Changed<ReplayPlaybackState>
transitions, swapping the banner text to "Replay complete" when
state moves Playing → Completed and despawning entirely when state
returns to Inactive (either via the Stop button, completion linger
expiry, or external reset).
Five tests cover spawn-on-Playing, progress text, stop-button
clears state and despawns, despawn-on-Inactive, and Completed
banner text swap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes the replay feature from disk-only to a real in-engine
playback path. A new ReplayPlaybackState resource models a three-
state machine (Inactive / Playing / Completed); start_replay_playback
resets the live game to the recorded deal via
GameState::new_with_mode(seed, draw_mode, mode) and a tick system
fires the canonical MoveRequestEvent / DrawRequestEvent for each
recorded move at REPLAY_MOVE_INTERVAL_SECS (0.45s).
The reset path bypasses NewGameRequestEvent because the existing
event always sources draw_mode from Settings — a Draw-1 replay
would silently coerce to Draw-3 (or vice versa) on a player whose
preference doesn't match the recording. Inserting GameStateResource
directly applies the recording's exact draw_mode and sidesteps the
abandon-current-game confirmation modal that would otherwise block
playback.
Recording suppression during playback is non-invasive: a sibling
system snapshots RecordingReplay's length on entry to playback and
truncates the buffer back to that mark every frame while is_playing
or is_completed. game_plugin's recording append paths are
untouched.
Completion lingers for REPLAY_COMPLETION_LINGER_SECS (5s) so the
overlay can show "Replay complete" before the auto-clear flips
state to Inactive.
Six new tests cover the state transitions, tick cadence, canonical
event firing, completion, stop-clears-state, and the
recording-suppression contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`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>
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>
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>
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>
- 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>
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>
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>