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:
@@ -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
|
||||
|
||||
|
||||
@@ -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" } ] },
|
||||
]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user