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.
183 lines
11 KiB
Markdown
183 lines
11 KiB
Markdown
# 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.
|