From f7850c007535354311684a42cc01f397ecfc87b4 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 00:24:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20auto-complete=20=E2=80=94=20car?= =?UTF-8?q?ds=20auto-deal=20to=20foundations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solitaire_app/src/main.rs | 9 +- solitaire_core/src/game_state.rs | 67 +++++++ solitaire_engine/src/animation_plugin.rs | 21 +++ solitaire_engine/src/auto_complete_plugin.rs | 179 +++++++++++++++++++ solitaire_engine/src/lib.rs | 2 + 5 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 solitaire_engine/src/auto_complete_plugin.rs diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 42d0e79..a1893c0 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,10 +1,10 @@ use bevy::prelude::*; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_engine::{ - AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin, - DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, PausePlugin, - ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, - WeeklyGoalsPlugin, + AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin, + ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, + PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin, + TimeAttackPlugin, WeeklyGoalsPlugin, }; fn main() { @@ -31,6 +31,7 @@ fn main() { .add_plugins(CardPlugin) .add_plugins(InputPlugin) .add_plugins(AnimationPlugin) + .add_plugins(AutoCompletePlugin) .add_plugins(StatsPlugin::default()) .add_plugins(ProgressPlugin::default()) .add_plugins(AchievementPlugin::default()) diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index a275bdf..e814263 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -340,6 +340,31 @@ impl GameState { }) } + /// Returns the next `(from, to)` move that advances auto-complete, or + /// `None` if no such move exists (or `is_auto_completable` is not set). + /// + /// Scans tableau piles 0–6 in order, returning the first top card that + /// can be placed on any foundation pile. The scan order ensures Aces are + /// resolved before higher ranks that depend on them. + pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> { + if !self.is_auto_completable || self.is_won { + return None; + } + let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; + for i in 0..7 { + let tableau = PileType::Tableau(i); + if let Some(card) = self.piles[&tableau].cards.last() { + for &suit in &suits { + let foundation = PileType::Foundation(suit); + if can_place_on_foundation(card, &self.piles[&foundation], suit) { + return Some((tableau, foundation)); + } + } + } + } + None + } + /// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0). pub fn compute_time_bonus(&self) -> i32 { scoring_time_bonus(self.elapsed_seconds) @@ -651,4 +676,46 @@ mod tests { g.elapsed_seconds = 100; assert_eq!(g.compute_time_bonus(), 7000); } + + // --- next_auto_complete_move --- + + #[test] + fn next_auto_complete_move_returns_none_on_fresh_game() { + // A fresh game has stock and face-down cards — not auto-completable. + assert!(new_game().next_auto_complete_move().is_none()); + } + + #[test] + fn next_auto_complete_move_finds_ace_on_auto_completable_board() { + use crate::card::{Card, Rank}; + + let mut g = new_game(); + // Clear stock and waste to satisfy auto-complete precondition. + g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + // Clear all tableau piles and put a single face-up Ace of Clubs + // into Tableau(0); all other piles empty. + for i in 0..7 { + g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); + } + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { + id: 99, + suit: Suit::Clubs, + rank: Rank::Ace, + face_up: true, + }); + g.is_auto_completable = true; + + let mv = g.next_auto_complete_move().expect("should find a move"); + assert_eq!(mv.0, PileType::Tableau(0)); + assert_eq!(mv.1, PileType::Foundation(Suit::Clubs)); + } + + #[test] + fn next_auto_complete_move_returns_none_when_already_won() { + let mut g = new_game(); + g.is_auto_completable = true; + g.is_won = true; + assert!(g.next_auto_complete_move().is_none()); + } } diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index c1a3f61..0e5ab17 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -6,6 +6,7 @@ use bevy::prelude::*; use crate::achievement_plugin::display_name_for; +use crate::auto_complete_plugin::AutoCompleteState; use crate::card_plugin::CardEntity; use crate::challenge_plugin::ChallengeAdvancedEvent; use crate::daily_challenge_plugin::DailyChallengeCompletedEvent; @@ -80,6 +81,7 @@ impl Plugin for AnimationPlugin { handle_time_attack_toast, handle_challenge_toast, handle_settings_toast, + handle_auto_complete_toast, tick_toasts, ) .after(GameMutation), @@ -233,6 +235,25 @@ fn handle_settings_toast( } } +/// Shows a one-time "Auto-completing..." toast when auto-complete activates. +fn handle_auto_complete_toast( + mut commands: Commands, + state: Option>, + mut shown: Local, +) { + let Some(s) = state else { return }; + if s.is_changed() { + if s.active { + if !*shown { + *shown = true; + spawn_toast(&mut commands, "Auto-completing…".to_string(), 2.0); + } + } else { + *shown = false; + } + } +} + fn tick_toasts( mut commands: Commands, time: Res