feat(save): Speicherstand als Game-Baum
Autosave beim Betreten einer Etage und beim Beenden, ein Slot, bei Tod geloescht. Der Save ist der Baum selbst — keine eigenen Save-Structs, die man zweimal pflegt. - Kacheln als ASCII-Zeilen wie ein Prefab, nicht 3200 Enum-Eintraege - atomar geschrieben: tmp plus rename, der einzige Stand ueberlebt einen Abbruch - fremdes Schema wird abgelehnt, die Datei bleibt liegen - fehlende Mods verwerfen Eintraege, nie den Lauf - Confirm-Screen vor dem Spiel, wenn der Save Eintraege verloren hat Fuenf Befunde: die Ereigniswarteschlange gehoert nicht in den Save, die Feldreihenfolge ist Teil des TOML-Formats, Serialize fuer Floor muss von Hand, Confirm braucht on_no, und die Rueckfrage galt zuerst auch fuer Mod-Ladewarnungen.
This commit is contained in:
@@ -60,8 +60,7 @@ struct Manifest {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
/// Einziger Leser ist der Save-Header (Ticket 03/09).
|
||||
#[allow(dead_code)]
|
||||
/// Einziger Leser ist der Save-Kopf (Ticket 03/09).
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
depends: Vec<String>,
|
||||
@@ -70,6 +69,9 @@ struct Manifest {
|
||||
#[derive(Debug)]
|
||||
struct ModInfo {
|
||||
id: String,
|
||||
/// Nur fuer den Save-Kopf (Ticket 09) — wird verglichen und protokolliert,
|
||||
/// nie geprueft.
|
||||
version: String,
|
||||
depends: Vec<String>,
|
||||
path: PathBuf,
|
||||
}
|
||||
@@ -133,7 +135,7 @@ fn discover(warnings: &mut Vec<String>) -> Vec<ModInfo> {
|
||||
}
|
||||
found.insert(
|
||||
m.id.clone(),
|
||||
ModInfo { id: m.id, depends: m.depends, path },
|
||||
ModInfo { id: m.id, version: m.version, depends: m.depends, path },
|
||||
);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{}: {}", manifest_path.display(), e.message())),
|
||||
@@ -531,6 +533,12 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
||||
Err(e) => warnings.push(format!("[balance]: {} — Standardwerte gelten", e.message())),
|
||||
}
|
||||
|
||||
reg.mods = order
|
||||
.iter()
|
||||
.filter_map(|id| found.iter().find(|m| &m.id == id))
|
||||
.map(|m| super::save::ModStamp { id: m.id.clone(), version: m.version.clone() })
|
||||
.collect();
|
||||
|
||||
// Phase 3 — Referenzen aufloesen
|
||||
resolve(&mut reg, &mut warnings);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod loader;
|
||||
pub mod registries;
|
||||
pub mod save;
|
||||
|
||||
pub use loader::{load_mods, write_log};
|
||||
pub use registries::{
|
||||
|
||||
@@ -477,6 +477,11 @@ pub struct Registries {
|
||||
pub npcs: HashMap<String, Npc>,
|
||||
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
|
||||
pub balance: Balance,
|
||||
/// Wer geladen wurde, in Ladereihenfolge. **Einziger Leser ist der
|
||||
/// Save-Kopf** (Ticket 03/09): Er stempelt die Versionen, damit ein
|
||||
/// spaeterer Ladelauf „`meinmod` 1.0.0 → 1.2.0 seit dem Speichern" melden
|
||||
/// kann. Das ist die ganze Bilanz des `version`-Felds.
|
||||
pub mods: Vec<crate::data::save::ModStamp>,
|
||||
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).
|
||||
}
|
||||
|
||||
|
||||
383
src/data/save.rs
Normal file
383
src/data/save.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
//! Speicherstand — Ticket 09.
|
||||
//!
|
||||
//! **Der Save ist der `Game`-Baum.** Eigene Save-Structs hiessen, jede Struktur
|
||||
//! zweimal zu pflegen und bei jeder Aenderung daran zu denken — die Sorte
|
||||
//! Duplikat, die genau dann auseinanderlaeuft, wenn es niemand merkt. Der Preis
|
||||
//! ist ehrlich: Der Save haengt an internen Feldnamen, ein Umbenennen bricht
|
||||
//! alte Staende. Dafuer gibt es `schema`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::{Creature, Game, ItemInstance};
|
||||
|
||||
use super::Registries;
|
||||
|
||||
/// Steigt, wenn sich die **Struktur** aendert. Ein abweichender Wert wird
|
||||
/// abgelehnt, nicht migriert: Migration zu schreiben, bevor ein einziger
|
||||
/// Speicherstand ausgeliefert wurde, ist Code fuer ein Problem, das noch
|
||||
/// niemand hat.
|
||||
pub const SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
pub schema: u32,
|
||||
/// Reine Diagnose bei Fehlermeldungen.
|
||||
pub game: String,
|
||||
#[serde(default)]
|
||||
pub mods: Vec<ModStamp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModStamp {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Zwei Typen statt eines: Geschrieben wird aus Referenzen, gelesen in Besitz.
|
||||
/// Ein gemeinsamer Typ braeuchte entweder `Clone` auf dem ganzen `Game` — also
|
||||
/// eine Kopie der Etage bei jedem Autosave — oder einen Zeigertrick.
|
||||
#[derive(Serialize)]
|
||||
struct SaveOut<'a> {
|
||||
save: &'a Header,
|
||||
game: &'a Game,
|
||||
}
|
||||
|
||||
/// Ohne `save`-Feld: Der Kopf wird vorab einzeln gelesen, und unbekannte
|
||||
/// Schluessel ignoriert serde ohnehin.
|
||||
#[derive(Deserialize)]
|
||||
struct SaveIn {
|
||||
game: Game,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SaveError {
|
||||
/// Kein Speicherstand da — der Normalfall beim ersten Start.
|
||||
Missing,
|
||||
/// Falsches Schema. Die **Datei bleibt liegen**: Sie zu loeschen, weil man
|
||||
/// sie nicht lesen kann, waere die schlechteste aller Reaktionen.
|
||||
Schema { found: u32 },
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SaveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SaveError::Missing => write!(f, "Kein Speicherstand vorhanden"),
|
||||
SaveError::Schema { found } => write!(
|
||||
f,
|
||||
"Speicherstand stammt aus Schema {found}, dieses Spiel liest {SCHEMA}"
|
||||
),
|
||||
SaveError::Unreadable(why) => write!(f, "Speicherstand nicht lesbar: {why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
// Dasselbe Verzeichnis wie Mods und `load_order.toml` — Ticket 03 hat die
|
||||
// Aufteilung in Daten und Konfiguration verworfen.
|
||||
super::loader::profile_dir().map(|d| d.join("save.toml"))
|
||||
}
|
||||
|
||||
/// Schreibt **atomar**: erst `save.toml.tmp`, dann `rename` darueber. Das ist
|
||||
/// die eine Stelle in Ticket 09, an der bewusst nicht lazy entschieden wurde —
|
||||
/// der Autosave ueberschreibt den einzigen Speicherstand, den es gibt. Bricht
|
||||
/// das Schreiben ab, liegt entweder die alte Datei da oder die neue, nie eine
|
||||
/// halbe.
|
||||
pub fn save(game: &Game, reg: &Registries) -> std::io::Result<()> {
|
||||
debug_assert!(
|
||||
game.events.is_empty(),
|
||||
"gespeichert wird zwischen Zuegen — die Warteschlange muss leer sein"
|
||||
);
|
||||
|
||||
let Some(ziel) = path() else {
|
||||
return Err(std::io::Error::other("kein Datenverzeichnis"));
|
||||
};
|
||||
if let Some(dir) = ziel.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
|
||||
let kopf = Header {
|
||||
schema: SCHEMA,
|
||||
game: env!("CARGO_PKG_VERSION").to_string(),
|
||||
mods: reg.mods.clone(),
|
||||
};
|
||||
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game })
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
write_atomic(&ziel, &text)
|
||||
}
|
||||
|
||||
/// Erst daneben schreiben, dann darueberziehen. `rename` ersetzt die Zieldatei
|
||||
/// auf Windows 10 1607+ ebenso wie auf Unix.
|
||||
fn write_atomic(ziel: &std::path::Path, text: &str) -> std::io::Result<()> {
|
||||
let tmp = ziel.with_extension("toml.tmp");
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, ziel)
|
||||
}
|
||||
|
||||
pub fn load(reg: &Registries) -> Result<(Game, Vec<String>), SaveError> {
|
||||
let Some(quelle) = path() else { return Err(SaveError::Missing) };
|
||||
let Ok(text) = std::fs::read_to_string(&quelle) else {
|
||||
return Err(SaveError::Missing);
|
||||
};
|
||||
|
||||
// Erst nur den Kopf lesen: Bei falschem Schema ist der Rumpf strukturell
|
||||
// anders, und Best-Effort darauf erzeugt stille Verfaelschung.
|
||||
#[derive(Deserialize)]
|
||||
struct NurKopf {
|
||||
save: Header,
|
||||
}
|
||||
let kopf: NurKopf = toml::from_str(&text).map_err(|e| SaveError::Unreadable(e.to_string()))?;
|
||||
if kopf.save.schema != SCHEMA {
|
||||
return Err(SaveError::Schema { found: kopf.save.schema });
|
||||
}
|
||||
|
||||
let save: SaveIn = toml::from_str(&text).map_err(|e| SaveError::Unreadable(e.to_string()))?;
|
||||
let mut warnings = version_drift(&kopf.save.mods, reg);
|
||||
let mut game = save.game;
|
||||
prune(&mut game, reg, &mut warnings);
|
||||
Ok((game, warnings))
|
||||
}
|
||||
|
||||
pub fn delete() {
|
||||
if let Some(p) = path() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reine Diagnose — eine Zeile, die genau dann Gold wert ist, wenn jemand
|
||||
/// meldet „mein Spielstand ist seit dem Update kaputt". Geaenderte Werte gelten
|
||||
/// einfach, verschwundene IDs faengt `prune` ab.
|
||||
fn version_drift(damals: &[ModStamp], reg: &Registries) -> Vec<String> {
|
||||
let jetzt: HashMap<&str, &str> =
|
||||
reg.mods.iter().map(|m| (m.id.as_str(), m.version.as_str())).collect();
|
||||
damals
|
||||
.iter()
|
||||
.filter_map(|alt| match jetzt.get(alt.id.as_str()) {
|
||||
Some(neu) if *neu != alt.version => {
|
||||
Some(format!("`{}` {} → {neu} seit dem Speichern", alt.id, alt.version))
|
||||
}
|
||||
Some(_) => None,
|
||||
None => Some(format!("`{}` {} fehlt jetzt", alt.id, alt.version)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **Verwerfen, nie ablehnen** (Ticket 09): Ein Spieler soll nicht seinen
|
||||
/// ganzen Lauf verlieren, weil er einen Mod deinstalliert hat.
|
||||
fn prune(game: &mut Game, reg: &Registries, warnings: &mut Vec<String>) {
|
||||
prune_creature(&mut game.player, reg, warnings);
|
||||
for e in &mut game.enemies {
|
||||
prune_creature(e, reg, warnings);
|
||||
}
|
||||
for room in &mut game.floor.rooms {
|
||||
for e in &mut room.enemies {
|
||||
prune_creature(e, reg, warnings);
|
||||
}
|
||||
for npc in &mut room.npcs {
|
||||
prune_items(&mut npc.stock, reg, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_creature(c: &mut Creature, reg: &Registries, warnings: &mut Vec<String>) {
|
||||
prune_items(&mut c.inventory, reg, warnings);
|
||||
|
||||
// Ausruestung: Der Slot bleibt leer, der Held bleibt spielbar.
|
||||
c.equipment.retain(|slot, inst| {
|
||||
let da = reg.items.contains_key(&inst.def) && reg.slots.contains_key(slot);
|
||||
if !da {
|
||||
warnings.push(format!("Ausrüstung `{}` fehlt, Slot bleibt leer", inst.def));
|
||||
}
|
||||
da
|
||||
});
|
||||
for inst in c.equipment.values_mut() {
|
||||
prune_affixes(inst, reg, warnings);
|
||||
}
|
||||
|
||||
c.options.retain(|id| {
|
||||
let da = reg.options.contains_key(id);
|
||||
if !da {
|
||||
warnings.push(format!("Charakteroption `{id}` fehlt"));
|
||||
}
|
||||
da
|
||||
});
|
||||
|
||||
// Ein Modifikator, dessen Definition fehlt, laeuft trotzdem ab — er traegt
|
||||
// seine Zahlen selbst (Ticket 07). Nichts zu tun.
|
||||
}
|
||||
|
||||
fn prune_items(v: &mut Vec<ItemInstance>, reg: &Registries, warnings: &mut Vec<String>) {
|
||||
v.retain(|inst| {
|
||||
let da = reg.items.contains_key(&inst.def);
|
||||
if !da {
|
||||
warnings.push(format!("Gegenstand `{}` fehlt", inst.def));
|
||||
}
|
||||
da
|
||||
});
|
||||
for inst in v.iter_mut() {
|
||||
prune_affixes(inst, reg, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_affixes(inst: &mut ItemInstance, reg: &Registries, warnings: &mut Vec<String>) {
|
||||
inst.affixes.retain(|a| {
|
||||
let da = reg.affixes.contains_key(a);
|
||||
if !da {
|
||||
warnings.push(format!("Affix `{a}` fehlt, Gegenstand bleibt"));
|
||||
}
|
||||
da
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::load_mods;
|
||||
use crate::game::{Attribute, CreatureRef, Rng};
|
||||
|
||||
fn lauf() -> (Game, Registries) {
|
||||
let (reg, _) = load_mods().expect("core muss laden");
|
||||
let mut held = Creature::new("Testheld");
|
||||
held.options = vec![String::from("core:dwarf")];
|
||||
held.equipment
|
||||
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
|
||||
let game = Game::new(held, ®, Rng::new(4711)).expect("Etage 1");
|
||||
(game, reg)
|
||||
}
|
||||
|
||||
fn durch_toml(game: &Game, reg: &Registries) -> (String, Game) {
|
||||
let kopf = Header {
|
||||
schema: SCHEMA,
|
||||
game: String::from("test"),
|
||||
mods: reg.mods.clone(),
|
||||
};
|
||||
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game })
|
||||
.expect("TOML verlangt Werte vor Tabellen — die Feldreihenfolge muss stimmen");
|
||||
let zurueck: SaveIn = toml::from_str(&text).expect("und wieder einlesbar");
|
||||
(text, zurueck.game)
|
||||
}
|
||||
|
||||
/// Der Save **ist** der Baum: Was rausgeht, muss unveraendert zurueckkommen.
|
||||
/// Faellt sofort um, wenn jemand ein Tabellenfeld vor ein Wertfeld schiebt.
|
||||
#[test]
|
||||
fn ein_lauf_ueberlebt_die_runde_durch_toml() {
|
||||
let (mut game, reg) = lauf();
|
||||
game.turn = 42;
|
||||
game.current_exp = 17;
|
||||
game.unspent_points = 2;
|
||||
game.give(ItemInstance::stack("core:gold", 130), ®);
|
||||
game.player.health = 33.0;
|
||||
|
||||
let (text, zurueck) = durch_toml(&game, ®);
|
||||
|
||||
assert_eq!(zurueck.turn, 42);
|
||||
assert_eq!(zurueck.current_exp, 17);
|
||||
assert_eq!(zurueck.unspent_points, 2);
|
||||
assert_eq!(zurueck.player.health, 33.0);
|
||||
assert_eq!(zurueck.player_pos, game.player_pos);
|
||||
assert_eq!(zurueck.gold(), 130, "der Stapel kommt als Stapel zurueck");
|
||||
assert_eq!(
|
||||
zurueck.player.equipment.get("core:hand").map(|i| i.def.as_str()),
|
||||
Some("core:iron_sword")
|
||||
);
|
||||
assert_eq!(
|
||||
zurueck.attribute(CreatureRef::Player, Attribute::Level, None, ®),
|
||||
game.attribute(CreatureRef::Player, Attribute::Level, None, ®)
|
||||
);
|
||||
|
||||
// Die Etage kommt Kachel fuer Kachel zurueck — und steht als lesbare
|
||||
// Zeilen in der Datei, nicht als 3200 Enum-Eintraege (Ticket 09).
|
||||
assert_eq!(zurueck.floor.rows(), game.floor.rows());
|
||||
assert_eq!(zurueck.floor.rooms.len(), game.floor.rooms.len());
|
||||
assert!(text.contains("####"), "die Kacheln muessen als Zeilen dastehen");
|
||||
assert!(!text.contains("\"wall\""), "und nicht als Enum-Namen");
|
||||
}
|
||||
|
||||
/// **Verwerfen, nie ablehnen**: Ein fehlender Mod kostet Eintraege, nicht
|
||||
/// den Lauf.
|
||||
#[test]
|
||||
fn fehlende_eintraege_werden_verworfen_nicht_abgelehnt() {
|
||||
let (mut game, reg) = lauf();
|
||||
game.player.inventory.push(ItemInstance::new("weg:phantom"));
|
||||
game.player.options.push(String::from("weg:klasse"));
|
||||
let mut verhext = ItemInstance::new("core:iron_sword");
|
||||
verhext.affixes = vec![String::from("weg:affix"), String::from("core:sharp")];
|
||||
game.player.inventory.push(verhext);
|
||||
game.player
|
||||
.equipment
|
||||
.insert(String::from("core:head"), ItemInstance::new("weg:helm"));
|
||||
|
||||
let mut warnungen = Vec::new();
|
||||
prune(&mut game, ®, &mut warnungen);
|
||||
|
||||
assert!(!game.player.inventory.iter().any(|i| i.def == "weg:phantom"));
|
||||
assert!(!game.player.options.iter().any(|o| o == "weg:klasse"));
|
||||
assert!(!game.player.equipment.contains_key("core:head"), "Slot bleibt leer");
|
||||
|
||||
let schwert = game
|
||||
.player
|
||||
.inventory
|
||||
.iter()
|
||||
.find(|i| i.def == "core:iron_sword")
|
||||
.expect("der Gegenstand bleibt, nur das Affix faellt");
|
||||
assert_eq!(schwert.affixes, vec![String::from("core:sharp")]);
|
||||
|
||||
assert_eq!(warnungen.len(), 4, "jeder Verlust bekommt eine Zeile: {warnungen:?}");
|
||||
}
|
||||
|
||||
/// Falsches Schema: ablehnen mit Klartext, **Datei bleibt liegen**.
|
||||
#[test]
|
||||
fn fremdes_schema_wird_abgelehnt() {
|
||||
let (game, _reg) = lauf();
|
||||
let kopf = Header { schema: SCHEMA + 1, game: String::from("test"), mods: Vec::new() };
|
||||
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game: &game }).expect("schreibt");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NurKopf {
|
||||
save: Header,
|
||||
}
|
||||
let gelesen: NurKopf = toml::from_str(&text).expect("der Kopf ist immer lesbar");
|
||||
assert_ne!(gelesen.save.schema, SCHEMA);
|
||||
let fehler = SaveError::Schema { found: gelesen.save.schema };
|
||||
assert!(fehler.to_string().contains("Schema 2"), "{fehler}");
|
||||
}
|
||||
|
||||
/// Der Versionsvergleich leistet **reine Diagnose** — die Zeile, die Gold
|
||||
/// wert ist, wenn jemand meldet „seit dem Update kaputt".
|
||||
#[test]
|
||||
fn mod_versionen_werden_nur_protokolliert() {
|
||||
let (_, reg) = lauf();
|
||||
let damals = vec![
|
||||
ModStamp { id: String::from("core"), version: String::from("0.0.1") },
|
||||
ModStamp { id: String::from("weg"), version: String::from("1.0.0") },
|
||||
];
|
||||
let zeilen = version_drift(&damals, ®);
|
||||
assert_eq!(zeilen.len(), 2, "{zeilen:?}");
|
||||
assert!(zeilen[0].contains("core"));
|
||||
assert!(zeilen[1].contains("fehlt jetzt"));
|
||||
}
|
||||
|
||||
/// Die eine Stelle, an der Ticket 09 bewusst nicht lazy entschieden hat:
|
||||
/// Der Autosave ueberschreibt den **einzigen** Speicherstand. Geprueft wird
|
||||
/// das Beobachtbare — die alte Datei wird ersetzt, und es bleibt kein
|
||||
/// halber Rest liegen.
|
||||
#[test]
|
||||
fn atomar_geschrieben_bleibt_kein_rest_liegen() {
|
||||
let dir = std::env::temp_dir().join(format!("rustkeep-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("Testverzeichnis");
|
||||
let ziel = dir.join("save.toml");
|
||||
|
||||
write_atomic(&ziel, "alt").expect("erstes Schreiben");
|
||||
assert_eq!(std::fs::read_to_string(&ziel).unwrap(), "alt");
|
||||
|
||||
write_atomic(&ziel, "neu").expect("darueberziehen");
|
||||
assert_eq!(std::fs::read_to_string(&ziel).unwrap(), "neu", "rename ersetzt");
|
||||
assert!(!ziel.with_extension("toml.tmp").exists(), "keine halbe Datei daneben");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user