docs: ticket 08 (event-trigger-system) aufloesen, nachtraege an 01 und 07, trigger bedingung und quest ins glossar

This commit is contained in:
2026-08-12 10:34:12 +02:00
parent f25f393880
commit d5a6feb091
5 changed files with 216 additions and 1 deletions

View File

@@ -87,6 +87,19 @@ Event-System.
| Tags | Freie namespaced Strings am Wesen, Grundlage für Bedingungen |
| Sprache | Schadenstyp `poison` ≠ Modifikator `core:poisoned` („Vergiftet") |
### Nachtrag aus Ticket 08: `start_quest`
Der Katalog hat **sieben** Aktionen, nicht sechs. `start_quest` kam mit
[Ticket 08](08-event-trigger-system.md) dazu, weil eine Quest sonst ab dem ersten Zug mitzählte und
Questketten beim Betreten des Kellers bereits erfüllt wären.
```toml
{ action = "start_quest", quest = "core:rattenplage", target = "self" }
```
Das hier angeforderte Bedingungsvokabular steht ebenfalls in 08, zusammen mit dem **Trigger** als
vierter Wirkungsform.
### Bewusst nicht entschieden
Das **Bedingungsvokabular** für `condition`. Es kommt aus Ticket 08, damit Quests und Modifikatoren

View File

@@ -147,6 +147,21 @@ Modifikatoren werden ausdrücklich **nicht** beim Ausrüsten in `modifiers` hine
das Lesen billiger und das Schreiben gefährlich: Jedes Ablegen müsste exakt die richtigen Einträge
wieder herausoperieren, und ein Affix-Wechsel ginge an der Kopie vorbei.
### Nachtrag aus Ticket 08
Die Lesesignatur trägt einen **optionalen Gegner**:
```rust
Game::attribute(&self, who: CreatureRef, attr: Attribute, against: Option<CreatureRef>, reg: &Registries) -> f32
```
Ohne ihn ist ein bedingter Modifikator aus 01 („+5 Schaden gegen Untote") beim Auslesen von `damage`
nicht auswertbar — die Funktion wüsste nicht, gegen wen geschlagen wird. Der Charakterbogen gibt
`None`.
Dazu kommt `events: Vec<Event>` als Feld in `Game`: Ticket 08 arbeitet Events als Warteschlange ab,
weil synchrones Feuern über `self.quests` iterieren müsste, während `self.apply` erneut läuft.
### Auflagen an andere Tickets
- **08 Event- und Trigger-System**: Form von `QuestProgress`; das Bedingungsvokabular, das 01 bereits

View File

@@ -1,7 +1,7 @@
# 08 — Event- und Trigger-System
Type: grilling
Status: open
Status: resolved
Blocked by: 07
## Question
@@ -30,3 +30,159 @@ Ergebnis: Eventliste mit Nutzlast, Bedingungs-Grammatik, TOML-Beispiel für eine
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.
### 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.

View File

@@ -66,6 +66,7 @@ auch über Ticket 13.
- [05 — Research: Script-Engine](issues/05-research-script-engine.md) ([#6](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/6)) — Empfehlung Rhai wegen Fehlermeldungen mit Zeile und Spalte, billiger Typ-Brücke, AST-Hot-Reload und Build ohne `cc`. Gemessen: mlua liest unter `StdLib::ALL_SAFE` per `io.open` echte Dateien — „safe" heißt dort speichersicher, nicht fähigkeitsbeschränkt. Lua gewinnt bei der Modder-Vertrautheit; WASM scheidet für 1.0 aus. **Kernfolge: reden Scripts nur über das Effekt-Vokabular, ist die Engine-Wahl umkehrbar.** Entscheidung offen in Ticket 13.
- [06 — Research: ratatui-App-Architektur](issues/06-research-ratatui-architektur.md) ([#7](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/7)) — Screen-Stack über zentralem `Game`-State statt Elm oder Component-Trait; `game/` bleibt ratatui-frei; Popups per `Clear` auf Sub-`Rect` statt Widget-Stack; blockierendes `event::read()` ohne Tick; Dungeon-Grid ist Eigenbau, es gibt kein Widget dafür; ratatui 0.30.2 mit MSRV 1.88; `KeyEventKind::Press`-Filter unter Windows Pflicht. Voller Bericht in [research/06](research/06-ratatui-architektur.md).
- [07 — Game-State-Baum](issues/07-game-state-baum.md) ([#8](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/8)) — `Registries` lebt **neben** `Game`, nicht darin: als Feld kollidierte `&mut game.player` mit `&game.registries` bei jedem Methodenaufruf. Verweise per `String`-ID, keine Indizes (die hängen an der Ladereihenfolge). Ein `Creature` für Held und Gegner, aber **zwei Felder** `player` und `enemies` statt einer `Vec` — Solo-Held heißt, „Gegner greift Gegner an" existiert nicht, und zwei Felder sind ein disjunkter Borrow. Ein `Floor`, kein Backtracking; Händler müssen deshalb auf jeder Etage vorkommen können. **Instanzzustand kommt**: `ItemInstance { def, affixes }`, und **Affixe sind die sechste Registry** — dadurch bleibt der Save eine ID-Liste und der Preis `base_price × Π price_factor`. Stapeln bei gleicher `def` und gleicher `affixes`. `enum CreatureRef`, dazu je ein Eingang `Game::apply` und `Game::attribute` — Letzteres auf `Game`, weil Auren beim Träger wohnen und ein `Creature` seine Attribute allein gar nicht ausrechnen kann. Kein Attribut-Cache.
- [08 — Event- und Trigger-System](issues/08-event-trigger-system.md) ([#9](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/9)) — **Der Trigger ist die vierte Wirkungsform** neben Modifikator, Aktion und Fähigkeit: `{ on, condition, actions }` darf überall stehen, wo `modifiers` und `actions` stehen — sonst entstünde dieselbe Maschine ein zweites Mal in 14 als `on_hit`. Eine Bedingungsgrammatik für Trigger *und* Modifikatoren: getaggtes Enum, Liste ist UND, `not` je Eintrag, **kein ODER** (zwei Einträge oder ein gemeinsames Tag). Neun feste Events ohne Namensraum, jedes mit `source`/`target`; `attack_hit` und `damage_taken` bleiben getrennt, weil der eine beim Angreifer und der andere beim Getroffenen hängt. Im Trigger ist `self` immer der Träger. **Warteschlange statt synchron** — synchrones Feuern wäre ein Borrow-Fehler (über `self.quests` iterieren, während `self.apply` läuft) — mit Zähler bei 100 Events pro Zug statt Zyklengraph. Trigger werden gesucht, nicht indiziert. **Quest = Trigger mit `count`**, siebte Registry, erledigte bleiben markiert stehen. Zwei Nachträge an geschlossene Tickets: `start_quest` als siebte Aktion in 01, und `against: Option<CreatureRef>` plus `events: Vec<Event>` in 07.
## Not yet specified