Merge branch 'feat/m6-speicherstand'
This commit is contained in:
@@ -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 |
|
| **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* |
|
| **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.
|
||||||
|
|||||||
@@ -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
|
plötzlich danebenging. Kein neuer Test, keine Überlegung — eine Zusage aus Ticket 14 wäre sonst
|
||||||
stillschweigend gebrochen gewesen.
|
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:
|
**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.
|
- **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ struct Manifest {
|
|||||||
id: String,
|
id: String,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
name: String,
|
name: String,
|
||||||
/// Einziger Leser ist der Save-Header (Ticket 03/09).
|
/// Einziger Leser ist der Save-Kopf (Ticket 03/09).
|
||||||
#[allow(dead_code)]
|
|
||||||
version: String,
|
version: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
depends: Vec<String>,
|
depends: Vec<String>,
|
||||||
@@ -70,6 +69,9 @@ struct Manifest {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ModInfo {
|
struct ModInfo {
|
||||||
id: String,
|
id: String,
|
||||||
|
/// Nur fuer den Save-Kopf (Ticket 09) — wird verglichen und protokolliert,
|
||||||
|
/// nie geprueft.
|
||||||
|
version: String,
|
||||||
depends: Vec<String>,
|
depends: Vec<String>,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -133,7 +135,7 @@ fn discover(warnings: &mut Vec<String>) -> Vec<ModInfo> {
|
|||||||
}
|
}
|
||||||
found.insert(
|
found.insert(
|
||||||
m.id.clone(),
|
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())),
|
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())),
|
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
|
// Phase 3 — Referenzen aufloesen
|
||||||
resolve(&mut reg, &mut warnings);
|
resolve(&mut reg, &mut warnings);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
pub mod loader;
|
pub mod loader;
|
||||||
pub mod registries;
|
pub mod registries;
|
||||||
|
pub mod save;
|
||||||
|
|
||||||
pub use loader::{load_mods, write_log};
|
pub use loader::{load_mods, write_log};
|
||||||
pub use registries::{
|
pub use registries::{
|
||||||
|
|||||||
@@ -477,6 +477,11 @@ pub struct Registries {
|
|||||||
pub npcs: HashMap<String, Npc>,
|
pub npcs: HashMap<String, Npc>,
|
||||||
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
|
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
|
||||||
pub balance: Balance,
|
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).
|
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
383
src/data/save.rs
Normal file
383
src/data/save.rs
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
//! Speicherstand — Ticket 09.
|
||||||
|
//!
|
||||||
|
//! **Der Save ist der `Game`-Baum.** Eigene Save-Structs hiessen, jede Struktur
|
||||||
|
//! zweimal zu pflegen und bei jeder Aenderung daran zu denken — die Sorte
|
||||||
|
//! Duplikat, die genau dann auseinanderlaeuft, wenn es niemand merkt. Der Preis
|
||||||
|
//! ist ehrlich: Der Save haengt an internen Feldnamen, ein Umbenennen bricht
|
||||||
|
//! alte Staende. Dafuer gibt es `schema`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::game::{Creature, Game, ItemInstance};
|
||||||
|
|
||||||
|
use super::Registries;
|
||||||
|
|
||||||
|
/// Steigt, wenn sich die **Struktur** aendert. Ein abweichender Wert wird
|
||||||
|
/// abgelehnt, nicht migriert: Migration zu schreiben, bevor ein einziger
|
||||||
|
/// Speicherstand ausgeliefert wurde, ist Code fuer ein Problem, das noch
|
||||||
|
/// niemand hat.
|
||||||
|
pub const SCHEMA: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Header {
|
||||||
|
pub schema: u32,
|
||||||
|
/// Reine Diagnose bei Fehlermeldungen.
|
||||||
|
pub game: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mods: Vec<ModStamp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModStamp {
|
||||||
|
pub id: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Typen statt eines: Geschrieben wird aus Referenzen, gelesen in Besitz.
|
||||||
|
/// Ein gemeinsamer Typ braeuchte entweder `Clone` auf dem ganzen `Game` — also
|
||||||
|
/// eine Kopie der Etage bei jedem Autosave — oder einen Zeigertrick.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SaveOut<'a> {
|
||||||
|
save: &'a Header,
|
||||||
|
game: &'a Game,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ohne `save`-Feld: Der Kopf wird vorab einzeln gelesen, und unbekannte
|
||||||
|
/// Schluessel ignoriert serde ohnehin.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SaveIn {
|
||||||
|
game: Game,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SaveError {
|
||||||
|
/// Kein Speicherstand da — der Normalfall beim ersten Start.
|
||||||
|
Missing,
|
||||||
|
/// Falsches Schema. Die **Datei bleibt liegen**: Sie zu loeschen, weil man
|
||||||
|
/// sie nicht lesen kann, waere die schlechteste aller Reaktionen.
|
||||||
|
Schema { found: u32 },
|
||||||
|
Unreadable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SaveError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SaveError::Missing => write!(f, "Kein Speicherstand vorhanden"),
|
||||||
|
SaveError::Schema { found } => write!(
|
||||||
|
f,
|
||||||
|
"Speicherstand stammt aus Schema {found}, dieses Spiel liest {SCHEMA}"
|
||||||
|
),
|
||||||
|
SaveError::Unreadable(why) => write!(f, "Speicherstand nicht lesbar: {why}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path() -> Option<PathBuf> {
|
||||||
|
// Dasselbe Verzeichnis wie Mods und `load_order.toml` — Ticket 03 hat die
|
||||||
|
// Aufteilung in Daten und Konfiguration verworfen.
|
||||||
|
super::loader::profile_dir().map(|d| d.join("save.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schreibt **atomar**: erst `save.toml.tmp`, dann `rename` darueber. Das ist
|
||||||
|
/// die eine Stelle in Ticket 09, an der bewusst nicht lazy entschieden wurde —
|
||||||
|
/// der Autosave ueberschreibt den einzigen Speicherstand, den es gibt. Bricht
|
||||||
|
/// das Schreiben ab, liegt entweder die alte Datei da oder die neue, nie eine
|
||||||
|
/// halbe.
|
||||||
|
pub fn save(game: &Game, reg: &Registries) -> std::io::Result<()> {
|
||||||
|
debug_assert!(
|
||||||
|
game.events.is_empty(),
|
||||||
|
"gespeichert wird zwischen Zuegen — die Warteschlange muss leer sein"
|
||||||
|
);
|
||||||
|
|
||||||
|
let Some(ziel) = path() else {
|
||||||
|
return Err(std::io::Error::other("kein Datenverzeichnis"));
|
||||||
|
};
|
||||||
|
if let Some(dir) = ziel.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let kopf = Header {
|
||||||
|
schema: SCHEMA,
|
||||||
|
game: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
mods: reg.mods.clone(),
|
||||||
|
};
|
||||||
|
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game })
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
|
write_atomic(&ziel, &text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erst daneben schreiben, dann darueberziehen. `rename` ersetzt die Zieldatei
|
||||||
|
/// auf Windows 10 1607+ ebenso wie auf Unix.
|
||||||
|
fn write_atomic(ziel: &std::path::Path, text: &str) -> std::io::Result<()> {
|
||||||
|
let tmp = ziel.with_extension("toml.tmp");
|
||||||
|
std::fs::write(&tmp, text)?;
|
||||||
|
std::fs::rename(&tmp, ziel)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(reg: &Registries) -> Result<(Game, Vec<String>), SaveError> {
|
||||||
|
let Some(quelle) = path() else { return Err(SaveError::Missing) };
|
||||||
|
let Ok(text) = std::fs::read_to_string(&quelle) else {
|
||||||
|
return Err(SaveError::Missing);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erst nur den Kopf lesen: Bei falschem Schema ist der Rumpf strukturell
|
||||||
|
// anders, und Best-Effort darauf erzeugt stille Verfaelschung.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NurKopf {
|
||||||
|
save: Header,
|
||||||
|
}
|
||||||
|
let kopf: NurKopf = toml::from_str(&text).map_err(|e| SaveError::Unreadable(e.to_string()))?;
|
||||||
|
if kopf.save.schema != SCHEMA {
|
||||||
|
return Err(SaveError::Schema { found: kopf.save.schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
let save: SaveIn = toml::from_str(&text).map_err(|e| SaveError::Unreadable(e.to_string()))?;
|
||||||
|
let mut warnings = version_drift(&kopf.save.mods, reg);
|
||||||
|
let mut game = save.game;
|
||||||
|
prune(&mut game, reg, &mut warnings);
|
||||||
|
Ok((game, warnings))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete() {
|
||||||
|
if let Some(p) = path() {
|
||||||
|
let _ = std::fs::remove_file(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reine Diagnose — eine Zeile, die genau dann Gold wert ist, wenn jemand
|
||||||
|
/// meldet „mein Spielstand ist seit dem Update kaputt". Geaenderte Werte gelten
|
||||||
|
/// einfach, verschwundene IDs faengt `prune` ab.
|
||||||
|
fn version_drift(damals: &[ModStamp], reg: &Registries) -> Vec<String> {
|
||||||
|
let jetzt: HashMap<&str, &str> =
|
||||||
|
reg.mods.iter().map(|m| (m.id.as_str(), m.version.as_str())).collect();
|
||||||
|
damals
|
||||||
|
.iter()
|
||||||
|
.filter_map(|alt| match jetzt.get(alt.id.as_str()) {
|
||||||
|
Some(neu) if *neu != alt.version => {
|
||||||
|
Some(format!("`{}` {} → {neu} seit dem Speichern", alt.id, alt.version))
|
||||||
|
}
|
||||||
|
Some(_) => None,
|
||||||
|
None => Some(format!("`{}` {} fehlt jetzt", alt.id, alt.version)),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Verwerfen, nie ablehnen** (Ticket 09): Ein Spieler soll nicht seinen
|
||||||
|
/// ganzen Lauf verlieren, weil er einen Mod deinstalliert hat.
|
||||||
|
fn prune(game: &mut Game, reg: &Registries, warnings: &mut Vec<String>) {
|
||||||
|
prune_creature(&mut game.player, reg, warnings);
|
||||||
|
for e in &mut game.enemies {
|
||||||
|
prune_creature(e, reg, warnings);
|
||||||
|
}
|
||||||
|
for room in &mut game.floor.rooms {
|
||||||
|
for e in &mut room.enemies {
|
||||||
|
prune_creature(e, reg, warnings);
|
||||||
|
}
|
||||||
|
for npc in &mut room.npcs {
|
||||||
|
prune_items(&mut npc.stock, reg, warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_creature(c: &mut Creature, reg: &Registries, warnings: &mut Vec<String>) {
|
||||||
|
prune_items(&mut c.inventory, reg, warnings);
|
||||||
|
|
||||||
|
// Ausruestung: Der Slot bleibt leer, der Held bleibt spielbar.
|
||||||
|
c.equipment.retain(|slot, inst| {
|
||||||
|
let da = reg.items.contains_key(&inst.def) && reg.slots.contains_key(slot);
|
||||||
|
if !da {
|
||||||
|
warnings.push(format!("Ausrüstung `{}` fehlt, Slot bleibt leer", inst.def));
|
||||||
|
}
|
||||||
|
da
|
||||||
|
});
|
||||||
|
for inst in c.equipment.values_mut() {
|
||||||
|
prune_affixes(inst, reg, warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.options.retain(|id| {
|
||||||
|
let da = reg.options.contains_key(id);
|
||||||
|
if !da {
|
||||||
|
warnings.push(format!("Charakteroption `{id}` fehlt"));
|
||||||
|
}
|
||||||
|
da
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Modifikator, dessen Definition fehlt, laeuft trotzdem ab — er traegt
|
||||||
|
// seine Zahlen selbst (Ticket 07). Nichts zu tun.
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_items(v: &mut Vec<ItemInstance>, reg: &Registries, warnings: &mut Vec<String>) {
|
||||||
|
v.retain(|inst| {
|
||||||
|
let da = reg.items.contains_key(&inst.def);
|
||||||
|
if !da {
|
||||||
|
warnings.push(format!("Gegenstand `{}` fehlt", inst.def));
|
||||||
|
}
|
||||||
|
da
|
||||||
|
});
|
||||||
|
for inst in v.iter_mut() {
|
||||||
|
prune_affixes(inst, reg, warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_affixes(inst: &mut ItemInstance, reg: &Registries, warnings: &mut Vec<String>) {
|
||||||
|
inst.affixes.retain(|a| {
|
||||||
|
let da = reg.affixes.contains_key(a);
|
||||||
|
if !da {
|
||||||
|
warnings.push(format!("Affix `{a}` fehlt, Gegenstand bleibt"));
|
||||||
|
}
|
||||||
|
da
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::data::load_mods;
|
||||||
|
use crate::game::{Attribute, CreatureRef, Rng};
|
||||||
|
|
||||||
|
fn lauf() -> (Game, Registries) {
|
||||||
|
let (reg, _) = load_mods().expect("core muss laden");
|
||||||
|
let mut held = Creature::new("Testheld");
|
||||||
|
held.options = vec![String::from("core:dwarf")];
|
||||||
|
held.equipment
|
||||||
|
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
|
||||||
|
let game = Game::new(held, ®, Rng::new(4711)).expect("Etage 1");
|
||||||
|
(game, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn durch_toml(game: &Game, reg: &Registries) -> (String, Game) {
|
||||||
|
let kopf = Header {
|
||||||
|
schema: SCHEMA,
|
||||||
|
game: String::from("test"),
|
||||||
|
mods: reg.mods.clone(),
|
||||||
|
};
|
||||||
|
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game })
|
||||||
|
.expect("TOML verlangt Werte vor Tabellen — die Feldreihenfolge muss stimmen");
|
||||||
|
let zurueck: SaveIn = toml::from_str(&text).expect("und wieder einlesbar");
|
||||||
|
(text, zurueck.game)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Save **ist** der Baum: Was rausgeht, muss unveraendert zurueckkommen.
|
||||||
|
/// Faellt sofort um, wenn jemand ein Tabellenfeld vor ein Wertfeld schiebt.
|
||||||
|
#[test]
|
||||||
|
fn ein_lauf_ueberlebt_die_runde_durch_toml() {
|
||||||
|
let (mut game, reg) = lauf();
|
||||||
|
game.turn = 42;
|
||||||
|
game.current_exp = 17;
|
||||||
|
game.unspent_points = 2;
|
||||||
|
game.give(ItemInstance::stack("core:gold", 130), ®);
|
||||||
|
game.player.health = 33.0;
|
||||||
|
|
||||||
|
let (text, zurueck) = durch_toml(&game, ®);
|
||||||
|
|
||||||
|
assert_eq!(zurueck.turn, 42);
|
||||||
|
assert_eq!(zurueck.current_exp, 17);
|
||||||
|
assert_eq!(zurueck.unspent_points, 2);
|
||||||
|
assert_eq!(zurueck.player.health, 33.0);
|
||||||
|
assert_eq!(zurueck.player_pos, game.player_pos);
|
||||||
|
assert_eq!(zurueck.gold(), 130, "der Stapel kommt als Stapel zurueck");
|
||||||
|
assert_eq!(
|
||||||
|
zurueck.player.equipment.get("core:hand").map(|i| i.def.as_str()),
|
||||||
|
Some("core:iron_sword")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
zurueck.attribute(CreatureRef::Player, Attribute::Level, None, ®),
|
||||||
|
game.attribute(CreatureRef::Player, Attribute::Level, None, ®)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die Etage kommt Kachel fuer Kachel zurueck — und steht als lesbare
|
||||||
|
// Zeilen in der Datei, nicht als 3200 Enum-Eintraege (Ticket 09).
|
||||||
|
assert_eq!(zurueck.floor.rows(), game.floor.rows());
|
||||||
|
assert_eq!(zurueck.floor.rooms.len(), game.floor.rooms.len());
|
||||||
|
assert!(text.contains("####"), "die Kacheln muessen als Zeilen dastehen");
|
||||||
|
assert!(!text.contains("\"wall\""), "und nicht als Enum-Namen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Verwerfen, nie ablehnen**: Ein fehlender Mod kostet Eintraege, nicht
|
||||||
|
/// den Lauf.
|
||||||
|
#[test]
|
||||||
|
fn fehlende_eintraege_werden_verworfen_nicht_abgelehnt() {
|
||||||
|
let (mut game, reg) = lauf();
|
||||||
|
game.player.inventory.push(ItemInstance::new("weg:phantom"));
|
||||||
|
game.player.options.push(String::from("weg:klasse"));
|
||||||
|
let mut verhext = ItemInstance::new("core:iron_sword");
|
||||||
|
verhext.affixes = vec![String::from("weg:affix"), String::from("core:sharp")];
|
||||||
|
game.player.inventory.push(verhext);
|
||||||
|
game.player
|
||||||
|
.equipment
|
||||||
|
.insert(String::from("core:head"), ItemInstance::new("weg:helm"));
|
||||||
|
|
||||||
|
let mut warnungen = Vec::new();
|
||||||
|
prune(&mut game, ®, &mut warnungen);
|
||||||
|
|
||||||
|
assert!(!game.player.inventory.iter().any(|i| i.def == "weg:phantom"));
|
||||||
|
assert!(!game.player.options.iter().any(|o| o == "weg:klasse"));
|
||||||
|
assert!(!game.player.equipment.contains_key("core:head"), "Slot bleibt leer");
|
||||||
|
|
||||||
|
let schwert = game
|
||||||
|
.player
|
||||||
|
.inventory
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.def == "core:iron_sword")
|
||||||
|
.expect("der Gegenstand bleibt, nur das Affix faellt");
|
||||||
|
assert_eq!(schwert.affixes, vec![String::from("core:sharp")]);
|
||||||
|
|
||||||
|
assert_eq!(warnungen.len(), 4, "jeder Verlust bekommt eine Zeile: {warnungen:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Falsches Schema: ablehnen mit Klartext, **Datei bleibt liegen**.
|
||||||
|
#[test]
|
||||||
|
fn fremdes_schema_wird_abgelehnt() {
|
||||||
|
let (game, _reg) = lauf();
|
||||||
|
let kopf = Header { schema: SCHEMA + 1, game: String::from("test"), mods: Vec::new() };
|
||||||
|
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game: &game }).expect("schreibt");
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NurKopf {
|
||||||
|
save: Header,
|
||||||
|
}
|
||||||
|
let gelesen: NurKopf = toml::from_str(&text).expect("der Kopf ist immer lesbar");
|
||||||
|
assert_ne!(gelesen.save.schema, SCHEMA);
|
||||||
|
let fehler = SaveError::Schema { found: gelesen.save.schema };
|
||||||
|
assert!(fehler.to_string().contains("Schema 2"), "{fehler}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Versionsvergleich leistet **reine Diagnose** — die Zeile, die Gold
|
||||||
|
/// wert ist, wenn jemand meldet „seit dem Update kaputt".
|
||||||
|
#[test]
|
||||||
|
fn mod_versionen_werden_nur_protokolliert() {
|
||||||
|
let (_, reg) = lauf();
|
||||||
|
let damals = vec![
|
||||||
|
ModStamp { id: String::from("core"), version: String::from("0.0.1") },
|
||||||
|
ModStamp { id: String::from("weg"), version: String::from("1.0.0") },
|
||||||
|
];
|
||||||
|
let zeilen = version_drift(&damals, ®);
|
||||||
|
assert_eq!(zeilen.len(), 2, "{zeilen:?}");
|
||||||
|
assert!(zeilen[0].contains("core"));
|
||||||
|
assert!(zeilen[1].contains("fehlt jetzt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die eine Stelle, an der Ticket 09 bewusst nicht lazy entschieden hat:
|
||||||
|
/// Der Autosave ueberschreibt den **einzigen** Speicherstand. Geprueft wird
|
||||||
|
/// das Beobachtbare — die alte Datei wird ersetzt, und es bleibt kein
|
||||||
|
/// halber Rest liegen.
|
||||||
|
#[test]
|
||||||
|
fn atomar_geschrieben_bleibt_kein_rest_liegen() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("rustkeep-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).expect("Testverzeichnis");
|
||||||
|
let ziel = dir.join("save.toml");
|
||||||
|
|
||||||
|
write_atomic(&ziel, "alt").expect("erstes Schreiben");
|
||||||
|
assert_eq!(std::fs::read_to_string(&ziel).unwrap(), "alt");
|
||||||
|
|
||||||
|
write_atomic(&ziel, "neu").expect("darueberziehen");
|
||||||
|
assert_eq!(std::fs::read_to_string(&ziel).unwrap(), "neu", "rename ersetzt");
|
||||||
|
assert!(!ziel.with_extension("toml.tmp").exists(), "keine halbe Datei daneben");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,14 @@
|
|||||||
//! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim
|
//! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim
|
||||||
//! Auslesen aufsummiert wird.
|
//! Auslesen aufsummiert wird.
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::attributes::Attribute;
|
use super::attributes::Attribute;
|
||||||
|
|
||||||
/// Sieben feste Kennzeichnungen, wie Schaden gemindert wird. Engine-Vokabular
|
/// Sieben feste Kennzeichnungen, wie Schaden gemindert wird. Engine-Vokabular
|
||||||
/// ohne Namensraum. Zu jedem Typ gehoert eine Resistenz, und Resistenz ist der
|
/// ohne Namensraum. Zu jedem Typ gehoert eine Resistenz, und Resistenz ist der
|
||||||
/// *einzige* Minderungsmechanismus (Ticket 01).
|
/// *einzige* Minderungsmechanismus (Ticket 01).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[repr(usize)]
|
#[repr(usize)]
|
||||||
pub enum DamageType {
|
pub enum DamageType {
|
||||||
@@ -121,7 +121,7 @@ pub enum Action {
|
|||||||
|
|
||||||
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
|
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
|
||||||
/// [`Attribute`]: Attribute werden gerechnet, Zustand wird gesetzt.
|
/// [`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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum StateField {
|
pub enum StateField {
|
||||||
Health,
|
Health,
|
||||||
@@ -142,11 +142,12 @@ impl Action {
|
|||||||
|
|
||||||
/// Ein befristeter Modifikator, der auf einem Wesen liegt. Er **wohnt beim
|
/// Ein befristeter Modifikator, der auf einem Wesen liegt. Er **wohnt beim
|
||||||
/// Traeger** (Ticket 01) — stirbt der Traeger, verschwindet die Aura von selbst.
|
/// Traeger** (Ticket 01) — stirbt der Traeger, verschwindet die Aura von selbst.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ActiveModifier {
|
pub struct ActiveModifier {
|
||||||
/// ID des Registry-Eintrags, damit `remove_modifier` ihn findet.
|
/// ID des Registry-Eintrags, damit `remove_modifier` ihn findet.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub attribute: Attribute,
|
pub attribute: Attribute,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub damage_type: Option<DamageType>,
|
pub damage_type: Option<DamageType>,
|
||||||
pub flat: f32,
|
pub flat: f32,
|
||||||
pub percent: f32,
|
pub percent: f32,
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
//! Der Katalog ist **fest** und Engine-Vokabular ohne Namensraum. Ein Modder
|
//! Der Katalog ist **fest** und Engine-Vokabular ohne Namensraum. Ein Modder
|
||||||
//! kann ihn nicht erweitern; das ist Absicht (siehe Glossar, „Attribut").
|
//! 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
|
/// Fester Katalog aus Ticket 01. `level` steht hier, weil Bedingungen und
|
||||||
/// Formeln es lesen — die Quelle fuer Aufstiegspunkte ist aber `base.level`
|
/// Formeln es lesen — die Quelle fuer Aufstiegspunkte ist aber `base.level`
|
||||||
/// und nicht dieser gelesene Wert (Ticket 15).
|
/// 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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[repr(usize)]
|
#[repr(usize)]
|
||||||
pub enum Attribute {
|
pub enum Attribute {
|
||||||
@@ -88,7 +88,7 @@ impl Attribute {
|
|||||||
/// Die Grundwerte eines Wesens. Bewusst ein Array statt vierzehn benannter
|
/// Die Grundwerte eines Wesens. Bewusst ein Array statt vierzehn benannter
|
||||||
/// Felder: `get` und `set` sind damit je eine Zeile statt je eines
|
/// Felder: `get` und `set` sind damit je eine Zeile statt je eines
|
||||||
/// vierzehnarmigen `match`.
|
/// vierzehnarmigen `match`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Attributes([f32; Attribute::COUNT]);
|
pub struct Attributes([f32; Attribute::COUNT]);
|
||||||
|
|
||||||
impl Attributes {
|
impl Attributes {
|
||||||
|
|||||||
@@ -56,40 +56,42 @@ pub enum CreatureRef {
|
|||||||
Enemy(usize),
|
Enemy(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Creature {
|
pub struct Creature {
|
||||||
pub name: String,
|
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).
|
/// Zustand, scharf getrennt von den Attributen (Ticket 01).
|
||||||
pub health: f32,
|
pub health: f32,
|
||||||
pub stamina: 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
|
/// Gewaehlte Charakteroptionen. Ohne dieses Feld haette der Held nach dem
|
||||||
/// Charakterbau keine Rasse mehr (Ticket 07).
|
/// Charakterbau keine Rasse mehr (Ticket 07).
|
||||||
pub options: Vec<String>,
|
pub options: Vec<String>,
|
||||||
pub inventory: Vec<ItemInstance>,
|
|
||||||
pub equipment: HashMap<String, ItemInstance>,
|
|
||||||
pub tags: Vec<String>,
|
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`,
|
/// **Nur befristete** Modifikatoren. Rasse und Klasse kommen aus `options`,
|
||||||
/// Ausruestung aus `equipment` — hineinzukopieren machte das Lesen billiger
|
/// Ausruestung aus `equipment` — hineinzukopieren machte das Lesen billiger
|
||||||
/// und das Schreiben gefaehrlich (Ticket 07).
|
/// und das Schreiben gefaehrlich (Ticket 07).
|
||||||
pub modifiers: Vec<ActiveModifier>,
|
pub modifiers: Vec<ActiveModifier>,
|
||||||
/// Einmal beim Betreten des Kampfes gewuerfelt. Bewusst ein Zahlenwert am
|
pub equipment: HashMap<String, ItemInstance>,
|
||||||
/// Wesen und keine Reihenfolgeliste: Die waere eine **zweite** Indexliste
|
|
||||||
/// mit demselben Ungueltigkeitsproblem beim Tod eines Gegners (Ticket 14).
|
|
||||||
pub initiative: i32,
|
|
||||||
pub cooldowns: HashMap<String, u32>,
|
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 {
|
impl Creature {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! wirkt einmal, Faehigkeit bietet an — der Trigger sagt *wann*. Er darf
|
//! wirkt einmal, Faehigkeit bietet an — der Trigger sagt *wann*. Er darf
|
||||||
//! ueberall stehen, wo `modifiers` und `actions` stehen.
|
//! ueberall stehen, wo `modifiers` und `actions` stehen.
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::action::{Action, DamageType};
|
use super::action::{Action, DamageType};
|
||||||
use super::condition::Condition;
|
use super::condition::Condition;
|
||||||
@@ -16,7 +16,7 @@ use super::creature::CreatureRef;
|
|||||||
/// `attack_hit` und `damage_taken` bleiben getrennt: Ersteres feuert beim
|
/// `attack_hit` und `damage_taken` bleiben getrennt: Ersteres feuert beim
|
||||||
/// Angreifer (dort haengt das flammende Schwert), Letzteres beim Getroffenen
|
/// Angreifer (dort haengt das flammende Schwert), Letzteres beim Getroffenen
|
||||||
/// (dort haengt der Dornenpanzer).
|
/// (dort haengt der Dornenpanzer).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum EventKind {
|
pub enum EventKind {
|
||||||
TurnStarted,
|
TurnStarted,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
//! hiesse, die Prefabs zweimal zu parsen.
|
//! hiesse, die Prefabs zweimal zu parsen.
|
||||||
#![allow(dead_code)]
|
#![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 {
|
pub enum Tile {
|
||||||
Wall,
|
Wall,
|
||||||
Floor,
|
Floor,
|
||||||
@@ -15,7 +18,7 @@ pub enum Tile {
|
|||||||
Entrance,
|
Entrance,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Rect {
|
pub struct Rect {
|
||||||
pub x: u16,
|
pub x: u16,
|
||||||
pub y: u16,
|
pub y: u16,
|
||||||
@@ -41,50 +44,112 @@ impl Rect {
|
|||||||
/// Ein NPC auf der Etage. Sein Warenkorb wird **einmal bei der Erzeugung**
|
/// Ein NPC auf der Etage. Sein Warenkorb wird **einmal bei der Erzeugung**
|
||||||
/// gezogen und liegt danach fest — sonst wuerfelte jedes Oeffnen des Ladens neu
|
/// gezogen und liegt danach fest — sonst wuerfelte jedes Oeffnen des Ladens neu
|
||||||
/// und der Spieler koennte sich das Angebot herbeischliessen (M4).
|
/// und der Spieler koennte sich das Angebot herbeischliessen (M4).
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct NpcInstance {
|
pub struct NpcInstance {
|
||||||
pub pos: (u16, u16),
|
pub pos: (u16, u16),
|
||||||
pub def: String,
|
pub def: String,
|
||||||
|
/// Tabelle, also zuletzt (TOML).
|
||||||
pub stock: Vec<super::creature::ItemInstance>,
|
pub stock: Vec<super::creature::ItemInstance>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Room {
|
pub struct Room {
|
||||||
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
|
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
|
||||||
pub prefab: String,
|
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
|
/// Die `e`-Plaetze aus dem Prefab — bleiben erhalten, damit die Anzeige
|
||||||
/// weiss, wo etwas stand.
|
/// weiss, wo etwas stand.
|
||||||
pub enemy_spawns: Vec<(u16, u16)>,
|
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
|
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
|
||||||
/// stehen hier Positionen und keine Gegenstaende.
|
/// stehen hier Positionen und keine Gegenstaende.
|
||||||
pub loot: Vec<(u16, u16)>,
|
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
|
/// Die `n`-Plaetze, schon aufgeloest. Anders als bei `e` nennt das Prefab
|
||||||
/// den NPC — ein Schmied skaliert nicht mit der Tiefe.
|
/// den NPC — ein Schmied skaliert nicht mit der Tiefe.
|
||||||
pub npcs: Vec<NpcInstance>,
|
pub npcs: Vec<NpcInstance>,
|
||||||
pub cleared: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Floor {
|
pub struct Floor {
|
||||||
pub number: u8,
|
pub number: u8,
|
||||||
pub width: u16,
|
pub width: u16,
|
||||||
pub height: 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 tiles: Vec<Tile>,
|
||||||
pub rooms: Vec<Room>,
|
|
||||||
/// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum.
|
/// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub player_room: Option<usize>,
|
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 {
|
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 {
|
pub fn tile(&self, x: u16, y: u16) -> Tile {
|
||||||
if x >= self.width || y >= self.height {
|
if x >= self.width || y >= self.height {
|
||||||
return Tile::Wall;
|
return Tile::Wall;
|
||||||
|
|||||||
@@ -25,41 +25,60 @@ use crate::data::Registries;
|
|||||||
|
|
||||||
/// Wie ein Lauf endet (Ticket 10/14). Sieg ueber das **Fehlen** der naechsten
|
/// Wie ein Lauf endet (Ticket 10/14). Sieg ueber das **Fehlen** der naechsten
|
||||||
/// Etage — es gibt keine Zahl „5" im Code.
|
/// 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 {
|
pub enum RunOutcome {
|
||||||
Died,
|
Died,
|
||||||
Won,
|
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 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 player_pos: (u16, u16),
|
||||||
pub turn: u64,
|
pub turn: u64,
|
||||||
/// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14).
|
/// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14).
|
||||||
pub rng: Rng,
|
pub rng: Rng,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub over: Option<RunOutcome>,
|
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.
|
/// Letzte Meldung fuer die Statuszeile.
|
||||||
pub last: String,
|
pub last: String,
|
||||||
/// Kampflog, die letzten Zeilen.
|
/// Kampflog, die letzten Zeilen.
|
||||||
pub journal: Vec<String>,
|
pub journal: Vec<String>,
|
||||||
/// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
|
/// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub talking_to: Option<usize>,
|
pub talking_to: Option<usize>,
|
||||||
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
|
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
|
||||||
/// `game/` kennt keine Screens.
|
/// `game/` kennt keine Screens.
|
||||||
|
#[serde(default)]
|
||||||
pub open_shop: bool,
|
pub open_shop: bool,
|
||||||
/// Erfahrung und unverteilte Punkte stehen an `Game`, **nicht am
|
/// Erfahrung und unverteilte Punkte stehen an `Game`, **nicht am
|
||||||
/// `Creature`** (Ticket 15): Nur der Held hat beides, und ein Feld, das
|
/// `Creature`** (Ticket 15): Nur der Held hat beides, und ein Feld, das
|
||||||
/// fuer 99 % der Exemplare bedeutungslos ist, waere ein Kommentar in
|
/// fuer 99 % der Exemplare bedeutungslos ist, waere ein Kommentar in
|
||||||
/// Feldform — den der Speicherstand fuer jeden Gegner mitschriebe.
|
/// Feldform — den der Speicherstand fuer jeden Gegner mitschriebe.
|
||||||
|
#[serde(default)]
|
||||||
pub current_exp: u32,
|
pub current_exp: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub unspent_points: u32,
|
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 {
|
impl Game {
|
||||||
|
|||||||
23
src/main.rs
23
src/main.rs
@@ -18,7 +18,7 @@ use game::{Creature, ItemInstance};
|
|||||||
fn main() {
|
fn main() {
|
||||||
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
|
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
|
||||||
// druckt ueber `Debug` und setzt dem Spieler `LoadError { .. }` vor.
|
// 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,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("{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);
|
let log_name = data::write_log(&warnings);
|
||||||
|
|
||||||
// Der Seed wird genau einmal frisch gezogen. Ab hier steht er im Spiel und
|
// Der Seed wird genau einmal frisch gezogen. Ab hier steht er im Spiel und
|
||||||
// spaeter im Speicherstand — Nachladen wuerfelt nicht neu (Ticket 14).
|
// 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!(
|
eprintln!(
|
||||||
"Keine Etage mit number = 1 gefunden. Erwartet wird ein [[floors]]-Eintrag in mods/core."
|
"Keine Etage mit number = 1 gefunden. Erwartet wird ein [[floors]]-Eintrag in mods/core."
|
||||||
);
|
);
|
||||||
std::process::exit(1);
|
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}");
|
eprintln!("Terminal-Fehler: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
|
|||||||
use super::grid::GridWidget;
|
use super::grid::GridWidget;
|
||||||
use super::screen::{Response, Screen};
|
use super::screen::{Response, Screen};
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use crate::data::Registries;
|
use crate::data::{Registries, save};
|
||||||
use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome};
|
use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome};
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -28,6 +28,17 @@ impl App {
|
|||||||
/// Der Spielzustand fuehrt, der Stack folgt: `Game.enemies` und `Game.over`
|
/// Der Spielzustand fuehrt, der Stack folgt: `Game.enemies` und `Game.over`
|
||||||
/// sind die Wahrheit. Ohne das muesste jede Stelle, die einen Kampf startet
|
/// sind die Wahrheit. Ohne das muesste jede Stelle, die einen Kampf startet
|
||||||
/// oder beendet, daran denken, den Screen mitzuschieben.
|
/// 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) {
|
fn sync_screens(&mut self) {
|
||||||
let in_combat = !self.game.enemies.is_empty();
|
let in_combat = !self.game.enemies.is_empty();
|
||||||
let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. }));
|
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 {
|
let mut app = App {
|
||||||
game,
|
game,
|
||||||
registries,
|
registries,
|
||||||
screens: vec![Screen::MainMenu],
|
screens,
|
||||||
warnings,
|
warnings,
|
||||||
log_name,
|
log_name,
|
||||||
rejected: None,
|
rejected: None,
|
||||||
@@ -99,11 +133,13 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
|
|||||||
}
|
}
|
||||||
app.screens.pop();
|
app.screens.pop();
|
||||||
if app.screens.is_empty() {
|
if app.screens.is_empty() {
|
||||||
|
app.autosave();
|
||||||
break Ok(());
|
break Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Response::Push(s) => app.screens.push(s),
|
Response::Push(s) => app.screens.push(s),
|
||||||
Response::Command(cmd) => {
|
Response::Command(cmd) => {
|
||||||
|
let etage_vorher = app.game.floor.number;
|
||||||
// Hier steht die Zusage aus Ticket 07 auf dem Pruefstand:
|
// Hier steht die Zusage aus Ticket 07 auf dem Pruefstand:
|
||||||
// `&mut app.game` neben `&app.registries` in einem Aufruf.
|
// `&mut app.game` neben `&app.registries` in einem Aufruf.
|
||||||
app.rejected = match app.game.execute(cmd, &app.registries) {
|
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
|
// die UI — die reagiert nur darauf. Dasselbe gilt fuer den
|
||||||
// Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt.
|
// Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt.
|
||||||
app.sync_screens();
|
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::Dialog { pick } => draw_dialog(frame, area, app, *pick),
|
||||||
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
|
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::Debug { pick } => draw_debug(frame, area, app, *pick),
|
||||||
Screen::GameOver => draw_game_over(frame, area, app),
|
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
|
/// Die Werkbank. Eine Liste, vier Tasten — was sich mit zwei vorhandenen
|
||||||
/// Kommandos nachstellen laesst, steht hier nicht.
|
/// Kommandos nachstellen laesst, steht hier nicht.
|
||||||
fn draw_debug(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
|
fn draw_debug(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
|
|||||||
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
|
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
|
||||||
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
|
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
|
||||||
/// `Option<Popup>`, weil Popup ueber Popup real ist.
|
/// `Option<Popup>`, weil Popup ueber Popup real ist.
|
||||||
|
#[derive(Clone)]
|
||||||
pub enum Screen {
|
pub enum Screen {
|
||||||
MainMenu,
|
MainMenu,
|
||||||
Explore,
|
Explore,
|
||||||
@@ -38,6 +39,16 @@ pub enum Screen {
|
|||||||
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
||||||
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
||||||
GameOver,
|
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
|
/// Werkbank. Immer mit gebaut statt hinter `cfg(debug_assertions)`: Das
|
||||||
/// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer
|
/// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer
|
||||||
/// ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht spaeter nach.
|
/// 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
|
/// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein
|
||||||
/// `&mut` auf genau die `Vec`, in der er liegt. Nebeneffekt — `Response::Command`
|
/// `&mut` auf genau die `Vec`, in der er liegt. Nebeneffekt — `Response::Command`
|
||||||
/// ist der einzige Weg nach `game/`.
|
/// ist der einzige Weg nach `game/`.
|
||||||
|
#[derive(Clone)]
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
None,
|
None,
|
||||||
Pop,
|
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,
|
Screen::GameOver => Response::Quit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user