# 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 }` statt `on_yes: Command`. Die Rückfrage beim Laden braucht keinen Command, sondern einen Screen-Wechsel. `Response::Command` ist bereits eine Variante, der bisherige Fall bleibt also erhalten — der Sonderfall entfällt ersatzlos, die Struktur wird dabei *kleiner* | ### Befunde aus M6 **Die Ereigniswarteschlange gehört nicht in den Save.** „Der Kampfzustand fällt bei serde-Ableitung gratis mit ab" stimmt für `enemies`, `initiative` und `cooldowns` — nicht für `Game.events`. Die ist Zwischenstand *innerhalb* eines Zuges: `drain_events` leert sie nach jeder einzelnen Handlung, und gespeichert wird ausschließlich zwischen Zügen. Sie mitzuschreiben hieße, immer eine leere Liste zu speichern. Sie steht jetzt auf `#[serde(skip)]`, mit einem `debug_assert` in `save()`, das die Behauptung festhält statt sie nur zu behaupten. Angenehmer Nebeneffekt: Ohne `events` taucht `CreatureRef` im Save gar nicht auf — und damit auch kein `{ Enemy = 3 }` als Inline-Tabelle in einer Datei, die lesbar sein soll. **Die Feldreihenfolge ist Teil des Formats.** TOML verlangt Werte vor Tabellen. „Der Save *ist* der Baum" heißt deshalb: Die Reihenfolge der Felder in `Game`, `Creature`, `Room` und `Floor` ist keine Kosmetik mehr, sondern eine Bedingung. `Game`, `Creature` und `Room` mussten umsortiert werden — alles Skalare nach oben, alles Verschachtelte nach unten. Der Rundlauf-Test fällt sofort um, wenn jemand das wieder vermischt; das ist die einzige Absicherung, die es dafür gibt. **`Serialize` für `Floor` muss von Hand.** Der Zeilenumbruch im Kachelgitter braucht die Breite, und ein feldweises `#[serde(with = …)]` sieht nur sein eigenes Feld. `Deserialize` bleibt abgeleitet (die Zeilen werden einfach aneinandergehängt), `Serialize` sind fünfzehn Zeilen. Das ist genau die eine handgeschriebene Ausnahme, die das Ticket vorgesehen hat — nur an einer anderen Stelle als gedacht. **Die Rückfrage braucht `on_no`.** Ticket 11 hatte `Confirm { text, on_yes: Box }` beschlossen und „Nein" implizit als Wegklicken gedacht. Im Ladefall heißt Nein aber *beenden und den Mod nachinstallieren* — genau die Wahl, die es nach dem Laden nicht mehr gibt. `on_no` ist ein Feld mehr und spart eine ungeschriebene Regel. **Die Rückfrage gilt nur für Verworfenes aus dem Save.** Beim ersten Bauen hing sie an *allen* Warnungen, also auch an denen des Mod-Ladelaufs — und damit hätte ein kaputter TOML-Eintrag in einem Mod den Start blockiert. Ticket 04 hat dafür ausdrücklich „eine Zeile, kein Screen" entschieden. Beide Zahlen werden jetzt getrennt gezählt, obwohl beide in dieselbe Logdatei laufen.