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

View File

@@ -4,14 +4,14 @@
//! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim
//! Auslesen aufsummiert wird.
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use super::attributes::Attribute;
/// Sieben feste Kennzeichnungen, wie Schaden gemindert wird. Engine-Vokabular
/// ohne Namensraum. Zu jedem Typ gehoert eine Resistenz, und Resistenz ist der
/// *einzige* Minderungsmechanismus (Ticket 01).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(usize)]
pub enum DamageType {
@@ -121,7 +121,7 @@ pub enum Action {
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
/// [`Attribute`]: Attribute werden gerechnet, Zustand wird gesetzt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StateField {
Health,
@@ -142,11 +142,12 @@ impl Action {
/// Ein befristeter Modifikator, der auf einem Wesen liegt. Er **wohnt beim
/// Traeger** (Ticket 01) — stirbt der Traeger, verschwindet die Aura von selbst.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveModifier {
/// ID des Registry-Eintrags, damit `remove_modifier` ihn findet.
pub id: String,
pub attribute: Attribute,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub damage_type: Option<DamageType>,
pub flat: f32,
pub percent: f32,

View File

@@ -3,12 +3,12 @@
//! Der Katalog ist **fest** und Engine-Vokabular ohne Namensraum. Ein Modder
//! kann ihn nicht erweitern; das ist Absicht (siehe Glossar, „Attribut").
use serde::Deserialize;
use serde::{Deserialize, Serialize};
/// Fester Katalog aus Ticket 01. `level` steht hier, weil Bedingungen und
/// Formeln es lesen — die Quelle fuer Aufstiegspunkte ist aber `base.level`
/// und nicht dieser gelesene Wert (Ticket 15).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(usize)]
pub enum Attribute {
@@ -88,7 +88,7 @@ impl Attribute {
/// Die Grundwerte eines Wesens. Bewusst ein Array statt vierzehn benannter
/// Felder: `get` und `set` sind damit je eine Zeile statt je eines
/// vierzehnarmigen `match`.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attributes([f32; Attribute::COUNT]);
impl Attributes {

View File

@@ -56,40 +56,42 @@ pub enum CreatureRef {
Enemy(usize),
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Creature {
pub name: String,
/// Grundwerte. `base.level` ist die einzige Quelle fuer Aufstiegspunkte
/// (Ticket 15) — das *gelesene* Attribut darf hoeher ausfallen.
pub base: Attributes,
/// Zustand, scharf getrennt von den Attributen (Ticket 01).
pub health: f32,
pub stamina: f32,
/// Einmal beim Betreten des Kampfes gewuerfelt (Ticket 14).
pub initiative: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
/// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat
/// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger.
pub def: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
/// **Die Ansage**: Was dieses Wesen im naechsten Zug tun wird — verbindlich
/// und deshalb stoerbar.
pub intent: Option<String>,
/// Grundwerte. `base.level` ist die einzige Quelle fuer Aufstiegspunkte
/// (Ticket 15) — das *gelesene* Attribut darf hoeher ausfallen.
pub base: Attributes,
/// Gewaehlte Charakteroptionen. Ohne dieses Feld haette der Held nach dem
/// Charakterbau keine Rasse mehr (Ticket 07).
pub options: Vec<String>,
pub inventory: Vec<ItemInstance>,
pub equipment: HashMap<String, ItemInstance>,
pub tags: Vec<String>,
/// Faehigkeiten, die *nicht* aus Optionen oder Ausruestung kommen.
pub abilities: Vec<String>,
// Ab hier stehen TOML-**Tabellen**. Die Reihenfolge ist keine Kosmetik:
// TOML verlangt Werte vor Tabellen, und der Speicherstand ist der Baum
// selbst (Ticket 09).
pub inventory: Vec<ItemInstance>,
/// **Nur befristete** Modifikatoren. Rasse und Klasse kommen aus `options`,
/// Ausruestung aus `equipment` — hineinzukopieren machte das Lesen billiger
/// und das Schreiben gefaehrlich (Ticket 07).
pub modifiers: Vec<ActiveModifier>,
/// Einmal beim Betreten des Kampfes gewuerfelt. Bewusst ein Zahlenwert am
/// Wesen und keine Reihenfolgeliste: Die waere eine **zweite** Indexliste
/// mit demselben Ungueltigkeitsproblem beim Tod eines Gegners (Ticket 14).
pub initiative: i32,
pub equipment: HashMap<String, ItemInstance>,
pub cooldowns: HashMap<String, u32>,
/// **Die Ansage**: Was dieses Wesen im naechsten Zug tun wird. Am Zugende
/// festgelegt und damit verbindlich — nur so ist sie *stoerbar*. Eine
/// Vorschau, die sich mit der Lage aendert, waere keine Information,
/// sondern eine Vermutung, und darauf laesst sich keine Taktik bauen.
pub intent: Option<String>,
/// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat
/// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger.
pub def: Option<String>,
/// Faehigkeiten, die *nicht* aus Optionen oder Ausruestung kommen.
pub abilities: Vec<String>,
}
impl Creature {

View File

@@ -4,7 +4,7 @@
//! wirkt einmal, Faehigkeit bietet an — der Trigger sagt *wann*. Er darf
//! ueberall stehen, wo `modifiers` und `actions` stehen.
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use super::action::{Action, DamageType};
use super::condition::Condition;
@@ -16,7 +16,7 @@ use super::creature::CreatureRef;
/// `attack_hit` und `damage_taken` bleiben getrennt: Ersteres feuert beim
/// Angreifer (dort haengt das flammende Schwert), Letzteres beim Getroffenen
/// (dort haengt der Dornenpanzer).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
TurnStarted,

View File

@@ -6,7 +6,10 @@
//! hiesse, die Prefabs zweimal zu parsen.
#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tile {
Wall,
Floor,
@@ -15,7 +18,7 @@ pub enum Tile {
Entrance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rect {
pub x: u16,
pub y: u16,
@@ -41,50 +44,112 @@ impl Rect {
/// Ein NPC auf der Etage. Sein Warenkorb wird **einmal bei der Erzeugung**
/// gezogen und liegt danach fest — sonst wuerfelte jedes Oeffnen des Ladens neu
/// und der Spieler koennte sich das Angebot herbeischliessen (M4).
#[derive(Debug)]
#[derive(Debug, Serialize, Deserialize)]
pub struct NpcInstance {
pub pos: (u16, u16),
pub def: String,
/// Tabelle, also zuletzt (TOML).
pub stock: Vec<super::creature::ItemInstance>,
}
#[derive(Debug)]
#[derive(Debug, Serialize, Deserialize)]
pub struct Room {
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
pub prefab: String,
pub rect: Rect,
/// Aus welcher Tabelle die Haufen dieses Raums ziehen — Prefab-`loot` oder,
/// wenn es fehlt, die Etagentabelle (Ticket 16).
pub loot_table: String,
pub cleared: bool,
/// Die `e`-Plaetze aus dem Prefab — bleiben erhalten, damit die Anzeige
/// weiss, wo etwas stand.
pub enemy_spawns: Vec<(u16, u16)>,
/// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` —
/// **verschoben, nicht kopiert** (Ticket 10).
pub enemies: Vec<super::creature::Creature>,
/// Aus welcher Tabelle die Haufen dieses Raums ziehen — Prefab-`loot` oder,
/// wenn es fehlt, die Etagentabelle. **Eine** ID je Raum: Alle Haufen ziehen
/// ohnehin aus derselben, sie je Haufen zu wiederholen waere Redundanz
/// (Ticket 16).
pub loot_table: String,
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
/// stehen hier Positionen und keine Gegenstaende.
pub loot: Vec<(u16, u16)>,
// Tabellen zuletzt (TOML).
pub rect: Rect,
/// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` —
/// **verschoben, nicht kopiert** (Ticket 10).
pub enemies: Vec<super::creature::Creature>,
/// Die `n`-Plaetze, schon aufgeloest. Anders als bei `e` nennt das Prefab
/// den NPC — ein Schmied skaliert nicht mit der Tiefe.
pub npcs: Vec<NpcInstance>,
pub cleared: bool,
}
#[derive(Debug)]
#[derive(Debug, Deserialize)]
pub struct Floor {
pub number: u8,
pub width: u16,
pub height: u16,
/// **Die eine handgeschriebene Ausnahme** (Ticket 09): Im Speicherstand
/// stehen ASCII-Zeilen wie in einem Prefab, nicht 3200 Enum-Eintraege. Rund
/// 3 KB, lesbar, im Notfall von Hand reparierbar.
#[serde(deserialize_with = "rows_to_tiles")]
pub tiles: Vec<Tile>,
pub rooms: Vec<Room>,
/// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_room: Option<usize>,
pub rooms: Vec<Room>,
}
impl Tile {
fn symbol(self) -> char {
match self {
Tile::Wall => '#',
Tile::Floor => '.',
Tile::Door => '+',
Tile::Stairs => '>',
Tile::Entrance => '<',
}
}
/// Unbekannte Zeichen werden zur Wand. Ein handverbogener Speicherstand
/// soll den Helden einmauern, nicht das Spiel abbrechen.
fn from_symbol(c: char) -> Tile {
match c {
'.' => Tile::Floor,
'+' => Tile::Door,
'>' => Tile::Stairs,
'<' => Tile::Entrance,
_ => Tile::Wall,
}
}
}
fn rows_to_tiles<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Tile>, D::Error> {
let rows = Vec::<String>::deserialize(d)?;
Ok(rows.iter().flat_map(|r| r.chars()).map(Tile::from_symbol).collect())
}
/// `Serialize` von Hand — und **nur** deshalb, weil der Zeilenumbruch die
/// Breite braucht, die ein feldweises `serde(with)` nicht sieht. Der Rest ist
/// eins zu eins der Baum; eigene Save-Structs hiessen, jede Struktur zweimal zu
/// pflegen (Ticket 09).
impl Serialize for Floor {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("Floor", 6)?;
st.serialize_field("number", &self.number)?;
st.serialize_field("width", &self.width)?;
st.serialize_field("height", &self.height)?;
st.serialize_field("tiles", &self.rows())?;
st.serialize_field("player_room", &self.player_room)?;
// Tabellen zuletzt: TOML verlangt Werte vor Tabellen.
st.serialize_field("rooms", &self.rooms)?;
st.end()
}
}
impl Floor {
/// Das Kachelgitter als Textzeilen — dasselbe Format wie ein Prefab.
pub fn rows(&self) -> Vec<String> {
self.tiles
.chunks(self.width as usize)
.map(|z| z.iter().map(|t| t.symbol()).collect())
.collect()
}
pub fn tile(&self, x: u16, y: u16) -> Tile {
if x >= self.width || y >= self.height {
return Tile::Wall;

View File

@@ -25,41 +25,60 @@ use crate::data::Registries;
/// Wie ein Lauf endet (Ticket 10/14). Sieg ueber das **Fehlen** der naechsten
/// Etage — es gibt keine Zahl „5" im Code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunOutcome {
Died,
Won,
}
/// **Der Speicherstand ist dieser Baum** (Ticket 09) — keine eigenen
/// Save-Structs, die man zweimal pflegt und die genau dann auseinanderlaufen,
/// wenn es niemand merkt.
///
/// Die Feldreihenfolge ist deshalb keine Kosmetik: TOML verlangt Werte vor
/// Tabellen. Alles Skalare steht oben, alles Verschachtelte unten.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Game {
pub player: Creature,
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
pub enemies: Vec<Creature>,
pub floor: Floor,
pub player_pos: (u16, u16),
pub turn: u64,
/// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14).
pub rng: Rng,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub over: Option<RunOutcome>,
/// Warteschlange statt Rekursion (Ticket 08): Synchron zu feuern hiesse,
/// ueber `self.enemies` zu iterieren, waehrend `self.apply` erneut laeuft.
pub events: std::collections::VecDeque<Event>,
/// Letzte Meldung fuer die Statuszeile.
pub last: String,
/// Kampflog, die letzten Zeilen.
pub journal: Vec<String>,
/// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub talking_to: Option<usize>,
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
/// `game/` kennt keine Screens.
#[serde(default)]
pub open_shop: bool,
/// Erfahrung und unverteilte Punkte stehen an `Game`, **nicht am
/// `Creature`** (Ticket 15): Nur der Held hat beides, und ein Feld, das
/// fuer 99 % der Exemplare bedeutungslos ist, waere ein Kommentar in
/// Feldform — den der Speicherstand fuer jeden Gegner mitschriebe.
#[serde(default)]
pub current_exp: u32,
#[serde(default)]
pub unspent_points: u32,
// TODO(M6): `quests` (Ticket 08).
/// Warteschlange statt Rekursion (Ticket 08). **Nicht im Speicherstand:**
/// Sie ist Zwischenstand *innerhalb* eines Zuges — `drain_events` leert sie
/// nach jeder Handlung, und gespeichert wird nur zwischen Zuegen. Sie
/// mitzuschreiben hiesse, immer eine leere Liste zu speichern.
#[serde(skip)]
pub events: std::collections::VecDeque<Event>,
// Ab hier Tabellen.
pub player: Creature,
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
pub enemies: Vec<Creature>,
pub floor: Floor,
// TODO(M7): `quests` (Ticket 08).
}
impl Game {

View File

@@ -18,7 +18,7 @@ use game::{Creature, ItemInstance};
fn main() {
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
// druckt ueber `Debug` und setzt dem Spieler `LoadError { .. }` vor.
let (registries, warnings) = match data::load_mods() {
let (registries, mut warnings) = match data::load_mods() {
Ok(v) => v,
Err(e) => {
eprintln!("{e}");
@@ -26,18 +26,35 @@ fn main() {
}
};
// Ein Speicherstand, kein Slot-Menue (Ticket 09). Liegt einer da, wird er
// fortgesetzt; sonst faengt ein Lauf an. Bei Tod loescht die Schleife ihn,
// also entscheidet allein sein Vorhandensein.
let (geladen, verworfen) = match data::save::load(&registries) {
Ok((game, aus_dem_save)) => (Some(game), aus_dem_save),
Err(data::save::SaveError::Missing) => (None, Vec::new()),
// Falsches Schema oder kaputte Datei: melden, **liegen lassen** und neu
// anfangen. Loeschen waere die schlechteste aller Reaktionen.
Err(e) => (None, vec![e.to_string()]),
};
// Getrennt gezaehlt: Mod-Ladewarnungen sind eine Zeile im Hauptmenue
// (Ticket 04), nur Verworfenes aus dem Save fragt zurueck (Ticket 09).
let rueckfragen = verworfen.len();
warnings.extend(verworfen);
let log_name = data::write_log(&warnings);
// Der Seed wird genau einmal frisch gezogen. Ab hier steht er im Spiel und
// spaeter im Speicherstand — Nachladen wuerfelt nicht neu (Ticket 14).
let Some(game) = game::Game::new(starting_hero(), &registries, game::Rng::from_clock()) else {
let Some(game) =
geladen.or_else(|| game::Game::new(starting_hero(), &registries, game::Rng::from_clock()))
else {
eprintln!(
"Keine Etage mit number = 1 gefunden. Erwartet wird ein [[floors]]-Eintrag in mods/core."
);
std::process::exit(1);
};
if let Err(e) = ui::run(game, registries, warnings.len(), log_name) {
if let Err(e) = ui::run(game, registries, warnings.len(), rueckfragen, log_name) {
eprintln!("Terminal-Fehler: {e}");
std::process::exit(1);
}

View File

@@ -10,7 +10,7 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
use super::grid::GridWidget;
use super::screen::{Response, Screen};
use super::theme;
use crate::data::Registries;
use crate::data::{Registries, save};
use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome};
pub struct App {
@@ -28,6 +28,17 @@ impl App {
/// Der Spielzustand fuehrt, der Stack folgt: `Game.enemies` und `Game.over`
/// sind die Wahrheit. Ohne das muesste jede Stelle, die einen Kampf startet
/// oder beendet, daran denken, den Screen mitzuschieben.
/// Beim Beenden und beim Betreten einer Etage. Ein gescheiterter Autosave
/// bricht das Spiel nicht ab — er landet in der Statuszeile.
fn autosave(&mut self) {
if self.game.over.is_some() {
return;
}
if let Err(e) = save::save(&self.game, &self.registries) {
self.game.last = format!("Speichern fehlgeschlagen: {e}");
}
}
fn sync_screens(&mut self) {
let in_combat = !self.game.enemies.is_empty();
let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. }));
@@ -63,11 +74,34 @@ impl App {
}
}
pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option<String>) -> std::io::Result<()> {
pub fn run(
game: Game,
registries: Registries,
warnings: usize,
verworfen: usize,
log_name: Option<String>,
) -> std::io::Result<()> {
let mut screens = vec![Screen::MainMenu];
// Die Rueckfrage steht **vor** dem Spiel, nicht als Zeile darin (Ticket
// 09): Wer hier „nein" sagt, will den fehlenden Mod nachinstallieren — und
// diese Wahl hat er nur jetzt, danach ist der verkrueppelte Held gespeichert.
if verworfen > 0 {
let wo = log_name.as_deref().unwrap_or("kein Log schreibbar");
screens.push(Screen::Confirm {
text: format!(
"{verworfen} Einträge aus dem Speicherstand fehlen (siehe logs/{wo}).
Fortfahren?"
),
on_yes: Box::new(Response::Pop),
on_no: Box::new(Response::Quit),
});
}
let mut app = App {
game,
registries,
screens: vec![Screen::MainMenu],
screens,
warnings,
log_name,
rejected: None,
@@ -99,11 +133,13 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
}
app.screens.pop();
if app.screens.is_empty() {
app.autosave();
break Ok(());
}
}
Response::Push(s) => app.screens.push(s),
Response::Command(cmd) => {
let etage_vorher = app.game.floor.number;
// Hier steht die Zusage aus Ticket 07 auf dem Pruefstand:
// `&mut app.game` neben `&app.registries` in einem Aufruf.
app.rejected = match app.game.execute(cmd, &app.registries) {
@@ -117,8 +153,22 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
// die UI — die reagiert nur darauf. Dasselbe gilt fuer den
// Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt.
app.sync_screens();
// Autosave beim **Betreten einer Etage** (Ticket 09). Bei
// Tod stattdessen loeschen — Permadeath, und zwar erst hier
// beim `GameOver`-Screen: Stuerzt das Spiel in der Sekunde
// davor ab, ist der letzte Autosave noch da, und der stammt
// vom Betreten der Etage, verschenkt also nichts.
if app.game.over.is_some() {
save::delete();
} else if app.game.floor.number != etage_vorher {
app.autosave();
}
}
Response::Quit => {
app.autosave();
break Ok(());
}
Response::Quit => break Ok(()),
}
}
})
@@ -141,6 +191,7 @@ fn draw(frame: &mut Frame, app: &App) {
}
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
Screen::Confirm { text, .. } => draw_confirm(frame, area, text),
Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
Screen::GameOver => draw_game_over(frame, area, app),
}
@@ -347,6 +398,17 @@ fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: boo
);
}
fn draw_confirm(frame: &mut Frame, area: Rect, text: &str) {
let mut zeilen: Vec<Line> = text.lines().map(Line::from).collect();
zeilen.push(Line::from(""));
zeilen.push(Line::from("j / Enter fortfahren").style(Style::new().fg(Color::Yellow)));
zeilen.push(Line::from("n / Esc beenden").style(Style::new().fg(Color::DarkGray)));
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(" Rückfrage ")),
area,
);
}
/// Die Werkbank. Eine Liste, vier Tasten — was sich mit zwei vorhandenen
/// Kommandos nachstellen laesst, steht hier nicht.
fn draw_debug(frame: &mut Frame, area: Rect, app: &App, pick: usize) {

View File

@@ -8,6 +8,7 @@ use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
/// `Option<Popup>`, weil Popup ueber Popup real ist.
#[derive(Clone)]
pub enum Screen {
MainMenu,
Explore,
@@ -38,6 +39,16 @@ pub enum Screen {
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
GameOver,
/// Rueckfrage. Ticket 09 wollte `on_yes: Box<Response>` und ein implizites
/// „Nein = wegklicken" — das reicht nicht: Beim Ladefall heisst Nein
/// *beenden und den Mod nachinstallieren*, denn diese Wahl hat der Spieler
/// nur **vor** dem Laden. Das symmetrische Paar ist ein Feld mehr und
/// spart die ungeschriebene Regel.
Confirm {
text: String,
on_yes: Box<Response>,
on_no: Box<Response>,
},
/// Werkbank. Immer mit gebaut statt hinter `cfg(debug_assertions)`: Das
/// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer
/// ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht spaeter nach.
@@ -63,6 +74,7 @@ pub enum Screen {
/// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein
/// `&mut` auf genau die `Vec`, in der er liegt. Nebeneffekt — `Response::Command`
/// ist der einzige Weg nach `game/`.
#[derive(Clone)]
pub enum Response {
None,
Pop,
@@ -344,6 +356,12 @@ impl Screen {
}
}
Screen::Confirm { on_yes, on_no, .. } => match key {
KeyCode::Char('j' | 'y') | KeyCode::Enter => (**on_yes).clone(),
KeyCode::Char('n' | 'q') | KeyCode::Esc => (**on_no).clone(),
_ => Response::None,
},
Screen::GameOver => Response::Quit,
}
}