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>
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>
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>
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>
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>
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>
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>
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).
Server-side endpoint tests already exist in solitaire_server. This
adds the client-side counterpart: five integration tests in
solitaire_data/tests/sync_round_trip.rs that drive
SolitaireServerClient against an in-process axum::serve harness with
an in-memory SQLite database, covering:
- register_login_push_pull_round_trip — happy path: register, push
non-default stats, pull from a fresh client, assert the merged
payload reflects the pushed values
- pull_after_concurrent_pushes_merges_correctly — two clients on one
user push different games_played values, verify the server-side
merge returns the max
- unauthenticated_pull_returns_authentication_error — pull without
tokens surfaces SyncError::Auth as expected
- jwt_refresh_on_401_succeeds — replace the access token with one
whose exp is two hours stale (same signing key), pull triggers
401 → /api/auth/refresh → retry, asserts the call ultimately
succeeds
- pull_after_account_deletion_returns_default_or_error — register,
push, delete via the trait, confirm the next push surfaces a
result rather than panicking
keyring_core's mock store is installed once per process via Once;
each test uses a unique username so the shared store doesn't
cross-contaminate. Production code in sync_client.rs needed no
changes — the Box<dyn SyncProvider> design plus the mock keyring
were sufficient to drive every flow from outside.
solitaire_server is added as a path dev-dependency along with the
direct crates the harness needs (axum, sqlx, jsonwebtoken, uuid,
chrono, solitaire_sync); no runtime deps changed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Conservative cleanup pass — applied only the high-signal pedantic
lints whose fixes either remove genuine waste or read more naturally,
skipping anything stylistic that would bloat the diff.
- map_unwrap_or: 29 .map(...).unwrap_or(...) sites collapsed to
.map_or / .is_some_and / .map_or_else equivalents
- uninlined_format_args: 7 production format!/write!/println! sites
rewritten to the inline-argument style; assert! sites in test code
intentionally untouched
- match_same_arms: 2 redundant arms collapsed where the bodies were
identical and the merger didn't obscure intent
Public API is unchanged. No dependencies added or removed. The
pedantic warning count dropped from 840 to 807 (-33). Out-of-scope
findings — needless_pass_by_value on Bevy Res params, false-positive
explicit_iter_loop on Bevy Query iterators, items_after_statements
inside test mods, and the "ask before changing" merge logic in
solitaire_sync — were intentionally deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings gains an optional window_geometry field (size + position)
serialized via #[serde(default)] so legacy settings.json files without
the field deserialize cleanly to None. On launch the app restores
the persisted dimensions and position; first run and pre-upgrade
saves keep the existing 1280x800 centered default.
settings_plugin records changes from WindowResized and WindowMoved
into a PendingWindowGeometry resource and writes them to disk through
the existing atomic .tmp+rename path once the events have stayed
quiet for WINDOW_GEOMETRY_DEBOUNCE_SECS (0.5s). A merge_geometry
helper preserves whichever component (size or position) the latest
event burst didn't carry, so a position-only WindowMoved never wipes
the recorded size.
Pure should_persist_geometry and merge_geometry helpers are unit
tested for the boundary cases. Headless integration tests cover the
full flow: a single resize event then a quiet window persists, a
move event after a resize updates only position, a rapid storm
collapses to the final size, and a quiet frame with no events
leaves the geometry untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnimSpeed is a fieldless enum; adding Copy lets `scaled_duration`
and other helpers take it by value through `&AnimSpeed` deref
without requiring a `.clone()` at every call site. Prerequisite
for the upcoming animation token-routing work.
Art pass (Phase 4):
- Generate placeholder PNG assets: face.png, back_0–4.png, bg_0–4.png via
solitaire_assetgen gen_art binary (16×16 RGBA, embedded via include_bytes!)
- Add FiraMono-Medium font (assets/fonts/main.ttf) embedded at compile time
- Add FontPlugin: loads font at startup, exposes FontResource; gracefully
falls back to default handle when Assets<Font> absent (MinimalPlugins tests)
- Wire CardImageSet into card_plugin: face/back PNGs replace solid-colour
sprites when available; tests continue using colour fallback via MinimalPlugins
- Wire BackgroundImageSet into table_plugin: bg PNGs replace solid-colour
background; empty set inserted when Assets<Image> absent in tests
- Fix hint highlight system (input_plugin): tint sprite.color directly instead
of replacing the whole Sprite (which would discard the image handle)
- Export FontPlugin, FontResource, CardImageSet from solitaire_engine::lib
- Register FontPlugin in solitaire_app before other plugins
Dependency upgrades (latest releases):
- keyring "2" → keyring "4" + keyring-core "1" (v4 split architecture into
separate core library crate)
- auth_tokens.rs: Entry::new now returns Result; delete_password →
delete_credential; NoDefaultStore error variant handled
- solitaire_app: add keyring::use_native_store(true) at startup for Linux
Secret Service / macOS Keychain / Windows Credential Store selection
ARCHITECTURE.md: fix Edition 2025→2021, update asset pipeline section,
add FontPlugin/CardImageSet/BackgroundImageSet to plugin and resource tables,
update Section 14 to reflect actual include_bytes!() rendering approach,
add Decision Log entries for embedded PNG and font decisions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deletes the solitaire_gpgs crate and all GPGS references from settings,
sync client, profile plugin, CLAUDE.md, and ARCHITECTURE.md. The
self-hosted server covers all sync needs without the Android-only backend.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pkg/solitaire-quest/PKGBUILD: builds solitaire_app binary, depends on
alsa-lib, libxkbcommon, systemd-libs (Bevy Linux requirements); check()
runs only non-Bevy crates (solitaire_core, solitaire_sync) since Bevy
integration tests require a GPU/display unavailable in chroot
- pkg/solitaire-quest-server/PKGBUILD: builds solitaire_server binary,
installs systemd service unit and hardened environment file template
- pkg/solitaire-quest-server/solitaire-quest-server.service: systemd unit
with ProtectSystem=strict, NoNewPrivileges, dedicated service user
- pkg/solitaire-quest-server/server.env: documented env template installed
to /etc/solitaire-quest-server/server.env (mode 0640, listed in backup=)
- LICENSE: add MIT license
- Cargo.toml: add license = "MIT" to [workspace.package]
- All member crates: add license.workspace = true
Both PKGBUILDs follow the Arch Rust package guidelines:
prepare() uses --locked + cargo fetch
build() uses --frozen --release -p <crate>
RUSTUP_TOOLCHAIN=stable and CARGO_TARGET_DIR=target set in each stage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
(all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
on normal draws; add move_count_increments_on_recycle regression test
P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
(solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)
P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
new_game_confirm_window_is_positive
P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing
P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order
P3 — documentation:
- Add struct-level /// to 12 Plugin structs (ChallengePlugin, CursorPlugin,
AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win
card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves 15 violations found by `cargo clippy --workspace --tests -D warnings`:
- Remove unused imports (Card, Rank) in cursor_plugin tests
- Replace absurd i32::MAX comparison with a meaningful >= 0 check
- Use range .contains() instead of manual >= && <= (manual_range_contains)
- Move impl FromRequestParts before test module in middleware.rs (items_after_test_module)
- Move _VEC3_REFERENCED const before test module in input_plugin.rs
- Convert runtime assert on constant to const { assert!(...) }
- Use .contains() instead of .iter().any() for slice membership
- Replace .get(...).is_none() with !.contains_key(...) in HashMap checks
- Collapse Default::default() + field assignment into struct literal initializers
across solitaire_sync, solitaire_data, and solitaire_engine test helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add FeedbackAnimPlugin with three card feedback animations:
- #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting
destination pile cards; 0.3 s damped sine wave
- #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent);
1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards
- #69 Deal animation: slides each card from stock position to its deal
position on NewGameRequestEvent (move_count == 0), using existing
CardAnim with 0.04 s per-card stagger
Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay
are public and covered by 6 unit tests. Fix pre-existing compile/clippy
errors: stubbed handle_confirm_input/handle_game_over_input, removed dead
CycleCardBack/CycleBackground variants, annotated ambient_handle field,
and fixed draw_mode.clone() in pause_plugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.
Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).
Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).
Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.
Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.
Task #39: Daily challenge HUD constraint label (time limit / target score).
Task #40: Undo-count HUD label; amber colour when undos > 0.
Task #44: Win-streak and level line on pause screen.
Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.
Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.
Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task #34: CardFlipAnim component + start_flip_anim/tick_flip_anim systems animate revealed
cards by squashing scale.x to 0 then expanding back to 1 (2×0.08 s). Skipped at Instant speed.
Task #35: spawn_pile_markers now adds a Text2d child (S/H/D/C, 45% alpha) on Foundation
markers so the suit is visible while the pile is empty.
Task #43: Tableau pile markers get a "K" Text2d child (35% alpha) indicating only Kings land
on empty columns.
Task #38: update_drag_shadow system maintains a single ShadowEntity while dragging — a
card_w+8 × card_h+8 dark semi-transparent sprite at z−1 behind the top dragged card.
Also fixed pre-existing clippy/compiler errors in hud_plugin, pause_plugin, stats_plugin,
cursor_plugin, and settings_plugin (missing imports, too-many-arguments, doc formatting).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two new weekly goals — "Win 1 game in under 5 minutes" and
"Win 1 Draw-3 game" — broadening variety beyond the existing three.
WeeklyGoalContext gains a draw_mode field so the new WinDrawThree
variant can match on draw mode. Existing tests updated to pre-complete
new goals where the win conditions overlap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 4 more rounds of 5 seeds each, organized by difficulty tier.
Seeds wrap modulo list length so the pool grows non-destructively.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds delete_account() as a default no-op on the SyncProvider trait.
SolitaireServerClient sends DELETE /api/account with JWT (retry on 401).
The server handler already existed (DELETE /api/account, ON DELETE CASCADE).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add fetch_daily_challenge() to SyncProvider trait (default: Ok(None))
- SolitaireServerClient calls GET /api/daily-challenge (public endpoint)
and returns the ChallengeGoal; non-2xx responses return Ok(None) so
callers fall back to the local date-hash seed
- DailyChallengePlugin spawns an async task on Startup (only when
SyncProviderResource is present) and polls it in Update; on success
it overwrites DailyChallengeResource.seed with the server's seed,
ensuring all players worldwide get the same deal on a given date
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PullTask and PullTaskResult now carry Result<SyncPayload, SyncError>
instead of Result<SyncPayload, String>. poll_pull_result pattern-matches
on the error variant to show user-friendly messages:
Network → "Can't reach server — check your connection"
Auth → "Login expired — tap Sync Now after re-logging in"
Other → original error Display
Also removed the stale TODO comment from SyncError in lib.rs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Reward enum to solitaire_core with CardBack/Background/BonusXp/Badge variants
- Wire rewards into ALL_ACHIEVEMENTS per architecture spec
- evaluate_on_win now applies rewards on first unlock: pushes cosmetic
indices into PlayerProgress, awards BonusXp (with level-up detection),
and marks reward_granted = true so rewards are never double-granted
- Add selected_card_back / selected_background fields to Settings
- Settings panel grows Card Back and Background cycle rows, shown only
when the player has unlocked more than the default (index 0)
- cycle_unlocked() cycles only through earned options
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Press L to open a leaderboard overlay. On open, an async fetch is
dispatched against the active SyncProvider. Results cache in
LeaderboardResource; the panel rebuilds live when data arrives.
Closing during a fetch is safe (ClosedThisFrame flag prevents
re-spawning the panel in the same frame as the user's despawn command).
Format: ranked table with player name, best score, and fastest win time.
Non-authenticated / LocalOnly providers return an empty list gracefully.
- solitaire_data: add fetch_leaderboard() to SyncProvider trait
(default → Ok([])) and implement in SolitaireServerClient
- solitaire_engine: new LeaderboardPlugin with 5 unit tests
- solitaire_app: register LeaderboardPlugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Save game_state.json on app exit and on pause open so players can
resume interrupted sessions. Delete the file on win, loss, or new-game
start. Restore the saved game on launch if it exists and isn't won.
- solitaire_core: add pile_map_serde module so HashMap<PileType,Pile>
round-trips through JSON (serialized as Vec of pairs)
- solitaire_data: add game_state_file_path, load_game_state_from,
save_game_state_to, delete_game_state_at with 8 new unit tests
- solitaire_engine/GamePlugin: restore saved game on startup, expose
GameStatePath resource, save on AppExit, delete on new-game and win
- solitaire_engine/PausePlugin: save on pause open (guards against
OS-level kills while the overlay is showing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily
challenge, account deletion, JWT middleware, rate limiting via
tower_governor, SQLite migrations, health endpoint
- solitaire_server: expose build_test_router (no rate limiting) so
integration tests work without a peer IP in oneshot requests
- solitaire_sync: SyncPayload, merge logic, shared API types
- solitaire_data: SyncProvider trait, LocalOnlyProvider,
SolitaireServerClient, auth_tokens keyring integration, blanket
Box<dyn SyncProvider> impl
- solitaire_data/settings: derive Default on SyncBackend (clippy fix)
- .sqlx/: offline query cache so server compiles without a live DB
- sqlx: removed non-existent "offline" feature flag
- keyring v2: fixed Entry::new() returning Result<Entry>
- sqlx 0.8: all SQLite TEXT columns wrapped in Option<T>
- Integration tests: max_connections(1) on in-memory pool so all
connections share the same schema
All 191 tests pass; cargo clippy -D warnings clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- solitaire_data::Settings { sfx_volume, first_run_complete } with
atomic JSON persistence and clamping sanitizer.
- SettingsPlugin (engine): [ / ] adjust SFX volume by 0.1, clamped;
persists on change; emits SettingsChangedEvent. No-op at rails.
- AudioPlugin applies sfx_volume to kira's main track at startup
and on every change so live tweaks take effect without restart.
- Brief "SFX: N%" toast on each change. Help cheat sheet updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 6 part 4b (partial):
- GameMode::Challenge variant in solitaire_core. undo() returns
RuleViolation when mode is Challenge so the player commits to each
decision.
- solitaire_data::challenge defines a stable CHALLENGE_SEEDS list with
challenge_seed_for(index) wrapping modulo length.
- PlayerProgress.challenge_index (serde-default for older saves) tracks
how far the player has progressed.
- ChallengePlugin advances the cursor on Challenge-mode wins, persists,
and emits ChallengeAdvancedEvent. Pressing X starts a Challenge-mode
game with the current seed; gated to level >= CHALLENGE_UNLOCK_LEVEL (5).
- InputPlugin's Z key (Zen mode) is now also gated to level >= 5.
Time Attack and unlock UI still deferred.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 6 part 2b:
- solitaire_data::weekly defines WeeklyGoalKind, WeeklyGoalDef,
WeeklyGoalContext, current_iso_week_key, and three starter goals
(5 wins, 3 no-undo wins, 3 fast wins).
- PlayerProgress gains weekly_goal_week_iso, roll_weekly_goals_if_new_week,
and record_weekly_progress (returns true exactly once per goal completion).
- WeeklyGoalsPlugin evaluates GameWonEvent against WEEKLY_GOALS, rolls the
week if needed, increments matching counters, awards WEEKLY_GOAL_XP for
newly-completed goals, persists progress, and fires
WeeklyGoalCompletedEvent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 6 part 2 (partial):
- daily_seed_for(date) and PlayerProgress::record_daily_completion in
solitaire_data, with streak logic that increments on consecutive days,
resets on a skipped day, and is idempotent on same-day re-completions.
- DailyChallengePlugin tracks today's seed, awards +100 XP and updates
the streak when the player wins a game whose seed matches. Pressing C
starts a new game with the daily seed.
- LevelUpEvent toast in AnimationPlugin announces level changes.
- AchievementContext gains daily_challenge_streak; daily_devotee
achievement unlocks at streak >= 7. AchievementPlugin reads
ProgressResource and runs after ProgressUpdate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
level_for_xp implements the two-segment level formula from
ARCHITECTURE.md §13. xp_for_win = base 50 + linearly-scaled speed bonus
(10..=50 for sub-2-minute wins) + 25 if no undo was used. PlayerProgress
exposes add_xp returning the previous level so callers can detect
level-up events.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>