feat: when-filter am trigger schliesst die luecke aus m3

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.
This commit is contained in:
2026-08-12 17:44:26 +02:00
parent b25dd1c49a
commit cdcabcce21
5 changed files with 170 additions and 24 deletions

View File

@@ -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 Motivbeispiel, hat aber kein Feld dafür festgelegt. Ohne es bräuchte jeder Wahrscheinlichkeitseffekt
ein Script — und Scripting liegt laut 13 hinter 1.0. 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 Die Lücke, die M3 aufgedeckt hat: Die Events oben tragen Nutzlast, aber **die Bedingungsgrammatik kann
nicht prüfen.** `Check` sieht nur Wesen, nie das Ereignis. Damit ist „Dornenpanzer reflektiert nur sie nicht prüfen** — `Check` fragt immer ein *Wesen* (`who`), nie das Ereignis. „Dornenpanzer
Feuer" oder „nur bei kritischen Treffern" derzeit nicht formulierbar. reflektiert nur Feuer" war damit nicht formulierbar.
In M3 wurden `Event.damage_type` und `Event.label` deshalb gestrichen: Felder, die niemand auslesen Gelöst wird das mit einem **eigenen Feld am Trigger**, nicht mit einer weiteren Prüfung in `condition`:
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 ```toml
kommen (M4), denn dort werden solche Effekte erwartet. [[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 ### Neun Events, feste Liste

View File

@@ -36,3 +36,20 @@ base_price = 1
stackable = true stackable = true
symbol = "$" symbol = "$"
color = "yellow" 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" } ] },
]

View File

@@ -39,11 +39,19 @@ pub struct Item {
pub color: Option<String>, pub color: Option<String>,
#[serde(default)] #[serde(default)]
pub modifiers: Vec<Modifier>, pub modifiers: Vec<Modifier>,
/// Was beim Benutzen passiert (Trank, Schriftrolle).
#[serde(default)]
pub actions: Vec<Action>,
/// 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<String>,
#[serde(default)]
pub triggers: Vec<Trigger>,
/// Ersetzt den genannten Eintrag und uebernimmt dessen ID (Ticket 03). /// Ersetzt den genannten Eintrag und uebernimmt dessen ID (Ticket 03).
#[serde(default)] #[serde(default)]
pub overrides: Option<String>, pub overrides: Option<String>,
// TODO(M3+): `actions`, `abilities`, `triggers` — brauchen erst Kampf und
// Event-System.
} }
fn default_symbol() -> char { fn default_symbol() -> char {

View File

@@ -6,7 +6,7 @@
use serde::Deserialize; use serde::Deserialize;
use super::action::Action; use super::action::{Action, DamageType};
use super::condition::Condition; use super::condition::Condition;
use super::creature::CreatureRef; use super::creature::CreatureRef;
@@ -33,27 +33,62 @@ pub enum EventKind {
CombatEnded, 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)] #[derive(Debug, Clone)]
pub struct Event { pub struct Event {
pub kind: EventKind, pub kind: EventKind,
pub source: CreatureRef, pub source: CreatureRef,
pub target: Option<CreatureRef>, pub target: Option<CreatureRef>,
/// 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<DamageType>,
pub prefab: Option<String>,
} }
impl Event { impl Event {
pub fn new(kind: EventKind, source: CreatureRef) -> Self { 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 { pub fn to(mut self, target: CreatureRef) -> Self {
self.target = Some(target); self.target = Some(target);
self 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<String>) -> 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<DamageType>,
#[serde(default)]
pub prefab: Option<String>,
}
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 = <event>, condition = [...], actions = [...] }` — die vierte /// `{ on = <event>, condition = [...], actions = [...] }` — die vierte
@@ -62,6 +97,9 @@ impl Event {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct Trigger { pub struct Trigger {
pub on: EventKind, pub on: EventKind,
/// `condition` fragt **Wesen**, `when` filtert das **Ereignis**.
#[serde(default)]
pub when: EventFilter,
#[serde(default)] #[serde(default)]
pub condition: Vec<Condition>, pub condition: Vec<Condition>,
#[serde(default)] #[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"); let t: Trigger = toml::from_str("on = \"turn_started\"").expect("minimal");
assert_eq!(t.chance, 1.0); assert_eq!(t.chance, 1.0);
assert!(t.actions.is_empty()); 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));
} }
} }

View File

@@ -238,9 +238,14 @@ impl Game {
out.extend(def.triggers.iter()); out.extend(def.triggers.iter());
} }
} }
// TODO(M4): Trigger an Gegenstaenden, Affixen und Charakteroptionen — // Getragene Ausruestung — der Dornenpanzer haengt hier.
// sie brauchen dieselben drei Zeilen, sobald die Registries for inst in c.equipment.values() {
// das Feld tragen. 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.retain(|t| t.on == kind);
out out
} }
@@ -302,8 +307,12 @@ impl Game {
// Dornenpanzer haengt (Ticket 08/14). Beide feuern **nach** dem // Dornenpanzer haengt (Ticket 08/14). Beide feuern **nach** dem
// Schaden: Ein Trigger, der Brennen auflegt, ist ein eigener // Schaden: Ein Trigger, der Brennen auflegt, ist ein eigener
// Schadensfall mit eigener Minderungskette. // Schadensfall mit eigener Minderungskette.
self.events.push_back(Event::new(EventKind::AttackHit, source).to(victim)); self.events.push_back(
self.events.push_back(Event::new(EventKind::DamageTaken, victim).to(source)); 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); self.check_death(victim, source);
} }
@@ -409,6 +418,9 @@ impl Game {
let triggers = self.triggers_for(carrier, event.kind, reg); let triggers = self.triggers_for(carrier, event.kind, reg);
for t in triggers { for t in triggers {
if !t.when.matches(&event) {
continue;
}
let other = if carrier == event.source { event.target } else { Some(event.source) }; let other = if carrier == event.source { event.target } else { Some(event.source) };
if !self.conditions_hold(&t.condition, carrier, other, reg) { if !self.conditions_hold(&t.condition, carrier, other, reg) {
continue; continue;
@@ -653,8 +665,10 @@ impl Game {
if room != self.floor.player_room { if room != self.floor.player_room {
self.floor.player_room = room; self.floor.player_room = room;
if let Some(i) = room { if let Some(i) = room {
self.events let prefab = self.floor.rooms[i].prefab.clone();
.push_back(Event::new(EventKind::RoomEntered, CreatureRef::Player)); self.events.push_back(
Event::new(EventKind::RoomEntered, CreatureRef::Player).in_prefab(prefab),
);
self.enter_room(i); self.enter_room(i);
} }
} }