2301cc65d3a4ebd56256e48bd32c5b768c42b140
710 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
93660c2217 |
feat(engine): N keypress now opens the real Confirm/Cancel modal
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> |
||
|
|
56e2e6f151 |
feat(engine): empty-state copy + onboarding hints across panels
- 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> |
||
|
|
cc635328be |
fix(engine): popover rows stay visible regardless of action-bar fade
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> |
||
|
|
a4bc063497 |
fix(engine): Settings rows use full-width layout to prevent overlap
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>
|
||
|
|
540869c851 |
feat(engine): "Copy share link" Stats button — clipboards the replay URL
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> |
||
|
|
bdac754b26 |
feat(engine): "Won before" HUD indicator on rematched seeds
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> |
||
|
|
f863d85c35 |
fix(engine): preserve saved game while restore prompt is unanswered
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> |
||
|
|
3c7a0eb4fb |
feat(engine): restore prompt on launch — Continue or start fresh
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>
|
||
|
|
d489e7a31b |
feat(engine): solver-vetted seed selection on AsyncComputeTaskPool
"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> |
||
|
|
f2f30c8002 |
docs: adopt unified-3.0 Claude rule set + trim duplications
Adopts the four-file rule set the player added to the working tree:
- CLAUDE.md grows from a 114-line pointer doc to the 571-line
`unified-3.0` rulebook: hard global constraints (§2), engine
rules (§3), asset rules (§4), code standards (§5), build +
verification (§6), git workflow (§7), the change-control
ASK BEFORE list (§8), and the Context Injection System (§14).
- CLAUDE_SPEC.md — formal architecture spec: crate dependency
graph with forbidden_deps, data ownership map, state-machine
invariants ("52 cards always exist", "no duplicate IDs",
"all cards belong to exactly one pile"), sync merge contract,
server contract, validation checklist.
- CLAUDE_WORKFLOW.md — two-agent Builder/Guardian pipeline with
hard-fail patterns that auto-reject (core uses IO/Bevy/network,
GameState mutated outside GameLogicSystem, blocking async on
main thread, duplicate logic, merge altered incorrectly).
- CLAUDE_PROMPT_PACK.md — task-type templates.
Three duplicate rule passages removed:
- CLAUDE_SPEC.md §0 dropped no_panics_in_core / core_is_pure /
event_driven_engine — already canonical in CLAUDE.md §2.1, §2.3,
§3.1. Kept single_source_of_truth and sync_is_additive (those
describe data flow, not in CLAUDE.md).
- CLAUDE_SPEC.md §11 Prohibited Patterns now references CLAUDE.md
§11 instead of restating the same five forbidden items.
- ARCHITECTURE.md Design Principles dropped the pure-core /
no-panics / UI-first bullets — those are enforcement constraints
living in CLAUDE.md §2.1, §2.3, §3.3; this file describes the
design that motivates them. Kept the offline-first, one-language,
and plugin-based-Bevy bullets (those are descriptive, not
enforcement).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a49a340a30 |
chore: prune low-value tests per CLAUDE_SPEC.md §10 + WORKFLOW §8
The Quat-flagged "≥3 tests per feature" inflation produced 43 tests
that don't earn their existence — default-value, serde-derive
round-trips on plain structs, single-field clamp tests, near-
duplicates, and trivial constant-equals-itself tests. None pin a
behaviour contract or a regression on a real bug.
Removed across `solitaire_data` and `solitaire_core`:
settings.rs −22 default-value, round-trip, legacy-format,
and per-field sanitized clamp tests. Adjust
and load-error tests retained — those exercise
real method logic.
progress.rs −1 generic round-trip on plain struct.
challenge.rs −1 challenge_count() returns CHALLENGE_SEEDS.len()
literally — testing it asserts the implementation
against itself.
game_state.rs −3 undo_count starts at 0, GameMode default is
Classic, time_attack score starts at 0 — all
default-value tests on freshly-constructed state.
card.rs −5 rank_value_ace + rank_value_king subsumed by
rank_values_are_sequential; suit_red + suit_black
consolidated into one complementarity test;
card_face_up_field_reflects_construction was
testing the struct literal.
Workspace: 1208 → 1165 passing tests (−43). clippy --workspace
--all-targets clean.
Future work: brief sub-agents for tests that pin a behaviour
contract or regression on a real bug, not a count of N. See
`feedback_test_discipline.md` in auto-memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
27cdf78ce0 |
docs: cut v0.17.0 — solver-driven hints + replay-rate slider
Two follow-up commits on top of v0.16.0: -v0.17.0 |
||
|
|
faa6c5efc4 |
docs: reconcile SESSION_HANDOFF with actually-shipped state
The post-v0.16.0 table marked the replay-rate slider as `(pending)` but |
||
|
|
487b99bbc9 |
docs: SESSION_HANDOFF refresh — solver hints + replay slider, async deferred
Documents the two follow-ups landed on top of v0.16.0 (solver-driven
hints in
|
||
|
|
53e3b816cf |
feat(settings,engine): replay-playback rate slider in Settings → Gameplay
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> |
||
|
|
87275bf340 |
feat(core,engine): solver-driven hints with heuristic fallback
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>
|
||
|
|
56647d7f0d |
docs: CHANGELOG + SESSION_HANDOFF refresh for v0.16.0
CHANGELOG gains a [0.16.0] section covering the modal-feel polish round: per-modal Overflow::scroll_y on Achievements / Help / Stats / Profile / Leaderboard, pointer cursor on hover for every Button, same-frame focus on modal open (attach + auto_focus moved to PostUpdate), and click-outside-to-dismiss for the six read-only modals via a new ScrimDismissible marker. The bottom-of-file compare links thread the new tag into the chain. Test count updated to 1196. SESSION_HANDOFF rewritten for the post-v0.16.0 state. Punch list collapsed to two release-prep items (smoke-test, desktop packaging) plus the carryover from v0.15.0's next-round candidates that didn't ship this round (solver-driven hints, replay-rate slider, solver progress overlay, async solver, "won previously" indicator, replay sharing). Resume prompt asks A–E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>v0.16.0 |
||
|
|
cbf2483028 |
feat(engine): opt Profile / Leaderboard / Home into scrim-click dismiss
Follow-up to
|
||
|
|
a54201e97b |
feat(engine): click-outside-to-dismiss for read-only modals
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> |
||
|
|
48e412177c |
fix(engine): focus arrives on the same frame a modal opens
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> |
||
|
|
cd54ce1bb0 |
feat(engine): pointer cursor on hover over interactive buttons
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> |
||
|
|
7a3032b74c |
fix(engine): scroll the modals whose content overflows the viewport
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> |
||
|
|
89699a8a86 |
docs: SESSION_HANDOFF refresh for post-v0.15.0 (follow-up)
The previous v0.15.0 doc commit only landed CHANGELOG — the SESSION_HANDOFF write silently no-op'd due to a Write tool param mix-up. This commit lands the matching handoff refresh: - Status block updated to v0.15.0 / HEAD / 1178 tests - New v0.15.0 changelog table covering the seven feature commits (Bevy trim, replay playback core + overlay + Stats wiring, rolling replay history, Cinephile achievement, solver + toggle) - Open punch list collapsed to two release-prep items (smoke-test, desktop packaging) and six fresh next-round candidates (solver-driven hints — now unblocked, replay-rate slider, solver progress overlay, async solver, "won previously" indicator, replay sharing) - Resume prompt asks A–E Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
70165da103 |
docs: CHANGELOG + SESSION_HANDOFF refresh for v0.15.0
CHANGELOG gains a [0.15.0] section covering 7 commits since v0.14.0: Bevy default-features trim (51 transitive crates dropped), in-engine replay playback core + overlay banner + Stats button wiring, rolling replay history (last 8 wins) with selector UI, "Cinephile" achievement (#19), and the Klondike solver + "Winnable deals only" toggle. The bottom-of-file compare links thread the new tag into the chain. Test count updated to 1178. SESSION_HANDOFF rewritten for the post-v0.15.0 state. Open punch list collapsed to two release-prep items (smoke-test, desktop packaging) and six fresh next-round candidates: solver-driven hints (now unblocked), playback-rate slider, solver progress overlay, solver-on-async-compute, per-deal "won previously" indicator, replay sharing. Resume prompt asks A–E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>v0.15.0 |
||
|
|
8a5fa8751c |
feat(core,engine): Klondike solver and "Winnable deals only" toggle
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> |
||
|
|
bf660df971 |
feat(core,engine): "Cinephile" achievement for completing a replay
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> |
||
|
|
13a8a012ee |
feat(data,engine): rolling replay history (last 8 wins)
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> |
||
|
|
02ababa65f |
feat(engine): wire Stats Watch Replay button to in-engine playback
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>
|
||
|
|
9c36b49729 |
feat(engine): replay-playback overlay banner with Stop button
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> |
||
|
|
8e90574437 |
feat(engine): in-engine replay playback core
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> |
||
|
|
95fcdad5d2 |
chore: disable Bevy default features to drop unused audio stack
Closes Quat investigation #2. The project uses kira for audio (cpal 0.17 + alsa 0.10), but Bevy's default feature set still pulled bevy_audio → rodio → cpal 0.15 + alsa 0.9 + symphonia codecs — about 50 transitive crates the binary never executes. Workspace Cargo.toml's bevy entry now declares default-features = false plus an explicit allow-list of the features actually used (default_app subset + default_platform desktop subset + common_api + 2D + UI rendering). The list is derived analytically from the leaves of Bevy 0.18's 2d and ui meta-features; built cleanly on the first try with no missing-symbol errors. Features intentionally omitted vs Bevy default: - bevy_audio (kira handles audio directly) - bevy_animation (custom CardAnimation, not Bevy's) - bevy_gilrs, bevy_gizmos, bevy_picking variants, bevy_post_process, scene, hdr, sysinfo_plugin (none used) - webgl2, web, android-* (desktop-only; solitaire_wasm is Bevy-free and uses wasm-bindgen + solitaire_core directly) - wayland (X11 chosen; Wayland can be added later if requested) Dependency-tree size for solitaire_app drops from 628 unique crates to 577 (-51). Verified gone: bevy_audio, rodio, cpal 0.15. The remaining cpal 0.17 and symphonia 0.5 are pulled by kira, not Bevy. solitaire_wasm needed no changes — it doesn't depend on bevy. All 1134 tests pass; clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>v0.14.0 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |