- 1-4 cave slots by map size, 50% each (at least one); knight treasure caves only where a mission places them - dwarves fight intruders near the cave, never chase far, heal at home, grow with distance and the player's army, never count for victory - rune chest: one free rune forge tower (chain bolt to three more enemies), upgradeable for gold, sold once it comes back (next mission / free play at once), unbuilt ones carry over - new tool (key N) shown in the tower menu while a rune tower can be built or upgraded Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
4.9 KiB
Rust
139 lines
4.9 KiB
Rust
// Savegames: JSON with format tag and version, containing the game state, the camera
|
|
// and the player's settings (settings are always applied when a savegame is loaded).
|
|
|
|
use crate::game::Game;
|
|
use crate::render3d::Cam;
|
|
use crate::settings::Settings;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub const FORMAT: &str = "dungeoncraft-save";
|
|
pub const VERSION: u32 = 2; // 2: variable map size, several portals, packed tiles
|
|
pub const AUTOSAVE_KEY: &str = "dc.autosave";
|
|
|
|
#[derive(Serialize)]
|
|
struct SaveOut<'a> {
|
|
format: &'a str,
|
|
version: u32,
|
|
game: &'a Game,
|
|
camera: &'a Cam,
|
|
settings: &'a Settings,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SaveIn {
|
|
format: String,
|
|
version: u32,
|
|
game: Game,
|
|
camera: Option<Cam>,
|
|
settings: Option<Settings>,
|
|
}
|
|
|
|
pub struct Loaded {
|
|
pub game: Game,
|
|
pub camera: Option<Cam>,
|
|
pub settings: Option<Settings>,
|
|
}
|
|
|
|
pub fn to_json(game: &Game, camera: &Cam, settings: &Settings) -> String {
|
|
serde_json::to_string(&SaveOut { format: FORMAT, version: VERSION, game, camera, settings }).unwrap_or_default()
|
|
}
|
|
|
|
// Err contains a language key describing the problem
|
|
pub fn from_bytes(data: &[u8]) -> Result<Loaded, &'static str> {
|
|
let s: SaveIn = serde_json::from_slice(data).map_err(|_| "save.err_invalid")?;
|
|
if s.format != FORMAT {
|
|
return Err("save.err_invalid");
|
|
}
|
|
if s.version > VERSION {
|
|
return Err("save.err_version");
|
|
}
|
|
let mut game = s.game;
|
|
game.map.activate();
|
|
game.recount();
|
|
Ok(Loaded { game, camera: s.camera, settings: s.settings })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn save_roundtrip() {
|
|
let mut g = Game::new(42, 96, 128);
|
|
let (cx, cy) = g.map.core;
|
|
g.mark_rect(cx - 5, cy - 5, cx + 8, cy + 8, true);
|
|
for _ in 0..3000 {
|
|
g.update(0.05);
|
|
}
|
|
let cam = Cam::new(10.0, 20.0);
|
|
let mut settings = Settings::default();
|
|
settings.language = "de".into();
|
|
let json = to_json(&g, &cam, &settings);
|
|
println!("savegame size: {} KB", json.len() / 1024);
|
|
let l = from_bytes(json.as_bytes()).expect("load");
|
|
assert_eq!(l.game.time, g.time);
|
|
assert_eq!(l.game.ents.len(), g.ents.len());
|
|
assert_eq!(l.game.gold, g.gold);
|
|
assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.kind == b.kind && a.owner == b.owner && a.marked == b.marked));
|
|
assert_eq!(l.settings.unwrap().language, "de");
|
|
assert_eq!(l.camera.unwrap().tx, 10.0);
|
|
assert_eq!((l.game.map.w, l.game.map.h), (96, 128));
|
|
assert_eq!(l.game.map.portals.len(), g.map.portals.len());
|
|
assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.var == b.var && a.hp == b.hp && a.food == b.food && a.gold == b.gold));
|
|
// the loaded game keeps running identically
|
|
let mut a = l.game;
|
|
let mut b = g;
|
|
for _ in 0..2000 {
|
|
a.update(0.05);
|
|
b.update(0.05);
|
|
}
|
|
assert_eq!(a.gold, b.gold);
|
|
assert_eq!(a.ents.len(), b.ents.len());
|
|
assert!(from_bytes(b"{\"format\":\"x\"}").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn huge_map() {
|
|
let t0 = std::time::Instant::now();
|
|
let mut g = Game::new(7, 256, 256);
|
|
let gen = t0.elapsed();
|
|
assert_eq!(g.map.portals.len(), 16);
|
|
assert_eq!(g.map.treasures.len(), 0); // knight caves are replaced by dwarf caves
|
|
assert!((1..=4).contains(&g.dwarf_caves.len()));
|
|
let (cx, cy) = g.map.core;
|
|
g.mark_rect(cx - 5, cy - 5, cx + 8, cy + 8, true);
|
|
let t1 = std::time::Instant::now();
|
|
for _ in 0..(120 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
let sim = t1.elapsed();
|
|
let json = to_json(&g, &Cam::new(0.0, 0.0), &Settings::default());
|
|
println!("256x256: generate {:?}, 2 min simulated in {:?}, savegame {} KB", gen, sim, json.len() / 1024);
|
|
let l = from_bytes(json.as_bytes()).expect("load");
|
|
assert_eq!(l.game.map.t.len(), 256 * 256);
|
|
}
|
|
|
|
// writes a savegame shortly before a wave (with planned entry points) when DC_SAVE_OUT is set
|
|
#[test]
|
|
fn dump_entry_warning_save() {
|
|
let Ok(path) = std::env::var("DC_SAVE_OUT") else { return };
|
|
let mut g = Game::new(11, 64, 64);
|
|
for _ in 0..(60 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
g.wave_timer = 31.0;
|
|
for _ in 0..30 {
|
|
g.update(0.05); // the scouts warn 30 s before the wave
|
|
}
|
|
assert!(!g.planned_entries.is_empty());
|
|
let (cx, cy) = g.map.core;
|
|
g.spawn(crate::game::EK::Chainbreaker, false, (cx - 1) as f32 + 0.5, (cy - 1) as f32 + 0.5, 1);
|
|
g.spawn(crate::game::EK::Ogre, false, (cx + 4) as f32 + 0.5, (cy - 1) as f32 + 0.5, 1);
|
|
let (ex, ey) = (cx + 1, cy + 1);
|
|
let mut cam = Cam::new(ex as f32 + 0.5, ey as f32 + 0.5);
|
|
cam.zoom = 12.0;
|
|
std::fs::write(path, to_json(&g, &cam, &Settings { language: "en".into(), ..Settings::default() })).unwrap();
|
|
}
|
|
}
|
|
|