diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 624d343..1d69396 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -1,8 +1,8 @@ # Solitaire Quest — Session Handoff -> Last updated: 2026-04-24 +> Last updated: 2026-04-25 > Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git -> Test count: **214 passing** (83 core + 54 data + 77 engine), `cargo clippy --workspace -- -D warnings` clean +> Test count: **222 passing** (83 core + 54 data + 85 engine), `cargo clippy --workspace -- -D warnings` clean --- @@ -140,19 +140,27 @@ All sub-phases (3A–3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin - `ChallengePlugin` advances the cursor on Challenge-mode wins, persists, fires `ChallengeAdvancedEvent`. **X** key starts a Challenge-mode game with the current seed. - Both **Z** (Zen) and **X** (Challenge) are gated to `level >= CHALLENGE_UNLOCK_LEVEL` (5). +### Phase 6 (part 4c) — Time Attack + Unlock UI ✅ COMPLETE + +- `GameMode::TimeAttack` variant added to core (no scoring/undo changes — just a session marker). +- `TimeAttackPlugin` (engine) — `TimeAttackResource { active, remaining_secs, wins }` (session-only, not persisted), `TimeAttackEndedEvent { wins }`. **T** starts a session (gated to level ≥ 5) and deals a TimeAttack-mode game; the timer (`TIME_ATTACK_DURATION_SECS = 600.0`) decrements each frame; wins during the active session bump the counter and auto-deal a fresh game. +- `AnimationPlugin` surfaces `TimeAttackEndedEvent` as a 5-second summary toast. +- `StatsPlugin` overlay (**S**) appends an "Unlocks" subsection (card backs / backgrounds, sorted/deduped, "None" when empty) and a live "Time Attack" panel showing remaining minutes/seconds + wins while a session is active. +- Helper `format_id_list` factored out + tested. + ## What Is Next -### Phase 6 (part 4c) — Time Attack + Unlock UI +### Phase 7 — Audio + Polish -- **Time Attack mode**: 10-minute countdown, auto-deal a fresh game on win, score = total wins; on timer expiry show summary. Likely needs a `TimeAttackResource { remaining: f32, wins: u32 }` and a system that decrements `remaining` and ends the session. -- **Card-back / background unlock UI** for `unlocked_card_backs` / `unlocked_backgrounds`. Achievement rewards already populate these vecs via the persisted `AchievementRecord.reward_granted` flag — UI just needs to surface what's available. +- Audio (`kira`): card deal/flip/place/invalid SFX, win fanfare, ambient loop. Volume sliders in a Settings overlay. +- Onboarding: first-run hint overlay (rules summary + key list). +- Pause menu (Esc currently logs a placeholder). - Optional: ChallengeAdvancedEvent → toast in `AnimationPlugin`. -### Phases 7–8 (in order after Phase 6 part 4c) +### Phase 8 — Sync | Phase | Scope | |---|---| -| Phase 7 | Audio (`kira`), polish, hints, onboarding, pause menu | | Phase 8A–C | Local storage + `SyncProvider` + self-hosted Axum server + client | | Phase 8D | GPGS stub fully wired into settings UI | diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index c7fc7a0..6305190 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,7 +1,8 @@ use bevy::prelude::*; use solitaire_engine::{ AchievementPlugin, AnimationPlugin, CardPlugin, ChallengePlugin, DailyChallengePlugin, - GamePlugin, InputPlugin, ProgressPlugin, StatsPlugin, TablePlugin, WeeklyGoalsPlugin, + GamePlugin, InputPlugin, ProgressPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, + WeeklyGoalsPlugin, }; fn main() { @@ -27,5 +28,6 @@ fn main() { .add_plugins(DailyChallengePlugin) .add_plugins(WeeklyGoalsPlugin) .add_plugins(ChallengePlugin) + .add_plugins(TimeAttackPlugin) .run(); } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 573642e..f5506da 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -22,12 +22,16 @@ pub enum DrawMode { /// - `Zen`: scoring suppressed (stays at 0); undo allowed; intended for relaxed play. /// - `Challenge`: standard scoring, **undo disabled** (returns /// `MoveError::RuleViolation`). +/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute +/// countdown around the session and auto-deals a fresh game on every win +/// (see `solitaire_engine::TimeAttackPlugin`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum GameMode { #[default] Classic, Zen, Challenge, + TimeAttack, } /// Snapshot of game state used for undo. diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 87f25bc..2b382e8 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -12,6 +12,7 @@ use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::progress_plugin::LevelUpEvent; +use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent; /// Duration of a card slide (move) animation in seconds. @@ -22,6 +23,7 @@ const ACHIEVEMENT_TOAST_SECS: f32 = 3.0; const LEVELUP_TOAST_SECS: f32 = 3.0; const DAILY_TOAST_SECS: f32 = 3.0; const WEEKLY_TOAST_SECS: f32 = 3.0; +const TIME_ATTACK_TOAST_SECS: f32 = 5.0; const CASCADE_STAGGER: f32 = 0.05; const CASCADE_DURATION: f32 = 0.5; @@ -59,6 +61,7 @@ impl Plugin for AnimationPlugin { .add_event::() .add_event::() .add_event::() + .add_event::() .add_systems( Update, ( @@ -68,6 +71,7 @@ impl Plugin for AnimationPlugin { handle_levelup_toast, handle_daily_toast, handle_weekly_toast, + handle_time_attack_toast, tick_toasts, ) .after(GameMutation), @@ -181,6 +185,19 @@ fn handle_weekly_toast( } } +fn handle_time_attack_toast( + mut commands: Commands, + mut events: EventReader, +) { + for ev in events.read() { + spawn_toast( + &mut commands, + format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }), + TIME_ATTACK_TOAST_SECS, + ); + } +} + fn tick_toasts( mut commands: Commands, time: Res