The Reward::BonusXp path in evaluate_on_win was adding XP directly
without sending XpAwardedEvent, so players saw no "+25 XP" toast when
the no_undo achievement first unlocked. Adds the missing event send
and a regression test verifying the event fires on unlock.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Daily challenge completions (+100 XP) and weekly goal bonuses (+75 XP)
now fire XpAwardedEvent so the player sees a "+N XP" toast — consistent
with the post-win XP toast already shown by ProgressPlugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously every SettingsChangedEvent fired an "SFX: X%" toast, even
when the music volume was adjusted in the Settings panel. Now the toast
handler tracks the previous SFX and music levels independently and only
shows a toast for whichever value actually changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Face-down cards in tableau columns now use a TABLEAU_FACEDOWN_FAN_FRAC
of 0.12 instead of the full 0.25, so the visible face-up portion of
each column is easier to read at a glance — matching standard Klondike
rendering where only the playable cards are prominently spread.
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>
Zen mode is intended for relaxed play with no performance pressure.
The HUD now clears score and elapsed-time displays when GameMode::Zen
is active, matching the ARCHITECTURE.md spec ("No timer. No score
display."). The mode badge still shows "ZEN" so the player knows
which mode they're in.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ProgressPlugin now fires XpAwardedEvent on every win. AnimationPlugin
shows a "+N XP" toast so players see XP feedback immediately after
winning. Server leaderboard opt-in endpoint also now validates that
display_name is at most 32 characters.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces silent info!() log calls with on-screen toasts when the player
presses Z/X/T without reaching the required unlock level. Any system
can now fire InfoToastEvent(message) to surface a brief text overlay
without depending on a specific plugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pressing N during an active game (move_count > 0, not won) now shows a
"Press N again to start a new game" toast and only starts a new game if
N is pressed a second time within 3 seconds. Starting a fresh game or
pressing N after a win still acts immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a move exposes a face-down tableau card, game_plugin now fires
CardFlippedEvent carrying the flipped card's id. AudioPlugin listens
and plays card_flip.wav so the reveal has satisfying audio feedback.
Two unit tests verify the event fires only when needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each achievement row now displays:
- "Reward: Card Back #N / Background #N / +N XP / Badge" (green, all entries)
- "Unlocked YYYY-MM-DD" (dim, unlocked entries only)
Helps players understand what they earn and when they earned it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Classic + DrawOne shows nothing (clean default). Classic + DrawThree shows
a "Draw 3" badge in the mode position so the player always knows their
current game variant without opening the stats or settings screen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds "Next Level: N XP (P%)" line so players can see how far they are
through the current level without doing the arithmetic themselves.
Tested with three unit tests covering level 0, mid-level, and level 10.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
During Time Attack sessions the time display updates every frame with the
remaining countdown (from TimeAttackResource) rather than the per-second
game clock tick, giving the player a live countdown. Non-Time-Attack mode
is unchanged — the clock still updates once per second via game state change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
"You Win!" now displays "You Win! Score: N Time: M:SS" so the player
sees their result without opening the stats screen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inner card capped at 88% of window height with Overflow::clip_y so
the panel stays on-screen even when many rows are present. Matches the
same approach used by the leaderboard panel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stats screen (S key) now shows "Challenge: N / total" in the Progression
section so players can see how far they've advanced through the challenge
seed list without leaving the screen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds HudPlugin with a persistent top-left overlay that shows score,
move count, and elapsed time during every game. A mode badge highlights
DAILY, CHALLENGE, ZEN, or TIME ATTACK when the game is not in Classic
mode. HUD updates whenever GameStateResource changes (moves and per-second
time ticks) without a separate polling system.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Card backs: selected_card_back index maps to distinct Color values in card rendering
- Backgrounds: selected_background index applied in TablePlugin alongside theme
- Both re-render immediately on SettingsChangedEvent
- Stats screen now shows Games Lost, Draw 1/3 Wins, and Lifetime Score
- Daily challenge win no longer credited if server-supplied target_score or max_time_secs constraints are not met
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
evaluate_weekly_goals discarded the return value of add_xp(bonus_xp),
so a level-up triggered by a weekly goal completion never fired
LevelUpEvent — the level-up toast and mode-unlock at L5 were silently
skipped. Now captures prev_level, checks leveled_up_from(), and sends
LevelUpEvent matching the pattern used by progress_plugin.
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>
evaluate_weekly_goals only ran the roll on GameWonEvent, so stale goal
progress from a prior week would linger until the player's next win.
A new Startup system calls roll_weekly_goals_if_new_week on launch and
persists immediately when the week has rolled over.
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>
The help overlay only listed S and H; added Achievements (A),
Leaderboard (L), and Settings (O) which were already implemented
but undocumented in the cheat sheet.
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>
Adds a full-screen overlay listing all achievements with unlock status.
Unlocked achievements show in gold with a check mark; locked ones are
greyed out. Secret achievements that are still locked are hidden. Header
shows unlocked/total count. Press A again to dismiss.
Two new unit tests: spawns on first A press, dismisses on second.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When is_auto_completable flips true (stock/waste empty, all cards
face-up), AutoCompletePlugin fires MoveRequestEvent every 120 ms,
driving cards to the foundation one at a time without player input.
An "Auto-completing…" toast announces the sequence.
- solitaire_core: add next_auto_complete_move() to GameState with
3 new unit tests
- solitaire_engine: new AutoCompletePlugin with detect + drive systems
and 4 unit tests; animation_plugin shows one-shot toast on activation
- solitaire_app: register AutoCompletePlugin
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>
- Added ManualSyncRequestEvent to events.rs (exported from lib.rs).
- SyncPlugin now handles ManualSyncRequestEvent: if no pull is in
flight, spawns a new AsyncComputeTaskPool task and sets status to
Syncing. Ignores duplicate requests while a pull is active.
- Settings panel "Sync" section now shows the status text alongside a
"Sync Now" button that fires ManualSyncRequestEvent.
- Cleaned up stale doc comment in input_plugin.rs (Esc pause note).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Settings panel now shows a "Sync" section with a live status line:
- "Status: idle" (no SyncPlugin installed)
- "Status: syncing…" (pull in progress)
- "Last synced: Xs ago" (successful pull)
- "Sync error: <msg>" (failed pull)
The text is snapshotted from SyncStatusResource when the panel opens and
updated reactively via update_sync_status_text whenever SyncStatusResource
changes while the panel is visible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Settings panel "coming soon" stubs replaced with live controls:
- Draw Mode toggle (Draw 1 / Draw 3): new games read draw_mode from
SettingsResource instead of the previous game's mode. Falls back to
the current game's mode in headless/test contexts where SettingsPlugin
is absent.
- Theme selector (Green → Blue → Dark → Green): SettingsChangedEvent
drives TablePlugin's background Sprite colour so the table re-colours
immediately without a restart.
- Music Volume [−]/[+]: dedicated kira sub-tracks created for SFX and
music on startup. SFX sounds are routed to the SFX track; the music
track exists for future ambient audio. Both volumes are set on
SettingsChangedEvent and at startup.
Also fixed: time_attack timer_expiry test double-fires when
MinimalPlugins time delta is nonzero — removed the intermediate
0.001-remaining update step.
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>
OnboardingPlugin spawns a centered welcome banner at PostStartup
when Settings.first_run_complete is false. Any key or mouse
press dismisses it, sets the flag, and persists settings.json
so returning players never see it again.
Co-Authored-By: Claude Opus 4.7 <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>
- New MoveRejectedEvent fires from end_drag when the cursor is over
a real pile but the placement is illegal. AudioPlugin plays
card_invalid.wav on it.
- New PausePlugin + PausedResource: Esc toggles a full-window
overlay and the flag. tick_elapsed_time and advance_time_attack
skip work while paused. Help cheat sheet updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- New solitaire_assetgen crate with gen_sfx binary: synthesizes
five 44.1kHz mono 16-bit PCM WAVs (flip/place/deal/invalid/fanfare)
from an LCG noise source + sine/square synths. Output committed
under assets/audio/.
- AudioPlugin (engine): embeds the WAVs via include_bytes!, decodes
once with kira::StaticSoundData, plays on Draw / Move / NewGame /
GameWon events. card_invalid is loaded but unused — wiring it
needs a MoveRejectedEvent.
- AudioManager kept on the main thread (NonSend) since cpal is !Send
on some platforms; degrades gracefully if no audio device present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- HelpPlugin: full-window cheat sheet listing every keybinding,
toggled with H or ?. Three unit tests cover open/close/slash.
- AnimationPlugin: ChallengeAdvancedEvent now surfaces as a
3-second "Challenge N cleared!" toast.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Core: GameMode::TimeAttack variant (no scoring/undo changes — session marker only)
- Engine: TimeAttackPlugin with TimeAttackResource, TimeAttackEndedEvent,
T hotkey (gated to level >= 5), auto-deal on win, summary toast
- Engine: Stats overlay (S) gains an Unlocks subsection (card backs /
backgrounds, sorted/deduped) and a live Time Attack panel while active
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 4 (partial):
- GameState now tracks elapsed_seconds via tick_elapsed_time in GamePlugin
(per-second increment while not won). Pure helper advance_elapsed makes
the tick logic directly testable without mocking Bevy Time.
- New GameMode enum (Classic / Zen) on GameState. Zen mode suppresses
scoring in move_cards and undo. GameState::new_with_mode allows callers
to construct non-Classic games; the existing GameState::new still
defaults to Classic. mode is serde(default) for backwards-compatible
persistence.
- NewGameRequestEvent gains an optional mode field; handle_new_game
honours it (falling back to the current game's mode when None).
- InputPlugin: pressing Z starts a fresh Zen-mode game.
Time Attack, Challenge mode, level-5 unlock gating, and unlock UI are
still deferred.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 6 part 3 (partial):
- AnimationPlugin now shows a 3-second toast on DailyChallengeCompletedEvent
and WeeklyGoalCompletedEvent.
- Stats overlay (S key) appends a Progression section with level, total XP,
daily streak, and a live Weekly Goals list pulling from WEEKLY_GOALS.
Special modes (Time Attack / Challenge / Zen) and unlock UI 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>
On GameWonEvent, computes xp_for_win(time, used_undo) from
solitaire_data, calls PlayerProgress::add_xp, and emits LevelUpEvent
when the level changes. Persists atomically through the configurable
storage path; ProgressPlugin::headless() disables I/O for tests.
Introduces ProgressUpdate system set so future systems can run after
progress mutations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On GameWonEvent, build an AchievementContext from StatsResource + GameState
+ wall-clock hour, evaluate ALL_ACHIEVEMENTS, flip newly-satisfied records
to unlocked, persist atomically, and emit AchievementUnlockedEvent for
each new unlock. AnimationPlugin's toast resolves the event's ID to the
achievement's display name via achievement_plugin::display_name_for.
Introduces StatsUpdate system set so AchievementPlugin can reliably run
after StatsResource reflects the win. AchievementPlugin::headless() used
in tests to avoid touching ~/.local/share/solitaire_quest/achievements.json.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
StatsPlugin loads stats on startup, persists them on every GameWonEvent
and abandoned NewGameRequestEvent (>=1 move, not won), and provides a
full-window overlay toggled with `S` showing games played/won, win rate,
streak, best score, fastest win, and average win time.
The storage path is configurable via StatsPlugin::storage_path: the
default ctor uses dirs::data_dir(); StatsPlugin::headless() disables
I/O entirely so tests don't read or overwrite the user's real
stats.json. record_abandoned runs before GameMutation so it reads
move_count before handle_new_game clobbers it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>