Merge branch 'opencode/clever-wolf'

This commit is contained in:
Harald Hoyer 2026-05-09 14:02:18 +02:00
commit 6eef94b783
15 changed files with 505 additions and 34 deletions

47
Cargo.lock generated
View file

@ -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]]

View file

@ -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"] }

View file

@ -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.

View file

@ -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.

47
TODO/GAL-56.md Normal file
View file

@ -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<NextState<AppState>>,
mut restart: ResMut<RestartPressed>,
) {
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`.

View file

@ -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,

View file

@ -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 {

View file

@ -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<HighScore>, score: Res<Score>) {
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<Entity, Or<(With<GameOverUI>, With<RestartMessage>)>>,
query: Query<Entity, Or<(With<GameOverUI>, With<RestartMessage>, With<HighScoreText>)>>,
) {
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<Entity, Or<(With<Bullet>, With<EnemyBullet>, With<Enemy>)>>,
query: Query<
Entity,
Or<(
With<Bullet>,
With<EnemyBullet>,
With<Enemy>,
With<Explosion>,
)>,
>,
) {
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<HighScore>) {
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<Entity, With<StartMenuUI>>) {
pub fn cleanup_start_menu_ui(
mut commands: Commands,
query: Query<Entity, Or<(With<StartMenuUI>, With<HighScoreText>)>>,
) {
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<NextState<AppState>>,
mut restart: ResMut<RestartPressed>,
mut score: ResMut<Score>,
mut lives: ResMut<PlayerLives>,
mut stage: ResMut<CurrentStage>,
mut formation: ResMut<FormationState>,
) {
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::<AppState>::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::<Score>();
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::<AppState>::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::<PlayerLives>();
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::<AppState>::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::<CurrentStage>();
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::<AppState>::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::<FormationState>();
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"
);
}
}

View file

@ -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::<Score>()
.insert_resource(HighScore {
value: persistence::load(&persistence::save_path()).value,
})
.init_resource::<CurrentStage>()
.init_resource::<FormationState>()
.init_resource::<StageConfigurations>()
@ -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,

73
src/persistence.rs Normal file
View file

@ -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::<HighScore>(&content).unwrap_or(HighScore { value: 0 })
}
static SAVE_PATH_OVERRIDE: std::sync::OnceLock<PathBuf> = 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);
}
}

View file

@ -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(

View file

@ -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,
}

View file

@ -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<PlayerLives>, query: Query<&Player>) ->
query.is_empty() && lives.count > 0
}
// --- High Score ---
pub fn check_high_score(score: Res<Score>, mut high_score: ResMut<HighScore>) {
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(

60
tests/high_score.rs Normal file
View file

@ -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::<HighScore>();
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::<HighScore>();
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::<HighScore>();
assert_eq!(
high_score.value, 99,
"HighScore should remain unchanged when score is lower"
);
}

View file

@ -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,