3e11e9e79a622ecca894910744f5eaeff0a50ffc
325 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
fe41b502ac |
docs: CHANGELOG + SESSION_HANDOFF refresh for v0.13.0
CHANGELOG gains a [0.13.0] section covering the third UX iteration round on top of v0.12.0: - Tooltip-delay slider, streak fire, score-breakdown reveal - Card backs follow active theme - Drag-with-keyboard - Right-click radial menu Plus two code-review fixes (Removed: sccache wiring, Fixed: bundled- only font handling). The bottom-of-file compare links thread the new tag into the existing chain. Test count updated to 1053. SESSION_HANDOFF gains a "Session 7 round 3" table summarising the six commits and rolls the punch list forward — UX candidate list exhausted again, fresh six-item list seeded for a future round (daily-challenge calendar, theme-picker thumbnails, per-mode high scores, in-progress auto-save for Zen/Time Attack, configurable scoring weights, win replays). Resume prompt asks A/B/C/D about push, smoke-test, next round, or packaging. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>v0.13.0 |
||
|
|
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>
|
||
|
|
13dd44bd1b |
chore: remove project-level sccache rustc-wrapper config
Code-review feedback: sccache shouldn't be a per-project build dependency. Cargo's incremental cache already covers what sccache offers for a single project, and forcing rustc-wrapper = "sccache" project-wide means every contributor has to install sccache or prepend RUSTC_WRAPPER= to bypass the wrapper. .cargo/config.toml only existed to wire sccache and pin SCCACHE_DIR to a project-local cache. Removing the file entirely so plain `cargo build` works without any extra setup. The .cargo directory is empty after the deletion and removed too. .gitignore's /.sccache-cache line is harmless cruft and stays — players who already have a populated .sccache-cache directory keep it ignored. 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> |
||
|
|
61d891fb76 |
docs: CHANGELOG + SESSION_HANDOFF refresh for v0.12.0
CHANGELOG gains a [0.12.0] section covering the second UX iteration round on top of v0.11.0: - Foundation completion flourish - Drag-cancel return tween - Focus ring breathing - First-win achievement onboarding toast - Mode Launcher digit shortcuts - Card aspect-ratio fix (1.4 → 1.4523) - Plus the README and CHANGELOG-add docs that rode along The bottom-of-file compare links thread the new tag into the existing chain (Unreleased → 0.12.0 → 0.11.0 → ...). Test count updated to 1007. SESSION_HANDOFF now distinguishes session 7 round 1 (v0.11.0, morning) from round 2 (v0.12.0, afternoon) — keeping the audit trail readable instead of conflating them. The release-prep punch list collapses to the three tag/push/packaging items; the UX iteration list opens with six fresh candidates for whoever picks the next round. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>v0.12.0 |
||
|
|
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> |
||
|
|
9f095c4039 |
docs: add CHANGELOG.md covering v0.9.0 through v0.11.0
The CHANGELOG didn't exist; v0.11.0 felt too meaty to land without one and starting from v0.10.0+ would have made the file feel rootless. The format follows Keep a Changelog 1.1.0 with the standard Added / Changed / Fixed / Removed sections per release plus a Migration block when relevant. v0.11.0 (2026-05-02) — full coverage of the card-theme system, HUD overhaul, drag-feel polish (drop overlay, drop shadows, stock count badge, unlocked foundations), the FiraMono fontdb fix, and the schema-version bump that invalidates pre-v2 game_state.json saves on launch. 982 tests, zero clippy. v0.10.0 (2026-04-29) — PNG art pipeline, Bevy 0.15 → 0.18 migration, kira 0.9 → 0.12 migration, Rust edition 2024 + MSRV 1.95, custom font, JWT-secret-at-startup fix, SmartIpKeyExtractor, MessageReader touch-input fix. v0.9.0 (2026-04-28) — initial public-tagged release: workspace structure, modal scaffold, design-token system, four-tier HUD, progression, sync server, splash, focus rings, tooltips, achievement integration tests, all the foundation work that predates the card-theme rewrite. README gains a Changelog section linking to the new file. The bottom-of-file compare links use the corrected github.com/funman300/Rusty_Solitaire URL so the rendered page on GitHub auto-generates the correct diff views once the tags are pushed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d8c70341f4 |
docs: refresh README for v0.11.0 — card themes, HUD overhaul, drag feel
The README hadn't been touched since before the card-theme system landed and was missing every UX feel improvement from v0.11.0. Anyone discovering the repo on the GitHub release page would have seen pre-theme copy. Features list now covers card themes (bundled default + user zip-installable), the modern HUD (reserved band + action-bar auto-fade), and the four drag-feel improvements (drop highlights, drop shadows, stock count badge, unlocked foundations). Controls table fixes three real discrepancies: Undo is U not Z/Ctrl+Z (the README inverted the bindings), Help is F1 not H, and Z actually toggles Zen mode. Adds the previously undocumented Tab / Shift+Tab focus cycle, Enter activation, F11 fullscreen, double- click to auto-move, and the G forfeit shortcut. Notes that every action is also a visible UI button so the keyboard list is optional-accelerator only — matches the project's UI-first rule. Adds a small Card Themes section explaining how to install a theme (drop a directory or zip-import via Settings → Cosmetic) without diving into SVG technicals. Test count updated to 982 to reflect v0.11.0 baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
063269c70e |
docs: update repo URL references to corrected Rusty_Solitaire spelling
The GitHub repo was renamed from Rusty_Solitare to Rusty_Solitaire (adding the missing 'i'). The local origin remote has been updated via `git remote set-url`; this commit updates the three doc references that hardcoded the old URL. SESSION_HANDOFF.md's "Canonical remote" section now names the new URL and explains the rename for future readers, including the note that local clone directories may still be named Rusty_Solitare — that's a local-only name and works fine, only the GitHub repo URL changed. docs/SESSION_HANDOFF.md (older snapshot, unchanged otherwise) gets its single URL line corrected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>v0.11.0 |
||
|
|
b126df82b2 |
docs: refresh SESSION_HANDOFF for session 7 UX-iteration round complete
Session 6 closed with a four-item UX punch list (unlock foundations,
drop shadows, drop-target highlights, stock badge). All four shipped
in session 7, plus an unrelated font-fallback fix surfaced by a
second-machine smoke test that landed before the UX work.
Refreshes the doc to reflect:
- HEAD:
|
||
|
|
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
|
||
|
|
9a3d7f3876 |
docs: refresh SESSION_HANDOFF for session 6 + UX-iteration direction
Captures today's six commits (theme loader fix, exit-warn silence, two font-warn rounds, HUD band, action fade), updates HEAD/test counts, records that the player redirected from "cut v0.11.0 / package" to "keep iterating on UX," and lists the new four-item UX punch list (unlock foundations, drop shadows, drop highlighting, stock badge). Resume prompt is rewritten so a fresh agent on a different machine picks up cleanly: notes GitHub is the canonical remote (Gitea drift caused commits to silently miss the alex machine earlier in session), flags that the in-progress save format will invalidate when (1) lands, and explicitly defers the release-prep items. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
160637d1c8 |
docs: update remote URL reference to github.com/funman300/Rusty_Solitare
Mirrors the move of the canonical remote from git.aleshym.co to GitHub. The git remote itself was switched via 'git remote set-url origin'; this updates the one stale URL in docs/SESSION_HANDOFF.md that named the old host. |
||
|
|
43f13c615e |
chore: workspace cleanup after card-theme phase landings
Drops dead deps and stale doc content carried over from the pre-MIT art swap. Cargo.toml manifests: - solitaire_core no longer depends on chrono (no source references it since the original sync-payload timestamps moved to solitaire_data). - solitaire_sync no longer depends on serde_json (the sync types use serde-derive with whatever serializer the caller picks; the old json-specific helpers were removed earlier). Cargo.lock pruned by `cargo build` to drop the now-untransitively- referenced versions. CREDITS.md redistribution clause: "LGPL and OFL notices" tightened to "MIT (project + hayeah card art) and OFL (FiraMono)" since the LGPL art is gone. SESSION_HANDOFF.md: - HEAD bumped to 924a1e2; test count to 960; 9 ignored. - Punch list rewritten — the xCards-URL line is obsolete (we did the swap), v0.1.0 tag exists locally, and player smoke-test is the current top item. - New "Card-theme system (CARD_PLAN.md, fully shipped)" section summarises the seven-phase end-to-end flow so a future session has the integration map without re-reading the plan. - Optional list gains the SVG-vs-layout aspect-ratio note as a cosmetic-only follow-up. Removed the locked worktree at .claude/worktrees/agent-aa55a94d18c669d70 left behind by a prior Claude session. cargo build / clippy --workspace --all-targets -- -D warnings / test --workspace all green (960 passed, 0 failed, 9 ignored). |
||
|
|
924a1e2af7 |
feat(engine): card-theme picker in Settings → Cosmetic
Wires the runtime theme system (CARD_PLAN.md phases 1–7) into the
visible Settings UI so a player can switch between every theme
discovered by `ThemeRegistry` without restarting.
solitaire_data/src/settings.rs
Settings gains `selected_theme_id: String` (default "default"),
guarded by `#[serde(default = "default_theme_id")]` so existing
settings.json files deserialize cleanly.
solitaire_engine/src/settings_plugin.rs
- SettingsButton::SelectTheme(String) variant + focus order 85.
- sync_settings_panel_visibility now reads
Option<Res<ThemeRegistry>>, snapshots id+display_name pairs, and
threads them into spawn_settings_panel. When the registry is
absent (tests under MinimalPlugins) the picker silently skips —
every existing test continues to pass unchanged.
- theme_picker_row helper: like picker_row but keyed by String
rather than usize, with chips wide enough for theme display
names. Attaches the canonical tooltip ("Choose card-face
artwork. Imported themes appear here.") and the FocusRow marker
so Left/Right arrows cycle within the row.
- Click handler updates settings.selected_theme_id, persists, and
fires SettingsChangedEvent — same shape as every other picker.
solitaire_engine/src/theme/plugin.rs
- load_default_theme renamed to load_initial_theme; reads
SettingsResource on Startup and seeds ActiveTheme from
settings.selected_theme_id (falling back to embedded default).
- react_to_settings_theme_change watches SettingsChangedEvent,
no-ops when the active theme already matches, and otherwise
swaps ActiveTheme — the existing
sync_card_image_set_with_active_theme system then refreshes
every card sprite on the next AssetEvent::LoadedWithDependencies.
cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
|
||
|
|
a6b8348332 |
docs: refresh README + ARCHITECTURE for hayeah art + theme system
Updates the prose mentions of card-face provenance to point at
hayeah/playing-cards-assets (MIT) instead of xCards (LGPL-3.0), in
sync with the upstream art swap (
|
||
|
|
b98cb8a99f |
feat(assets): swap card art to hayeah/playing-cards-assets (MIT)
Replaces the previous xCards-derived card faces (LGPL-3.0) with
hayeah/playing-cards-assets, which itself derives from the
public-domain vector-playing-cards Google Code project. The whole
package is MIT now — see CREDITS.md for the new attribution table
and the simpler license summary.
solitaire_engine/assets/themes/default/
52 face SVGs (clubs/diamonds/hearts/spades × ace/2-10/jack/queen/
king) — copied from hayeah, renamed to the canonical
`{suit}_{rank}.svg` form `CardKey::manifest_name` produces. The
bundled default theme manifest references each by the same name.
back.svg — original midnight-purple-themed card back, hand-written
to match the project's design tokens (BG_BASE / BG_ELEVATED /
ACCENT_PRIMARY / ACCENT_SECONDARY). MIT, original work.
assets/cards/faces/{RANK}{SUIT}.png
52 PNGs regenerated from the new SVGs at 750-tall via resvg 0.47.
These remain the legacy backwards-compat path that
`card_plugin::load_card_images` reads at startup; once the runtime
theme system finishes loading the embedded default theme, the
CardImageSet's face handles are overwritten with the SVG-rendered
variants and these PNGs become moot. Keeping them in place avoids
a brief blank-card flash before the async theme load completes.
solitaire_engine/src/assets/sources.rs
embed_default_svg!() macro + DEFAULT_THEME_SVGS table that bundles
every face + the back into the binary at compile time via
include_bytes!. populate_embedded_default_theme now iterates the
table so the EmbeddedAssetRegistry is populated under the same
asset paths the manifest references.
CREDITS.md
License summary collapses from MIT + LGPL-3.0 + OFL-1.1 to MIT +
OFL-1.1 (the OFL still applies to FiraMono). The hayeah upstream
URL replaces the previously-blank xCards entry.
cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
|