From ddd7502a06feabac2af8f4edc3d3841f13028b63 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 27 Apr 2026 19:11:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20playability=20improvements=20?= =?UTF-8?q?=E2=80=94=20input=20intelligence,=20audio,=20HUD,=20onboarding?= =?UTF-8?q?=20(#27=E2=80=93#30,=20#37,=20#39=E2=80=93#40,=20#44,=20#48?= =?UTF-8?q?=E2=80=93#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solitaire_app/src/main.rs | 7 +- solitaire_core/src/achievement.rs | 42 +++ solitaire_core/src/game_state.rs | 125 +++++++ solitaire_core/src/rules.rs | 13 +- solitaire_data/src/stats.rs | 26 ++ solitaire_engine/src/achievement_plugin.rs | 25 ++ solitaire_engine/src/audio_plugin.rs | 124 ++++++- solitaire_engine/src/events.rs | 6 + solitaire_engine/src/game_plugin.rs | 240 ++++++++++++- solitaire_engine/src/input_plugin.rs | 379 ++++++++++++++++++++- solitaire_engine/src/onboarding_plugin.rs | 119 +++++-- solitaire_engine/src/progress_plugin.rs | 21 ++ solitaire_server/src/auth.rs | 59 ++++ solitaire_server/src/challenge.rs | 20 ++ solitaire_server/tests/server_tests.rs | 87 ++++- solitaire_sync/src/merge.rs | 22 ++ 16 files changed, 1269 insertions(+), 46 deletions(-) diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 3c91bdf..3934090 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -2,9 +2,9 @@ use bevy::prelude::*; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_engine::{ AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin, - ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, HudPlugin, InputPlugin, - LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, - SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, + ChallengePlugin, CursorPlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, HudPlugin, + InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, + StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, }; fn main() { @@ -29,6 +29,7 @@ fn main() { .add_plugins(GamePlugin) .add_plugins(TablePlugin) .add_plugins(CardPlugin) + .add_plugins(CursorPlugin) .add_plugins(InputPlugin) .add_plugins(AnimationPlugin) .add_plugins(AutoCompletePlugin) diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs index 9ee4345..881d77e 100644 --- a/solitaire_core/src/achievement.rs +++ b/solitaire_core/src/achievement.rs @@ -577,4 +577,46 @@ mod tests { let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); assert!(!ids.contains(&"speed_demon")); } + + #[test] + fn check_achievements_returns_multiple_when_conditions_met() { + // A context where first_win, on_a_roll, and no_undo all trigger at once. + let mut c = ctx(); + c.games_won = 1; + c.win_streak_current = 3; + c.last_win_used_undo = false; + c.last_win_time_seconds = 999; + + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"first_win"), "first_win should unlock"); + assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock"); + assert!(ids.contains(&"no_undo"), "no_undo should unlock"); + assert!(ids.len() >= 3, "at least 3 achievements must fire simultaneously"); + } + + #[test] + fn perfectionist_implies_no_undo_both_fire_together() { + // perfectionist requires !used_undo && score >= 5000, which is a strict + // superset of no_undo's condition. Both must appear in the result. + let mut c = ctx(); + c.games_won = 1; + c.last_win_used_undo = false; + c.last_win_score = 5_000; + c.last_win_time_seconds = 999; + + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"perfectionist"), "perfectionist must unlock"); + assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does"); + } + + #[test] + fn perfectionist_score_well_above_threshold_still_passes() { + let mut c = ctx(); + c.games_won = 1; + c.last_win_used_undo = false; + c.last_win_score = 50_000; + + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"perfectionist"), "score far above threshold must pass"); + } } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index c0d0b79..526a812 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -474,6 +474,63 @@ mod tests { assert_eq!(g.piles[&PileType::Stock].cards.len(), 21); } + #[test] + fn draw_three_partial_draw_when_fewer_than_three_remain() { + let mut g = GameState::new(42, DrawMode::DrawThree); + // Replace the stock with exactly 2 cards so the draw is a partial batch. + let two_cards: Vec = g.piles[&PileType::Stock].cards[..2].to_vec(); + g.piles.get_mut(&PileType::Stock).unwrap().cards = two_cards; + g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + + g.draw().unwrap(); + + assert_eq!(g.piles[&PileType::Waste].cards.len(), 2, "only 2 cards should move when stock has 2"); + assert!(g.piles[&PileType::Stock].cards.is_empty()); + } + + #[test] + fn draw_three_all_drawn_cards_are_face_up() { + let mut g = GameState::new(42, DrawMode::DrawThree); + g.draw().unwrap(); + assert!( + g.piles[&PileType::Waste].cards.iter().all(|c| c.face_up), + "all drawn cards must be face-up in waste" + ); + } + + #[test] + fn draw_three_undo_returns_all_cards_to_stock() { + let mut g = GameState::new(42, DrawMode::DrawThree); + let stock_before = g.piles[&PileType::Stock].cards.len(); + + g.draw().unwrap(); + assert_eq!(g.piles[&PileType::Waste].cards.len(), 3); + + g.undo().unwrap(); + assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before); + assert!(g.piles[&PileType::Waste].cards.is_empty()); + } + + #[test] + fn draw_three_recycle_restores_waste_to_stock_face_down() { + let mut g = GameState::new(42, DrawMode::DrawThree); + // Drain all 24 stock cards into waste via repeated draws. + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } + let waste_count = g.piles[&PileType::Waste].cards.len(); + assert!(waste_count > 0); + + // Recycle: drawing when stock is empty returns all waste cards to stock. + g.draw().unwrap(); + assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count); + assert!(g.piles[&PileType::Waste].cards.is_empty()); + assert!( + g.piles[&PileType::Stock].cards.iter().all(|c| !c.face_up), + "recycled cards must be face-down" + ); + } + #[test] fn draw_from_empty_stock_recycles_waste() { let mut g = new_game(); @@ -691,6 +748,43 @@ mod tests { assert_eq!(g.undo_count, u32::MAX, "undo_count must saturate at u32::MAX"); } + // --- Fields excluded from undo snapshot --- + + #[test] + fn undo_does_not_roll_back_elapsed_seconds() { + // elapsed_seconds tracks wall time and must be monotonic; undo must never + // reduce it, otherwise the time-bonus calculation would be gamed. + let mut g = new_game(); + g.elapsed_seconds = 120; + g.draw().unwrap(); + g.undo().unwrap(); + assert_eq!(g.elapsed_seconds, 120, "undo must leave elapsed_seconds unchanged"); + } + + #[test] + fn undo_does_not_roll_back_recycle_count() { + // recycle_count is a lifetime counter used for the 'comeback' achievement; + // rolling it back on undo would make the condition unachievable after recycling. + let mut g = new_game(); + // Drain stock and recycle to increment recycle_count. + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } + g.draw().unwrap(); // recycle + assert_eq!(g.recycle_count, 1); + // Now draw one more card and undo it. + g.draw().unwrap(); + g.undo().unwrap(); + assert_eq!(g.recycle_count, 1, "undo must leave recycle_count unchanged"); + } + + #[test] + fn undo_after_win_returns_game_already_won() { + let mut g = new_game(); + g.is_won = true; + assert_eq!(g.undo(), Err(MoveError::GameAlreadyWon)); + } + // --- Scoring --- #[test] @@ -753,6 +847,37 @@ mod tests { // fact that move_cards' score path is identical to Classic. } + // --- GameMode: TimeAttack --- + + #[test] + fn time_attack_mode_field_persists() { + let g = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::TimeAttack); + assert_eq!(g.mode, GameMode::TimeAttack); + } + + #[test] + fn time_attack_allows_undo() { + let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack); + g.draw().unwrap(); + // TimeAttack does not disable undo — only Challenge does. + assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode"); + } + + #[test] + fn time_attack_score_starts_at_zero() { + let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack); + assert_eq!(g.score, 0); + } + + #[test] + fn time_attack_draw_three_combination() { + // TimeAttack + DrawThree is a valid combination; verify construction. + let g = GameState::new_with_mode(7, DrawMode::DrawThree, GameMode::TimeAttack); + assert_eq!(g.mode, GameMode::TimeAttack); + assert_eq!(g.draw_mode, DrawMode::DrawThree); + assert_eq!(g.piles[&PileType::Stock].cards.len(), 24); + } + // --- Auto-complete --- #[test] diff --git a/solitaire_core/src/rules.rs b/solitaire_core/src/rules.rs index 4e19342..eaca819 100644 --- a/solitaire_core/src/rules.rs +++ b/solitaire_core/src/rules.rs @@ -21,7 +21,8 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool { match pile.cards.last() { None => card.rank.value() == 13, Some(top) => { - card.rank.value() + 1 == top.rank.value() + top.face_up + && card.rank.value() + 1 == top.rank.value() && card.suit.is_red() != top.suit.is_red() } } @@ -152,4 +153,14 @@ mod tests { let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]); assert!(!can_place_on_tableau(&c, &p)); } + + #[test] + fn tableau_face_down_destination_top_is_invalid() { + // A face-down top card must never be a valid placement target. + let c = card(Suit::Hearts, Rank::Nine); + let mut top = card(Suit::Spades, Rank::Ten); + top.face_up = false; + let p = pile_with(PileType::Tableau(0), vec![top]); + assert!(!can_place_on_tableau(&c, &p)); + } } diff --git a/solitaire_data/src/stats.rs b/solitaire_data/src/stats.rs index 3bba21d..4f661fd 100644 --- a/solitaire_data/src/stats.rs +++ b/solitaire_data/src/stats.rs @@ -152,4 +152,30 @@ mod tests { assert_eq!(s.draw_one_wins, 1); assert_eq!(s.draw_three_wins, 1); } + + #[test] + fn win_streak_best_never_decreases_after_shorter_subsequent_streak() { + let mut s = StatsSnapshot::default(); + // Build a streak of 5. + for _ in 0..5 { + s.update_on_win(100, 60, &DrawMode::DrawOne); + } + assert_eq!(s.win_streak_best, 5); + // Lose (abandon), resetting current. + s.record_abandoned(); + assert_eq!(s.win_streak_current, 0); + assert_eq!(s.win_streak_best, 5, "best must survive the loss"); + // Win once — current becomes 1, best must remain 5. + s.update_on_win(100, 60, &DrawMode::DrawOne); + assert_eq!(s.win_streak_current, 1); + assert_eq!(s.win_streak_best, 5, "best must not drop to match shorter streak"); + } + + #[test] + fn lifetime_score_saturates_at_u64_max() { + let mut s = StatsSnapshot::default(); + s.lifetime_score = u64::MAX - 100; + s.update_on_win(200, 60, &DrawMode::DrawOne); + assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow"); + } } diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 885582c..5884a3b 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -478,6 +478,31 @@ mod tests { ); } + #[test] + fn no_undo_achievement_does_not_fire_when_undo_was_used() { + let mut app = headless_app(); + // Simulate a win where the player used undo at least once. + app.world_mut() + .resource_mut::() + .0 + .undo_count = 1; + + app.world_mut().send_event(GameWonEvent { + score: 1000, + time_seconds: 300, + }); + app.update(); + + // "no_undo" awards BonusXp(25). If undo was used it must NOT fire. + let events = app.world().resource::>(); + let mut cursor = events.get_cursor(); + let xp_events: Vec = cursor.read(events).map(|e| e.amount).collect(); + assert!( + !xp_events.contains(&25), + "BonusXp(25) must not fire when undo_count > 0; got {xp_events:?}" + ); + } + fn press(app: &mut App, key: KeyCode) { let mut input = app.world_mut().resource_mut::>(); input.release(key); diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index 3c6d198..3127343 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -27,8 +27,9 @@ use kira::tween::Tween; use crate::events::{ CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, - NewGameRequestEvent, + NewGameRequestEvent, UndoRequestEvent, }; +use crate::pause_plugin::PausedResource; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; /// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`), @@ -54,6 +55,17 @@ pub struct AudioState { music_track: Option, } +/// Tracks which audio channels the player has silenced via the M / Shift+M shortcuts. +/// +/// These booleans override the `sfx_volume` / `music_volume` settings. When +/// `true`, the corresponding track is forced to 0. When toggled back to `false` +/// the volume is restored from `SettingsResource`. +#[derive(Resource, Default)] +pub struct MuteState { + pub sfx_muted: bool, + pub music_muted: bool, +} + pub struct AudioPlugin; impl Plugin for AudioPlugin { @@ -72,7 +84,8 @@ impl Plugin for AudioPlugin { None => (None, None), }; - app.insert_non_send_resource(AudioState { manager, sfx_track, music_track }); + app.insert_non_send_resource(AudioState { manager, sfx_track, music_track }) + .init_resource::(); let library = build_library(); if let Some(lib) = library { @@ -87,6 +100,7 @@ impl Plugin for AudioPlugin { .add_event::() .add_event::() .add_event::() + .add_event::() .add_event::() .add_systems( Startup, @@ -101,7 +115,9 @@ impl Plugin for AudioPlugin { play_on_new_game, play_on_win, play_on_card_flip, + play_on_undo, apply_volume_on_change, + handle_mute_keys, ), ); } @@ -168,16 +184,62 @@ fn apply_initial_volume( set_music_volume(&mut audio, music); } +fn play_on_undo( + mut events: EventReader, + mut audio: NonSendMut, + lib: Option>, +) { + let Some(lib) = lib else { return }; + for _ in events.read() { + play(&mut audio, &lib.flip); + } +} + fn apply_volume_on_change( mut events: EventReader, mut audio: NonSendMut, + mute: Option>, ) { for ev in events.read() { - set_sfx_volume(&mut audio, ev.0.sfx_volume); - set_music_volume(&mut audio, ev.0.music_volume); + let sfx_muted = mute.as_ref().is_some_and(|m| m.sfx_muted); + let music_muted = mute.as_ref().is_some_and(|m| m.music_muted); + set_sfx_volume(&mut audio, if sfx_muted { 0.0 } else { ev.0.sfx_volume }); + set_music_volume(&mut audio, if music_muted { 0.0 } else { ev.0.music_volume }); } } +/// `M` toggles mute for all audio; `Shift+M` toggles music only. +/// Volumes are restored from `SettingsResource` on unmute. +fn handle_mute_keys( + keys: Res>, + mut audio: NonSendMut, + mut mute: ResMut, + settings: Option>, + paused: Option>, +) { + if paused.is_some_and(|p| p.0) || !keys.just_pressed(KeyCode::KeyM) { + return; + } + let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight); + let (sfx_vol, music_vol) = settings + .as_ref() + .map(|s| (s.0.sfx_volume, s.0.music_volume)) + .unwrap_or((1.0, 0.5)); + + if shift { + // Shift+M: toggle music mute only, SFX unaffected. + mute.music_muted = !mute.music_muted; + } else { + // M: mute all if either channel is audible; unmute all otherwise. + let new_state = !(mute.sfx_muted && mute.music_muted); + mute.sfx_muted = new_state; + mute.music_muted = new_state; + } + + set_sfx_volume(&mut audio, if mute.sfx_muted { 0.0 } else { sfx_vol }); + set_music_volume(&mut audio, if mute.music_muted { 0.0 } else { music_vol }); +} + fn play_on_draw( mut events: EventReader, mut audio: NonSendMut, @@ -267,4 +329,58 @@ mod tests { let lib = build_library(); assert!(lib.is_some(), "embedded SFX failed to decode"); } + + // ----------------------------------------------------------------------- + // MuteState toggle logic (pure, no AudioManager needed) + // ----------------------------------------------------------------------- + + /// Helper that mirrors the toggle logic inside `handle_mute_keys` + /// for M (mute-all). + fn toggle_all(mute: &mut MuteState) { + let new_state = !(mute.sfx_muted && mute.music_muted); + mute.sfx_muted = new_state; + mute.music_muted = new_state; + } + + /// Helper that mirrors the toggle logic for Shift+M (music-only). + fn toggle_music(mute: &mut MuteState) { + mute.music_muted = !mute.music_muted; + } + + #[test] + fn mute_all_toggles_both_channels() { + let mut m = MuteState::default(); + toggle_all(&mut m); + assert!(m.sfx_muted && m.music_muted, "M should mute both channels"); + toggle_all(&mut m); + assert!(!m.sfx_muted && !m.music_muted, "second M should unmute both channels"); + } + + #[test] + fn shift_m_toggles_music_only() { + let mut m = MuteState::default(); + toggle_music(&mut m); + assert!(m.music_muted, "Shift+M should mute music"); + assert!(!m.sfx_muted, "Shift+M must not mute SFX"); + toggle_music(&mut m); + assert!(!m.music_muted, "second Shift+M should unmute music"); + } + + #[test] + fn mute_all_while_music_already_muted_mutes_sfx_too() { + let mut m = MuteState::default(); + // Music already muted via Shift+M. + toggle_music(&mut m); + assert!(m.music_muted && !m.sfx_muted); + // M should mute sfx (not-all-muted → mute-all). + toggle_all(&mut m); + assert!(m.sfx_muted && m.music_muted, "M unmutes neither — it mutes all when sfx was audible"); + } + + #[test] + fn mute_all_when_both_already_muted_unmutes_both() { + let mut m = MuteState { sfx_muted: true, music_muted: true }; + toggle_all(&mut m); + assert!(!m.sfx_muted && !m.music_muted, "M should unmute both when all were muted"); + } } diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 950d59e..711ea44 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -87,3 +87,9 @@ pub struct InfoToastEvent(pub String); pub struct XpAwardedEvent { pub amount: u64, } + +/// Fired by `InputPlugin` when the player presses G to forfeit the current +/// game. Consumed by `StatsPlugin` which records the abandoned game, +/// persists stats, and starts a fresh deal. +#[derive(Event, Debug, Clone, Copy, Default)] +pub struct ForfeitEvent; diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 054c59e..8f57074 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -15,8 +15,8 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state save_game_state_to}; use crate::events::{ - DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, - UndoRequestEvent, + DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent, NewGameRequestEvent, + StateChangedEvent, UndoRequestEvent, }; use crate::resources::{DragState, GameStateResource, SyncStatusResource}; @@ -64,6 +64,7 @@ impl Plugin for GamePlugin { .add_event::() .add_event::() .add_event::() + .add_event::() .add_systems( Update, ( @@ -75,7 +76,10 @@ impl Plugin for GamePlugin { .chain() .in_set(GameMutation), ) + .add_systems(Update, check_no_moves.after(GameMutation)) + .init_resource::() .add_systems(Update, tick_elapsed_time) + .add_systems(Update, auto_save_game_state) .add_systems(Last, save_game_state_on_exit); } } @@ -236,6 +240,136 @@ fn handle_undo( } } +// --------------------------------------------------------------------------- +// Task #29 — No-moves detection +// --------------------------------------------------------------------------- + +/// Returns `true` if the current game state has at least one legal move. +/// +/// Considers: +/// - Any non-empty Stock or Waste pile (draw / recycle is always available). +/// - Any face-up card on Waste or Tableau piles that can legally move to any +/// Foundation or Tableau destination. +pub fn has_legal_moves(game: &GameState) -> bool { + use solitaire_core::card::Suit; + use solitaire_core::pile::PileType; + use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; + + // If stock or waste is non-empty, the player can always draw. + if !game.piles.get(&PileType::Stock).is_some_and(|p| p.cards.is_empty()) + || !game.piles.get(&PileType::Waste).is_some_and(|p| p.cards.is_empty()) + { + return true; + } + + let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; + + // Check each playable source pile. + let sources: Vec = { + let mut v = vec![PileType::Waste]; + for i in 0..7_usize { + v.push(PileType::Tableau(i)); + } + v + }; + + for from in &sources { + let Some(from_pile) = game.piles.get(from) else { continue }; + let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue }; + + // Check foundations. + for &suit in &suits { + let dest = PileType::Foundation(suit); + if let Some(dest_pile) = game.piles.get(&dest) { + if can_place_on_foundation(card, dest_pile, suit) { + return true; + } + } + } + + // Check tableau piles. + for i in 0..7_usize { + let dest = PileType::Tableau(i); + if dest == *from { + continue; + } + if let Some(dest_pile) = game.piles.get(&dest) { + if can_place_on_tableau(card, dest_pile) { + return true; + } + } + } + } + + false +} + +/// After each `StateChangedEvent`, check if the game has no legal moves. +/// Fires `InfoToastEvent` once per "stuck" state. Resets when any new +/// `StateChangedEvent` arrives. +fn check_no_moves( + mut events: EventReader, + game: Res, + mut toast: EventWriter, + mut already_fired: Local, +) { + // Reset the debounce flag on every state change so if something changes + // we re-evaluate on the next state change. + let had_event = events.read().next().is_some(); + // Drain remaining events to avoid leaking. + events.clear(); + + if !had_event { + return; + } + + // Reset debounce whenever the state changes. + *already_fired = false; + + if game.0.is_won { + return; + } + + if !has_legal_moves(&game.0) && !*already_fired { + toast.send(InfoToastEvent( + "No moves available \u{2014} press D to draw or N for a new game".to_string(), + )); + *already_fired = true; + } +} + +const AUTO_SAVE_INTERVAL_SECS: f32 = 30.0; + +/// Accumulated real-world seconds since the last auto-save. Exposed as a +/// `Resource` so tests can pre-seed it past the threshold without needing to +/// control `Time::delta_secs()`. +#[derive(Resource, Default)] +pub struct AutoSaveTimer(pub f32); + +/// Periodically saves game state every 30 real-world seconds while a game is +/// in progress. The timer uses real delta time (not game elapsed_seconds) so +/// it keeps ticking even if the game clock is paused. +fn auto_save_game_state( + time: Res