Skip to main content

delvewright_dsl/
trigger.rs

1//! Environment triggers and the props a player interacts with.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::serde_fields::default_true;
7use crate::{AnchorId, AssemblyId, FlagId, NpcId, QuestEffect, StateCompare, TriggerId};
8
9#[cfg(doc)]
10use crate::stepped_blocks;
11
12/// A stage-5 environment trigger (DSL v0.4). Emission uses vanilla-intended
13/// primitives only (spec-0008 §7): `strike`/`use` read a `minecraft:interaction`
14/// entity's attack/interaction records; `approach` is a `distance` selector on
15/// the tick. Look-at / break-attempt detection is excluded on principle (no
16/// vanilla primitive).
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
18#[serde(deny_unknown_fields)]
19pub struct EnvTrigger {
20    /// Unique trigger id (`trigger/<kebab>`).
21    pub id: TriggerId,
22    /// The anchor this trigger watches. Required for `strike` / `use` /
23    /// `approach`, which watch a *place*; **absent** for `strike-npc` (DSL
24    /// v0.6), which watches a *character* and names it in `on.npc` instead —
25    /// there is no cell for the author to supply and no cell the compiler
26    /// would use. Either mismatch is `DW0194`.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub at: Option<AnchorId>,
29    /// **The visible object the click acts on** (spec-0093 §6.5): for a `use`
30    /// or a `strike` trigger, the block the compiler places at `at` — a lever,
31    /// a bell, a lamp, a stone. A `use` on a block vanilla reports the use of
32    /// (a lever, a button, a bell) fires through vanilla's `default_block_use`
33    /// criterion and summons no hitbox; any other prop, and every `strike`, is
34    /// placed with the `minecraft:interaction` hitbox fitted over it as its hit
35    /// area. A click trigger with no `prop` on open air is refused (`DW0963`):
36    /// the hitbox is invisible and is never the object. A `prop` on an event
37    /// with no cell of its own — an approach, a `strike-npc`, a
38    /// `strike-assembly` — is `DW0964`.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub prop: Option<Prop>,
41    /// The event that fires it.
42    pub on: TriggerOn,
43    /// Flags that must be set before the trigger can fire (DSL v0.4).
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub requires_flags: Vec<FlagId>,
46    /// Negative flag gate (DSL v0.6): the trigger is
47    /// **suppressed** while ANY listed flag is set (by any player — flags are
48    /// campaign state). The dual of `requires_flags`, so an "armed between two
49    /// story beats" trigger needs no re-arm plumbing: e.g. a strike-the-giant
50    /// retaliation trigger with `requires_flags: [flag/sealed]` and
51    /// `forbids_flags: [flag/asleep]` arms when the cave seals and stands down
52    /// the moment the wake beat takes over.
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub forbids_flags: Vec<FlagId>,
55    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
56    /// hold for this gate to be open. The third field of the one gate, carried by
57    /// every gate consumer — never by the verb that first wanted it. Default
58    /// empty, so a pre-0.10 campaign is byte-identical.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub requires_state: Vec<StateCompare>,
61    /// Fire at most once (default `true`, mirroring objective completion). Set
62    /// `false` to allow re-firing every time the condition is met.
63    #[serde(default = "default_true")]
64    pub once: bool,
65    /// **Who the trigger's effects address** (DSL v0.11). Default
66    /// [`TriggerAudience::Party`], so every campaign written before this field
67    /// existed is byte-identical.
68    ///
69    /// A trigger is two different things depending on what the author means by
70    /// it. A pressure plate that opens a gate and narrates the room is a **party
71    /// beat**: everyone should see it, and it does not matter who stepped on the
72    /// plate. A barred door that answers *"this cannot be opened from this
73    /// side"* is a **reply to one person**: broadcasting it tells four players
74    /// about a door three of them are nowhere near.
75    ///
76    /// Until this field the second was inexpressible, which is why the two verbs
77    /// that needed it (`close-gate`'s seal answer, and nothing at all for a
78    /// shortcut door) grew their own private reply machinery instead. The
79    /// capability belongs to the press, not to the verb.
80    #[serde(default, skip_serializing_if = "TriggerAudience::is_party")]
81    pub audience: TriggerAudience,
82    /// Effects fired when the trigger matches.
83    pub effects: Vec<QuestEffect>,
84}
85
86impl EnvTrigger {
87    /// The anchor this trigger watches, if it watches a place at all. `None`
88    /// for `strike-npc`, whose target is a character.
89    pub fn at_anchor(&self) -> Option<&str> {
90        self.at.as_ref().map(|a| a.as_str())
91    }
92
93    /// Whether this trigger's bundle is addressed to the player who pressed it.
94    pub fn addresses_presser(&self) -> bool {
95        self.audience == TriggerAudience::Presser
96    }
97
98    /// Whether vanilla can name the player whose act fired this trigger, which
99    /// is what `audience: presser` needs: a right-click (`use`, through
100    /// `minecraft:player_interacted_with_entity`) and a step (`step`, a player
101    /// in the cell). A left-click is recorded as a UUID no command can become;
102    /// everything else is refused by `DW0427`.
103    pub fn attributes_its_actor(&self) -> bool {
104        matches!(self.on, TriggerOn::Use | TriggerOn::Step)
105    }
106}
107
108/// Who an [`EnvTrigger`]'s effects address (DSL v0.11).
109///
110/// **This is a dispatch decision, not a cosmetic one.** A `party` trigger is
111/// polled on the tick with no executor, so `@s` does not exist and every
112/// player-facing command addresses `@a`. A `presser` trigger is dispatched by a
113/// `minecraft:player_interacted_with_entity` advancement — the one vanilla
114/// primitive that runs a function *as the player who clicked* — so `@s` is the
115/// presser and the bundle addresses them alone. A `presser` trigger `on: step`
116/// is polled as `execute as @a[<the cell>]`, so `@s` is each player who stepped
117/// on, on their own step.
118///
119/// The click primitive exists for **right-clicks only**. Vanilla records a
120/// left-click on an interaction entity in NBT (which names a UUID no command can
121/// become) and offers no criterion for it, so `presser` on a `strike` is refused
122/// (`DW0427`) rather than approximated: per CLAUDE.md's no-hack rule, a
123/// capability with no vanilla primitive under it is excluded, never faked
124/// downstream.
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126#[serde(rename_all = "kebab-case")]
127pub enum TriggerAudience {
128    /// The whole party (the default, and what every trigger did before v0.11).
129    #[default]
130    Party,
131    /// The one player whose press fired it: the right-click of a `use`, the
132    /// step of a `step`.
133    Presser,
134}
135
136impl TriggerAudience {
137    /// Serde skip predicate: the default needs no field on the wire, so a
138    /// canonical round-trip of a pre-0.11 campaign is byte-identical.
139    fn is_party(&self) -> bool {
140        *self == TriggerAudience::Party
141    }
142}
143
144/// The event an [`EnvTrigger`] watches (DSL v0.4).
145#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
146#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
147pub enum TriggerOn {
148    /// The player attacks (left-clicks) the interaction entity at the anchor.
149    Strike,
150    /// The player uses (right-clicks) the interaction entity at the anchor.
151    Use,
152    /// The player comes within `range` blocks of the anchor.
153    Approach {
154        /// Approach radius (blocks).
155        range: u32,
156    },
157    /// A player steps onto the anchor's cell, which holds a block a step fires
158    /// — a pressure plate or the tripwire string ([`stepped_blocks`]); the
159    /// piece places it, and a cell that holds anything else is `DW0917`.
160    ///
161    /// Detected as a player whose hitbox is in the cell (the selector a plate
162    /// or tripwire trap fires on), edge-latched so standing on the plate fires
163    /// once. With `audience: presser` each player who steps on is dispatched
164    /// as `@s` on their own step: the act and the actor are the same fact, a
165    /// body in the cell, so no player is inferred after the event.
166    Step,
167    /// The player attacks (left-clicks) an **NPC's body** (DSL v0.6).
168    ///
169    /// The place-based [`TriggerOn::Strike`] cannot express "hit the giant": it
170    /// summons its own `minecraft:interaction` at a *cell*, and a large NPC's
171    /// body eclipses that cell (`DW0359`), so the click never reaches the
172    /// trigger — the owner's island round-7 finding. This form has no cell. It
173    /// rides the interaction entity the NPC already owns, which is the entity a
174    /// click on that NPC reaches by construction.
175    ///
176    /// Right-click and left-click stay separate all the way down: a
177    /// `minecraft:interaction` records them in two distinct NBT fields
178    /// (`interaction` and `attack`), so the NPC's dialogue keeps the right-click
179    /// and this trigger takes the left-click, on one shared hitbox.
180    StrikeNpc {
181        /// The NPC (stage-2 ref) whose body is the target.
182        npc: NpcId,
183    },
184    /// The player attacks (left-clicks) an **assembly's hitbox** (spec-0082).
185    ///
186    /// The exact shape of [`TriggerOn::StrikeNpc`]: no `at`, because the target
187    /// is an object with a hitbox of its own, and the trigger rides it. Melee
188    /// only — the hitbox is a `minecraft:interaction`, and an arrow passes
189    /// through one without writing its `attack` record (spec-0082 §8 row 5).
190    /// `once: false` with an `add-state` is how a hit count is built.
191    StrikeAssembly {
192        /// The assembly (stage-5 `assemblies` ref) whose hitbox is the target.
193        assembly: AssemblyId,
194    },
195}
196
197impl TriggerOn {
198    /// The kebab tag (`strike` / `use` / `approach` / `step` / `strike-npc` /
199    /// `strike-assembly`).
200    pub fn kind(&self) -> &'static str {
201        match self {
202            TriggerOn::Strike => "strike",
203            TriggerOn::Use => "use",
204            TriggerOn::Approach { .. } => "approach",
205            TriggerOn::Step => "step",
206            TriggerOn::StrikeNpc { .. } => "strike-npc",
207            TriggerOn::StrikeAssembly { .. } => "strike-assembly",
208        }
209    }
210
211    /// Whether this event is a click on a `minecraft:interaction` hitbox — a
212    /// `strike`, a `use`, a `strike-npc`, a `strike-assembly`. An `approach`
213    /// and a `step` are a body's position, read on the tick, and have no
214    /// hitbox.
215    pub fn is_click(&self) -> bool {
216        matches!(
217            self,
218            TriggerOn::Strike
219                | TriggerOn::Use
220                | TriggerOn::StrikeNpc { .. }
221                | TriggerOn::StrikeAssembly { .. }
222        )
223    }
224
225    /// Whether this event needs an `at` anchor — true for everything that
226    /// watches a place, false for `strike-npc` and `strike-assembly`, which
227    /// watch an object that carries its own hitbox.
228    pub fn needs_anchor(&self) -> bool {
229        !matches!(
230            self,
231            TriggerOn::StrikeNpc { .. } | TriggerOn::StrikeAssembly { .. }
232        )
233    }
234
235    /// The assembly whose hitbox this event watches (`strike-assembly` only).
236    pub fn assembly_target(&self) -> Option<&AssemblyId> {
237        match self {
238            TriggerOn::StrikeAssembly { assembly } => Some(assembly),
239            _ => None,
240        }
241    }
242
243    /// The NPC whose body this event watches (`strike-npc` only).
244    pub fn npc_target(&self) -> Option<&NpcId> {
245        match self {
246            TriggerOn::StrikeNpc { npc } => Some(npc),
247            _ => None,
248        }
249    }
250}
251
252/// A prop block for an `interact` objective or a `use` trigger (DSL v0.4;
253/// spec-0093 §6.5). The block is the affordance the player interacts with; its
254/// id is validated against the pinned 1.21.11 block registry (`DW0193`).
255///
256/// **When the block is one a hand presses** — a lever or a button
257/// ([`crate::blockshape::is_hand_pressed`]) — the block IS the detector: the
258/// compiler summons no `minecraft:interaction` hitbox and the act is vanilla's
259/// own, reported by the `default_block_use` advancement criterion at the
260/// block's cell. Any other block is placed and the invisible hitbox stands in
261/// its cell, because vanilla reports no use of it.
262#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
263#[serde(deny_unknown_fields)]
264pub struct Prop {
265    /// Vanilla block id, with an optional blockstate suffix (e.g.
266    /// `minecraft:lever[face=floor,facing=north]`).
267    pub block: String,
268}
269
270impl Prop {
271    /// Whether this prop's block is one a hand presses — the block is then the
272    /// act's own detector (spec-0093 §6.5).
273    pub fn is_hand_pressed(&self) -> bool {
274        crate::blockshape::is_hand_pressed(&self.block)
275    }
276}
277
278// ---------------------------------------------------------------------------
279// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
280// ---------------------------------------------------------------------------
281
282use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
283use crate::envelope::Campaign;
284use crate::quest::check::check_effect_v04;
285use crate::registry::{AnchorRegistry, BlockRegistry};
286use crate::validate::{
287    AnchorProviders, check_block_field, for_each_trigger_effect_deep, station_kind_diag,
288};
289use std::collections::{BTreeMap, BTreeSet};
290
291crate::dw_code! {
292    /// (v0.4) An environment trigger id is malformed (`DW0110`-style) or
293    /// duplicated within the stage-5 `triggers` namespace.
294    pub const TRIGGER_INVALID: DwCode = DwCode::new("DW0194", ExitTier::Build);
295}
296
297crate::dw_code! {
298    /// (v0.4, added round-6) A `use` trigger anchored where an NPC stands.
299    /// Right-click on an NPC already belongs to its dialogue advancement; a
300    /// second interaction hitbox in the same cell makes the client's entity
301    /// ray-pick ambiguous, and whichever entity loses the tie is silently dead
302    /// — the round-6 island soft-lock class (an exactly co-located hitbox
303    /// starved the giant's dialogue of every right-click). `strike` triggers
304    /// are exempt: a left-click has no dialogue meaning, so the compiler rides
305    /// the trigger's tag on the NPC's own hitbox instead of summoning a second
306    /// one. Validation-tier (exit 1).
307    pub const USE_TRIGGER_ON_NPC: DwCode = DwCode::new("DW0350", ExitTier::Build);
308}
309
310crate::dw_code! {
311    /// (v0.11) **A press answer addressed to a click vanilla cannot attribute.**
312    /// A trigger declares `audience: presser` on something other than an
313    /// `on: use` or an `on: step`.
314    ///
315    /// `minecraft:player_interacted_with_entity` is the only vanilla criterion
316    /// that runs a function as the player who clicked, and it fires on
317    /// right-clicks alone; a step is a player standing in the cell, which a
318    /// positional selector names. A left-click is recorded in the interaction entity's
319    /// `attack` NBT — a UUID no command can become — and an `approach` involves no
320    /// click at all. Approximating it (polling the record and assuming the nearest
321    /// player) is the downstream folklore CLAUDE.md's no-hack rule excludes, so the
322    /// capability is refused rather than faked.
323    pub const TRIGGER_AUDIENCE_UNATTRIBUTABLE: DwCode = DwCode::new("DW0427", ExitTier::Build);
324}
325
326crate::dw_code! {
327    /// (v0.11) **A trigger id in the compiler's reserved `dw-` namespace.** The
328    /// compiler synthesizes triggers of its own — today the press answer every
329    /// sealed gate and shortcut door gives (`trigger/dw-press-…`) — and two
330    /// triggers sharing an id would share one `dw_trig_…` tag and one emitted
331    /// function, so one of them would silently disappear. Reserving the prefix
332    /// makes the collision impossible by construction instead of improbable.
333    pub const TRIGGER_ID_RESERVED: DwCode = DwCode::new("DW0428", ExitTier::Build);
334}
335
336crate::dw_code! {
337    /// (v0.11) **A sealed body with no press answer**, uniformly over the
338    /// pressable class. A `shortcuts[]` door or
339    /// a `close-gate`'s wall is sealed, and nothing says what it answers when the
340    /// party presses it — no `use` trigger anchored on it, and (for a
341    /// `close-gate`) no authored `sealed_hint`.
342    ///
343    /// The compiler deliberately does **not** fill that silence. A baked default
344    /// is the compiler making a design statement — about tone, about what this
345    /// specific door is — on the author's behalf, and then never telling them it
346    /// did; an error makes the author say it. Same rule as "no hacks at any
347    /// layer": if content needs a thing, the DSL exposes it and the author
348    /// declares it, rather than a lower layer inventing it.
349    ///
350    /// One rule for the whole pressable class: two objects of the same class do
351    /// not get two defaulting policies, which would be the "capability keyed to
352    /// the verb" defect this very surface is CLAUDE.md's worked example of.
353    pub const SEALED_BODY_UNANSWERED: DwCode = DwCode::new("DW0429", ExitTier::Build);
354}
355
356/// `DW0427`/`DW0428`: the two ways a trigger's **press answer** surface can be
357/// declared wrong (DSL v0.11).
358///
359/// Both are about the trigger as an *object*, not about any effect inside it, so
360/// they sit together and are checked over the one trigger authority.
361pub(crate) fn press_answer_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
362    for (i, t) in c.quests.content.triggers.iter().enumerate() {
363        if t.addresses_presser() && !t.attributes_its_actor() {
364            d.push(Diagnostic::error(
365                TRIGGER_AUDIENCE_UNATTRIBUTABLE,
366                "quests",
367                format!("/content/triggers/{i}/audience"),
368                format!(
369                    "trigger `{}` watches a `{}` and asks for `audience: presser`, but vanilla \
370                     names the player only for a RIGHT-click and a step. \
371                     `minecraft:player_interacted_with_entity` is the one criterion that runs a \
372                     function as the clicker, and a step is a player standing in the cell; a \
373                     left-click is recorded in the interaction entity's `attack` NBT, which names \
374                     a UUID no command can become, and an `approach` is not attributed. Guessing \
375                     — polling the record and hoping the nearest player is the striker — is the \
376                     kind of downstream folklore this engine refuses (CLAUDE.md: a capability \
377                     with no vanilla primitive under it is excluded, not faked). Prescription: \
378                     make it an `on: use` or `on: step` trigger, or drop `audience` and let the \
379                     beat address the party",
380                    t.id,
381                    t.on.kind()
382                ),
383            ));
384        }
385        let local = crate::l10n::local_id(t.id.as_str());
386        if local.starts_with(RESERVED_TRIGGER_PREFIX) {
387            d.push(Diagnostic::error(
388                TRIGGER_ID_RESERVED,
389                "quests",
390                format!("/content/triggers/{i}/id"),
391                format!(
392                    "trigger id `{}` opens with `{RESERVED_TRIGGER_PREFIX}`, which the compiler \
393                     reserves for the triggers it synthesizes itself — today the press answer \
394                     every sealed gate and shortcut door gives (`trigger/dw-press-…`). Two \
395                     triggers with one id would share one `dw_trig_…` tag and one emitted \
396                     function, so one of them would silently vanish. Prescription: rename it; any \
397                     kebab id not starting with `{RESERVED_TRIGGER_PREFIX}` is yours",
398                    t.id
399                ),
400            ));
401        }
402    }
403}
404
405/// `DW0429`: **a sealed body the campaign never answers** (DSL v0.11),
406/// uniformly over the pressable class.
407///
408/// A sealed thing is something the party walks up to and pushes on — a `shortcut`
409/// door on the wrong side of the loop, a `close-gate`'s wall — and the press has
410/// to say something. The compiler will not say it for them: a baked default is a
411/// design statement (about tone, about what this thing is) made on the author's
412/// behalf and never disclosed, so the obligation is stated instead of filled.
413///
414/// **One rule for the whole pressable class, not one per verb.** A shortcut door
415/// and a sealed gate are two objects of the same class, and giving them two
416/// defaulting policies would be exactly the "capability keyed to the verb" defect
417/// CLAUDE.md's worked example is about — which this surface *is*. So above the
418/// fence both are held to the same obligation, and `plan::press_answer_sites`
419/// carries the single shared list they are read from.
420///
421/// **Two ways to discharge it**, and they are the same thing said at two layers:
422///
423/// * a `use` trigger anchored on the body — the general verb, available to every
424///   pressable object (`QuestsContent::answers_press_at`);
425/// * for a `close-gate`, an authored `sealed_hint` — the sugar, which *is* the
426///   author defining the wording. The compiler lowering that onto the general
427///   path is not the compiler putting words in a player's mouth.
428///
429/// A `strike` discharges neither: pressing a thing is a right-click, and a
430/// left-click reply is a gesture the player may never make.
431pub(crate) fn press_obligation_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
432    let quests = &c.quests.content;
433
434    // Every gate anchor some `close-gate` seals, and whether any firing on it
435    // authored a wording. Keyed by ANCHOR because the seal is a place, not an
436    // event — the same reason `plan::collect_seal_hints` dedups by anchor and
437    // `DW0423` refuses two firings that disagree.
438    let mut sealed: BTreeMap<&str, (bool, String)> = BTreeMap::new();
439    crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
440        let Some(anchor) = eff.close_gate_anchor() else {
441            return;
442        };
443        let entry = sealed
444            .entry(anchor.as_str())
445            .or_insert_with(|| (false, path.to_string()));
446        entry.0 |= eff.close_gate_sealed_hint().is_some();
447    });
448    for (anchor, (authored, path)) in sealed {
449        if authored || quests.answers_press_at(anchor) {
450            continue;
451        }
452        d.push(Diagnostic::error(
453            SEALED_BODY_UNANSWERED,
454            "quests",
455            path,
456            format!(
457                "this `close-gate` seals `{anchor}`, and nothing says what the wall answers when \
458                 the party presses it. A seal is a thing the party walks back to and pushes on, \
459                 so the press has to say something — and the compiler will not word it for you: a \
460                 baked default decides this wall's tone on your behalf and never tells you it \
461                 did. Two ways to say it, and either is enough: add `\"sealed_hint\": \"<what the \
462                 wall says>\"` to this effect, or anchor a trigger on the gate — \
463                 `{{\"id\": \"trigger/<name>\", \"at\": \"{anchor}\", \"on\": {{\"on\": \"use\"}}, \
464                 \"once\": false, \"audience\": \"presser\", \"effects\": [{{\"type\": \"narrate\", \
465                 \"style\": \"actionbar\", \"text\": \"<what the wall says>\"}}]}}`. The trigger form \
466                 is the general one and can carry a sound, a flag gate or any other effect"
467            ),
468        ));
469    }
470
471    for (i, sc) in quests.shortcuts.iter().enumerate() {
472        let gate = sc.gate.as_str();
473        if quests.answers_press_at(gate) {
474            continue;
475        }
476        d.push(Diagnostic::error(
477            SEALED_BODY_UNANSWERED,
478            "quests",
479            format!("/content/shortcuts/{i}"),
480            format!(
481                "shortcut `{}` bars the gate `{gate}` from world-load, and nothing in the \
482                 campaign answers a right-click on it — so a player who walks the long way \
483                 round, arrives at the wrong side of the door and pushes on it is told nothing. \
484                 That is the press a shortcut loop most invites. The compiler will not word it \
485                 for you: a baked default would be the engine deciding this door's tone and \
486                 never saying that it had. A `shortcut` carries no wording field, deliberately — \
487                 the line is a trigger. Prescription: add a trigger anchored on the gate — \
488                 `{{\"id\": \"trigger/<name>\", \"at\": \"{gate}\", \"on\": {{\"on\": \"use\"}}, \
489                 \"once\": false, \"audience\": \"presser\", \"effects\": [{{\"type\": \"narrate\", \
490                 \"style\": \"actionbar\", \"text\": \"<what the door says>\"}}]}}` — which rides \
491                 the door's own hitboxes, fires only from the sealed side, and retires when the \
492                 door opens. Any `use` trigger on `{gate}` discharges this, whatever it does",
493                sc.id
494            ),
495        ));
496    }
497}
498
499/// The id prefix the compiler reserves for triggers it synthesizes
500/// (`plan::press_answer_trigger_id`). Stated here because the *reservation* is a
501/// DSL-level fact even though today's only user is in the compiler.
502const RESERVED_TRIGGER_PREFIX: &str = "dw-";
503
504/// Every effect anchor of an environment trigger (`DW0142`/`DW0871`), at any
505/// nesting depth, resolved against the union of every known area's anchors.
506pub(crate) fn trigger_anchor_checks(
507    c: &Campaign,
508    providers: &AnchorProviders,
509    d: &mut Vec<Diagnostic>,
510) {
511    // Environment triggers are global (no owning area), so their effect anchors
512    // resolve against the union of every known area's anchors — the same
513    // resolved-or-diagnostic rule as quest effects, applied at the only scope a
514    // trigger has. Skipped entirely when some area binds a pool / an unknown
515    // prefab, because then the union is not the whole truth.
516    if providers.all_areas_known() {
517        for (ti, t) in c.quests.content.triggers.iter().enumerate() {
518            for_each_trigger_effect_deep(t, |path, eff| {
519                for (suffix, anchor, demands) in eff.anchor_refs() {
520                    if let Some(f) = station_kind_diag(
521                        providers,
522                        anchor.as_str(),
523                        demands,
524                        &format!("`{}`", eff.verb.tag()),
525                        "quests",
526                        format!("/content/triggers/{ti}/{path}/{suffix}"),
527                    ) {
528                        d.push(f);
529                        continue;
530                    }
531                    if providers.union().contains(anchor.as_str()) {
532                        continue;
533                    }
534                    d.push(Diagnostic::error(
535                        codes::ANCHOR_UNRESOLVED,
536                        "quests",
537                        format!("/content/triggers/{ti}/{path}/{suffix}"),
538                        format!(
539                            "`{verb}` anchor `{anchor}` in an environment trigger is not \
540                             provided by any area's prefab — {}",
541                            providers.anchor_remedy(
542                                "use an anchor a prefab exposes (anchor names come from prefab \
543                                 metadata; do NOT invent one)"
544                            ),
545                            verb = eff.verb.tag(),
546                        ),
547                    ));
548                }
549            });
550        }
551    }
552}
553
554/// An environment trigger's effect `requires_flags` / `forbids_flags`, at any
555/// nesting depth, name a produced flag (`DW0172`).
556pub(crate) fn trigger_effect_flag_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
557    let quests = &c.quests.content;
558
559    let declared_flags: BTreeSet<String> = crate::validate::produced_flags(c);
560
561    // v0.6: environment-trigger effect `requires_flags` / `forbids_flags`
562    // resolution (DW0172).
563    for (i, t) in quests.triggers.iter().enumerate() {
564        for_each_trigger_effect_deep(t, |path, eff| {
565            for (n, f) in eff.requires_flags().iter().enumerate() {
566                if !declared_flags.contains(f.as_str()) {
567                    d.push(Diagnostic::error(
568                        codes::FLAG_UNKNOWN,
569                        "quests",
570                        format!("/content/triggers/{i}/{path}/when/requires_flags/{n}"),
571                        format!(
572                            "effect `requires_flags` references flag `{f}`, which no `set-flag` \
573                             effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
574                             earlier, or correct the flag name"
575                        ),
576                    ));
577                }
578            }
579            for (n, f) in eff.forbids_flags().iter().enumerate() {
580                if !declared_flags.contains(f.as_str()) {
581                    d.push(Diagnostic::error(
582                        codes::FLAG_UNKNOWN,
583                        "quests",
584                        format!("/content/triggers/{i}/{path}/when/forbids_flags/{n}"),
585                        format!(
586                            "effect `forbids_flags` references flag `{f}`, which no `set-flag` \
587                             effect ever produces — the gate can never suppress anything; add the \
588                             producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
589                             name"
590                        ),
591                    ));
592                }
593            }
594        });
595    }
596}
597
598/// A trigger's `prop` block id is in the block registry (`DW0193`).
599pub(crate) fn trigger_prop_checks(
600    c: &Campaign,
601    blocks: &dyn BlockRegistry,
602    d: &mut Vec<Diagnostic>,
603) {
604    let quests = &c.quests.content;
605
606    // --- block ids: interact props + set-block effects (quest + trigger) ---
607    // A trigger's `prop` (spec-0093 §6.5) is the same object class as an
608    // interact's and is held to the same registry.
609    for (i, t) in quests.triggers.iter().enumerate() {
610        if let Some(prop) = &t.prop {
611            check_block_field(
612                blocks,
613                &prop.block,
614                format!("/content/triggers/{i}/prop/block"),
615                "triggers[].prop",
616                "minecraft:lever[face=floor,facing=north]",
617                d,
618            );
619        }
620    }
621}
622
623/// Environment trigger declarations (spec-0008 §7): id syntax and uniqueness
624/// (`DW0194`), the `at` / `strike-npc` target (`DW0194`, `DW0142`, `DW0112`), an
625/// `approach` range, a `use` trigger on an NPC's cell (`DW0350`), the trigger's
626/// own flags (`DW0172`) and every effect's references ([`check_effect_v04`]).
627pub(crate) fn trigger_decl_checks(
628    c: &Campaign,
629    anchors: &dyn AnchorRegistry,
630    blocks: &dyn BlockRegistry,
631    flags: &BTreeSet<&str>,
632    d: &mut Vec<Diagnostic>,
633) {
634    let quests = &c.quests.content;
635    let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
636    let declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
637
638    // area anchor sets (single-prefab areas only) + whether any pool area exists.
639    let providers = AnchorProviders::build(c, anchors);
640
641    // --- environment triggers ---
642    let mut seen_triggers: BTreeSet<&str> = BTreeSet::new();
643    for (i, t) in quests.triggers.iter().enumerate() {
644        if !t.id.is_valid_syntax() {
645            d.push(Diagnostic::error(
646                TRIGGER_INVALID,
647                "quests",
648                format!("/content/triggers/{i}/id"),
649                format!(
650                    "malformed trigger id `{}` — trigger ids must be lowercase kebab-case with \
651                     the `trigger/` prefix (e.g. `trigger/pressure-plate`)",
652                    t.id
653                ),
654            ));
655        }
656        if !seen_triggers.insert(t.id.as_str()) {
657            d.push(Diagnostic::error(
658                TRIGGER_INVALID,
659                "quests",
660                format!("/content/triggers/{i}/id"),
661                format!(
662                    "duplicate trigger id `{}` — rename one so every trigger id is unique",
663                    t.id
664                ),
665            ));
666        }
667        // `at` names a place; `strike-npc` names a character. Exactly one of the
668        // two must be supplied, so neither form can be authored half-way (an
669        // ignored anchor would read as meaningful and silently do nothing).
670        match (t.on.needs_anchor(), t.at_anchor()) {
671            (true, None) => d.push(Diagnostic::error(
672                TRIGGER_INVALID,
673                "quests",
674                format!("/content/triggers/{i}/at"),
675                format!(
676                    "trigger `{}` fires on `{}`, which watches a place, but declares no `at` \
677                     anchor — add one ({}), or switch to `strike-npc` if the target is an NPC's \
678                     body",
679                    t.id,
680                    t.on.kind(),
681                    providers
682                        .anchor_remedy("anchor names come from prefab metadata; do NOT invent one"),
683                ),
684            )),
685            (false, Some(at)) => d.push(Diagnostic::error(
686                TRIGGER_INVALID,
687                "quests",
688                format!("/content/triggers/{i}/at"),
689                format!(
690                    "trigger `{}` fires on `{}`, whose target is {} — it watches no cell, so \
691                     the `at` anchor `{at}` names nothing and would be silently ignored. \
692                     Remove `at`.",
693                    t.id,
694                    t.on.kind(),
695                    match (t.on.npc_target(), t.on.assembly_target()) {
696                        (Some(n), _) => format!("NPC `{n}`'s body"),
697                        (_, Some(m)) => format!("assembly `{m}`'s hitbox"),
698                        _ => "an object".to_string(),
699                    }
700                ),
701            )),
702            (true, Some(at)) if !providers.resolvable(at) => d.push(Diagnostic::error(
703                codes::ANCHOR_UNRESOLVED,
704                "quests",
705                format!("/content/triggers/{i}/at"),
706                format!(
707                    "trigger `at` anchor `{at}` is not provided by any area's prefab — {}",
708                    providers.anchor_remedy(
709                        "set `at` to an anchor some area's prefab exposes (anchor names come from \
710                         prefab metadata; do NOT invent one)"
711                    ),
712                ),
713            )),
714            _ => {}
715        }
716        // A `strike-npc` target must be a real stage-2 NPC: the trigger's tag
717        // rides that NPC's hitbox, so an unknown id would emit a tag on nothing
718        // and the trigger could never fire.
719        if let Some(npc) = t.on.npc_target()
720            && !c.npcs.content.npcs.iter().any(|n| n.id == *npc)
721        {
722            d.push(Diagnostic::error(
723                codes::DANGLING_REF,
724                "quests",
725                format!("/content/triggers/{i}/on/npc"),
726                format!(
727                    "`strike-npc` trigger `{}` targets NPC `{npc}`, which stage 2 does not \
728                     declare — use a declared npc id",
729                    t.id
730                ),
731            ));
732        }
733        if let TriggerOn::Approach { range } = &t.on
734            && *range == 0
735        {
736            d.push(Diagnostic::error(
737                TRIGGER_INVALID,
738                "quests",
739                format!("/content/triggers/{i}/on/range"),
740                "`approach` trigger `range` must be > 0 — set a positive block radius (e.g. 3)"
741                    .to_string(),
742            ));
743        }
744        if matches!(t.on, TriggerOn::Use)
745            && let Some(at) = t.at_anchor()
746            && let Some(npc) = c
747                .npcs
748                .content
749                .npcs
750                .iter()
751                .find(|n| n.anchor.as_str() == at && n.offset == [0, 0, 0])
752        {
753            d.push(Diagnostic::error(
754                USE_TRIGGER_ON_NPC,
755                "quests",
756                format!("/content/triggers/{i}/at"),
757                format!(
758                    "`use` trigger `{}` is anchored at `{}`, where NPC `{}` stands — a \
759                     right-click there already belongs to the NPC's dialogue, and two \
760                     interaction hitboxes in one cell race for the same click (the loser is \
761                     silently dead, which can soft-lock the delve). Move the trigger to its \
762                     own anchor, or express the interaction as a dialogue option on the NPC. \
763                     (To make an NPC's body itself the target, use `strike-npc`.)",
764                    t.id, at, npc.id
765                ),
766            ));
767        }
768        for (m, f) in t.requires_flags.iter().enumerate() {
769            if !flags.contains(f.as_str()) {
770                d.push(Diagnostic::error(
771                    codes::FLAG_UNKNOWN,
772                    "quests",
773                    format!("/content/triggers/{i}/requires_flags/{m}"),
774                    format!(
775                        "trigger `requires_flags` references flag `{f}`, which no `set-flag` \
776                         effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
777                         somewhere, or correct the flag name"
778                    ),
779                ));
780            }
781        }
782        // v0.6: trigger-level `forbids_flags` — same unknown-flag treatment as
783        // `requires_flags` (DW0172).
784        for (m, f) in t.forbids_flags.iter().enumerate() {
785            if !flags.contains(f.as_str()) {
786                d.push(Diagnostic::error(
787                    codes::FLAG_UNKNOWN,
788                    "quests",
789                    format!("/content/triggers/{i}/forbids_flags/{m}"),
790                    format!(
791                        "trigger `forbids_flags` references flag `{f}`, which no `set-flag` \
792                         effect ever produces — the gate can never suppress anything; add the \
793                         producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag name"
794                    ),
795                ));
796            }
797        }
798        for_each_trigger_effect_deep(t, |path, eff| {
799            check_effect_v04(
800                eff,
801                blocks,
802                &declared_waves,
803                &format!("/content/triggers/{i}/{path}"),
804                &npc_ids,
805                d,
806            );
807        });
808    }
809}