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:
2026-08-12 22:01:12 +02:00
parent 7fdd8bbb00
commit d09fef7069
15 changed files with 698 additions and 64 deletions

View File

@@ -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);

View File

@@ -3,6 +3,7 @@
pub mod loader;
pub mod registries;
pub mod save;
pub use loader::{load_mods, write_log};
pub use registries::{

View File

@@ -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
View 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, &reg, 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), &reg);
game.player.health = 33.0;
let (text, zurueck) = durch_toml(&game, &reg);
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, &reg),
game.attribute(CreatureRef::Player, Attribute::Level, None, &reg)
);
// 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, &reg, &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, &reg);
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);
}
}