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

@@ -39,11 +39,19 @@ pub struct Item {
pub color: Option<String>,
#[serde(default)]
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).
#[serde(default)]
pub overrides: Option<String>,
// TODO(M3+): `actions`, `abilities`, `triggers` — brauchen erst Kampf und
// Event-System.
}
fn default_symbol() -> char {

View File

@@ -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<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 {
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<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
@@ -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<Condition>,
#[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));
}
}

View File

@@ -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);
}
}