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

11 KiB
Raw Permalink Blame History

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.

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.

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