feat(persistence): add high score saving/loading
Add HighScore resource, persistence module with save/load, and integration tests. Dependencies: serde, serde_json, dirs.
This commit is contained in:
parent
459e8a2353
commit
060a9a2a14
6 changed files with 189 additions and 3 deletions
47
Cargo.lock
generated
47
Cargo.lock
generated
|
|
@ -1638,7 +1638,10 @@ name = "bglga"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy",
|
"bevy",
|
||||||
|
"dirs",
|
||||||
"fastrand",
|
"fastrand",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2189,6 +2192,27 @@ dependencies = [
|
||||||
"unicode-xid",
|
"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]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2587,7 +2611,7 @@ dependencies = [
|
||||||
"vec_map",
|
"vec_map",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"windows 0.58.0",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3376,7 +3400,7 @@ version = "0.50.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3710,6 +3734,12 @@ version = "1.21.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "orbclient"
|
name = "orbclient"
|
||||||
version = "0.3.48"
|
version = "0.3.48"
|
||||||
|
|
@ -4063,6 +4093,17 @@ dependencies = [
|
||||||
"bitflags 2.9.0",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
|
|
@ -4172,7 +4213,7 @@ dependencies = [
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,6 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = "0.18"
|
bevy = "0.18"
|
||||||
fastrand = "2.0.1"
|
fastrand = "2.0.1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
dirs = "6"
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ pub struct StartButton;
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct RestartMessage;
|
pub struct RestartMessage;
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct HighScoreText;
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct Star {
|
pub struct Star {
|
||||||
pub speed: f32,
|
pub speed: f32,
|
||||||
|
|
|
||||||
73
src/persistence.rs
Normal file
73
src/persistence.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -167,3 +167,9 @@ pub struct AttackDiveTimer {
|
||||||
pub struct RestartPressed {
|
pub struct RestartPressed {
|
||||||
pub pressed: bool,
|
pub pressed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Resource, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct HighScore {
|
||||||
|
#[serde(rename = "high_score")]
|
||||||
|
pub value: u32,
|
||||||
|
}
|
||||||
|
|
|
||||||
60
tests/high_score.rs
Normal file
60
tests/high_score.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue