condition fragt Wesen, when filtert das Ereignis. Getrennt, weil dieselbe Bedingungsgrammatik auch beim Auslesen eines Attributs laeuft — dort gibt es kein Event, und eine Nutzlast-Pruefung in condition waere im Trigger sinnvoll und am Modifikator immer falsch, ohne dass der Modder es sieht. Nutzlast zurueck: damage_type und prefab. critical und amount bleiben draussen, bis jemand sie fordert. Dazu Item.triggers/actions/abilities und Trigger-Suche in der Ausruestung — mods/core/items.toml belegt es mit einem Dornenpanzer, der NUR Feuer reflektiert. 45 Tests.
275 lines
14 KiB
Markdown
275 lines
14 KiB
Markdown
# 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](../../../CONTEXT.md).
|
||
|
||
### Der Trigger ist die vierte Wirkungsform
|
||
|
||
```toml
|
||
{ 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
|
||
|
||
```toml
|
||
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_below`** mit einem Anteil (0.0–1.0). Die `ai`-Listen aus 14 brauchen „unter 30 % Leben", und
|
||
das ist mit `attribute` nicht ausdrückbar: `health` ist **Zustand**, kein Attribut (01), und ein
|
||
*Verhältnis* lässt sich mit `at_least`/`below` auf einem absoluten Wert nicht formulieren.
|
||
- **`chance`** am 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`:
|
||
|
||
```toml
|
||
[[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**:
|
||
|
||
```toml
|
||
[[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:
|
||
|
||
```rust
|
||
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.
|