feat(engine,core): add elapsed-time tick system and Zen GameMode

Phase 6 part 4 (partial):
- GameState now tracks elapsed_seconds via tick_elapsed_time in GamePlugin
  (per-second increment while not won). Pure helper advance_elapsed makes
  the tick logic directly testable without mocking Bevy Time.
- New GameMode enum (Classic / Zen) on GameState. Zen mode suppresses
  scoring in move_cards and undo. GameState::new_with_mode allows callers
  to construct non-Classic games; the existing GameState::new still
  defaults to Classic. mode is serde(default) for backwards-compatible
  persistence.
- NewGameRequestEvent gains an optional mode field; handle_new_game
  honours it (falling back to the current game's mode when None).
- InputPlugin: pressing Z starts a fresh Zen-mode game.

Time Attack, Challenge mode, level-5 unlock gating, and unlock UI are
still deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-25 14:14:57 -07:00
parent 6b793aa2ab
commit 8afb1f3fe5
6 changed files with 79 additions and 12 deletions
@@ -100,6 +100,7 @@ fn handle_start_daily_request(
if keys.just_pressed(KeyCode::KeyC) {
new_game.send(NewGameRequestEvent {
seed: Some(daily.seed),
mode: None,
});
}
}
+3
View File
@@ -1,6 +1,7 @@
//! Cross-system events used by the engine's plugins.
use bevy::prelude::Event;
use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
@@ -21,9 +22,11 @@ pub struct DrawRequestEvent;
pub struct UndoRequestEvent;
/// Request to start a new game. `seed = None` uses a system-time seed.
/// `mode = None` reuses the current game's `GameMode`.
#[derive(Event, Debug, Clone, Copy, Default)]
pub struct NewGameRequestEvent {
pub seed: Option<u64>,
pub mode: Option<GameMode>,
}
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
+12 -6
View File
@@ -103,7 +103,8 @@ fn handle_new_game(
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();
game.0 = GameState::new(seed, draw_mode);
let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
changed.send(StateChangedEvent);
}
}
@@ -253,7 +254,7 @@ mod tests {
.map(|c| c.id)
.collect();
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999) });
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let after: Vec<u32> = app
@@ -292,11 +293,16 @@ mod tests {
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
let mut elapsed = 0;
let mut acc = 0.0;
// 16ms × 60 frames/sec ≈ 1 second; should produce 1 tick.
for _ in 0..60 {
advance_elapsed(&mut elapsed, &mut acc, 1.0 / 60.0, false);
// 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!(elapsed == 1, "expected 1 second, got {elapsed}");
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]
+7 -1
View File
@@ -69,7 +69,13 @@ fn handle_keyboard(
undo.send(UndoRequestEvent);
}
if keys.just_pressed(KeyCode::KeyN) {
new_game.send(NewGameRequestEvent { seed: None });
new_game.send(NewGameRequestEvent::default());
}
if keys.just_pressed(KeyCode::KeyZ) {
new_game.send(NewGameRequestEvent {
seed: None,
mode: Some(solitaire_core::game_state::GameMode::Zen),
});
}
if keys.just_pressed(KeyCode::KeyD) {
draw.send(DrawRequestEvent);
+2 -2
View File
@@ -296,7 +296,7 @@ mod tests {
.move_count = 3;
app.world_mut()
.send_event(NewGameRequestEvent { seed: Some(999) });
.send_event(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let stats = &app.world().resource::<StatsResource>().0;
@@ -309,7 +309,7 @@ mod tests {
fn new_game_without_moves_does_not_record_abandoned() {
let mut app = headless_app();
app.world_mut()
.send_event(NewGameRequestEvent { seed: Some(42) });
.send_event(NewGameRequestEvent { seed: Some(42), mode: None });
app.update();
let stats = &app.world().resource::<StatsResource>().0;