Files
Ferrous-Solitaire/solitaire_engine/src/auto_complete_plugin.rs
T
funman300 f464aab543
Build and Deploy / build-and-push (push) Failing after 44s
fix(web): clean up wasm32 build warnings and wire /play route to Bevy canvas
- solitaire_data/sync_client.rs: fix SyncPayload/SyncResponse import split
  (SyncResponse is needed by LocalOnlyProvider which compiles on wasm32)
- solitaire_engine/assets/sources.rs: cfg-gate AssetApp/AssetSourceBuilder
  imports (only used in the non-wasm FileAssetReader block)
- solitaire_engine/auto_complete_plugin.rs: cfg-gate AUTO_COMPLETE_CHIME_VOLUME
- solitaire_engine/daily_challenge_plugin.rs: cfg-gate Task/AsyncComputeTaskPool
  imports and DailyChallengeTask struct (server fetch systems are non-wasm only)
- solitaire_engine/resources.rs: cfg-gate std::sync::Arc (TokioRuntimeResource
  is non-wasm only)
- solitaire_engine/settings_plugin.rs: cfg-gate ScanThemes variant, pill_button,
  and their match arms; fix refresh_registry import placement
- solitaire_server/src/lib.rs: point /play route at play.html (Bevy canvas);
  keep /play-classic serving game.html during transition period
- build_wasm.sh: add --no-typescript to wasm-bindgen call for canvas build
- solitaire_server/web/pkg: add canvas.js + canvas_bg.wasm build artifacts

wasm32 build and native clippy --workspace -D warnings both clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:55:39 -07:00

274 lines
9.3 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;
}
// Intentionally no `else if !is_auto_completable` branch here.
// Deactivating on every frame where `is_auto_completable` is false
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
// transiently returns `None` (e.g. while the previous move is still
// in-flight). The `is_won` check above already handles the definitive
// end-of-game case; `drive_auto_complete` simply retries next tick
// when no move is available yet.
}
/// 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 klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Rank, Suit};
use solitaire_core::game_state::{DrawMode, 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);
}
}