From cdcabcce216482051b2673303e48cdc80ebe801c Mon Sep 17 00:00:00 2001 From: MDietermann Date: Wed, 12 Aug 2026 17:44:26 +0200 Subject: [PATCH] feat: when-filter am trigger schliesst die luecke aus m3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../issues/08-event-trigger-system.md | 46 ++++++++-- mods/core/items.toml | 17 ++++ src/data/registries.rs | 12 ++- src/game/event.rs | 91 +++++++++++++++++-- src/game/mod.rs | 28 ++++-- 5 files changed, 170 insertions(+), 24 deletions(-) diff --git a/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md b/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md index f751b02..1d98ab3 100644 --- a/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md +++ b/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md @@ -82,16 +82,46 @@ Beim Bauen kamen zwei Prüfungen dazu, die oben fehlten: Motivbeispiel, hat aber kein Feld dafür festgelegt. Ohne es bräuchte jeder Wahrscheinlichkeitseffekt ein Script — und Scripting liegt laut 13 hinter 1.0. -### Offene Lücke aus M3: ein Trigger sieht sein Event nicht +### Nachtrag: `when` — der Ereignisfilter am Trigger -Die Events oben tragen Nutzlast (Schadenstyp, Prefab-ID, Menge) — **die Bedingungsgrammatik kann sie -nicht prüfen.** `Check` sieht nur Wesen, nie das Ereignis. Damit ist „Dornenpanzer reflektiert nur -Feuer" oder „nur bei kritischen Treffern" derzeit nicht formulierbar. +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. -In M3 wurden `Event.damage_type` und `Event.label` deshalb gestrichen: Felder, die niemand auslesen -kann, sind Kommentare in Feldform. Was fehlt, ist eine Prüfungsfamilie über die Nutzlast — etwa -`{ check = "damage_type", value = "fire" }`. Zu entscheiden, bevor Affixe und Gegenstands-Trigger -kommen (M4), denn dort werden solche Effekte erwartet. +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 diff --git a/mods/core/items.toml b/mods/core/items.toml index 3119571..a150d11 100644 --- a/mods/core/items.toml +++ b/mods/core/items.toml @@ -36,3 +36,20 @@ base_price = 1 stackable = true symbol = "$" color = "yellow" + +# Belegt den `when`-Filter in Daten: Der Panzer reflektiert NUR Feuer. +# Zwei Schadenstypen braeuchten zwei Trigger — ODER ist im Vokabular +# ausdruecklich nicht vorgesehen (Ticket 08). +[[items]] +id = "thorn_mail" +name = "Dornenpanzer" +base_price = 90 +slot = "core:body" +symbol = "[" +color = "red" +modifiers = [ { attribute = "resistance", damage_type = "fire", flat = 25 } ] +triggers = [ + { on = "damage_taken", + when = { damage_type = "fire" }, + actions = [ { action = "damage", amount = 3, damage_type = "fire", target = "target" } ] }, +] diff --git a/src/data/registries.rs b/src/data/registries.rs index fbabfcd..2c19780 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -39,11 +39,19 @@ pub struct Item { pub color: Option, #[serde(default)] pub modifiers: Vec, + /// Was beim Benutzen passiert (Trank, Schriftrolle). + #[serde(default)] + pub actions: Vec, + /// Faehigkeiten, die der Gegenstand **gewaehrt**, solange er getragen wird. + /// Nicht im Inventar tragbar — sonst koennte ein Magier seine Zauber + /// verkaufen (Ticket 02). + #[serde(default)] + pub abilities: Vec, + #[serde(default)] + pub triggers: Vec, /// Ersetzt den genannten Eintrag und uebernimmt dessen ID (Ticket 03). #[serde(default)] pub overrides: Option, - // TODO(M3+): `actions`, `abilities`, `triggers` — brauchen erst Kampf und - // Event-System. } fn default_symbol() -> char { diff --git a/src/game/event.rs b/src/game/event.rs index 4dcf016..a111eaf 100644 --- a/src/game/event.rs +++ b/src/game/event.rs @@ -6,7 +6,7 @@ use serde::Deserialize; -use super::action::Action; +use super::action::{Action, DamageType}; use super::condition::Condition; use super::creature::CreatureRef; @@ -33,27 +33,62 @@ pub enum EventKind { CombatEnded, } -/// **Befund aus M3:** Ticket 08 hat Events eine Nutzlast gegeben (Schadenstyp, -/// Prefab-ID, Menge), aber die Bedingungsgrammatik kann sie **gar nicht -/// pruefen** — `Check` sieht nur Wesen, nie das Ereignis. Bis das geklaert ist, -/// traegt ein Event nur, was tatsaechlich gelesen wird: wer und gegen wen. -/// Nutzlast auf Vorrat waere ein Feld, das niemand ausliest. #[derive(Debug, Clone)] pub struct Event { pub kind: EventKind, pub source: CreatureRef, pub target: Option, + /// Nutzlast. Nur was ein benanntes Beispiel braucht: `damage_type` fuer + /// „Dornenpanzer reflektiert nur Feuer", `prefab` fuer die Quest „betrete die + /// Bibliothek". `critical` und `amount` bleiben draussen, bis jemand sie + /// fordert (Nachtrag an Ticket 08). + pub damage_type: Option, + pub prefab: Option, } impl Event { pub fn new(kind: EventKind, source: CreatureRef) -> Self { - Self { kind, source, target: None } + Self { kind, source, target: None, damage_type: None, prefab: None } } pub fn to(mut self, target: CreatureRef) -> Self { self.target = Some(target); self } + + pub fn with_damage(mut self, dt: DamageType) -> Self { + self.damage_type = Some(dt); + self + } + + pub fn in_prefab(mut self, prefab: impl Into) -> Self { + self.prefab = Some(prefab.into()); + self + } +} + +/// Filtert das **Ereignis**, nicht ein Wesen — deshalb ein eigenes Feld und +/// keine weitere Pruefung in `condition`: Dieselbe Bedingungsgrammatik laeuft +/// auch beim Auslesen eines Attributs, und dort gibt es kein Event. Eine +/// Nutzlast-Pruefung in `condition` waere im Trigger sinnvoll und am Modifikator +/// immer falsch, ohne dass der Modder es sieht. +/// +/// Alle gesetzten Felder muessen zutreffen (implizit UND). **Ein Wert je Feld, +/// keine Negation** — wer Feuer *und* Eis reflektieren will, schreibt zwei +/// Trigger. Fehlt ein Feld, wird nicht gefiltert. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct EventFilter { + #[serde(default)] + pub damage_type: Option, + #[serde(default)] + pub prefab: Option, +} + +impl EventFilter { + pub fn matches(&self, event: &Event) -> bool { + self.damage_type.is_none_or(|dt| event.damage_type == Some(dt)) + && self.prefab.as_ref().is_none_or(|p| event.prefab.as_ref() == Some(p)) + } } /// `{ on = , condition = [...], actions = [...] }` — die vierte @@ -62,6 +97,9 @@ impl Event { #[derive(Debug, Clone, Deserialize)] pub struct Trigger { pub on: EventKind, + /// `condition` fragt **Wesen**, `when` filtert das **Ereignis**. + #[serde(default)] + pub when: EventFilter, #[serde(default)] pub condition: Vec, #[serde(default)] @@ -103,5 +141,44 @@ actions = [ { action = "damage", amount = 4, damage_type = "fire", target = "tar let t: Trigger = toml::from_str("on = \"turn_started\"").expect("minimal"); assert_eq!(t.chance, 1.0); assert!(t.actions.is_empty()); + assert!(t.when.matches(&Event::new(EventKind::TurnStarted, CreatureRef::Player))); + } + + /// `condition` fragt Wesen, `when` filtert das Ereignis — die Trennung aus + /// dem Nachtrag an Ticket 08. + #[test] + fn when_filtert_die_ereignis_nutzlast() { + let t: Trigger = toml::from_str( + r#" +on = "damage_taken" +when = { damage_type = "fire" } +"#, + ) + .expect("Filter-Form aus dem Nachtrag"); + + let feuer = Event::new(EventKind::DamageTaken, CreatureRef::Player) + .with_damage(DamageType::Fire); + let eis = + Event::new(EventKind::DamageTaken, CreatureRef::Player).with_damage(DamageType::Ice); + let ohne = Event::new(EventKind::DamageTaken, CreatureRef::Player); + + assert!(t.when.matches(&feuer)); + assert!(!t.when.matches(&eis), "der Dornenpanzer reflektiert nur Feuer"); + assert!(!t.when.matches(&ohne), "ohne Nutzlast passt der Filter nicht"); + } + + #[test] + fn mehrere_felder_sind_implizit_und() { + let f: EventFilter = + toml::from_str(r#"damage_type = "fire""#).expect("ein Feld reicht"); + let mut passend = Event::new(EventKind::RoomEntered, CreatureRef::Player) + .with_damage(DamageType::Fire); + assert!(f.matches(&passend)); + + let beide: EventFilter = + toml::from_str("damage_type = \"fire\"\nprefab = \"core:vault\"").expect("zwei Felder"); + assert!(!beide.matches(&passend), "prefab fehlt — alle Felder muessen zutreffen"); + passend = passend.in_prefab("core:vault"); + assert!(beide.matches(&passend)); } } diff --git a/src/game/mod.rs b/src/game/mod.rs index 12f92d9..0dbad8c 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -238,9 +238,14 @@ impl Game { out.extend(def.triggers.iter()); } } - // TODO(M4): Trigger an Gegenstaenden, Affixen und Charakteroptionen — - // sie brauchen dieselben drei Zeilen, sobald die Registries - // das Feld tragen. + // Getragene Ausruestung — der Dornenpanzer haengt hier. + for inst in c.equipment.values() { + if let Some(item) = reg.items.get(&inst.def) { + out.extend(item.triggers.iter()); + } + // TODO(M4): Affix-Trigger des Exemplars, sobald die Affix-Registry + // steht — dieselben zwei Zeilen. + } out.retain(|t| t.on == kind); out } @@ -302,8 +307,12 @@ impl Game { // Dornenpanzer haengt (Ticket 08/14). Beide feuern **nach** dem // Schaden: Ein Trigger, der Brennen auflegt, ist ein eigener // Schadensfall mit eigener Minderungskette. - self.events.push_back(Event::new(EventKind::AttackHit, source).to(victim)); - self.events.push_back(Event::new(EventKind::DamageTaken, victim).to(source)); + self.events.push_back( + Event::new(EventKind::AttackHit, source).to(victim).with_damage(*damage_type), + ); + self.events.push_back( + Event::new(EventKind::DamageTaken, victim).to(source).with_damage(*damage_type), + ); self.check_death(victim, source); } @@ -409,6 +418,9 @@ impl Game { let triggers = self.triggers_for(carrier, event.kind, reg); for t in triggers { + if !t.when.matches(&event) { + continue; + } let other = if carrier == event.source { event.target } else { Some(event.source) }; if !self.conditions_hold(&t.condition, carrier, other, reg) { continue; @@ -653,8 +665,10 @@ impl Game { if room != self.floor.player_room { self.floor.player_room = room; if let Some(i) = room { - self.events - .push_back(Event::new(EventKind::RoomEntered, CreatureRef::Player)); + let prefab = self.floor.rooms[i].prefab.clone(); + self.events.push_back( + Event::new(EventKind::RoomEntered, CreatureRef::Player).in_prefab(prefab), + ); self.enter_room(i); } }