docs: ticket 09 (save-schema) aufloesen, nachtraege an 02 und 11, speicherstand ins glossar

This commit is contained in:
2026-08-12 12:34:29 +02:00
parent 6442e104ab
commit bed9ef5762
5 changed files with 147 additions and 3 deletions

View File

@@ -93,6 +93,17 @@ Engine den Begriff „Startausrüstung" kennt.
`Item.value` → `base_price` (Grundwert des Gegenstands). Attribut `shop_price` → `price_modifier`
(Charaktereigenschaft, über die Charisma wirkt). Zwei Dinge, zwei Namen.
### Nachtrag aus Ticket 09: zwei Fähigkeiten sind implizit
Oben steht, Fähigkeiten würden „von Charakteroptionen und Gegenständen gewährt". Genau das erzeugt eine
Lücke: Ein Held, dessen Klasse nach einem Mod-Wechsel fehlt und der nichts trägt, hätte **gar keine**
Fähigkeit — und könnte seit Ticket 11 nicht einmal angreifen, weil `core:attack` eine Fähigkeit ist.
Deshalb: **`core:attack` und `core:flee` sind jedem [Wesen](../../../CONTEXT.md) implizit gewährt.**
Das ist keine Sonderregel für den Ladefall. Es trifft ebenso jeden Gegner ohne `abilities`-Eintrag, vor
dessen leerer Liste die KI aus Ticket 14 sonst stünde.
### Auflagen an andere Tickets
- **11 UI-Gerüst und Screen-Modell**: Charakterbogen und Charakterbau sind Schleifen über Registries,

View File

@@ -1,7 +1,7 @@
# 09 — Save-Schema
Type: grilling
Status: open
Status: resolved
Blocked by: 07
## Question
@@ -33,3 +33,117 @@ Ergebnis: Save-Schema als kommentiertes TOML-Beispiel plus Regeln für fehlende
**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* |

View File

