# 07 — Game-State-Baum Type: grilling Status: resolved Blocked by: 01, 02 ## Question Wie sieht der Weltzustand als Rust-Datenstruktur aus? Entschieden ist: ein `Game`-Ownership-Baum mit Verweisen über IDs oder Indizes, kein ECS. Zu klären: - Was besitzt was? Wo leben Held, aktuelle Etage, Räume, Gegner, Bodenloot, Questfortschritt? - Trennung von unveränderlichen Definitionen (aus Mods geladen) und veränderlichem Laufzeitzustand: zwei getrennte Strukturen, oder eine mit Verweisen in den Katalog? - Referenzform: `String`-ID überall, oder beim Laden zu `usize`-Index auflösen? Was kostet das beim Speichern? - Wo werden aktive Statuseffekte gehalten — am Träger oder in einer zentralen Liste? - Welche Operation kämpft absehbar mit dem Borrow-Checker (Gegner greift Held an, während beide im selben `Game` hängen), und wie wird das aufgelöst? Ergebnis: Skizze der Structs mit Besitzverhältnissen und Referenzform. ## Answer Begriffe im [Glossar](../../../CONTEXT.md). Dieses Ticket entscheidet **Besitz und Verweisform** — nicht die vollständige Feldliste, die kommt aus 14, 15 und 16. ### Definitionen und Zustand sind getrennt `Registries` (aus Mods geladen, unveränderlich) lebt **neben** `Game`, nicht darin. Signaturen werden durchgängig `fn irgendwas(&mut self, reg: &Registries)`. Der Grund ist ein Rust-Fakt, kein Geschmack: Läge `registries` als Feld in `Game`, kollidierte `game.player.angreifen(&game.registries)` — ein `&mut`-Borrow auf ein Feld plus ein `&`-Borrow auf ein anderes geht innerhalb einer Funktion, aber nicht über einen Methodenaufruf auf `&mut self` hinweg. Das ist kein Randfall, sondern jeder zweite Aufruf im Kampf. Ein Screen bekommt entsprechend `(&mut Game, &Registries)`, wie 06 es vorzeichnet. Laufzeitobjekte **verweisen** per `String`-ID, sie kopieren nicht. Kopien würden bedeuten, dass ein `overrides` aus Ticket 03 an bereits aufgesammelten Gegenständen vorbeigeht und der Save jede Definition mitschreiben müsste. Keine `usize`-Indizes und keine internierten Handles: Bei ein paar hundert Registry-Einträgen und *einer* Handlung pro Tastendruck kaufen sie Geschwindigkeit, die niemand messen kann, und bezahlen sie mit einer Auflösungsschicht in beide Richtungen plus einer neuen Fehlerklasse — Indizes hängen an der Ladereihenfolge, und die ändert sich bei jedem installierten Mod. Die Decke: Taucht je eine heiße Schleife auf, wird an *dieser* Stelle interniert, nicht global vorsorglich. ### Der Baum ```rust struct Game { player: Creature, enemies: Vec, floor: Floor, // Innenbau gehoert Ticket 10 floor_number: u8, quests: Vec, // Form gehoert Ticket 08 turn: u64, } struct Creature { name: String, base: Attributes, // Katalog aus Ticket 01 health: f32, stamina: f32, // Zustand, getrennt von den Attributen options: Vec, // Rasse, Klasse inventory: Vec, equipment: HashMap, tags: Vec, modifiers: Vec, // nur befristete } struct ItemInstance { def: ItemId, affixes: Vec } enum CreatureRef { Player, Enemy(usize) } ``` **Ein `Creature` für Held und Gegner.** Das Glossar spricht durchgehend von Wesen; zwei Typen hießen, die Kampfformeln aus 14 zweimal zu schreiben oder ein `trait Combatant` mit genau zwei Implementierungen einzuziehen — die Abstraktion, die Ticket 02 beim `ItemType` schon abgeräumt hat. **Held und Gegner sind zwei Felder, keine gemeinsame `Vec`.** Der Grund ist die Charter: Solo-Held, kein Party-System. „Gegner greift Gegner an" existiert nicht, also kauft die gemeinsame `Vec` eine Symmetrie, die das Spiel nie braucht, und bezahlt sie mit `split_at_mut` oder Herauskopieren in jeder Kampfroutine. Zwei Felder sind ein disjunkter Feld-Borrow, den Rust erlaubt. Fällt die Solo-Entscheidung je, ist das ohnehin ein Umbau der Kampfarchitektur — sie steht unter „Out of scope". **Ein `Floor`, kein `Vec`.** Abstiegs-Roguelike ohne Rückkehr: hält den Save klein und erspart dem Generator aus 10 die Garantie, dass Rückwege begehbar bleiben. Die Folge wird bewusst in Kauf genommen — der Spieler kann nicht zum Händler auf Etage 2 zurück, Händler müssen also als Prefab auf jeder Etage auftauchen können. `options` ist kein Beiwerk: Ohne das Feld hat der Held nach dem Charakterbau keine Rasse mehr, weil Rassen- und Klassenmodifikatoren sonst nirgends herkommen. ### Instanzzustand und die sechste Registry Zwei Exemplare desselben Gegenstands dürfen sich unterscheiden. Eine Instanz trägt aber keine frei zusammengesetzten Modifikatoren, sondern **Affix-IDs** — und Affixe sind eine neue, moddbare Registry neben den fünf aus Ticket 02: ```toml [[affixes]] id = "core:flaming" name = "Flammend" # -> "Flammendes Eisenschwert" price_factor = 1.5 modifiers = [ { attribute = "damage", flat = 4 } ] ``` Instanzzustand kostet an drei Stellen, und der Verweis auf Affixe macht zwei davon klein: Der **Save** bleibt eine Liste von IDs (`{ id = "core:iron_sword", affixes = ["core:flaming"] }`) statt serialisierter Modifikator-Strukturen, und die **Preisbildung** ist `base_price × Π price_factor`. Dazu sind Affixe Inhalt — ein Modder erfindet „Vampirisch", ohne dass die Engine davon weiß. Strukturell ist ein Affix dasselbe wie eine Charakteroption: benannte Modifikatorliste plus Metadaten. Mit frei zusammengesetzten Modifikatoren je Exemplar könnte ein zufällig erzeugtes Schwert theoretisch alles, praktisch aber nichts, das ein Modder benennen oder ein Händler bepreisen könnte — und die Anzeige hätte keinen Namen für das, was da liegt. **Stapelregel:** `stackable` **und** gleiche `def` **und** gleiche `affixes`. Ein Vergleich zweier `Vec`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu binden wäre eine Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass ein Trank mit Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn so baut. **Befund aus M4: Die Regel war bis dahin nur beschrieben.** `ItemInstance` hatte kein `count`, und der Beutezieher schob für `count = "50-120"` bis zu **120 einzelne Einträge** ins Inventar — 120 Zeilen im Inventar-Screen, und ein Kauf hätte 45 davon einzeln entfernen müssen. `ItemInstance` trägt jetzt ein `count`, und die Stapelregel wird beim Aufheben *ausgeführt* statt nur beschrieben. Damit bedeutet auch `stackable` aus Ticket 02 zum ersten Mal etwas — das Feld stand seit M1 in den Daten und hat nie jemand gelesen. ### Ein Eingang, ein Ausgang ```rust Game::apply(&mut self, action: &Action, source: CreatureRef, target: CreatureRef, reg: &Registries) Game::attribute(&self, who: CreatureRef, attr: Attribute, reg: &Registries) -> f32 ``` `CreatureRef` fällt direkt aus der Zwei-Felder-Entscheidung: Es gibt keinen gemeinsamen Index mehr, also braucht eine Aktion eine Adresse. Der **Lesezugang liegt auf `Game`, nicht auf `Creature`**. Ticket 01 hat entschieden, dass ein Modifikator mit `target = "all_enemies"` beim Träger wohnt, damit die Aura mit ihm stirbt. Folglich braucht das Lesen eines Gegner-Attributs Zugriff auf die Modifikatoren des Helden — ein `Creature` kann seine eigenen Attribute gar nicht ausrechnen. Zwei Rechenwege für dieselbe Formel aus 01 wären ein Bug, der sich als Balancing-Problem tarnt: Wer `creature.attribute()` direkt aufruft, weil es bequemer aussieht, vergisst die Aura. Ein Attributzugriff läuft die vier Quellen jedes Mal ab: `base` → `options` → `equipment` samt Affixen → befristete `modifiers`. **Kein Cache.** Das passiert pro Tastendruck, nicht pro Frame. Die Decke: Liest 14 dasselbe Attribut in einer Schleife zwanzigmal, wird *dort* einmal vorberechnet, statt hier vorsorglich ein Invalidierungsproblem zu bauen. Modifikatoren werden ausdrücklich **nicht** beim Ausrüsten in `modifiers` hineinkopiert. Das machte das Lesen billiger und das Schreiben gefährlich: Jedes Ablegen müsste exakt die richtigen Einträge wieder herausoperieren, und ein Affix-Wechsel ginge an der Kopie vorbei. ### Nachtrag aus Ticket 08 Die Lesesignatur trägt einen **optionalen Gegner**: ```rust Game::attribute(&self, who: CreatureRef, attr: Attribute, against: Option, reg: &Registries) -> f32 ``` Ohne ihn ist ein bedingter Modifikator aus 01 („+5 Schaden gegen Untote") beim Auslesen von `damage` nicht auswertbar — die Funktion wüsste nicht, gegen wen geschlagen wird. Der Charakterbogen gibt `None`. Dazu kommt `events: Vec` als Feld in `Game`: Ticket 08 arbeitet Events als Warteschlange ab, weil synchrones Feuern über `self.quests` iterieren müsste, während `self.apply` erneut läuft. ### Nachtrag aus Ticket 14 **`enemies` ist der laufende Kampf, nicht die Etage.** Das stand oben nie ausdrücklich da und wäre sonst falsch geraten worden: Bei Grid-Erkundung mit Menü-Kampf griffe sonst der Skelettkrieger drei Räume weiter jeden Zug mit an. Leer bedeutet kein Kampf; gefüllt wird die Liste von der Etage (10). Zwei neue Felder am `Creature`: - `initiative: i32` — einmal beim Betreten des Kampfes gewürfelt. Bewusst ein Zahlenwert am Wesen und keine Reihenfolgeliste aus `CreatureRef`: Die wäre eine **zweite** Indexliste mit demselben Ungültigkeitsproblem beim Tod eines Gegners. - `cooldowns: HashMap` — Abklingzeiten in Zügen. Dazu der RNG-Seed als Feld in `Game`, damit Nachladen nicht neu würfelt. Ticket 15 beantwortet außerdem die Frage, die 07 offen an es weitergereicht hatte — wo der EXP-Fortschritt wohnt: **`current_exp` und `unspent_points: u32` gehören an `Game`**, nicht an `Creature`. `base.level` bleibt am Wesen, weil Gegner echte Level haben und die Etagen-Skalierung sie liest; Erfahrung und unverteilte Punkte hat dagegen ausschließlich der Held, und ein Feld, das für 99 % der Exemplare bedeutungslos ist, schriebe der Save für jeden Gegner mit. `base.level` ist dabei die **einzige** Quelle für Punkte; `attribute(wer, "level")` darf durch Modifikatoren höher ausfallen, ohne welche zu schenken. Und die Antwort auf die Frage, die 07 offen an 14 gereicht hatte: **Tote Gegner werden am Ende von `Game::execute` entfernt**, nach dem Leerarbeiten der Event-Warteschlange. Innerhalb eines Zuges bleiben Indizes stabil. ### Auflagen an andere Tickets - **08 Event- und Trigger-System**: Form von `QuestProgress`; das Bedingungsvokabular, das 01 bereits angefordert hat. - **09 Save-Schema**: serialisiert `Game`. `ItemInstance` schreibt sich als `{ id, affixes }`. - **10 Raum-Prefab-Format**: Innenbau von `Floor` — Grid, Räume, Bodenloot, Ausgänge. Dazu der Händlerraum als Prefab, das auf jeder Etage auftauchen kann. - **14 Kampfformeln und Rundenablauf**: Aktionskatalog und Ziel-Auflösung — und der **Aufräumzeitpunkt für tote Gegner**. Ein `CreatureRef::Enemy(i)` überlebt das Entfernen aus der `Vec` nicht; wann aufgeräumt wird, entscheidet der Rundenablauf. - **16 Loot-Tabellen**: wer Affixe beim Loot vergibt und mit welcher Gewichtung. ### Folgen für bestehenden Code `Stats` zerfällt in `Attributes` (Katalog aus 01) und Zustandsfelder — heute stehen `max_health` und `current_health` nebeneinander in derselben Struktur. `Player` wird zu `Creature`. Neben `Item` als Definition entsteht `ItemInstance` als Laufzeitform. ### Befund aus dem Spielen — der Stapel hatte keine Verbraucher Ticket 07 beschreibt die Stapelregel, M4 hat sie gebaut: `ItemInstance.count`, `give` verschmilzt, `take` zieht ab. Gebaut wurde damit die **Erzeugungsseite** — Beute, Kauf, Verkauf. Die drei älteren Verbraucher blieben, wie sie waren, und die nahmen alle denselben Weg: `inventory.remove(index)`. Im Spiel sichtbar an zwei Heiltränken, von denen ein Schluck beide verbrauchte. Derselbe Fehler steckte in `equip` (legt den ganzen Stapel an) und dieselbe Lücke in `unequip` (schob am Verschmelzen vorbei zurück ins Inventar). Entschieden: **ein** Griff für alle Verbraucher — `take_at(index)` nimmt ein Stück aus dem Eintrag und räumt ihn ab, wenn er leer ist. Über den Index und nicht über die `def`, weil zwei Tränke mit verschiedenen Affixen sich die `def` teilen. `unequip` läuft jetzt durch `give`. Die Lehre ist nicht „Stapel sind schwierig", sondern: Ein Feld, das die Bedeutung eines Eintrags ändert, macht **jeden** bestehenden Leser zum Kandidaten. `count` einzuführen war ein Vierzeiler; die Verbraucher nachzuziehen war die eigentliche Arbeit, und sie wurde beim ersten Mal vergessen. Nebenbei aufgefallen: Der Heiltrank hatte nie `actions` — er war seit M1 verbrauchbar, ohne zu wirken. Kein Test hat es gemerkt, weil kein Test ihn getrunken hat.