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}