diff --git a/Cargo.lock b/Cargo.lock index 54d6166..a4d4833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1638,7 +1638,10 @@ name = "bglga" version = "0.1.0" dependencies = [ "bevy", + "dirs", "fastrand", + "serde", + "serde_json", ] [[package]] @@ -2189,6 +2192,27 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2587,7 +2611,7 @@ dependencies = [ "vec_map", "wasm-bindgen", "web-sys", - "windows 0.58.0", + "windows 0.61.3", ] [[package]] @@ -3376,7 +3400,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3710,6 +3734,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.48" @@ -4063,6 +4093,17 @@ dependencies = [ "bitflags 2.9.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.11.1" @@ -4172,7 +4213,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7c8147f..8d23f96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" [dependencies] bevy = "0.18" fastrand = "2.0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" [dev-dependencies] bevy = { version = "0.18", features = ["debug"] } diff --git a/TODO/GAL-50.md b/TODO/GAL-50.md index f021ee8..047964f 100644 --- a/TODO/GAL-50.md +++ b/TODO/GAL-50.md @@ -14,6 +14,7 @@ Score, lives, stage, high-score, start menu, and restart UI. - [x] [GAL-51](GAL-51.md) — Display Score, Lives, and Stage in the window title. - [ ] [GAL-52](GAL-52.md) — Display Score, Lives, and Stage on the screen using `bevy_ui`. -- [ ] [GAL-53](GAL-53.md) — Implement a High Score system (saving/loading). +- [x] [GAL-53](GAL-53.md) — Implement a High Score system (saving/loading). - [x] [GAL-54](GAL-54.md) — Create a Start Menu state with a "Start Game" button. - [ ] [GAL-55](GAL-55.md) — Add a "Press R to Restart" message to the `GameOver` screen and implement restart logic. +- [x] [GAL-56](GAL-56.md) — Score resource does not reset on game restart. diff --git a/TODO/GAL-53.md b/TODO/GAL-53.md index 2919823..31d70e5 100644 --- a/TODO/GAL-53.md +++ b/TODO/GAL-53.md @@ -1,7 +1,7 @@ --- id: GAL-53 title: Implement a High Score system (saving/loading) -status: Todo +status: Done parent: GAL-50 labels: [polish, ui] --- @@ -12,11 +12,11 @@ Implement a High Score system (saving/loading). ## Acceptance criteria -- [ ] High score persists to disk under a platform-appropriate path (e.g. `dirs::data_dir()/bglga/highscore.json`). -- [ ] Loaded on startup into a `HighScore` resource; defaults to 0 if the file is absent or corrupt (no panic). -- [ ] Updated only when `Score > HighScore` on entering `GameOver`, then persisted. -- [ ] Visible on Start Menu (best so far) and Game Over (best vs current). -- [ ] Save path overridable via env var or test-only setter so tests don't pollute the user's real config. +- [x] High score persists to disk under a platform-appropriate path (e.g. `dirs::data_dir()/bglga/highscore.json`). +- [x] Loaded on startup into a `HighScore` resource; defaults to 0 if the file is absent or corrupt (no panic). +- [x] Updated only when `Score > HighScore` on entering `GameOver`, then persisted. +- [x] Visible on Start Menu (best so far) and Game Over (best vs current). +- [x] Save path overridable via env var or test-only setter so tests don't pollute the user's real config. ## Integration test hints @@ -24,3 +24,7 @@ Implement a High Score system (saving/loading). - Pre-write malformed JSON; assert loader falls back to 0 and doesn't panic. - Run scenario: set `Score = 7000`; transition to `GameOver`; reload from disk; assert persisted value = 7000. - Concurrency / crash safety: write via temp file + rename, not in-place truncate. + +## Comments + +- 2026-05-07 — Branch `opencode/clever-wolf`, commit ff3416b — High score system implemented with persistence, UI display, and integration tests. diff --git a/TODO/GAL-56.md b/TODO/GAL-56.md new file mode 100644 index 0000000..159bd7c --- /dev/null +++ b/TODO/GAL-56.md @@ -0,0 +1,47 @@ +--- +id: GAL-56 +title: Score resource does not reset on game restart +status: Done +parent: GAL-50 +labels: [bug, ui] +--- + +# GAL-56: Score resource does not reset on game restart + +The `restart_game_system` in `src/game_state.rs` only resets `RestartPressed` and transitions to `Playing`. It does not reset `Score`, `PlayerLives`, `CurrentStage`, or `FormationState` resources. + +This means on subsequent games after the first, `Score` retains the previous game's final score. The Game Over screen shows the cumulative total ("Your Score") rather than the current game's score, and `check_high_score` (from GAL-53) compares against the accumulated score instead of the current game's score. + +## Acceptance criteria + +- [x] Restarting from `GameOver` resets `Score.value` to 0. +- [x] Restarting resets `PlayerLives.count` to starting value. +- [x] Restarting resets `CurrentStage.number` to 1. +- [x] Restarting resets `FormationState` to default. +- [x] Restarting despawns all `Enemy`, `Bullet`, `EnemyBullet`, `Explosion`, and `TractorBeam` entities. + +## Code context + +**`src/game_state.rs`** (restart system, current state): +```rust +pub fn restart_game_system( + mut next_state: ResMut>, + mut restart: ResMut, +) { + if restart.pressed { + restart.pressed = false; + next_state.set(AppState::Playing); + } +} +``` + +No resource reset logic exists. The `cleanup_game_entities` system runs on `OnExit(Playing)` but does not reset resources. + +## Comments + +- 2026-05-09 — Status set to In Progress. +- 2026-05-09 — Branch `opencode/clever-wolf`, commit 933d23c — Score, lives, stage, and formation reset on restart; explosions despawned on cleanup + +## Integration test hints + +- Build `App`, transition to `Playing`, set `Score = 5000`, transition to `GameOver`, restart, assert `Score.value == 0`. diff --git a/src/components.rs b/src/components.rs index c7fb877..96824f1 100644 --- a/src/components.rs +++ b/src/components.rs @@ -86,6 +86,9 @@ pub struct StartButton; #[derive(Component)] pub struct RestartMessage; +#[derive(Component)] +pub struct HighScoreText; + #[derive(Component)] pub struct Star { pub speed: f32, diff --git a/src/enemy.rs b/src/enemy.rs index 20f59c8..feb032a 100644 --- a/src/enemy.rs +++ b/src/enemy.rs @@ -11,8 +11,8 @@ use crate::constants::{ TRACTOR_BEAM_WIDTH, WINDOW_HEIGHT, WINDOW_WIDTH, }; use crate::resources::{ - AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations, - is_special_stage, + is_special_stage, AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, + StageConfigurations, }; const BOSS_BASE_CHANCE: f32 = 0.30; @@ -64,8 +64,8 @@ pub fn spawn_enemies( 0.0, ); - let boss_chance = BOSS_BASE_CHANCE - + (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP); + let boss_chance = + BOSS_BASE_CHANCE + (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP); let enemy_type = if fastrand::f32() < boss_chance { EnemyType::Boss } else { diff --git a/src/game_state.rs b/src/game_state.rs index 35c6f11..9477c50 100644 --- a/src/game_state.rs +++ b/src/game_state.rs @@ -1,7 +1,13 @@ use bevy::prelude::*; -use crate::components::{Bullet, Enemy, EnemyBullet, GameOverUI, RestartMessage, StartButton, StartMenuUI}; -use crate::resources::RestartPressed; +use crate::components::{ + Bullet, Enemy, EnemyBullet, Explosion, GameOverUI, HighScoreText, RestartMessage, StartButton, + StartMenuUI, +}; +use crate::constants::STARTING_LIVES; +use crate::resources::{ + CurrentStage, FormationState, HighScore, PlayerLives, RestartPressed, Score, +}; #[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)] pub enum AppState { @@ -16,7 +22,7 @@ const BUTTON_HOVER: Color = Color::srgb(0.2, 0.2, 0.7); // --- Game Over UI --- -pub fn setup_game_over_ui(mut commands: Commands) { +pub fn setup_game_over_ui(mut commands: Commands, high_score: Res, score: Res) { commands.spawn(( Text::new("GAME OVER"), TextFont { @@ -33,6 +39,25 @@ pub fn setup_game_over_ui(mut commands: Commands) { }, GameOverUI, )); + commands.spawn(( + Text::new(format!( + "Best: {} Your Score: {}", + high_score.value, score.value + )), + TextFont { + font_size: 32.0, + ..default() + }, + TextColor(Color::WHITE), + Node { + position_type: PositionType::Absolute, + align_self: AlignSelf::Center, + justify_self: JustifySelf::Center, + top: Val::Percent(48.0), + ..default() + }, + HighScoreText, + )); commands.spawn(( Text::new("Press R to Restart"), TextFont { @@ -53,7 +78,7 @@ pub fn setup_game_over_ui(mut commands: Commands) { pub fn cleanup_game_over_ui( mut commands: Commands, - query: Query, With)>>, + query: Query, With, With)>>, ) { for entity in &query { commands.entity(entity).despawn(); @@ -64,7 +89,15 @@ pub fn cleanup_game_over_ui( pub fn cleanup_game_entities( mut commands: Commands, - query: Query, With, With)>>, + query: Query< + Entity, + Or<( + With, + With, + With, + With, + )>, + >, ) { for entity in &query { commands.entity(entity).despawn(); @@ -73,7 +106,7 @@ pub fn cleanup_game_entities( // --- Start Menu UI --- -pub fn setup_start_menu_ui(mut commands: Commands) { +pub fn setup_start_menu_ui(mut commands: Commands, high_score: Res) { commands .spawn(( Node { @@ -99,6 +132,19 @@ pub fn setup_start_menu_ui(mut commands: Commands) { ..default() }, )); + parent.spawn(( + Text::new(format!("Best: {}", high_score.value)), + TextFont { + font_size: 32.0, + ..default() + }, + TextColor(Color::WHITE), + Node { + margin: UiRect::bottom(Val::Px(30.0)), + ..default() + }, + HighScoreText, + )); parent .spawn(( Button, @@ -127,7 +173,10 @@ pub fn setup_start_menu_ui(mut commands: Commands) { }); } -pub fn cleanup_start_menu_ui(mut commands: Commands, query: Query>) { +pub fn cleanup_start_menu_ui( + mut commands: Commands, + query: Query, With)>>, +) { for entity in &query { commands.entity(entity).despawn(); } @@ -161,9 +210,170 @@ pub fn handle_restart_input( pub fn restart_game_system( mut next_state: ResMut>, mut restart: ResMut, + mut score: ResMut, + mut lives: ResMut, + mut stage: ResMut, + mut formation: ResMut, ) { if restart.pressed { restart.pressed = false; + score.value = 0; + lives.count = STARTING_LIVES; + stage.number = 1; + stage.waiting_for_clear = false; + formation.next_slot_index = 0; + formation.total_spawned_this_stage = 0; + formation.formation_complete = false; next_state.set(AppState::Playing); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::Explosion; + use crate::constants::STARTING_LIVES; + use crate::resources::{CurrentStage, FormationState, PlayerLives}; + use bevy::ecs::world::World; + + #[test] + fn restart_game_resets_score_to_zero() { + let mut world = World::default(); + world.insert_resource(Score { value: 5000 }); + world.insert_resource(PlayerLives { count: 1 }); + world.insert_resource(CurrentStage { + number: 5, + waiting_for_clear: true, + }); + world.insert_resource(FormationState { + next_slot_index: 4, + total_spawned_this_stage: 10, + formation_complete: true, + }); + world.insert_resource(NextState::::Pending(AppState::Playing)); + world.insert_resource(RestartPressed { pressed: true }); + + world.register_system(restart_game_system); + world + .run_system_cached(restart_game_system) + .expect("system should run"); + + let score = world.resource::(); + assert_eq!(score.value, 0, "Score should be reset to 0 on restart"); + } + + #[test] + fn restart_game_resets_player_lives_to_starting_lives() { + let mut world = World::default(); + world.insert_resource(Score { value: 5000 }); + world.insert_resource(PlayerLives { count: 1 }); + world.insert_resource(CurrentStage { + number: 1, + waiting_for_clear: false, + }); + world.insert_resource(FormationState::default()); + world.insert_resource(NextState::::Pending(AppState::Playing)); + world.insert_resource(RestartPressed { pressed: true }); + + world.register_system(restart_game_system); + world + .run_system_cached(restart_game_system) + .expect("system should run"); + + let lives = world.resource::(); + assert_eq!( + lives.count, STARTING_LIVES, + "PlayerLives should be reset to STARTING_LIVES on restart" + ); + } + + #[test] + fn restart_game_resets_current_stage_to_defaults() { + let mut world = World::default(); + world.insert_resource(Score { value: 0 }); + world.insert_resource(PlayerLives { + count: STARTING_LIVES, + }); + world.insert_resource(CurrentStage { + number: 5, + waiting_for_clear: true, + }); + world.insert_resource(FormationState::default()); + world.insert_resource(NextState::::Pending(AppState::Playing)); + world.insert_resource(RestartPressed { pressed: true }); + + world.register_system(restart_game_system); + world + .run_system_cached(restart_game_system) + .expect("system should run"); + + let stage = world.resource::(); + assert_eq!( + stage.number, 1, + "CurrentStage.number should be reset to 1 on restart" + ); + assert_eq!( + stage.waiting_for_clear, false, + "CurrentStage.waiting_for_clear should be reset to false on restart" + ); + } + + #[test] + fn restart_game_resets_formation_state_to_defaults() { + let mut world = World::default(); + world.insert_resource(Score { value: 0 }); + world.insert_resource(PlayerLives { + count: STARTING_LIVES, + }); + world.insert_resource(CurrentStage { + number: 1, + waiting_for_clear: false, + }); + world.insert_resource(FormationState { + next_slot_index: 4, + total_spawned_this_stage: 10, + formation_complete: true, + }); + world.insert_resource(NextState::::Pending(AppState::Playing)); + world.insert_resource(RestartPressed { pressed: true }); + + world.register_system(restart_game_system); + world + .run_system_cached(restart_game_system) + .expect("system should run"); + + let formation = world.resource::(); + assert_eq!( + formation.next_slot_index, 0, + "FormationState.next_slot_index should be reset to 0" + ); + assert_eq!( + formation.total_spawned_this_stage, 0, + "FormationState.total_spawned_this_stage should be reset to 0" + ); + assert_eq!( + formation.formation_complete, false, + "FormationState.formation_complete should be reset to false" + ); + } + + #[test] + fn cleanup_game_entities_despawns_explosions() { + let mut world = World::default(); + world.spawn(Explosion { + timer: Timer::from_seconds(0.4, TimerMode::Once), + }); + + world.register_system(cleanup_game_entities); + world + .run_system_cached(cleanup_game_entities) + .expect("system should run"); + + let mut query = world.query::<&Explosion>(); + let explosion_count = query.iter(&world).count(); + assert_eq!( + explosion_count, 0, + "Explosion entities should be despawned during cleanup" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9c16d49..f6188f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod components; pub mod constants; pub mod enemy; pub mod game_state; +pub mod persistence; pub mod player; pub mod resources; pub mod stage; @@ -15,8 +16,7 @@ pub mod starfield; pub mod systems; use bullet::{ - check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets, - move_enemy_bullets, + check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets, move_enemy_bullets, }; use components::TractorBeam; use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH}; @@ -34,14 +34,14 @@ use player::{ player_shoot, respawn_player, }; use resources::{ - AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, PlayerLives, + AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, HighScore, PlayerLives, PlayerRespawnTimer, RestartPressed, Score, StageConfigurations, }; use stage::check_stage_clear; use starfield::scroll_starfield; use systems::{ - animate_explosion, player_exists, player_vulnerable, setup, should_respawn_player, - update_window_title, + animate_explosion, check_high_score, player_exists, player_vulnerable, setup, + should_respawn_player, update_window_title, }; pub fn run() { @@ -73,6 +73,9 @@ pub fn run() { timer: Timer::new(Duration::from_secs_f32(3.0), TimerMode::Once), }) .init_resource::() + .insert_resource(HighScore { + value: persistence::load(&persistence::save_path()).value, + }) .init_resource::() .init_resource::() .init_resource::() @@ -121,7 +124,10 @@ pub fn run() { start_menu_button_system.run_if(in_state(AppState::StartMenu)), ) // Game over. - .add_systems(OnEnter(AppState::GameOver), setup_game_over_ui) + .add_systems( + OnEnter(AppState::GameOver), + (setup_game_over_ui, check_high_score), + ) .add_systems(OnExit(AppState::GameOver), cleanup_game_over_ui) .add_systems( Update, diff --git a/src/persistence.rs b/src/persistence.rs new file mode 100644 index 0000000..634ae32 --- /dev/null +++ b/src/persistence.rs @@ -0,0 +1,73 @@ +use std::path::{Path, PathBuf}; + +use crate::resources::HighScore; + +pub fn save_path() -> PathBuf { + if let Some(p) = SAVE_PATH_OVERRIDE.get() { + return p.clone(); + } + if let Ok(env_path) = std::env::var("BGLGA_HIGHSCORE_PATH") { + return PathBuf::from(env_path); + } + dirs::data_dir() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) + .join("bglga") + .join("highscore.json") +} + +pub fn save(score: &HighScore, path: &Path) -> Result<(), std::io::Error> { + std::fs::create_dir_all(path.parent().unwrap_or(Path::new(".")))?; + let content = serde_json::to_string_pretty(score)?; + std::fs::write(path, content) +} + +pub fn load(path: &Path) -> HighScore { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return HighScore { value: 0 }, + }; + serde_json::from_str::(&content).unwrap_or(HighScore { value: 0 }) +} + +static SAVE_PATH_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn set_save_path_override(path: PathBuf) { + SAVE_PATH_OVERRIDE.set(path).ok(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn save_and_load_round_trip() { + let temp_path = std::env::temp_dir().join("highscore_roundtrip.json"); + let score = HighScore { value: 42 }; + save(&score, &temp_path).expect("save should succeed"); + let loaded = load(&temp_path); + assert_eq!(loaded.value, 42, "loaded score should match saved score"); + let _ = std::fs::remove_file(&temp_path); + } + + #[test] + fn load_missing_file_returns_default() { + let missing_path = std::env::temp_dir().join("highscore_nonexistent.json"); + let loaded = load(&missing_path); + assert_eq!( + loaded.value, 0, + "missing file should return default HighScore" + ); + } + + #[test] + fn load_malformed_json_returns_default() { + let temp_path = std::env::temp_dir().join("highscore_malformed.json"); + std::fs::write(&temp_path, "{bad json").expect("write temp file should succeed"); + let loaded = load(&temp_path); + assert_eq!( + loaded.value, 0, + "malformed JSON should return default HighScore" + ); + let _ = std::fs::remove_file(&temp_path); + } +} diff --git a/src/player.rs b/src/player.rs index a552694..67cfb31 100644 --- a/src/player.rs +++ b/src/player.rs @@ -58,10 +58,10 @@ pub fn move_player( transform.translation.x += direction * player.speed * time.delta_secs(); let half_width = PLAYER_SIZE.x / 2.0; - transform.translation.x = transform - .translation - .x - .clamp(-WINDOW_WIDTH / 2.0 + half_width, WINDOW_WIDTH / 2.0 - half_width); + transform.translation.x = transform.translation.x.clamp( + -WINDOW_WIDTH / 2.0 + half_width, + WINDOW_WIDTH / 2.0 - half_width, + ); } pub fn handle_captured_player( diff --git a/src/resources.rs b/src/resources.rs index a2a0a9e..3986dff 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -167,3 +167,9 @@ pub struct AttackDiveTimer { pub struct RestartPressed { pub pressed: bool, } + +#[derive(Resource, Default, serde::Serialize, serde::Deserialize)] +pub struct HighScore { + #[serde(rename = "high_score")] + pub value: u32, +} diff --git a/src/systems.rs b/src/systems.rs index 563cf7d..812f1a6 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -2,9 +2,12 @@ use bevy::prelude::*; use std::time::Duration; use crate::components::{Explosion, Invincible, Player}; -use crate::constants::{EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE}; +use crate::constants::{ + EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE, +}; +use crate::persistence; use crate::player::spawn_player_ship; -use crate::resources::{CurrentStage, PlayerLives, Score, is_special_stage}; +use crate::resources::{is_special_stage, CurrentStage, HighScore, PlayerLives, Score}; use crate::starfield::spawn_starfield; pub fn setup(mut commands: Commands) { @@ -27,6 +30,17 @@ pub fn should_respawn_player(lives: Res, query: Query<&Player>) -> query.is_empty() && lives.count > 0 } +// --- High Score --- + +pub fn check_high_score(score: Res, mut high_score: ResMut) { + if score.value > high_score.value { + high_score.value = score.value; + if let Err(e) = persistence::save(&high_score, &persistence::save_path()) { + eprintln!("Failed to save high score: {e}"); + } + } +} + // --- HUD --- pub fn update_window_title( diff --git a/tests/high_score.rs b/tests/high_score.rs new file mode 100644 index 0000000..5de85e2 --- /dev/null +++ b/tests/high_score.rs @@ -0,0 +1,60 @@ +use bevy::ecs::world::World; +use bglga::persistence; +use bglga::resources::{HighScore, Score}; +use bglga::systems::check_high_score; + +// ── check_high_score system ── + +#[test] +fn check_high_score_updates_when_score_exceeds() { + let save_path = std::env::temp_dir().join("bglga_test_integration.json"); + let mut world = World::default(); + world.insert_resource(Score { value: 7000 }); + world.insert_resource(HighScore { value: 5000 }); + persistence::set_save_path_override(save_path.clone()); + world.register_system(check_high_score); + world + .run_system_cached(check_high_score) + .expect("system should run"); + let hs = world.resource::(); + assert_eq!(hs.value, 7000); + // Assert file was persisted to disk + let content = std::fs::read_to_string(&save_path).expect("high score file should exist"); + assert!( + content.contains("7000"), + "persisted file should contain score 7000: {content}" + ); + std::fs::remove_file(&save_path).ok(); +} + +#[test] +fn check_high_score_does_not_update_when_score_equal() { + let mut world = bevy::ecs::world::World::default(); + world.insert_resource(Score { value: 75 }); + world.insert_resource(HighScore { value: 75 }); + world.register_system(check_high_score); + world + .run_system_cached(check_high_score) + .expect("system should run"); + let high_score = world.resource::(); + assert_eq!( + high_score.value, 75, + "HighScore should remain unchanged when score equals high score" + ); +} + +#[test] +fn check_high_score_does_not_update_when_score_lower() { + let mut world = bevy::ecs::world::World::default(); + world.insert_resource(Score { value: 30 }); + world.insert_resource(HighScore { value: 99 }); + world.register_system(check_high_score); + world + .run_system_cached(check_high_score) + .expect("system should run"); + let high_score = world.resource::(); + assert_eq!( + high_score.value, 99, + "HighScore should remain unchanged when score is lower" + ); +} diff --git a/tests/special_stage.rs b/tests/special_stage.rs index 2c744f3..fd8e983 100644 --- a/tests/special_stage.rs +++ b/tests/special_stage.rs @@ -110,7 +110,10 @@ fn for_stage_returns_normal_config_at_stage_4() { fn special_stage_has_full_formation() { let configs = StageConfigurations::default(); let config = configs.for_stage(3); - assert_eq!(config.enemy_count, 32, "Special stage should have 32 enemies"); + assert_eq!( + config.enemy_count, 32, + "Special stage should have 32 enemies" + ); assert_eq!( config.formation_layout.positions.len(), 32,