@@ -108,10 +108,16 @@ ohne Command hat den Spielzustand also nachweislich nicht angefasst.
**Ein `Response::Command` schließt alle Overlay-Screens.** Das Flag existiert bereits fürs Rendern und
beantwortet damit auch „schließt sich nach einer Rückfrage von selbst" — eine Zeile in der Schleife,
keine zusätzliche `Response`-Variante und keine Antwortliste. `Confirm { text, on_yes: Command }` wird
keine zusätzliche `Response`-Variante und keine Antwortliste. `Confirm { text, on_yes: Box<Response> }` wird
dadurch für jede Rückfrage wiederverwendbar, ohne eigene Variante je Anlass — **`Command` muss dafür
`Clone` sein**, sonst kommt das Kommando nicht aus dem Screen heraus, ohne ihn zu zerstören
(Befund aus Ticket 12). Dass `Inventory` als
(Befund aus Ticket 12).
*Nachtrag aus Ticket 09:* Das Feld hieß ursprünglich `on_yes: Command`. Die Rückfrage beim Laden eines
Speicherstands („Einträge fehlen, fortfahren?") braucht aber keinen Command, sondern einen
Screen-Wechsel. `Response` ist die allgemeinere Form und enthält `Command` bereits als Variante — der
bisherige Fall bleibt erhalten, der Sonderfall entfällt, und die Struktur wird dabei kleiner statt
größer. Dass `Inventory` als
Vollbild-Screen nach `UseItem` offen bleibt, fällt aus derselben Regel — man trinkt selten genau einen
Trank.

View File

@@ -67,6 +67,7 @@ auch über Ticket 13.
- [06 — Research: ratatui-App-Architektur](issues/06-research-ratatui-architektur.md) ([#7](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/7)) — Screen-Stack über zentralem `Game`-State statt Elm oder Component-Trait; `game/` bleibt ratatui-frei; Popups per `Clear` auf Sub-`Rect` statt Widget-Stack; blockierendes `event::read()` ohne Tick; Dungeon-Grid ist Eigenbau, es gibt kein Widget dafür; ratatui 0.30.2 mit MSRV 1.88; `KeyEventKind::Press`-Filter unter Windows Pflicht. Voller Bericht in [research/06](research/06-ratatui-architektur.md).
- [07 — Game-State-Baum](issues/07-game-state-baum.md) ([#8](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/8)) — `Registries` lebt **neben** `Game`, nicht darin: als Feld kollidierte `&mut game.player` mit `&game.registries` bei jedem Methodenaufruf. Verweise per `String`-ID, keine Indizes (die hängen an der Ladereihenfolge). Ein `Creature` für Held und Gegner, aber **zwei Felder** `player` und `enemies` statt einer `Vec` — Solo-Held heißt, „Gegner greift Gegner an" existiert nicht, und zwei Felder sind ein disjunkter Borrow. Ein `Floor`, kein Backtracking; Händler müssen deshalb auf jeder Etage vorkommen können. **Instanzzustand kommt**: `ItemInstance { def, affixes }`, und **Affixe sind die sechste Registry** — dadurch bleibt der Save eine ID-Liste und der Preis `base_price × Π price_factor`. Stapeln bei gleicher `def` und gleicher `affixes`. `enum CreatureRef`, dazu je ein Eingang `Game::apply` und `Game::attribute` — Letzteres auf `Game`, weil Auren beim Träger wohnen und ein `Creature` seine Attribute allein gar nicht ausrechnen kann. Kein Attribut-Cache.
- [08 — Event- und Trigger-System](issues/08-event-trigger-system.md) ([#9](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/9)) — **Der Trigger ist die vierte Wirkungsform** neben Modifikator, Aktion und Fähigkeit: `{ on, condition, actions }` darf überall stehen, wo `modifiers` und `actions` stehen — sonst entstünde dieselbe Maschine ein zweites Mal in 14 als `on_hit`. Eine Bedingungsgrammatik für Trigger *und* Modifikatoren: getaggtes Enum, Liste ist UND, `not` je Eintrag, **kein ODER** (zwei Einträge oder ein gemeinsames Tag). Neun feste Events ohne Namensraum, jedes mit `source`/`target`; `attack_hit` und `damage_taken` bleiben getrennt, weil der eine beim Angreifer und der andere beim Getroffenen hängt. Im Trigger ist `self` immer der Träger. **Warteschlange statt synchron** — synchrones Feuern wäre ein Borrow-Fehler (über `self.quests` iterieren, während `self.apply` läuft) — mit Zähler bei 100 Events pro Zug statt Zyklengraph. Trigger werden gesucht, nicht indiziert. **Quest = Trigger mit `count`**, siebte Registry, erledigte bleiben markiert stehen. Zwei Nachträge an geschlossene Tickets: `start_quest` als siebte Aktion in 01, und `against: Option<CreatureRef>` plus `events: Vec<Event>` in 07.
- [09 — Save-Schema](issues/09-save-schema.md) ([#10](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/10)) — **Layout wird gespeichert, nicht gewuerfelt**: Regenerieren aus dem Seed braeuchte dieselben Prefabs und denselben Generator-Code, sonst steht der Held nach einem Mod-Update in einer Wand — und der Raumzustand muss ohnehin mit, der Seed spart also nur 3 KB ASCII. **Ein Slot, Autosave bei Etagenbetreten und Beenden, Permadeath** (geloescht erst beim `GameOver`-Screen, nicht beim Tod). Der Save *ist* der `Game`-Baum per serde-Ableitung, eine Ausnahme fuers Kachelgitter. Schema-Version ist das einzige ausgewertete Header-Feld; abweichend ⇒ **ablehnen, Datei bleibt liegen** (Migration vor dem ersten ausgelieferten Save waere Code ohne Problem). Fehlende Mods: **verwerfen und warnen, nie ablehnen** — auch bei der Klasse. Mod-Version wird nur protokolliert, das ist die ehrliche Bilanz des Felds aus 03. Warnungen in dieselbe Sitzungsdatei wie der Mod-Ladelauf, plus **Rueckfrage vor dem Betreten** (der Spieler will vielleicht nachinstallieren statt weiterzuspielen). **Atomar geschrieben** ueber `.tmp` und `fs::rename` — die eine Stelle, an der bewusst nicht lazy entschieden wurde. Nachtraege: `core:attack`/`core:flee` implizit an 02, `Confirm { on_yes: Box<Response> }` an 11.
- [10 — Raum-Prefab-Format](issues/10-raum-prefab-format.md) ([#11](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/11)) — Freie Platzierung mit L-Korridoren; verbunden wird **Raum `i` mit Raum `i−1`**, weil eine Kette Erreichbarkeit *strukturell* garantiert statt sie nachtraeglich zu pruefen. Prefab als ASCII-Block mit Markern im Block, **Tueren explizit** (sonst stanzt der Generator Loecher in gezeichnete Waende); Rotation und Spiegelung erlaubt, `rotate = false` als Opt-out. **Ein `e` sagt „hier steht ein Gegner", nicht welcher** — das entscheidet die Etage, sonst waere jedes Prefab an eine Schwierigkeitsstufe gebunden. `[[prefabs]]` und `[[floors]]` sind die neunte und zehnte Registry; Auswahl durchgehend ueber **Tags**, Pflichtraeume zuerst platziert (das *ist* die Garantie), Flaechenpruefung beim Laden. **Kampf beginnt bei `room_entered`**, Gegner gehoeren zum Raum — das erspart Pfadsuche, Sichtlinien und Verfolgung. `Floor` mit `rooms`, `player_room: Option<usize>` (Korridor ist ein echter Zustand). **Siegbedingung erstmals definiert**: kein `[[floors]]` mit der naechsten Nummer = gewonnen, die Zahl „5" steht nirgends im Code. Nachtraege: `room_entered` an 08 (zwoelf Events), `GameOver { outcome }` an 14.
- [11 — UI-Gerüst und Screen-Modell](issues/11-ui-geruest-screen-modell.md) ([#12](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/12)) — **„Aktion" hiess zwei Dinge**: `Action` bleibt das TOML-Effektvokabular aus 01, `Command` wird das UI-Kommando, Eingang `Game::execute(cmd, reg)` — die eine Stelle, an der ein Zug endet (Modifikatoren ticken, Event-Warteschlange leerarbeiten). Zwölf Commands, **alle kosten einen Zug** (ein `costs_turn`-Flag waere eine Ausnahmeliste); Angriff und Flucht sind keine Varianten, sondern `UseAbility("core:attack")` und `UseAbility("core:flee")` — damit moddbar. Ein gemeinsamer `Vec<Screen>` fuer Screens und Popups; Screens **geben `Response` zurueck statt selbst zu schieben**, sonst haelt ein Screen ein `&mut` auf die `Vec`, in der er liegt. Ein `Response::Command` schliesst alle Overlays — dasselbe Flag, das schon fuers Rendern existiert. Zehn Screens, `ListState` in der Variante (der Stack erhaelt den Cursor gratis, solange der Screen liegt). Nur `KeyEventKind::Press`: bei `Repeat` bestimmte die Tastaturwiederholrate die Spielzeit; gegen zaehes Laufen hilft ein Renn-Kommando in `game/`. Tastenbelegung fest, Pfeile und `hjkl`. ratatui-Freiheit von `game/` prueft ein `#[test]` statt eines Workspace. Nachtrag an 01: `symbol` traegt das Zeichen und einen Farbnamen selbst — `ui/theme.rs` ist Code und damit fuer Modder unerreichbar.
- [12 — Prototyp: ratatui-Durchstich](issues/12-prototyp-ratatui-durchstich.md) ([#13](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/13)) — **Das Modell aus 11 traegt.** Primaerquelle: Branch `prototyp/12-ratatui-durchstich`, `cargo run --example durchstich`. Bestaetigt: `mod game` bleibt ratatui-frei ohne Disziplin, Grid als eigenes `impl Widget` ueber `buf[(x,y)].set_symbol(…)`, `Vec<Screen>` mit Overlay-Flag fuer Rendern *und* Auto-Schliessen, `Response` statt Stack-Mutation (kein Borrow-Aerger). **Der Windows-Fallstrick ist jetzt eine Zahl**: `Tasten 14 → Press 7 → Zuege 7` — ohne Filter kostete jeder Schritt zwei Zuege und damit doppelte Spielzeit. Zwei Korrekturen: 11 sagte „alle Commands kosten einen Zug", richtig ist **alle ausgefuehrten** (`Outcome { TurnPassed, Rejected(&str) }`, Grund reist mit zurueck); und `Command` muss `Clone` sein, damit `Confirm` es herausgeben kann. Ungetestet blieb `Registries` neben `Game` — der Durchstich laedt keine.

View File

@@ -196,6 +196,18 @@ zuerst.
Initiative ist bewusst eine Zahl *am Wesen* und keine Reihenfolgeliste — eine Liste wäre eine zweite
Sammlung von Verweisen, die beim Tod eines Gegners ungültig würde.
## Speicherstand
Der serialisierte Spielzustand, TOML, **genau einer**. Er wird beim Betreten einer [Etage](#etage) und
beim Beenden automatisch geschrieben; von Hand speichern kann man nicht, und bei Tod wird er
**gelöscht** — der Lauf ist dann vorbei.
Das Etagen-Layout steht als Text darin, nicht als Generator-Seed: Ein Mod-Update würde sonst ein
anderes Layout erzeugen und den Helden in eine Wand stellen.
Fehlt beim Laden ein [Mod](#mod), wird der betroffene Eintrag verworfen und gewarnt — nie der ganze
Speicherstand abgelehnt.
## Registry
Eine benannte Sammlung moddbarer Definitionen, geschlüsselt nach namespaced ID. Es gibt **zehn**, und