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:
@@ -1,10 +1,10 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
||||||
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
|
ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin,
|
||||||
ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin,
|
PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin,
|
||||||
WeeklyGoalsPlugin,
|
TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -31,6 +31,7 @@ fn main() {
|
|||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
.add_plugins(AnimationPlugin)
|
.add_plugins(AnimationPlugin)
|
||||||
|
.add_plugins(AutoCompletePlugin)
|
||||||
.add_plugins(StatsPlugin::default())
|
.add_plugins(StatsPlugin::default())
|
||||||
.add_plugins(ProgressPlugin::default())
|
.add_plugins(ProgressPlugin::default())
|
||||||
.add_plugins(AchievementPlugin::default())
|
.add_plugins(AchievementPlugin::default())
|
||||||
|
|||||||
@@ -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).
|
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||||||
pub fn compute_time_bonus(&self) -> i32 {
|
pub fn compute_time_bonus(&self) -> i32 {
|
||||||
scoring_time_bonus(self.elapsed_seconds)
|
scoring_time_bonus(self.elapsed_seconds)
|
||||||
@@ -651,4 +676,46 @@ mod tests {
|
|||||||
g.elapsed_seconds = 100;
|
g.elapsed_seconds = 100;
|
||||||
assert_eq!(g.compute_time_bonus(), 7000);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeCompletedEvent;
|
use crate::daily_challenge_plugin::DailyChallengeCompletedEvent;
|
||||||
@@ -80,6 +81,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_time_attack_toast,
|
handle_time_attack_toast,
|
||||||
handle_challenge_toast,
|
handle_challenge_toast,
|
||||||
handle_settings_toast,
|
handle_settings_toast,
|
||||||
|
handle_auto_complete_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.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(
|
fn tick_toasts(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
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,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
pub mod animation_plugin;
|
pub mod animation_plugin;
|
||||||
|
pub mod auto_complete_plugin;
|
||||||
pub mod audio_plugin;
|
pub mod audio_plugin;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
@@ -32,6 +33,7 @@ pub use daily_challenge_plugin::{
|
|||||||
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
||||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||||
|
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
|
|||||||
Reference in New Issue
Block a user