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

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