Files
rustkeep/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md
MDietermann 37b5820c5b feat(quests): Quests als Trigger mit Zaehler
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.
2026-08-13 07:43:46 +02:00

297 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
### 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.