Eine Quest IST ein Trigger mit count = N — kein zweiter Mechanismus. Gezaehlt wird erst ab start_quest, erledigte bleiben mit done = true stehen, und die Belohnung darf die naechste starten: das ist die Kette. - fuenfzehnte Registry, Trigger per serde(flatten) eingebettet - give_item als Aktion, damit eine Belohnung mehr als Erfahrung zahlt - QuestLog auf q, Fortschritt im Speicherstand, unbekannte IDs verworfen - eine Funktion entscheidet fuer alle Traeger, wann ein Trigger feuert Befund: Die Ordnungszahlen im Aktionskatalog widersprechen sich — vier Tickets zaehlten je vom eigenen Stand. Sie sind raus, CONTEXT.md fuehrt die Liste.
16 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
conditionwurde hierher delegiert, damit Quests und Modifikatoren dieselbe Grammatik benutzen statt zweier, die sich subtil unterscheiden. - 07:
Gamehältquests: Vec<QuestProgress>— die Form vonQuestProgressentscheidet dieses Ticket. Wesen werden überenum CreatureRef { Player, Enemy(usize) }adressiert, Mutationen laufen durch den einen EingangGame::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.
Nachträge aus M3
Beim Bauen kamen zwei Prüfungen dazu, die oben fehlten:
health_belowmit einem Anteil (0.0–1.0). Dieai-Listen aus 14 brauchen „unter 30 % Leben", und das ist mitattributenicht ausdrückbar:healthist Zustand, kein Attribut (01), und ein Verhältnis lässt sich mitat_least/belowauf einem absoluten Wert nicht formulieren.chanceam Trigger. Dieses Ticket nennt „Flammendes Schwert: 20 % Chance, Brennen auszulösen" als Motivbeispiel, hat aber kein Feld dafür festgelegt. Ohne es bräuchte jeder Wahrscheinlichkeitseffekt ein Script — und Scripting liegt laut 13 hinter 1.0.
Nachtrag: when — der Ereignisfilter am Trigger
Die Lücke, die M3 aufgedeckt hat: Die Events oben tragen Nutzlast, aber die Bedingungsgrammatik kann
sie nicht prüfen — Check fragt immer ein Wesen (who), nie das Ereignis. „Dornenpanzer
reflektiert nur Feuer" war damit nicht formulierbar.
Gelöst wird das mit einem eigenen Feld am Trigger, nicht mit einer weiteren Prüfung in condition:
[[triggers]]
on = "damage_taken"
when = { damage_type = "fire" }
condition = [ { check = "has_tag", who = "target", value = "core:undead" } ]
actions = [ { action = "damage", amount = 3, damage_type = "fire", target = "target" } ]
condition fragt Wesen, when filtert das Ereignis. Der Grund für die Trennung ist der
Auswertungskontext: Dieselbe Bedingungsgrammatik läuft auch beim Auslesen eines Attributs (bedingte
Modifikatoren aus 01), und dort gibt es kein Event. Eine Nutzlast-Prüfung in condition wäre also eine
Prüfung, deren Wahrheitswert vom Kontext abhängt — im Trigger sinnvoll, am Modifikator immer falsch. Der
Modder sähe das nicht, er sähe nur, dass sein Modifikator nie greift. Genau die stille Falle, die dieses
Projekt an anderen Stellen bewusst vermieden hat (Ring heilt beim Anlegen; level gegen base.level).
Was Ticket 08 ursprünglich verhindern wollte, waren zwei Bedingungsgrammatiken über Wesen, die auseinanderdriften. Ein Ereignisfilter ist keine zweite Fassung davon, sondern eine andere Frage: nicht wer, sondern was gerade passiert ist.
Nutzlast, die zurückkommt: damage_type (bei attack_hit und damage_taken) und prefab (bei
room_entered, sonst wäre die Quest „betrete die Bibliothek" nicht formulierbar). critical und
amount bleiben draußen — für beide gibt es kein Beispiel, das jemand gefordert hat, und das ist der
Unterschied zwischen einem Bedarf und einer Idee. Sie kosten später je ein Feld, ohne dass der Filter
sich ändert.
Form: eine Tabelle mit optionalen Feldern, alle vorhandenen müssen zutreffen (implizit UND, wie bei
condition). Ein Wert je Feld, keine Negation — dasselbe Argument wie beim ODER-Verbot oben: Wer
Feuer und Eis reflektieren will, schreibt zwei Trigger. Fehlt ein Feld, wird nicht gefiltert; das ist
die Negation, die man meistens meint.
Die Decke: Häufen sich Filter mit fünf Feldern und Ausnahmen, ist das das Signal, dass Stufe (c) fällig ist — nicht, dass der Filter eine Sprache werden soll.
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.
Nachtrag aus Ticket 10: zwölf Events
room_entered { prefab_id } kommt dazu. floor_entered deckt nur die Etage ab — „die Tür fällt hinter
dir zu", ein Hinterhalt oder eine Quest „finde die Bibliothek" brauchen den einzelnen Raum. Prefabs
tragen Trigger wie jeder andere Registry-Inhalt, neue Maschinerie kostet es also nicht.
Das Event trägt zugleich den Kampfbeginn: Ein room_entered in einen Raum mit lebenden Gegnern
verschiebt sie nach Game.enemies.
Beobachtung zur Liste selbst: Sie wurde hier als „fest und geschlossen" festgelegt, und seither hat jedes Ticket, das echte Spielsituationen beschreibt, einen Eintrag gebraucht — 14 zwei, 10 einen. Das spricht nicht gegen die Entscheidung (namespaced Mod-Events wären ohne Scripting weiterhin sinnlos), aber wer 15 oder 16 bearbeitet, sollte mit einem weiteren rechnen.
Nachtrag aus Ticket 14: elf Events statt neun
combat_started und combat_ended { outcome: Victory | Fled } kommen dazu. Ohne sie ist „gewinne drei
Kämpfe" nicht formulierbar, und combat_ended braucht die Unterscheidung — sonst zählt Weglaufen
als Erfolg.
Beide mit source = Player, ohne target. Platzierung im Zugablauf aus 14: combat_started bei
Schritt 1 neben turn_started, combat_ended bei Schritt 5 direkt nach dem retain — dann ist die
Gegnerliste bereits leer und ein Trigger sieht den Endzustand statt einer halb aufgeräumten
Zwischenstufe.
Keine dritte Variante für den Tod des Helden: creature_died mit source = Player deckt ihn ab, und
danach gibt es ohnehin keinen Trigger mehr, der etwas davon hätte.
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.
Befund aus M3: die Warteschlange war ein Stack. Die erste Umsetzung nahm Vec::pop(), also LIFO —
ein Folge-Event drängelte sich vor die schon wartenden. Genau das sollte die Warteschlange verhindern
(„hängt es hinten an, statt den Stack zu vertiefen"). Jetzt VecDeque mit pop_front. Die Formulierung
oben war richtig, die Umsetzung nicht, und kein Test hat es gemerkt — Reihenfolge fällt erst auf,
wenn zwei Trigger um dasselbe Ziel konkurrieren.
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_entereddeckt nur die Etage ab, nicht den einzelnen Raum. - 13 Script-Engine wählen:
emit_eventals Aktion und namespaced Events, sobald Stufe (c) steht. - 14 Kampfformeln und Rundenablauf: wo genau
attack_hitunddamage_takenim Zug feuern.
Befunde aus M7
„Eine Quest ist ein Trigger" hält — und zwar bis in den Code. Quest trägt den Trigger per
#[serde(flatten)], on/when/condition/actions/chance stehen also flach in derselben
TOML-Tabelle. Dieselbe Frage „feuert dieser Trigger auf dieses Ereignis?" beantwortet eine einzige
Funktion für Gegenstand, Modifikator, Gegner und Quest. Beim ersten Bauen stand die Prüfung zweimal
da; das war der Moment, in dem die Behauptung des Tickets fast nur eine Formulierung geblieben wäre.
Der feste Träger ist der Grund, warum Quests neben der Trägerschleife stehen. Das Ticket sagt „für eine Quest ist der Träger der Held". Im Code heißt das: Der Questlauf gehört neben die Schleife über die Beteiligten, nicht hinein — sonst würde jede Quest einmal pro Gegner gezählt.
Die Kette braucht keinen Sonderfall. Eine Belohnung, die start_quest ausführt, verlängert
Game.quests mitten im Zählen. Weil die Schleife über den Index läuft und ihre Länge vorher feststeht,
wird der neue Eintrag erst beim nächsten Ereignis gezählt — genau richtig, er war beim Auslöser noch
nicht da. Kein Zwischenpuffer nötig, nur die Reihenfolge richtig herum.
Die Ordnungszahlen im Aktionskatalog widersprechen sich. Ticket 08 nennt start_quest die
„siebte", Ticket 14 flee die „achte", M4 open_shop die „elfte" — jedes Ticket zählte von seinem
eigenen Stand aus, und keine der Zahlen stimmt mit den heute neun gebauten Varianten überein. Die
Ordnungszahlen sind aus dem Code raus; CONTEXT.md führt die Liste, und die ist zählbar.