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

@@ -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<Response> }` 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<Response> }` 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.

View File

@@ -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.

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,
}
}