Files
rustkeep/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md

9.3 KiB

08 — Event- und Trigger-System

Type: grilling Status: resolved Blocked by: 07

Question

Welche Ereignisse meldet das Spiel, und wie hängen Quests und Effekte daran?

Quests sind Bedingung plus Belohnung als Daten; Script-Mods hängen sich später an dieselben Events. Zu klären:

  • Welche Events gibt es, und welche Daten trägt jedes? enemy_killed, item_picked_up, floor_entered, npc_talked, turn_started, damage_taken, player_died.
  • Wie werden Events benannt und für Modder erweiterbar gemacht — feste Liste oder auch namespaced IDs?
  • Bedingungen: reines Zählen (töte 5 Ratten) oder auch Zustandsabfragen (habe 100 Gold, Level ≥ 3)? Braucht es Verknüpfung mit UND/ODER?
  • Wie wird ein Questfortschritt gespeichert und wie überlebt er das Deaktivieren des Quest-Mods?
  • Reihenfolge und Rekursion: Was passiert, wenn eine Belohnung selbst ein Event auslöst?
  • Ist das synchrone Aufrufe (Handler direkt ausführen) oder eine Warteschlange?

Ergebnis: Eventliste mit Nutzlast, Bedingungs-Grammatik, TOML-Beispiel für eine Quest.

Vorgaben aus anderen Tickets

  • 01: Das Bedingungsvokabular für condition wurde hierher delegiert, damit Quests und Modifikatoren dieselbe Grammatik benutzen statt zweier, die sich subtil unterscheiden.
  • 07: Game hält quests: Vec<QuestProgress> — die Form von QuestProgress entscheidet dieses Ticket. Wesen werden über enum CreatureRef { Player, Enemy(usize) } adressiert, Mutationen laufen durch den einen Eingang Game::apply(action, source, target, reg). Ein Event-Handler, der etwas bewirkt, geht durch dieselbe Tür.

Answer

Begriffe im Glossar.

Der Trigger ist die vierte Wirkungsform

{ on = "attack_hit", condition = [ … ], actions = [ … ] }

Modifikator rechnet um, Aktion wirkt einmal, Fähigkeit bietet an — der Trigger sagt wann. Er darf überall stehen, wo heute modifiers und actions stehen: an Gegenständen, Affixen, Charakteroptionen, Quests und später an Scripts.

Das kostet nichts: Ein Trigger ist eine Aktionsliste plus zwei Felder, und Ticket 01 hat Aktionen bereits vollständig definiert. Die Alternative wäre nicht weniger Code, sondern dieselbe Maschine ein zweites Mal — in Ticket 14, unter dem Namen on_hit. Ohne Trigger wäre „Flammendes Schwert: 20 % Chance, Brennen auszulösen" schlicht nicht ausdrückbar.

Bedingungen — eine Grammatik für alles

condition = [
  { check = "has_tag",   who = "target", value = "core:undead" },
  { check = "attribute", who = "self",   attribute = "level", at_least = 3 },
  { check = "has_item",  who = "self",   item = "core:key", count = 1, not = true },
]

Einheitliche Form, damit serde sie als getaggtes Enum liest. Die Liste ist implizit UND, jeder Eintrag ist über not negierbar. Kein ODER, kein Verschachteln.

Ein rekursiver Ausdrucksbaum bräuchte eine Klammer-Syntax, die in TOML niemand freiwillig schreibt, plus eine Auswertung mit Kurzschlusslogik. Die Decke wird offen benannt: „(untot ODER dämonisch) UND Level ≥ 3" wird zu zwei Einträgen dupliziert — oder der Modder hängt beiden Wesen ein Tag core:unheilig an, was ohnehin das billigere Mittel ist.

Bedingungen werden an zwei Stellen ausgewertet: beim Feuern eines Triggers und beim Auslesen eines Attributs (bedingte Modifikatoren aus 01). Letzteres erzwingt den Nachtrag an Ticket 07, siehe unten.

Neun Events, feste Liste

Event Nutzlast über source/target hinaus
turn_started —
attack_hit Waffe, Schadenstyp
damage_taken Menge, Schadenstyp
creature_died —
item_picked_up ItemInstance
item_used ItemInstance
floor_entered Etagennummer
npc_talked NPC-ID
quest_completed Quest-ID

Jedes Event trägt source: CreatureRef und target: Option<CreatureRef>. Kein eigenes player_died — das ist creature_died mit source = Player.

attack_hit und damage_taken bleiben getrennt: Ersteres feuert beim Angreifer (dort hängt das flammende Schwert), Letzteres beim Getroffenen (dort hängt der Dornenpanzer). Ein gemeinsames Event zwänge jeden Trigger, erst zu prüfen, auf welcher Seite er steht.

Auflösung: self ist der Träger des Triggers, target die andere Seite des Events. Für einen Gegenstand ist der Träger sein Besitzer, für eine Charakteroption das Wesen, für eine Quest der Held. Damit ist eine Quest kein Sonderfall, sondern ein Trigger mit festem Träger.

Keine Mod-Events für 1.0. Die Liste ist Engine-Vokabular ohne Namensraum, wie Attribute und Schadenstypen aus 01. Der Grund ist konkret: Mod-Stufen sind Daten → Typen → Scripting, und ohne Stufe (c) kann niemand ein eigenes Event auslösen — ein namespaced Event wäre Vokabular ohne Sender. Kommt Scripting (Ticket 13), wird emit_event eine gewöhnliche Aktion und die Liste bekommt eine Custom(String)-Variante. Ein Zusatz, kein Umbau.

