From 060a9a2a14608a06f2c2a4e54c79a77758d5e76f Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Thu, 7 May 2026 23:30:59 +0200 Subject: [PATCH] feat(persistence): add high score saving/loading Add HighScore resource, persistence module with save/load, and integration tests. Dependencies: serde, serde_json, dirs. --- Cargo.lock | 47 +++++++++++++++++++++++++++-- Cargo.toml | 3 ++ src/components.rs | 3 ++ src/persistence.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++ src/resources.rs | 6 ++++ tests/high_score.rs | 60 +++++++++++++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 src/persistence.rs create mode 100644 tests/high_score.rs 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 e656a0c..b638ce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,6 @@ edition = "2021" [dependencies] bevy = "0.18" fastrand = "2.0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" 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/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/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/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" + ); +}