diff --git a/.scratch/rustkeep-1.0/issues/09-save-schema.md b/.scratch/rustkeep-1.0/issues/09-save-schema.md index 048fe06..e78d965 100644 --- a/.scratch/rustkeep-1.0/issues/09-save-schema.md +++ b/.scratch/rustkeep-1.0/issues/09-save-schema.md @@ -147,3 +147,36 @@ nie eine halbe. |---|---| | **02** | **`core:attack` und `core:flee` sind jedem Wesen implizit gewährt.** Fähigkeiten kommen bisher ausschließlich von Charakteroptionen und Gegenständen — ein Held, dessen Klasse fehlt und der nichts trägt, hätte gar keine und könnte nicht einmal angreifen. Das ist keine Sonderregel für den Ladefall: Auch jeder Gegner ohne `abilities`-Eintrag trifft es, und die KI aus 14 stünde vor einer leeren Liste | | **11** | `Confirm { text, on_yes: Box }` statt `on_yes: Command`. Die Rückfrage beim Laden braucht keinen Command, sondern einen Screen-Wechsel. `Response::Command` ist bereits eine Variante, der bisherige Fall bleibt also erhalten — der Sonderfall entfällt ersatzlos, die Struktur wird dabei *kleiner* | + +### Befunde aus M6 + +**Die Ereigniswarteschlange gehört nicht in den Save.** „Der Kampfzustand fällt bei serde-Ableitung +gratis mit ab" stimmt für `enemies`, `initiative` und `cooldowns` — nicht für `Game.events`. Die ist +Zwischenstand *innerhalb* eines Zuges: `drain_events` leert sie nach jeder einzelnen Handlung, und +gespeichert wird ausschließlich zwischen Zügen. Sie mitzuschreiben hieße, immer eine leere Liste zu +speichern. Sie steht jetzt auf `#[serde(skip)]`, mit einem `debug_assert` in `save()`, das die +Behauptung festhält statt sie nur zu behaupten. + +Angenehmer Nebeneffekt: Ohne `events` taucht `CreatureRef` im Save gar nicht auf — und damit auch kein +`{ Enemy = 3 }` als Inline-Tabelle in einer Datei, die lesbar sein soll. + +**Die Feldreihenfolge ist Teil des Formats.** TOML verlangt Werte vor Tabellen. „Der Save *ist* der +Baum" heißt deshalb: Die Reihenfolge der Felder in `Game`, `Creature`, `Room` und `Floor` ist keine +Kosmetik mehr, sondern eine Bedingung. `Game`, `Creature` und `Room` mussten umsortiert werden — alles +Skalare nach oben, alles Verschachtelte nach unten. Der Rundlauf-Test fällt sofort um, wenn jemand das +wieder vermischt; das ist die einzige Absicherung, die es dafür gibt. + +**`Serialize` für `Floor` muss von Hand.** Der Zeilenumbruch im Kachelgitter braucht die Breite, und +ein feldweises `#[serde(with = …)]` sieht nur sein eigenes Feld. `Deserialize` bleibt abgeleitet (die +Zeilen werden einfach aneinandergehängt), `Serialize` sind fünfzehn Zeilen. Das ist genau die eine +handgeschriebene Ausnahme, die das Ticket vorgesehen hat — nur an einer anderen Stelle als gedacht. + +**Die Rückfrage braucht `on_no`.** Ticket 11 hatte `Confirm { text, on_yes: Box }` beschlossen +und „Nein" implizit als Wegklicken gedacht. Im Ladefall heißt Nein aber *beenden und den Mod +nachinstallieren* — genau die Wahl, die es nach dem Laden nicht mehr gibt. `on_no` ist ein Feld mehr +und spart eine ungeschriebene Regel. + +**Die Rückfrage gilt nur für Verworfenes aus dem Save.** Beim ersten Bauen hing sie an *allen* +Warnungen, also auch an denen des Mod-Ladelaufs — und damit hätte ein kaputter TOML-Eintrag in einem +Mod den Start blockiert. Ticket 04 hat dafür ausdrücklich „eine Zeile, kein Screen" entschieden. Beide +Zahlen werden jetzt getrennt gezählt, obwohl beide in dieselbe Logdatei laufen. diff --git a/.scratch/rustkeep-1.0/map.md b/.scratch/rustkeep-1.0/map.md index 795199e..663510e 100644 --- a/.scratch/rustkeep-1.0/map.md +++ b/.scratch/rustkeep-1.0/map.md @@ -161,6 +161,26 @@ Der dritte ist der interessanteste: Gefunden hat ihn ein **bestehender** Kampfte plötzlich danebenging. Kein neuer Test, keine Überlegung — eine Zusage aus Ticket 14 wäre sonst stillschweigend gebrochen gewesen. +**M6 — Speicherstand** (`feat/m6-speicherstand`, 70 Tests) + +| Zusage | Aus | Befund | +|---|---|---| +| Der Save **ist** der `Game`-Baum, keine eigenen Save-Structs | 09 | **bestätigt** — ein Rundlauf-Test hält es fest | +| Die Etage wird gespeichert, nicht gewürfelt | 09 | **bestätigt** — Kacheln als ASCII-Zeilen wie ein Prefab | +| Eine handgeschriebene Ausnahme fürs Kachelgitter | 09 | **bestätigt**, aber an anderer Stelle: `Serialize for Floor`, weil die Breite feldweise unsichtbar ist | +| Atomar schreiben (tmp + `rename`) | 09 | **bestätigt** — der einzige Speicherstand überlebt einen Abbruch | +| Falsches Schema: ablehnen, Datei bleibt liegen | 09 | **bestätigt** | +| Verwerfen, nie ablehnen | 09 | **bestätigt** — fehlender Mod kostet Einträge, nicht den Lauf | +| Mod-Versionen nur vergleichen und protokollieren | 03/09 | **bestätigt** — die ganze Bilanz des `version`-Felds | +| Kampfzustand fällt gratis mit ab | 09 | **fast** — die Ereigniswarteschlange gehört nicht hinein | +| `Confirm { text, on_yes }` | 11 | **braucht `on_no`** — Nein heißt hier beenden, nicht wegklicken | + +Fünf Befunde nachgetragen: **09** (die Ereigniswarteschlange ist Zwischenstand *innerhalb* eines Zuges +und beim Speichern immer leer), **09** (die Feldreihenfolge ist Teil des Formats — TOML verlangt Werte +vor Tabellen, „der Save ist der Baum" macht die Reihenfolge damit zur Bedingung), **09** (`Serialize` +für `Floor` von Hand statt feldweise), **11** (`Confirm` braucht `on_no`) und **04/09** (die Rückfrage +galt zuerst für alle Warnungen und hätte einen kaputten Mod-Eintrag zum Startblocker gemacht). + **Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen: - **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen. diff --git a/src/data/loader.rs b/src/data/loader.rs index efd8753..9e45048 100644 --- a/src/data/loader.rs +++ b/src/data/loader.rs @@ -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, @@ -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, path: PathBuf, } @@ -133,7 +135,7 @@ fn discover(warnings: &mut Vec) -> Vec { } 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), 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); diff --git a/src/data/mod.rs b/src/data/mod.rs index 59ad288..0504977 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -3,6 +3,7 @@ pub mod loader; pub mod registries; +pub mod save; pub use loader::{load_mods, write_log}; pub use registries::{ diff --git a/src/data/registries.rs b/src/data/registries.rs index d7ce2c1..b65b4a7 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -477,6 +477,11 @@ pub struct Registries { pub npcs: HashMap, /// 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, // TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13). } diff --git a/src/data/save.rs b/src/data/save.rs new file mode 100644 index 0000000..84763b4 --- /dev/null +++ b/src/data/save.rs @@ -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, +} + +#[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 { + // 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), 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 { + 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) { + 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) { + 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, reg: &Registries, warnings: &mut Vec) { + 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) { + 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); + } +} diff --git a/src/game/action.rs b/src/game/action.rs index 15bda12..c7514cf 100644 --- a/src/game/action.rs +++ b/src/game/action.rs @@ -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, pub flat: f32, pub percent: f32, diff --git a/src/game/attributes.rs b/src/game/attributes.rs index 5e0770c..d5368bc 100644 --- a/src/game/attributes.rs +++ b/src/game/attributes.rs @@ -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 { diff --git a/src/game/creature.rs b/src/game/creature.rs index 7a1a36a..a3c0d3d 100644 --- a/src/game/creature.rs +++ b/src/game/creature.rs @@ -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, + #[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, + /// 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, - pub inventory: Vec, - pub equipment: HashMap, pub tags: Vec, + /// Faehigkeiten, die *nicht* aus Optionen oder Ausruestung kommen. + pub abilities: Vec, + + // 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, /// **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, - /// 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, pub cooldowns: HashMap, - /// **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, - /// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat - /// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger. - pub def: Option, - /// Faehigkeiten, die *nicht* aus Optionen oder Ausruestung kommen. - pub abilities: Vec, } impl Creature { diff --git a/src/game/event.rs b/src/game/event.rs index a111eaf..ded7790 100644 --- a/src/game/event.rs +++ b/src/game/event.rs @@ -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, diff --git a/src/game/floor.rs b/src/game/floor.rs index 43571cb..d2050ac 100644 --- a/src/game/floor.rs +++ b/src/game/floor.rs @@ -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, } -#[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, - /// 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, /// Die `n`-Plaetze, schon aufgeloest. Anders als bei `e` nennt das Prefab /// den NPC — ein Schmied skaliert nicht mit der Tiefe. pub npcs: Vec, - 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, - pub rooms: Vec, /// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum. + #[serde(default, skip_serializing_if = "Option::is_none")] pub player_room: Option, + pub rooms: Vec, +} + +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, D::Error> { + let rows = Vec::::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(&self, s: S) -> Result { + 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 { + 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; diff --git a/src/game/mod.rs b/src/game/mod.rs index 3c0ad8c..92766d7 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -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, - 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, - /// Warteschlange statt Rekursion (Ticket 08): Synchron zu feuern hiesse, - /// ueber `self.enemies` zu iterieren, waehrend `self.apply` erneut laeuft. - pub events: std::collections::VecDeque, /// Letzte Meldung fuer die Statuszeile. pub last: String, /// Kampflog, die letzten Zeilen. pub journal: Vec, /// Mit wem gerade gesprochen wird — Index in `Room.npcs`. + #[serde(default, skip_serializing_if = "Option::is_none")] pub talking_to: Option, /// 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, + + // Ab hier Tabellen. + pub player: Creature, + /// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf. + pub enemies: Vec, + pub floor: Floor, + // TODO(M7): `quests` (Ticket 08). } impl Game { diff --git a/src/main.rs b/src/main.rs index 94b8e42..edc68aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(®istries) { + 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(), ®istries, game::Rng::from_clock()) else { + let Some(game) = + geladen.or_else(|| game::Game::new(starting_hero(), ®istries, 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); } diff --git a/src/ui/app.rs b/src/ui/app.rs index a45f934..fe1e6b7 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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) -> std::io::Result<()> { +pub fn run( + game: Game, + registries: Registries, + warnings: usize, + verworfen: usize, + log_name: Option, +) -> 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 = 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) { diff --git a/src/ui/screen.rs b/src/ui/screen.rs index a52f9e9..24a8edc 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -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` statt `current` plus /// `Option`, 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` 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, + on_no: Box, + }, /// 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, } }