4303ef3f5b3abd206e71308364230414f024e30c
306 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
b37f0cbec7 |
feat(engine): right-click radial menu for quick-drop without dragging
Power-user shortcut: hold right-click on a face-up card, a small ring of icons appears around the cursor with one entry per legal destination, release over an icon to fire MoveRequestEvent. Skips the drag motion entirely while preserving the existing RightClickHighlight tint on the actual pile markers. A new RadialMenuPlugin owns the flow. RightClickRadialState is a two-state enum (Idle / Active) carrying the source pile, lifted cards, pre-computed legal destinations + their world anchors, the ring centre, and the currently hovered icon index. Four chained systems handle press → cursor track → release/cancel → redraw, in that order so a single-tick test can't observe a half-state. Mutual exclusion with the left-button mouse drag is implicit — RadialMenuPlugin only listens to MouseButton::Right while the existing drag pipeline only listens to Left. RightClickHighlight co-exists at a lower z (50) than the radial overlay (Z_RADIAL_MENU = 60), so the brief pile-marker tint reads as the same set of legal destinations the radial offers. Cancel paths: release the right button outside any icon, press Esc, or press the left button. All three reset state to Idle without dispatching a move. Visual: a centre dot at the press location plus N icons at radius 80 px around it. For one destination the icon sits at 12 o'clock; for N icons they spread evenly clockwise. Hovered icon scales to 1.15× and tints STATE_SUCCESS so the focused choice is unambiguous. Twelve new tests pin the contract — five system-level (open on press over face-up card, release over destination fires move event, release in dead space cancels, Esc cancels, face-down doesn't open), seven on the pure helpers (radial_anchor_for_index, radial_hovered_index, legal_destinations_for_card). Tests inject cursor positions through a RadialCursorOverride resource so they work under MinimalPlugins where there's no PrimaryWindow or Camera. help_plugin's controls reference gains a new "Mouse" section covering double-click auto-move, right-click highlight, and the new "Hold RMB" radial. Onboarding slide 3 is intentionally left keyboard-only — the radial is a power-user discovery, not a first-run teach. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a0fc0d2605 |
feat(engine): keyboard-only drag-and-drop via Tab → Enter → arrows → Enter
Players can now complete an entire game without a mouse. Tab cycles
the keyboard cursor across draggable card stacks, Enter "lifts" the
focused stack into a destination-pick mode, arrow keys (or Tab)
cycle through the legal targets only, and Enter confirms the move.
Esc cancels — single-press in Lifted reverts to source-pick keeping
focus, second-press clears the source selection entirely.
A new KeyboardDragState resource models the two-mode flow without
touching SelectionState's existing source-pick contract:
Idle (Tab/Enter/auto-move via SelectionState)
Lifted {
source_pile, count, cards,
legal_destinations, pre-computed at lift time via
destination_index, can_place_on_foundation/_tableau
}
Mutual exclusion with mouse drag is sentinel-based: keyboard lift
sets DragState.active_touch_id = u64::MAX (KEYBOARD_DRAG_TOUCH_ID),
existing mouse handlers in input_plugin already short-circuit when
active_touch_id is Some, and the cleanup path only clears DragState
when the sentinel is present so the mouse path is never stomped.
Conversely keyboard input is suppressed when a real mouse/touch
drag is active.
The visual lift reuses the existing drag z-lift and shadow path so
the keyboard-lifted stack reads the same as a mouse-lifted one;
update_selection_highlight gains a green destination indicator on
the focused legal target while Lifted.
help_plugin's canonical hotkey list grows a "Keyboard drag"
section (Tab/Enter/Arrows/Esc/Space) and onboarding slide 3 picks
up a "Tab → Enter" entry so first-run players see the full path.
Seven new headless tests pin the contract: Tab cycles to first
draggable pile, Enter lifts the stack, arrow keys cycle only legal
destinations, Enter with destination fires MoveRequestEvent and
clears state, Esc reverts to source-pick, mouse-drag-active
suppresses keyboard input, double-Esc clears source selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7ed4f2cba9 |
feat(engine): card backs follow active theme
Themes already shipped a back.svg in their manifest but card_plugin
ignored it — face-down cards always rendered with the legacy
back_N.png picker, so swapping themes only swapped the faces. Now
the active theme's back rasterises alongside its faces and feeds
into the face-down sprite path; the legacy back_N.png picker remains
the fallback when a theme doesn't ship its own back (e.g. a
user-imported theme that only redefines faces).
theme/plugin.rs caches the active theme's back Handle<Image> in the
ActiveTheme resource on theme-load and theme-switch. card_plugin's
face-down branch reads ActiveTheme first; missing theme back →
legacy back_N.png path indexed by Settings.selected_card_back.
Settings → Cosmetic's card-back picker section gains a caption
("Active theme provides its own back") that surfaces when the
override is in effect, plus the swatch row dims to communicate the
read-only state. Settings file format unchanged — selected_card_back
still round-trips and only takes effect when the theme leaves the
back undefined.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ddc8f27c82 |
feat(engine): UX iteration round — tooltip slider, streak fire, score breakdown
Three small UX improvements bundled because they share ui_theme token
edits.
Tooltip-delay slider in Settings → Gameplay
- Settings.tooltip_delay_secs (f32, #[serde(default)] = 0.5) tunable
via "−" / "+" icon buttons next to a value readout. Range
[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS] = [0.0, 1.5] in
TOOLTIP_DELAY_STEP_SECS (0.1) increments. "Instant" label when
value is 0; "{n:.1} s" otherwise.
- ui_tooltip's hover-delay comparison reads from SettingsResource
with MOTION_TOOLTIP_DELAY_SECS as the fallback when the resource
is absent (test path). New tooltip_should_show(elapsed, delay)
pure helper covers the boundary cases.
- adjust_tooltip_delay clamps; sanitized() carries the clamp through
load. Five round-trip / default / legacy-deserialise tests.
Win-streak milestone fire animation
- New WinStreakMilestoneEvent { streak: u32 } fired from stats_plugin
when win_streak_current crosses any of [3, 5, 10] (only the
threshold crossing — not every subsequent win). HUD streak readout
scale-pulses 1.0 → 1.20 → 1.0 over MOTION_STREAK_FLOURISH_SECS
(0.6 s) on receipt; mirrors the foundation-flourish curve shape.
- Three threshold-crossing tests pin the firing contract.
Score-breakdown reveal on the win modal
- Win modal body replaces the single "Score: N" line with a
per-component reveal: Base score, Time bonus (m:ss), No-undo
bonus, Mode multiplier, separator, Total. Rows fade in over
MOTION_SCORE_BREAKDOWN_FADE_SECS (0.12 s) staggered by
MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s) so the math reads as
it animates. Skipped rows: zero time bonus, undo-tainted no-undo
bonus, multiplier == 1.0.
- Honours AnimSpeed::Instant: rows spawn fully visible, no stagger.
- New ScoreBreakdown::compute helper sources base from
GameWonEvent.score, time bonus from
solitaire_core::scoring::compute_time_bonus, no-undo from a +25
constant when undo_count == 0, mode multiplier from GameMode (Zen
zeros the total). 9 new tests cover the math and the reveal
cadence.
Test count net: +25 across the workspace (1007 → 1031).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
17f9b518f1 |
fix(engine): bundle fonts only and drop system-font fallback
Code-review feedback: the SVG rasteriser mixed three font-resolution layers (load_system_fonts + bundled FiraMono + lenient resolver appending CSS generics), which made card text rendering depend on which fonts the host machine happened to have. The Bevy UI face loaded separately at runtime via AssetServer. Picking option (a) from the review and applying it consistently: bundle FiraMono via include_bytes!() in BOTH layers, no system fallback anywhere. solitaire_engine/src/font_plugin.rs now embeds main.ttf at compile time and registers it with Assets<Font>. A parse failure aborts with "bundled FiraMono failed to parse — binary is corrupt"; the MinimalPlugins early-return stays as a "this plugin doesn't apply in headless tests" check (consumers query Option<Res<FontResource>> and degrade cleanly), not a production fallback. solitaire_engine/src/assets/svg_loader.rs drops load_system_fonts entirely, drops the lenient_font_resolver, and drops the five set_*_family pins. The new bundled_font_resolver ignores the SVG's font-family request and always returns the single bundled face — the bundled card SVGs reference Arial / Bitstream Vera Sans by name and we deliberately don't ship those, so routing every query to FiraMono keeps rasterisation deterministic. shared_fontdb asserts the embedded bytes parsed. The two layers now embed the same path (assets/fonts/main.ttf) independently, so they can't drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7dba772e67 |
feat(engine): digit shortcuts (1-5) launch modes from inside the Mode Launcher
Pressing M already opens the Home modal (which is the Mode Launcher
post-v0.11) and Tab cycles focus through the cards. The remaining
gap was direct keyboard activation of a specific mode — players had
to tab-and-enter or click. A new modal-scoped digit handler closes
that gap:
1 → Classic (NewGameRequestEvent)
2 → Daily Challenge (StartDailyChallengeRequestEvent)
3 → Zen (StartZenRequestEvent, gated at level 5)
4 → Challenge (StartChallengeRequestEvent, gated at level 5)
5 → Time Attack (StartTimeAttackRequestEvent, gated at level 5)
handle_home_digit_keys runs only when HomeScreen exists and short-
circuits otherwise — the digit keys can't accidentally launch a
mode mid-game. Locked modes (level < CHALLENGE_UNLOCK_LEVEL) silent-
no-op rather than firing a toast, mirroring the click-on-locked-card
behaviour without the InfoToastEvent (the click path's toast is the
authoritative "level too low" surface).
The HomePlugin Update tuple is now .chain()ed because the Bevy 0.18
parallel scheduler would otherwise let handle_home_card_click,
handle_home_cancel_button, and the new digit handler all queue a
HomeScreen despawn concurrently — the second buffer apply panics
on the already-despawned entity.
help_plugin gains a new "Mode Launcher (M)" section with the digit
rows and a level-5 unlock note. onboarding's slide-3 hotkey table
gets one new line ("M — Open Mode Launcher (then 1-5 to pick)") so
first-run players see the full path. The help-modal canonical list
now mirrors the onboarding teach.
Four new headless tests pin the contract: Digit1 launches Classic
and closes the modal; Digit3 at level 0 is a no-op (modal stays
open); Digit3 at unlock level launches Zen and closes; digit keys
outside the modal fire no events at all.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ca5788f714 |
feat(engine): one-shot achievement-onboarding toast on first win
After the player's very first win the engine now writes "First win! Press A to see your achievements." via InfoToastEvent, then flips a persisted Settings.shown_achievement_onboarding flag so the cue never re-fires. Mentions the A hotkey by name so the toast is actionable on its own. The toast path runs after StatsUpdate so games_won has been incremented to 1 when the system reads it; .after(GameMutation) keeps the post-move state visible. Three guards: first win only, flag was false, GameWonEvent fired this tick. Persistence mirrors onboarding_plugin's complete_onboarding pattern: save via save_settings_to with the existing SettingsStoragePath/Option<&PathBuf> graceful-fallback shape. Atomic .tmp+rename writes are unchanged. Settings gains a single bool field with #[serde(default)] so legacy settings.json files deserialize cleanly to false. The field is local-only by design — it's about UI teaching for THIS device, not progression — so SyncPayload and merge logic are untouched. Seven new tests pin the contract: default value is false, field round-trips through save/load, legacy JSON without the field deserializes to false, first win fires the toast and flips the flag, subsequent wins are silent, the fifth win on a synced device is silent (won't fire when games_won has been bumped via sync), and no win event means no toast. Toast duration is the existing animation_plugin QUEUED_TOAST_SECS = 2.5 s — InfoToastEvent is a tuple struct with no duration parameter, so the agent kept the existing event shape rather than expanding it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9887343d8b |
feat(engine): focus ring breathes at 1.4 s — gentle pulse instead of flat
The keyboard focus ring rendered as a static yellow outline. A new pulse_focus_overlay system modulates the overlay's BorderColor alpha with a sin curve over MOTION_FOCUS_PULSE_SECS (1.4 s), breathing the visible alpha between 0.65× and 1.0× of FOCUS_RING's native value. The motion is slow enough to read as a calm heartbeat in peripheral vision rather than a competing animation, and a focus change still draws the eye because the ring re-attaches at full brightness on the next pulse cycle. The pulse honours AnimSpeed::Instant by reading SettingsResource and skipping the modulation entirely (static FOCUS_RING colour) for reduced-motion users — matches the convention used elsewhere for animation gating. A pure focus_ring_pulse_factor(elapsed_secs) helper is unit-tested for the curve shape: 0.825 at t=0 (mid-point), 1.0 at the quarter-period peak, 0.65 at the three-quarter-period trough, and a sweep across two full periods stays within the [0.65, 1.0] range. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
525fe0fe76 |
feat(engine): drag-cancel return tween — smooth ease-out instead of shake
Illegal drops previously snapped each dragged card to its origin slot and ran a horizontal ShakeAnim wiggle for negative feedback — which read as punitive on every misclick. The rejection now plays a 150 ms quintic ease-out glide from the drop location back to the resting slot. The audio cue (card_invalid.wav) still fires so the player gets clear "no" feedback; the visual is just gentler. Both rejection paths in input_plugin (mouse end_drag and touch end_drag) construct a CardAnimation::slide(drag_pos → target_pos) with MotionCurve::Responsive — the curve module's own docs recommend Responsive specifically for invalid snap-back because its zero overshoot reads forgiving rather than jittery. card_plugin's update_card_entity gates its snap path on CardAnimation absence so the StateChangedEvent that follows a rejection no longer fights the in-flight tween. Mirrors how resize_cards_in_place already drops in-flight tweens during a window resize. ShakeAnim itself stays in feedback_anim_plugin — the right-click invalid-target and double-click in-place rejection paths still use it because there's no movement to interpolate, just a "no" wiggle. Only the drag-rejection path swaps to the smooth tween. Six new rejection-tween tests pin the contract: CardAnimation is inserted on every dragged card, start/end positions and z values match the drag-to-resting transition, duration matches the new MOTION_DRAG_REJECT_SECS token, and the curve is Responsive. The two legacy ShakeAnim drag-rejection tests are removed since their contract is intentionally inverted by this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
69ce9afab9 |
feat(engine): foundation completion flourish — King-on-foundation celebration
Now that foundations are unlocked and "completing" one is a real moment (rather than a foregone conclusion based on suit assignment), each Ace-through-King run gets its own small celebration when the King lands. Three layers fire on a single FoundationCompletedEvent emitted by game_plugin's handle_move when a successful move leaves a PileType::Foundation pile holding 13 cards: 1. King card scale-pulse via a new FoundationFlourish component. Triangular curve 1.0 → 1.15 → 1.0 over MOTION_FOUNDATION_FLOURISH _SECS (0.4s) — same shape as the existing ScorePulse so the feel matches. 2. Pile-marker tint flourish via FoundationMarkerFlourish — the foundation marker's sprite colour lerps to STATE_SUCCESS for the first half of the duration then fades back. Reuses the existing success-signal palette; no new colour token. 3. Audio cue: foundation_complete.wav, a synthesised C6→E6→G6 triad with 2nd-harmonic warmth and AR decay (~240 ms). Sits an octave above win_fanfare's root so the layered fourth-completion + win cascade reads cleanly. Generated via solitaire_assetgen's foundation_complete() function and embedded via include_bytes!(). The visual systems run .after(GameMutation) so the post-move pile state is visible when the King is identified. Both flourish components remove themselves once elapsed time exceeds duration — no animation queue or scheduler integration needed. Pure foundation_flourish_scale(elapsed, duration) helper is unit-tested for the curve, edge clamps, and zero-duration safety. Three integration tests on the firing logic verify the event fires exactly once when a King completes a foundation, doesn't fire for non-foundation moves, and doesn't fire when the foundation is at 12 cards. The fourth completion still co-occurs with the win cascade — the two layer cleanly because the flourish's scale is on the King card sprite while the cascade is a screen-shake + per-card rotation, and the foundation_complete ping is a higher octave than the win fanfare's root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
13aa0fd833 |
fix(engine): match CARD_ASPECT to hayeah SVG dimensions (1.4 → 1.4523)
Cards rendered ~3.6 % squashed vertically because layout.rs assumed a 1.4 height/width ratio while the bundled hayeah/playing-cards-assets SVGs are natively 167.087 × 242.667 (= 1.4523). The mismatch meant every face was scaled to fit a too-short box; pip arrangements and court-card art read slightly compressed. Bumps CARD_ASPECT to 1.4523 to match the SVG. The vertical-budget math in compute_layout (the height-based card_width candidate) uses CARD_ASPECT algebraically, so the tableau-fits-on-screen check adapts automatically — slightly smaller cards on aspect-ratio-tight windows, no visible regression on standard 16:9. Doc comments referencing the old 1.4 literal updated to point at CARD_ASPECT instead so this can't drift again. All 982 tests pass — the existing layout/test sentinel (card_size.y / card_size.x - CARD_ASPECT) used the constant by name and adapted for free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
655dfde736 |
feat(engine): stock-pile remaining-count badge
Players were recycling the stock blind — there's no in-world indicator of how many cards are left before the recycle. A small "·N" chip now sits at the top-right corner of the stock pile, showing the remaining count. The badge is a top-level world entity whose Transform.translation is recomputed each tick from the live LayoutResource (so window resizes and theme switches don't strand it), parented to neither the PileMarker nor any card. update_stock_count_badge spawns the entity on the first frame, then on every subsequent frame reads the stock pile's card count, writes the formatted text into the child Text2d, and toggles Visibility::Hidden when the count drops to zero — the same state where StockEmptyLabel's existing ↺ icon takes over, so the two never co-render. Z_STOCK_BADGE = 30 sits above stock cards (z ≈ 1) and below Z_DROP_OVERLAY = 50, so the badge stays visible during normal play but green drop-target washes still cover it while a card is being dragged. Card drop shadows live at negative local z relative to each card and don't compete with the badge plane. Tokens (STOCK_BADGE_BG, STOCK_BADGE_FG, Z_STOCK_BADGE) were already present in ui_theme from prior work; this commit only wires them up. The chip itself is 28×16 px, rendered with TYPE_CAPTION text in ACCENT_PRIMARY against BG_ELEVATED_HI. Four new tests pin the contract: badge shows "·24" on a fresh deal, hides when the stock empties, updates as the count drops, and the stock_card_count helper reports 0 when the pile is missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f712b89fe4 |
feat(engine): drop shadows on cards with lifted state during drag
Cards previously read as flat stickers on the felt — no separation cue, no sense the play surface had any depth. Each CardEntity now spawns a CardShadow child sprite: neutral black at 25 % alpha, sized to card_size + 4 px halo, offset (2, -3) and rendered at local z -0.05 so it sits behind its card. Cards in the active drag set switch to a lifted shadow: alpha 40 %, offset (4, -6), padding (8, 8). update_card_shadows_on_drag runs every Update and snaps each shadow to the right state based on DragState membership — no lerp, no animation cost. The pure card_shadow_params(is_dragged) helper is unit-tested for the four parameter values. resize_cards_in_place gains a third query for shadows so the in-place resize keeps shadows cheap (no Sprite regeneration); the shadow's current alpha is read to preserve idle vs lifted padding across a resize. update_card_entity's despawn_related call is followed by a fresh add_card_shadow_child so the shadow re-attaches when the card is repainted (face flip, settings change, theme swap). The pre-existing bulk drag-shadow under the whole lifted stack is untouched — per-card shadows complement it. All shadow values flow through eight new ui_theme tokens (CARD_SHADOW_COLOR, alphas, offsets, paddings, local z) so the visual is tunable in one place. Color is neutral black so the shadows don't conflict with color-blind mode's red/blue suit tints. Four new tests pin the contract: shadow params for idle and drag states, every CardEntity spawns with exactly one CardShadow child, and dragging shifts only the dragged shadow's offset while leaving unrelated shadows on the idle offset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f6c916641a |
feat(engine): visible drop-target overlay during drag
The existing update_drop_highlights system tinted PileMarker sprites green for valid drops, but the marker is a card-sized rectangle that sits behind the stack. Once a tableau column had any cards on it the marker was occluded and the highlight effectively invisible — the handoff's "drops feel guess-y because there's no preview" point. A new update_drop_target_overlays system spawns an overlay above every legal target during drag: a soft DROP_TARGET_FILL rectangle sized to the pile's actual visible footprint (full fanned column for tableaux, card-sized for foundations and empty tableaux) plus four thin DROP_TARGET_OUTLINE edges forming a 3 px border. Z_DROP_OVERLAY = 50 sits above static cards (z ~1) but below the dragged stack (DRAG_Z = 500), so the overlay never occludes the card the player is holding. The valid-target enumeration mirrors update_drop_highlights exactly so the rules can't drift, and pile geometry mirrors input_plugin's pile_drop_rect. The original marker-tint system is untouched; it still does its job for empty-pile placeholders. The overlay layer is purely additive — running alongside, not replacing. Token values reuse the existing STATE_SUCCESS hue (#4ADE80) at 10% fill / 75% outline so the overlay green matches the rest of the success-signal palette (foundation completion, sync OK, etc.). Three headless tests pin the contract: overlay spawns for valid tableau drops, doesn't spawn for invalid destinations, and despawns the moment the drag ends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
95df5421c9 |
feat(core): unlock foundations — Foundation(u8) slots, suit derived from contents
Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.
Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.
can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.
next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.
The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.
Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.
9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
is unaffected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
fdb6c2ecfe |
fix(engine): bundle FiraMono into SVG fontdb as last-resort fallback
The hayeah card SVGs reference Bitstream Vera Sans and Arial by name.
The lenient FontResolver from
|
||
|
|
c4970b16ea |
feat(engine): auto-fade HUD action buttons when cursor leaves the band
Player request: the Menu / Undo / Pause / Help / Modes / New Game buttons stay visible during play even when the player isn't looking at them. Fade them out when the cursor is in the play area, fade back in when it returns to the top of the window. Implementation mirrors video-player auto-hide UX: - HudActionFade resource holds (alpha, target). Default both 1.0 so the bar starts visible on first launch. - update_action_fade reads cursor.y each frame, sets target to 1.0 when the cursor is in the top reveal zone (HUD_BAND_HEIGHT + 32 px) or off-window (keyboard navigation), 0.0 otherwise. Lerps alpha toward target at 6/sec ≈ 167 ms per full transition. - apply_action_fade overrides BackgroundColor + child TextColor on every ActionButton. Runs in Last so a hover-state change in the same frame doesn't blip back to opaque mid-fade. No interactivity guard needed: hover requires the cursor to be on a button, and a faded button is geometrically out of reach (cursor must re-enter the reveal zone, which is exactly the trigger that fades the bar back in). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2c72e1fc87 |
feat(engine): reserve top band for HUD so it stops crowding the cards
Player report: the action button bar (Menu / Undo / Pause / Help / Modes / New Game) and Score / Moves / Timer text were sharing the same vertical band as the stock + foundation row, with no visual separation. The HUD read as part of the play surface. Two-part fix: 1. layout.rs reserves HUD_BAND_HEIGHT (64 px) at the top of the window. Card-grid math takes that off the available vertical budget so cards still fit; top_y shifts down by the same amount. New layout test pins the reservation. Existing worst_case_tableau_fits_vertically tests verify the height-budget arithmetic still holds. 2. hud_plugin.rs spawns a translucent purple band (BG_HUD_BAND, new token in ui_theme.rs at the BG_BASE hue with 0.70 alpha) filling that reserved zone. Z-index sits one rung below Z_HUD so action buttons paint on top while the band reads as their container. The band's bottom edge lines up with the top edge of the highest playable card, so the buttons feel anchored to a "tools strip" rather than floating in the play area. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
efa063fb8f |
fix(engine): fall through to system default font on unmatched family
Replaces the previous LogPlugin-filter approach (which suppresses the
warn message) with a fix at the source: a custom usvg FontResolver
that appends `sans-serif` and `serif` to every family-lookup query.
usvg's default selector queries fontdb with [SVG-requested families,
Serif] and emits `log::warn!("No match for '{family}'")` when the
query returns None. On systems without the SVG's named family (Arial
on Linux, etc.), every text node logs a warn even though the system
has perfectly good fonts available — the warn is a false negative
because fontdb's named-family lookup is exact-match only.
The new resolver appends both `Family::SansSerif` and `Family::Serif`
to the query, both resolved by fontdb (via fontconfig on Linux or
built-in defaults elsewhere) to whatever the system has installed.
The query now finds *some* face on any reasonably configured machine,
so `id.is_none()` is never true and the warn branch never fires. The
visible behaviour: SVGs that request unavailable named families now
silently use the system's default sans-serif font.
Reverts the LogPlugin filter from main.rs — silencing warns at the
log level was the wrong layer; fixing the lookup is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
78cf30e906 |
fix(engine): silence usvg font-substitution warn spam
The bundled hayeah card SVGs declare font-family="Arial" for rank/suit text. usvg matches family names exactly, so on systems without Arial installed (every Linux distro by default) every text node bridged a log::warn! into our tracing output — 50+ lines per launch. Two-part fix: - svg_loader now populates a process-wide fontdb with system fonts (lazy via OnceLock) so substitution actually has faces to fall through to. usvg::Options::default() ships an empty fontdb, which meant text glyphs had nothing to fall back on at all. - LogPlugin extends DEFAULT_FILTER with usvg::text=error so the residual "no match" warns drop. The substitution itself works; the message is purely informational because Arial truly isn't installed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9a9026e33a |
fix(engine): silence benign UnsupportedPlatform warn on exit
push_on_exit logged every error including LocalOnlyProvider's expected UnsupportedPlatform response, producing a misleading "sync push on exit failed" warning on every shutdown in local-only mode. Mirror the pull path: treat UnsupportedPlatform as silent no-op, warn only on real errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ab1d098877 |
fix(engine): use resolve_embed for sibling theme assets
`AssetPath::resolve` concatenates, so manifest-relative SVG paths ended up under `…/theme.ron/<name>.svg` and the asset server reported all 53 references missing. `resolve_embed` is the RFC 1808 sibling-resolution method that strips the base path's last segment first, giving the intended `…/<name>.svg`. Default theme now loads cleanly from the embedded:// source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |