feat(engine): auto-complete — cards auto-deal to foundations

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>
This commit is contained in:
root
2026-04-27 00:24:21 +00:00
parent 00f0383867
commit f7850c0075
5 changed files with 274 additions and 4 deletions
+21
View File
@@ -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<Res<AutoCompleteState>>,
mut shown: Local<bool>,
) {
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<Time>,
@@ -0,0 +1,179 @@
//! Automatic card-to-foundation sequencing once `is_auto_completable` is set.
//!
//! When `GameState::is_auto_completable` becomes `true`, this plugin fires
//! `MoveRequestEvent` for one card per `STEP_INTERVAL` seconds until the game
//! is won. A single toast announces the sequence; no player input is required.
//!
//! The plugin is intentionally passive: it only reads `GameStateResource` and
//! fires `MoveRequestEvent`. If for some reason `next_auto_complete_move`
//! returns `None` (e.g. a transient state), the plugin retries next tick.
use bevy::prelude::*;
use crate::events::{MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource;
/// Seconds between consecutive auto-complete moves.
const STEP_INTERVAL: f32 = 0.12;
/// Tracks whether auto-complete is active and when the next move fires.
#[derive(Resource, Default, Debug)]
pub struct AutoCompleteState {
/// `true` once we've detected `is_auto_completable` and started firing moves.
pub active: bool,
/// Countdown to the next move, in seconds.
cooldown: f32,
}
/// Plugin that drives the auto-complete sequence.
pub struct AutoCompletePlugin;
impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>()
.add_systems(
Update,
(detect_auto_complete, drive_auto_complete)
.chain()
.after(GameMutation),
);
}
}
/// Activates auto-complete when `is_auto_completable` flips to `true`.
/// Deactivates it on win or new game (any state where it should not be running).
fn detect_auto_complete(
mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>,
mut changed: EventReader<StateChangedEvent>,
) {
// Only re-evaluate on state changes to avoid per-frame allocations.
if changed.is_empty() && !game.is_changed() {
return;
}
changed.clear();
if game.0.is_won {
state.active = false;
return;
}
if game.0.is_auto_completable && !state.active {
state.active = true;
state.cooldown = 0.0; // fire first move immediately
} else if !game.0.is_auto_completable {
state.active = false;
}
}
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>,
time: Res<Time>,
mut moves: EventWriter<MoveRequestEvent>,
) {
if !state.active {
return;
}
state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 {
return;
}
let Some((from, to)) = game.0.next_auto_complete_move() else {
// No move available yet (race with game state update); try next tick.
return;
};
moves.send(MoveRequestEvent { from, to, count: 1 });
state.cooldown = STEP_INTERVAL;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(AutoCompletePlugin);
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.update();
app
}
/// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other
/// tableau piles empty, stock/waste empty, Clubs foundation empty.
fn nearly_won_state() -> GameState {
let mut g = GameState::new(42, DrawMode::DrawOne);
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
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;
g
}
#[test]
fn state_starts_inactive() {
let app = headless_app();
assert!(!app.world().resource::<AutoCompleteState>().active);
}
#[test]
fn detect_activates_when_auto_completable() {
let mut app = headless_app();
// Install a nearly-won state and fire StateChangedEvent.
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().send_event(StateChangedEvent);
app.update();
assert!(app.world().resource::<AutoCompleteState>().active);
}
#[test]
fn drive_fires_move_request_when_active() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().send_event(StateChangedEvent);
app.update(); // detect runs, sets active
app.update(); // drive fires the move
let events = app.world().resource::<Events<MoveRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
// At least one MoveRequestEvent should have been fired.
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
assert_eq!(fired[0].from, PileType::Tableau(0));
assert_eq!(fired[0].to, PileType::Foundation(Suit::Clubs));
}
#[test]
fn drive_deactivates_on_win() {
let mut app = headless_app();
// Inject a won game state — active should not be set.
let mut gs = nearly_won_state();
gs.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().send_event(StateChangedEvent);
app.update();
assert!(!app.world().resource::<AutoCompleteState>().active);
}
}
+2
View File
@@ -2,6 +2,7 @@
pub mod achievement_plugin;
pub mod animation_plugin;
pub mod auto_complete_plugin;
pub mod audio_plugin;
pub mod card_plugin;
pub mod challenge_plugin;
@@ -32,6 +33,7 @@ pub use daily_challenge_plugin::{
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use animation_plugin::{AnimationPlugin, CardAnim};
pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{