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

203 lines
10 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.
### 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 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.
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.