Files
rustkeep/.scratch/rustkeep-1.0/issues/09-save-schema.md
MDietermann d09fef7069 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.
2026-08-12 22:01:12 +02:00

183 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 09 — Save-Schema
Type: grilling
Status: resolved
Blocked by: 07
## Question
Was steht in einem Speicherstand, und wie überlebt er geänderte Mods?
Entschieden ist: TOML, Mod-Liste im Header, fehlender Mod bedeutet Warnung und Verwerfen statt Abbruch.
Zu klären:
- Was genau wird gespeichert? Held mit Stats und Effekten, Inventar, Ausrüstung, aktuelle Etage,
Etagen-Layout (oder Seed?), erkundete Räume, Gegnerzustand, Questfortschritt, Gold.
- Etage: gesamtes Layout speichern oder nur den Generator-Seed? Seed ist kompakt, bricht aber, sobald
der Generator oder ein Prefab-Mod sich ändert.
- Versionierung: Was steht im Header — Spielversion, Save-Schema-Version, Liste aus Mod-ID und Version?
- Migration: Was passiert bei älterem Schema? Migrieren, ablehnen, oder Best-Effort-Laden?
- Verwerfen im Detail: Wenn ein Item fehlt, wird der Slot leer — was, wenn die *Klasse* des Helden fehlt?
- Wie viele Speicherstände, Autosave, und speichert man mitten im Kampf?
Ergebnis: Save-Schema als kommentiertes TOML-Beispiel plus Regeln für fehlende Mods.
## Vorgaben aus anderen Tickets
- **03**: Der Mod-Header nutzt `manifest.id` und `version`. Da `depends` keine Versionsbereiche trägt,
ist `version` das einzige Signal, dass ein Mod sich geändert hat — und dieses Ticket ist sein
**einziger Leser** in 1.0. Fällt der Header, fällt das Feld.
- **07**: Serialisiert wird der `Game`-Baum. Alle Verweise sind ohnehin `String`-IDs, es gibt also
nichts zurückzuübersetzen. `ItemInstance` schreibt sich als `{ id, affixes }` — Affixe sind
Registry-Einträge, keine eingebetteten Modifikatoren, der Save bleibt damit eine ID-Liste. Es gibt
**eine** Etage, keine besuchten Vorgänger. `Creature.options` (Rasse, Klasse) muss mit, sonst hat der
Held nach dem Laden keine Herkunft mehr — womit die Frage „was, wenn die Klasse fehlt?" oben scharf
wird.
## Answer
Begriffe im [Glossar](../../../CONTEXT.md).
### Die Etage wird gespeichert, nicht gewürfelt
Ticket 14 hat einen seedbaren Generator geliefert, Regenerieren *wäre* also möglich. Drei Dinge
sprechen dagegen:
1. Regenerieren verlangt **dieselben Prefabs und denselben Generator-Code**. Ein Mod-Update oder eine
Engine-Änderung erzeugt ein anderes Layout — und der gespeicherte Held steht dann in einer Wand.
2. Der Raumzustand aus 10 (`cleared`, gefallene Gegner, genommene Beute, Bodenloot) lässt sich nicht
regenerieren und muss ohnehin mit. Der Seed spart also nur das Kachelgitter.
3. Die Rechnung: 80×40 Felder sind als ASCII-Zeilen **rund 3 KB** — im selben Format wie Prefabs, also
lesbar und im Notfall von Hand reparierbar.
Der Seed bleibt trotzdem im Save, aber für künftige Würfe (14), nicht zum Regenerieren.
### Ein Speicherstand, Permadeath
Autosave beim **Betreten einer Etage** und beim **Beenden**. Kein manuelles Speichern: Es brächte nur
die Wahl des Zeitpunkts, und genau die hat Ticket 14 mit dem Seed im Save bereits abgeräumt. Mehrere
benannte Slots wären eine Verwaltungsansicht für ein Spiel mit einem einzigen Helden.
Speichern **im Kampf ist erlaubt**, weil es beim Beenden ohnehin passieren muss; der Kampfzustand fällt
bei serde-Ableitung gratis mit ab.
**Bei Tod wird der Speicherstand gelöscht** — aber erst beim Erscheinen des `GameOver`-Screens, nicht
beim Fallen der Lebenspunkte. Stürzt das Spiel in der Sekunde dazwischen ab, ist der letzte Autosave
noch da, und da er vom Betreten der Etage stammt, verschenkt das nichts.
### Der Save *ist* der `Game`-Baum
`#[derive(Serialize, Deserialize)]` durchgehend, mit **einer** handgeschriebenen Ausnahme: dem
Kachelgitter als ASCII-Zeilen statt eines Arrays mit 3200 Einträgen.
Eigene Save-Structs hießen, jede Struktur zweimal zu pflegen und bei jeder Änderung daran zu denken —
die Sorte Duplikat, die genau dann auseinanderläuft, wenn es niemand merkt. Der Preis ist ehrlich: Der
Save hängt an internen Feldnamen, ein Umbenennen bricht alte Stände. Dafür gibt es die Schema-Version.
```toml
[save]
schema = 1 # das einzige Feld, das die Engine auswertet
game = "0.1.0" # Diagnose bei Fehlermeldungen
[[save.mods]]
id = "core"
version = "1.0.0"
```
Bewusst **kein** `floor` und `turn` im Header: Der `Game`-Baum serialisiert beide ohnehin ein paar
Zeilen weiter unten. Sie oben zu wiederholen hätte nur einem Speicherstand-Browser genützt, der sie
ohne Parsen des Rumpfs anzeigen will — und den gibt es bei einem Slot nicht.
Bei abweichender `schema`: **ablehnen** mit Klartextmeldung („Speicherstand stammt aus Schema 1, dieses
Spiel liest 2"), und die **Datei bleibt liegen**. Migration zu schreiben, bevor ein einziger
Speicherstand ausgeliefert wurde, ist Code für ein Problem, das noch niemand hat; Best-Effort auf einem
strukturell anderen Schema erzeugt stille Verfälschung statt eines Fehlers. Und eine Datei zu löschen,
weil man sie nicht lesen kann, wäre die schlechteste aller Reaktionen.
### Fehlende Mods: verwerfen, nie ablehnen
| Fehlt | Folge |
|---|---|
| Gegenstand im Inventar | Eintrag entfällt |
| Gegenstand in der Ausrüstung | Slot bleibt leer |
| Affix | entfällt, Gegenstand bleibt |
| Quest | Fortschritt entfällt (bereits in 08) |
| Prefab | egal — das Layout ist gespeichert |
| Charakteroption | entfällt, Held bleibt spielbar |
Ein Spieler soll nicht seinen ganzen Lauf verlieren, weil er einen Mod deinstalliert hat.
**Die Mod-Version wird verglichen und protokolliert, mehr nicht.** Verschwundene IDs fangen die Regeln
oben ab, geänderte Werte gelten einfach. Der Vergleich leistet reine Diagnose — eine Zeile
„`meinmod` 1.0.0 → 1.2.0 seit dem Speichern", die genau dann Gold wert ist, wenn jemand meldet „mein
Spielstand ist seit dem Update kaputt". Das ist die ehrliche Bilanz des `version`-Felds aus Ticket 03:
Fiele es weg, verlöre man diese Zeile und sonst nichts.
Warnungen gehen in **dieselbe Sitzungsdatei** wie die des Mod-Ladelaufs, nicht in eine eigene
`save-*.log`-Familie: Zwei Dateinamensmuster hießen zwei Rotationsregeln und zwei Orte zum Nachsehen.
Die Warnliste aus 04 lebt stattdessen für die ganze Sitzung und wird neu geschrieben, wenn sie wächst
— das ist sogar weniger Logik als zwei getrennte Läufe.
Dazu eine **Rückfrage vor dem Betreten des Spiels**: „12 Einträge fehlen (siehe `logs/load-….log`).
Fortfahren?"
Das weicht bewusst von 04s Regel „eine Zeile, kein Screen" ab, und zwar aus einem Grund: Der Spieler
will an dieser Stelle vielleicht gar nicht weiterspielen, sondern den fehlenden Mod nachinstallieren.
Diese Wahl hat er nur *vor* dem Laden — danach ist der verkrüppelte Held gespeichert.
### Atomar schreiben
`data_dir()/rustkeep/save.toml`, dasselbe Verzeichnis wie Mods und `load_order.toml` (03 hat die
Aufteilung in Daten und Konfiguration verworfen). Geschrieben wird nach `save.toml.tmp` und dann per
`fs::rename` darübergezogen — das ersetzt die Zieldatei auf Windows 10 1607+ ebenso wie auf Unix
(gegen die std-Doku geprüft).
**Das ist die eine Stelle in diesem Ticket, an der bewusst nicht lazy entschieden wurde.** Der Autosave
überschreibt den einzigen Speicherstand, den es gibt; bricht das Schreiben ab — Absturz, voller
Datenträger, geschlossener Deckel —, ist ein Lauf von einer Stunde weg. Temporärdatei plus `rename`
sind zwei Zeilen und machen den Verlust unmöglich: Es liegt entweder die alte Datei da oder die neue,
nie eine halbe.
**Gold braucht kein eigenes Feld** — es ist seit Ticket 01 ein gewöhnlicher stapelbarer Gegenstand
(`core:gold`) und liegt im Inventar wie alles andere.
### Zwei Nachträge an geschlossene Tickets
| Ticket | Nachtrag |
|---|---|
| **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.