Files
rustkeep/.scratch/rustkeep-1.0/issues/07-game-state-baum.md
MDietermann 036f60d463 fix(inventar): ein Stapel wird stueckweise verbraucht
use_item und equip entfernten den ganzen Eintrag — ein Schluck aus zwei
Heiltraenken verbrauchte beide. `count` kam in M4 dazu, die drei aelteren
Verbraucher wurden nicht mitgezogen.

Ein Griff fuer alle: take_at(index) nimmt ein Stueck und raeumt den
Eintrag ab, wenn er leer ist. unequip laeuft jetzt durch give, damit ein
abgelegter Stapel wieder verschmilzt.

Dabei aufgefallen: Der Heiltrank hatte nie `actions` — seit M1
verbrauchbar, ohne zu wirken.
2026-08-12 20:54:07 +02:00

241 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<Creature>,
floor: Floor, // Innenbau gehoert Ticket 10
floor_number: u8,
quests: Vec<QuestProgress>, // 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<OptionId>, // Rasse, Klasse
inventory: Vec<ItemInstance>,
equipment: HashMap<SlotId, ItemInstance>,
tags: Vec<String>,
modifiers: Vec<ActiveModifier>, // nur befristete
}
struct ItemInstance { def: ItemId, affixes: Vec<AffixId> }
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<Floor>`.** 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<String>`, 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<CreatureRef>, 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<Event>` 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<AbilityId, u32>` — 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.