Files
Ferrous-Solitaire/solitaire_engine/src/game_plugin.rs
T
funman300 b720588687 feat(engine): MoveRejectedEvent + PausePlugin (Esc)
- New MoveRejectedEvent fires from end_drag when the cursor is over
  a real pile but the placement is illegal. AudioPlugin plays
  card_invalid.wav on it.
- New PausePlugin + PausedResource: Esc toggles a full-window
  overlay and the flag. tick_elapsed_time and advance_time_attack
  skip work while paused. Help cheat sheet updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:56:35 -07:00

329 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Routes game-request events to `solitaire_core::GameState` and emits
//! state-change notifications.
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use solitaire_core::game_state::{DrawMode, GameState};
use crate::events::{
DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
UndoRequestEvent,
};
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
/// System set for `GamePlugin`'s state-mutating systems. Downstream plugins
/// that read the resulting `StateChangedEvent` should schedule themselves
/// `.after(GameMutation)` so updates propagate within a single frame.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct GameMutation;
/// Registers game resources, events, and the systems that route user intent
/// (events) into mutations on `GameState`.
pub struct GamePlugin;
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
app.insert_resource(GameStateResource(GameState::new(
seed_from_system_time(),
DrawMode::DrawOne,
)))
.init_resource::<DragState>()
.init_resource::<SyncStatusResource>()
.add_event::<MoveRequestEvent>()
.add_event::<DrawRequestEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<StateChangedEvent>()
.add_event::<crate::events::MoveRejectedEvent>()
.add_event::<GameWonEvent>()
.add_event::<crate::events::CardFlippedEvent>()
.add_event::<crate::events::AchievementUnlockedEvent>()
.add_systems(
Update,
(
handle_new_game,
handle_draw,
handle_move,
handle_undo,
)
.chain()
.in_set(GameMutation),
)
.add_systems(Update, tick_elapsed_time);
}
}
/// Pure, testable helper. Updates `elapsed_seconds` and drains the
/// fractional accumulator into whole-second ticks. No-op when `is_won`.
pub fn advance_elapsed(
elapsed_seconds: &mut u64,
accumulator: &mut f32,
delta_secs: f32,
is_won: bool,
) {
if is_won {
return;
}
*accumulator += delta_secs;
while *accumulator >= 1.0 {
*elapsed_seconds = elapsed_seconds.saturating_add(1);
*accumulator -= 1.0;
}
}
/// Increment `GameState.elapsed_seconds` once per real-world second while
/// the game is in progress (not won) and not paused. Stops counting on
/// win so the final time reflects how long the player took to solve the
/// deal; stops while the pause overlay is open.
fn tick_elapsed_time(
time: Res<Time>,
mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let is_won = game.0.is_won;
advance_elapsed(
&mut game.0.elapsed_seconds,
&mut accumulator,
time.delta_secs(),
is_won,
);
}
fn seed_from_system_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
fn handle_new_game(
mut new_game: EventReader<NewGameRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
) {
for ev in new_game.read() {
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
let draw_mode = game.0.draw_mode.clone();
let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
changed.send(StateChangedEvent);
}
}
fn handle_draw(
mut draws: EventReader<DrawRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
) {
for _ in draws.read() {
match game.0.draw() {
Ok(()) => {
changed.send(StateChangedEvent);
}
Err(e) => warn!("draw rejected: {e}"),
}
}
}
fn handle_move(
mut moves: EventReader<MoveRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut won: EventWriter<GameWonEvent>,
) {
for ev in moves.read() {
let was_won = game.0.is_won;
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
Ok(()) => {
changed.send(StateChangedEvent);
if !was_won && game.0.is_won {
won.send(GameWonEvent {
score: game.0.score,
time_seconds: game.0.elapsed_seconds,
});
}
}
Err(e) => warn!("move rejected {:?} -> {:?} x{}: {e}", ev.from, ev.to, ev.count),
}
}
}
fn handle_undo(
mut undos: EventReader<UndoRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
) {
for _ in undos.read() {
match game.0.undo() {
Ok(()) => {
changed.send(StateChangedEvent);
}
Err(e) => warn!("undo rejected: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use solitaire_core::pile::PileType;
/// Build a minimal headless `App` with just `GamePlugin` installed.
/// Overrides the default random seed so tests are deterministic.
fn test_app(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
// Override the system-time seed with a known value.
app.world_mut()
.resource_mut::<GameStateResource>()
.0 = GameState::new(seed, DrawMode::DrawOne);
app
}
#[test]
fn plugin_inserts_game_state_resource() {
let app = test_app(1);
assert!(app.world().get_resource::<GameStateResource>().is_some());
assert!(app.world().get_resource::<DragState>().is_some());
assert!(app.world().get_resource::<SyncStatusResource>().is_some());
}
#[test]
fn draw_request_advances_game_state() {
let mut app = test_app(42);
let stock_before = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Stock]
.cards
.len();
app.world_mut().send_event(DrawRequestEvent);
app.update();
let stock_after = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Stock]
.cards
.len();
let waste_after = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Waste]
.cards
.len();
assert_eq!(stock_after, stock_before - 1);
assert_eq!(waste_after, 1);
}
#[test]
fn draw_request_fires_state_changed_event() {
let mut app = test_app(42);
app.world_mut().send_event(DrawRequestEvent);
app.update();
let events = app.world().resource::<Events<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_some());
}
#[test]
fn undo_after_draw_restores_state() {
let mut app = test_app(42);
app.world_mut().send_event(DrawRequestEvent);
app.update();
app.world_mut().send_event(UndoRequestEvent);
app.update();
let g = &app.world().resource::<GameStateResource>().0;
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
assert_eq!(g.piles[&PileType::Waste].cards.len(), 0);
}
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<u32> = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Tableau(0)]
.cards
.iter()
.map(|c| c.id)
.collect();
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let after: Vec<u32> = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Tableau(0)]
.cards
.iter()
.map(|c| c.id)
.collect();
assert_ne!(before, after);
}
#[test]
fn advance_elapsed_drains_accumulator_into_whole_seconds() {
let mut elapsed = 0;
let mut acc = 0.0;
advance_elapsed(&mut elapsed, &mut acc, 2.5, false);
assert_eq!(elapsed, 2);
// Remaining 0.5 should still be in the accumulator.
advance_elapsed(&mut elapsed, &mut acc, 0.5, false);
assert_eq!(elapsed, 3);
}
#[test]
fn advance_elapsed_is_noop_when_won() {
let mut elapsed = 100;
let mut acc = 0.0;
advance_elapsed(&mut elapsed, &mut acc, 5.0, true);
assert_eq!(elapsed, 100);
assert_eq!(acc, 0.0);
}
#[test]
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
let mut elapsed = 0;
let mut acc = 0.0;
// 4 × 0.25 = 1.0 (exactly representable in f32) — must produce 1 tick.
for _ in 0..4 {
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
}
assert_eq!(elapsed, 1);
// Repeat once more for a total of 2 seconds.
for _ in 0..4 {
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
}
assert_eq!(elapsed, 2);
}
#[test]
fn invalid_move_does_not_fire_state_changed() {
let mut app = test_app(42);
// Stock -> Waste is InvalidDestination; no state change expected.
app.world_mut().send_event(MoveRequestEvent {
from: PileType::Stock,
to: PileType::Waste,
count: 1,
});
app.update();
let events = app.world().resource::<Events<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_none());
}
}