920f2c8597
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root) - Delete solitaire_core::pile — no external users - Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode - Remove schema_version field from GameState (redundant — deserializer stamps it from the constant) - Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
279 lines
9.7 KiB
Rust
279 lines
9.7 KiB
Rust
//! 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 bevy::window::RequestRedraw;
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use crate::audio_plugin::{AudioState, SoundLibrary};
|
|
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
|
use crate::game_plugin::GameMutation;
|
|
use crate::pause_plugin::PausedResource;
|
|
use crate::resources::GameStateResource;
|
|
|
|
/// Volume amplitude used for the auto-complete activation chime.
|
|
///
|
|
/// Plays the win fanfare at half volume so it is clearly distinguishable from
|
|
/// both normal card-place sounds and the full win fanfare that fires later.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
|
|
|
|
/// Seconds between consecutive auto-complete moves.
|
|
const STEP_INTERVAL: f32 = 0.12;
|
|
|
|
/// Seconds to wait after detection before firing the first auto-complete move.
|
|
///
|
|
/// This pause gives the player a moment to register that the game is
|
|
/// transitioning into auto-complete mode before cards start moving.
|
|
const AUTO_COMPLETE_INITIAL_DELAY: f32 = 0.75;
|
|
|
|
/// 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_message::<RequestRedraw>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
detect_auto_complete,
|
|
on_auto_complete_start,
|
|
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: MessageReader<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 = AUTO_COMPLETE_INITIAL_DELAY;
|
|
} else if !game.0.is_auto_completable && state.active {
|
|
// `is_auto_completable` only becomes false after an explicit undo
|
|
// (which puts a card back on the tableau or re-fills the stock/waste)
|
|
// or a new-game reset — never as a transient gap during a normal
|
|
// auto-complete sequence. Deactivate here so `drive_auto_complete`
|
|
// does not keep retrying indefinitely after the player undoes out of
|
|
// the sequence.
|
|
//
|
|
// Note: the transient-`None` case mentioned in older versions of this
|
|
// comment referred to `next_auto_complete_move()` returning `None`, not
|
|
// to `is_auto_completable` being false. Those are independent fields;
|
|
// `drive_auto_complete` still retries on a transient `None` return from
|
|
// `next_auto_complete_move` because that check happens there, not here.
|
|
state.active = false;
|
|
}
|
|
}
|
|
|
|
/// Plays a distinct chime the moment auto-complete first activates.
|
|
///
|
|
/// Uses a `Local<bool>` to remember the previous `active` state and fires
|
|
/// exactly once on the `false → true` edge. The win fanfare is played at half
|
|
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
|
|
/// not overwhelm the card-place sounds that follow immediately.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn on_auto_complete_start(
|
|
state: Res<AutoCompleteState>,
|
|
mut was_active: Local<bool>,
|
|
mut audio: Option<NonSendMut<AudioState>>,
|
|
lib: Option<Res<SoundLibrary>>,
|
|
) {
|
|
let now_active = state.active;
|
|
let edge = now_active && !*was_active;
|
|
*was_active = now_active;
|
|
|
|
if !edge {
|
|
return;
|
|
}
|
|
|
|
let (Some(audio), Some(lib)) = (audio.as_mut(), lib) else {
|
|
return;
|
|
};
|
|
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
|
|
}
|
|
|
|
// No audio on wasm — stub keeps the system registration unconditional.
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn on_auto_complete_start(state: Res<AutoCompleteState>, mut was_active: Local<bool>) {
|
|
*was_active = state.active;
|
|
}
|
|
|
|
/// 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>,
|
|
paused: Option<Res<PausedResource>>,
|
|
mut moves: MessageWriter<MoveRequestEvent>,
|
|
) {
|
|
if !state.active {
|
|
return;
|
|
}
|
|
if paused.is_some_and(|p| p.0) {
|
|
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.write(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::{Foundation, KlondikePile, Tableau};
|
|
use solitaire_core::card::{Rank, Suit};
|
|
use solitaire_core::{DrawMode, game_state::GameState};
|
|
|
|
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
|
|
}
|
|
|
|
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
|
let mut g = GameState::new(1, DrawMode::DrawOne);
|
|
g.set_test_stock_cards(Vec::new());
|
|
g.set_test_waste_cards(Vec::new());
|
|
for foundation in [
|
|
Foundation::Foundation1,
|
|
Foundation::Foundation2,
|
|
Foundation::Foundation3,
|
|
Foundation::Foundation4,
|
|
] {
|
|
g.set_test_foundation_cards(foundation, Vec::new());
|
|
}
|
|
for tableau in [
|
|
Tableau::Tableau1,
|
|
Tableau::Tableau2,
|
|
Tableau::Tableau3,
|
|
Tableau::Tableau4,
|
|
Tableau::Tableau5,
|
|
Tableau::Tableau6,
|
|
Tableau::Tableau7,
|
|
] {
|
|
g.set_test_tableau_cards(tableau, Vec::new());
|
|
}
|
|
g.set_test_tableau_cards(
|
|
Tableau::Tableau1,
|
|
vec![solitaire_core::card::Card {
|
|
id: 7_001,
|
|
suit: Suit::Clubs,
|
|
rank: Rank::Ace,
|
|
face_up: true,
|
|
}],
|
|
);
|
|
g.is_auto_completable = true;
|
|
let expected = (
|
|
KlondikePile::Tableau(Tableau::Tableau1),
|
|
KlondikePile::Foundation(Foundation::Foundation1),
|
|
);
|
|
assert_eq!(g.next_auto_complete_move(), Some(expected));
|
|
(g, expected)
|
|
}
|
|
|
|
#[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();
|
|
let mut g = GameState::new(42, DrawMode::DrawOne);
|
|
g.is_auto_completable = true;
|
|
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
|
app.world_mut().write_message(StateChangedEvent);
|
|
app.update();
|
|
|
|
assert!(app.world().resource::<AutoCompleteState>().active);
|
|
}
|
|
|
|
#[test]
|
|
fn drive_fires_move_request_when_active() {
|
|
let mut app = headless_app();
|
|
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
|
|
app.world_mut().resource_mut::<GameStateResource>().0 = g;
|
|
app.world_mut().write_message(StateChangedEvent);
|
|
app.update(); // detect runs, sets active
|
|
|
|
// Zero out the cooldown so drive fires on the next update regardless
|
|
// of the initial delay constant.
|
|
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
|
|
app.update(); // drive fires the move
|
|
|
|
let events = app.world().resource::<Messages<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, expected_from);
|
|
assert_eq!(fired[0].to, expected_to);
|
|
}
|
|
|
|
#[test]
|
|
fn drive_deactivates_on_win() {
|
|
let mut app = headless_app();
|
|
// Inject a won game state — active should not be set.
|
|
let (mut gs, _) = seeded_state_with_auto_move();
|
|
gs.is_won = true;
|
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
|
app.world_mut().write_message(StateChangedEvent);
|
|
app.update();
|
|
|
|
assert!(!app.world().resource::<AutoCompleteState>().active);
|
|
}
|
|
}
|