Files
rustkeep/.scratch/rustkeep-1.0/issues/07-game-state-baum.md

11 KiB
Raw Blame History

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

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:

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

Ein Eingang, ein Ausgang

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:

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.