Warteschlange statt Rekursion

Events landen in Game.events: Vec<Event> und werden nach der laufenden Aktion in einer Schleife leergearbeitet.

Der Grund ist zur Hälfte ein Rust-Fakt: Game::apply hält bereits &mut self. Synchron zu feuern hieße, über self.quests zu iterieren, während self.apply erneut aufgerufen wird — ein Borrow-Fehler, kein Stilproblem. Die andere Hälfte ist die Rekursionsfrage aus dem Ticketkörper: Eine Belohnung, die selbst ein Event auslöst, hängt es hinten an, statt den Stack zu vertiefen.

Gegen Zyklen — Trigger A löst B aus, B löst A aus — ein schlichter Zähler: maximal 100 Events pro Zug, danach Abbruch mit Warnzeile. Kein Zyklenerkennungs-Graph; der Zähler fängt denselben Fall und ist eine Zeile.

Trigger werden bei jedem Event gesucht, nicht indiziert — dieselbe Entscheidung wie „kein Attribut-Cache" in 07 und aus demselben Grund: Ein Index müsste bei jedem An- und Ablegen, jedem Loot und jedem Affix-Wechsel gepflegt werden, also genau das Invalidierungsproblem, das 07 vermieden hat. Die Decke ist hier konkret — der 100-Events-Zähler mal dreißig Trigger mal zwanzig Gegner ist die Grenze, ab der indiziert wird.

Quests

Eine Quest ist ein Trigger mit count = N (Standard 1) und damit die siebte Registry:

[[quests]]
id = "core:rattenplage"
name = "Rattenplage"
on = "creature_died"
count = 5
condition = [ { check = "has_tag", who = "target", value = "core:rat" } ]
actions = [ { action = "give_item", item = "core:gold", count = 50, target = "self" } ]

Jedes Mal, wenn das Event feuert und die Bedingung hält, wird hochgezählt; bei N feuern die actions als Belohnung. „Besitze 100 Gold" braucht keine zweite Mechanik, sondern fällt unter dieselbe Form: on = "item_picked_up", condition = has_item core:gold ≥ 100, count = 1.

QuestProgress { quest: QuestId, count: u32, done: bool }. Erledigte Quests bleiben mit done = true in der Liste stehen statt entfernt zu werden — sonst kann eine Kette, die auf quest_completed hört, nicht unterscheiden, ob eine Quest nie lief oder gerade fertig wurde, und ein wiederauflebender Zähler schüttete die Belohnung erneut aus.

Ein QuestProgress, dessen ID nach dem Laden in keiner Registry steht, wird mit Warnzeile verworfen — die Regel steht in der Charter, das Detail gehört Ticket 09.

Nachtrag an Ticket 01: start_quest als siebte Aktion

Der Katalog aus 01 hatte sechs Aktionen. start_quest kommt dazu, und die Erweiterung steht hier offen statt versteckt.

Alle Quests ab dem ersten Zug mitzählen zu lassen wäre lazier gewesen und macht das Spiel kaputt: Eine Kette „bring mir 5 Rattenschwänze" → „jetzt geh in den Keller" wäre beim Betreten des Kellers bereits erfüllt, bevor sie jemand erwähnt hat. Ein Handelsmod mit 40 Quests ließe 40 Zähler pro Event mitlaufen.

Als Aktion fügt sich start_quest bruchlos ein — im NPC-Dialog, als Questbelohnung (das ist die Kette), in on_create einer Charakteroption für eine Klassenquest. Ticket 01 hat den Katalog nicht für heilig erklärt, es kannte Quests nur noch nicht.

Nachtrag an Ticket 07: against in der Attribut-Signatur

Ticket 01 erlaubt Modifikatoren eine condition und nennt „+5 Schaden gegen Untote" als Motivation für Tags. Ticket 07 hat den Lesezugang als Game::attribute(who, attr, reg) festgelegt — darin steckt kein Gegner, die Bedingung ist also nicht auswertbar. Neue Signatur:

Game::attribute(&self, who: CreatureRef, attr: Attribute, against: Option<CreatureRef>, reg: &Registries) -> f32

Der Charakterbogen gibt None. Die Alternative — Modifikator-Bedingungen auf den Träger beschränken und „gegen Untote" als Trigger mit Zusatzschaden lösen — sieht lazier aus und ist es nicht: Der Zusatzschaden liefe ein zweites Mal durch die Minderungskette aus 01, Resistenz und Krit griffen getrennt. Das ist nicht dasselbe Ergebnis, sondern ein anderes. Und der Charakterbogen könnte „Schaden 12 (17 gegen Untote)" nicht mehr anzeigen, weil der Bonus kein Modifikator mehr wäre.

Dazu Game.events: Vec<Event> als neues Feld im Baum.

Auflagen an andere Tickets

  • 10 Raum-Prefab-Format: Raumbetreten als Trigger-Ort — floor_entered deckt nur die Etage ab, nicht den einzelnen Raum.
  • 13 Script-Engine wählen: emit_event als Aktion und namespaced Events, sobald Stufe (c) steht.
  • 14 Kampfformeln und Rundenablauf: wo genau attack_hit und damage_taken im Zug feuern.