Skip to main content

delvewright_dsl/
trap.rs

1//! Traps: what fires them, what they do and how they reset or are disarmed.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{AnchorId, FlagId, QuestEffect, StateCompare, TrapId};
7
8#[cfg(doc)]
9use crate::EnvTrigger;
10
11/// A stage-5 trap (DSL v0.6, spec-0011; command payloads spec-0022): an
12/// environmental hazard at one cell of a placed piece.
13///
14/// **What the prefab has to provide is one point anchor with the trigger block
15/// in its cell, and for most traps that is all.** [`Trap::at`] names the
16/// trigger/hazard cell; the piece places the plate, tripwire or trapped chest
17/// there (`DW0917`); the compiler models it as a hazard for the completability
18/// proofs (`DW0342`) and, for a disarmable trap, emits the disarm affordance. A
19/// [`payload`](Trap::payload) trap needs nothing else: **the compiler owns the
20/// detection**, emitting a per-tick, edge-latched `execute … if entity @a[<cell>]` and running the authored effect
21/// bundle from it.
22///
23/// Two things a piece must pre-wire, each for one case and neither for the
24/// common one:
25///
26/// * the legacy [`effect`](Trap::effect) — a `dispense` payload the prefab's own
27///   redstone fires — needs the anchor's `dispenser` socket cell, which the
28///   compiler fills. That is the case "harm is redstone-native" (spec-0011) was
29///   written about, and the only one in which no detection is emitted.
30/// * a **flag-gated** trap ([`requires_flags`](Trap::requires_flags) /
31///   [`forbids_flags`](Trap::forbids_flags)) needs the anchor's `trigger_block`,
32///   because gating removes the trigger block from the world while the gate is
33///   shut and puts it back verbatim (`DW0363`).
34///
35/// Player-vs-mob distinguishing matters in a sealed box-garden with controlled
36/// mobs, so `trapped-chest` (opened by a player) is called out as the only
37/// player-distinct trigger.
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40pub struct Trap {
41    /// Unique trap id (`trap/<kebab>`).
42    pub id: TrapId,
43    /// **The point anchor this trap sits on** — any anchor an area's prefab
44    /// provides, whatever it is called. Its cell is the trigger/hazard cell the
45    /// compiler models, and for a `payload` trap that cell, holding the block
46    /// its [`trigger`](Trap::trigger) names, is the whole of what the piece has
47    /// to provide: detection is the compiler's, the block is the piece's
48    /// (`DW0917`).
49    ///
50    /// The anchor additionally needs a `dispenser` socket for a legacy
51    /// [`effect`](Trap::effect) trap, and a `trigger_block` for a flag-gated one
52    /// (`DW0363`). `anchor/trap` is the name the shipped pieces use, and a name
53    /// is all it is.
54    pub at: AnchorId,
55    /// What springs the trap (all redstone-native).
56    pub trigger: TrapTrigger,
57    /// The **legacy** redstone consequence (spec-0011): a static dispenser
58    /// payload the prefab's own wiring fires. Superseded by [`Trap::payload`]
59    /// (spec-0022) — redstone now keeps only the trigger — but kept meaningful
60    /// so existing campaigns build unchanged. Optional since spec-0022; a trap
61    /// must declare `effect`, `payload`, or both (`DW0440`).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub effect: Option<TrapEffect>,
64    /// The **command payload** (spec-0022): an ordered effect list in the same
65    /// vocabulary quests use, run when the trigger fires. This is where a trap's
66    /// consequence lives now — the compiler owns the detection tick and the
67    /// effect vocabulary, so a trap's payload is authored like any other effect
68    /// bundle rather than built out of dust and repeaters. Expressiveness moves
69    /// from "what dust can carry" to "what the effect vocabulary can say":
70    /// `volley` and `collapse` (spec-0022's trap verbs) join `damage-players`,
71    /// `play-sound`, `narrate`, `set-flag` and `spawn-wave`.
72    ///
73    /// Empty = a pure spec-0011 redstone trap, which emits exactly what it
74    /// emitted before (byte-identical).
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub payload: Vec<QuestEffect>,
77    /// How dangerous the trap is. A `lethal` trap on the forced critical path
78    /// carries the completability obligation (`DW0342`); `harmful`/`nonlethal`
79    /// carry none. Defaults to `harmful`.
80    #[serde(default)]
81    pub lethality: Lethality,
82    /// Optional disarm affordance (quest-coupling): an anchor the player acts on to
83    /// turn the trap off — setting a flag and emptying the dispenser — before the
84    /// trap cell is forced.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub disarm: Option<TrapDisarm>,
87    /// Whether the trap re-arms after firing. `once` = single-shot (fires, then
88    /// spent — the survivability path); `rearm` = re-fires each trigger (default).
89    #[serde(default)]
90    pub reset: TrapReset,
91    /// Flags that must be set before the trap is considered active (mirrors
92    /// [`EnvTrigger::requires_flags`]). Default empty.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub requires_flags: Vec<FlagId>,
95    /// Negative flag gate (DSL v0.6): the trap is considered inactive while ANY
96    /// listed flag is set (mirrors [`EnvTrigger::forbids_flags`]). Default empty.
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub forbids_flags: Vec<FlagId>,
99    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
100    /// hold for this gate to be open. The third field of the one gate, carried by
101    /// every gate consumer — never by the verb that first wanted it. Default
102    /// empty, so a pre-0.10 campaign is byte-identical.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub requires_state: Vec<StateCompare>,
105}
106
107impl Trap {
108    /// `(item, count)` if this trap declares a legacy `dispense` effect.
109    pub fn dispense(&self) -> Option<(&str, u32)> {
110        match &self.effect {
111            Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
112            None => None,
113        }
114    }
115
116    /// Whether this trap is `lethal` (carries the `DW0342` obligation on the
117    /// forced critical path).
118    pub fn is_lethal(&self) -> bool {
119        matches!(self.lethality, Lethality::Lethal)
120    }
121}
122
123/// The mechanism that springs a [`Trap`] (DSL v0.6, spec-0011). All three are
124/// redstone-native — the hardware fires without any command — so the compiler
125/// emits no detection for them; it only models the trigger cell as a hazard and
126/// fills the dispenser payload. (`approach`, the compiler-detected v0.4 primitive,
127/// is deliberately *not* a trap trigger: it is already fully expressible as an
128/// [`EnvTrigger`], so admitting it here would only duplicate that surface.)
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
130#[serde(rename_all = "kebab-case")]
131pub enum TrapTrigger {
132    /// A pressure plate: any entity stepping on the cell. Auto-rearms on step-off.
133    PressurePlate,
134    /// A tripwire line: any entity crossing it. Auto-rearms.
135    Tripwire,
136    /// A trapped chest: a *player opening it* (comparator pulse). The only
137    /// player-distinct trigger — a controlled mob cannot spring it.
138    TrappedChest,
139}
140
141impl TrapTrigger {
142    /// The kebab tag (`pressure-plate` / `tripwire` / `trapped-chest`).
143    pub fn kind(&self) -> &'static str {
144        match self {
145            TrapTrigger::PressurePlate => "pressure-plate",
146            TrapTrigger::Tripwire => "tripwire",
147            TrapTrigger::TrappedChest => "trapped-chest",
148        }
149    }
150
151    /// Whether `block` (an id, with or without its blockstate) is the hardware
152    /// this trigger kind names: the block the party sees and springs. A plate
153    /// is any `*_pressure_plate`, a tripwire is the string itself
154    /// (`minecraft:tripwire`, not the hook), a trapped chest is
155    /// `minecraft:trapped_chest`.
156    pub fn is_trigger_block(&self, block: &str) -> bool {
157        let id = block.split('[').next().unwrap_or(block);
158        match self {
159            TrapTrigger::PressurePlate => id.ends_with("_pressure_plate"),
160            TrapTrigger::Tripwire => id == "minecraft:tripwire",
161            TrapTrigger::TrappedChest => id == "minecraft:trapped_chest",
162        }
163    }
164
165    /// The block [`TrapTrigger::is_trigger_block`] accepts, as a refusal names it.
166    pub fn trigger_block_name(&self) -> &'static str {
167        match self {
168            TrapTrigger::PressurePlate => "a pressure plate (`minecraft:*_pressure_plate`)",
169            TrapTrigger::Tripwire => "a tripwire string (`minecraft:tripwire`)",
170            TrapTrigger::TrappedChest => "a trapped chest (`minecraft:trapped_chest`)",
171        }
172    }
173}
174
175impl TrapTrigger {
176    /// The trigger kinds a body fires by walking onto them — a plate and a
177    /// tripwire. A trapped chest is opened, not stepped on.
178    pub const STEPPED: [TrapTrigger; 2] = [TrapTrigger::PressurePlate, TrapTrigger::Tripwire];
179}
180
181/// **Every block of the pinned registry that a step fires**, sorted: the
182/// registry's ids that [`TrapTrigger::is_trigger_block`] accepts for a
183/// [`TrapTrigger::STEPPED`] kind. Read from `crates/dsl/data/blocks-1.21.11.json`
184/// rather than listed, so a pin that adds a plate adds it here;
185/// `crates/delvec/tests/stepped_blocks_tag.rs` holds the set equal to vanilla's
186/// own `#pressure_plates` tag plus the tripwire string.
187pub fn stepped_blocks() -> Vec<&'static str> {
188    crate::blocks::BlockRegistry::v1_21_11()
189        .ids()
190        .filter(|id| TrapTrigger::STEPPED.iter().any(|k| k.is_trigger_block(id)))
191        .collect()
192}
193
194/// Whether `block` — an id, bare or namespaced, with or without a blockstate —
195/// is one a step fires ([`stepped_blocks`]).
196pub fn fires_on_step(block: &str) -> bool {
197    let id = block.split('[').next().unwrap_or(block);
198    let id = if id.contains(':') {
199        id.to_string()
200    } else {
201        format!("minecraft:{id}")
202    };
203    stepped_blocks().contains(&id.as_str())
204}
205
206/// What a [`Trap`] does when sprung (DSL v0.6, spec-0011). Externally tagged so a
207/// future effect adds a variant; a non-`dispense` key (e.g. `tnt`,
208/// `release-falling-block`, `crusher`) is an unknown variant → `DW0100`, keeping
209/// block-destroying and unmodeled effects out of the schema by construction
210/// (spec-0011 non-goals — no hardware the compiler cannot model reaches a world).
211#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
212#[serde(rename_all = "kebab-case", deny_unknown_fields)]
213pub enum TrapEffect {
214    /// Load the prefab's pre-wired dispenser with `count` of `item` (arrows, tipped
215    /// arrows, splash potions). The redstone fires it; terrain is untouched
216    /// (spec-0011 "primary lethal"). The item is round-tripped into the dispenser
217    /// `Items` NBT — a deterministic, static payload.
218    Dispense {
219        /// Vanilla item id (validated against the pinned 1.21.11 registry, `DW0341`).
220        item: String,
221        /// How many to load into the dispenser stack.
222        count: u32,
223    },
224}
225
226/// How dangerous a [`Trap`] is (DSL v0.6, spec-0011). Only `lethal` carries the
227/// forced-critical-path completability obligation (`DW0342`).
228#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
229#[serde(rename_all = "kebab-case")]
230pub enum Lethality {
231    /// Can kill a full-health player — carries the `DW0342` obligation on the path.
232    Lethal,
233    /// Hurts but is not designed to kill (the default).
234    #[default]
235    Harmful,
236    /// Cosmetic / trivial (a stumble, a scare).
237    Nonlethal,
238}
239
240/// Whether a [`Trap`] re-arms after firing (DSL v0.6, spec-0011).
241#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
242#[serde(rename_all = "kebab-case")]
243pub enum TrapReset {
244    /// Fires once, then is spent — the survivability path for a forced lethal trap
245    /// (respawn-safe with `keep_inventory`, non-re-triggering on the walk back; no
246    /// soft-loop).
247    Once,
248    /// Re-fires every time the trigger is met (default). A forced lethal `rearm`
249    /// trap must be avoidable or disarmable, else `DW0342`.
250    #[default]
251    Rearm,
252}
253
254/// A [`Trap`]'s disarm affordance (DSL v0.6, spec-0011): the player acts on the
255/// `via` anchor (an interaction the compiler emits, reusing the v0.4 interaction
256/// entity) to turn the trap off — setting `sets_flag` and emptying the dispenser —
257/// before the trap cell is forced. Discharges the `DW0342` obligation when the
258/// affordance is reachable ahead of the trap without crossing it.
259#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
260#[serde(deny_unknown_fields)]
261pub struct TrapDisarm {
262    /// The anchor the player interacts with to disarm.
263    pub via: AnchorId,
264    /// The flag set when the trap is disarmed (a new flag this trap produces; other
265    /// objectives/triggers may read it via `requires_flags`).
266    pub sets_flag: FlagId,
267}
268
269// ---------------------------------------------------------------------------
270// Validation
271// ---------------------------------------------------------------------------
272
273use std::collections::BTreeSet;
274
275use crate::Verb;
276use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
277use crate::envelope::Campaign;
278use crate::loot::check_stack_count;
279use crate::registry::{
280    AnchorRegistry, BlockRegistry, EntityRegistry, ItemBackedBlockRegistry, ItemRegistry,
281};
282use crate::validate::{
283    AnchorProviders, collect_declared_flags, for_each_trap_payload_deep, station_kind_diag,
284};
285
286crate::dw_code! {
287    /// (v0.6) Trap declaration structurally invalid (spec-0011): a malformed or
288    /// duplicated `trap/<id>`, an `at`/`disarm.via` that no area's prefab provides,
289    /// or a trap whose `disarm.via` collides with its own trigger anchor.
290    /// Validation-tier (exit 1). Renumbered off the spec's stale reserved number
291    /// (0197 — since taken).
292    pub const TRAP_INVALID: DwCode = DwCode::new("DW0340", ExitTier::Build);
293}
294
295crate::dw_code! {
296    /// (v0.6) A trap dispense-payload item id is not in the pinned 1.21.11 registry
297    /// (spec-0011; mirrors `DW0143`). Validation-tier (exit 1). Renumbered off the
298    /// spec's stale reserved number (0198 — since taken).
299    pub const TRAP_PAYLOAD_UNKNOWN: DwCode = DwCode::new("DW0341", ExitTier::Build);
300}
301
302crate::dw_code! {
303    /// (spec-0022) A trap declares **no consequence at all**: neither the legacy
304    /// redstone `effect` nor a command `payload`. A trap that does nothing is
305    /// mute hardware the completability proofs would nonetheless reason about,
306    /// so it is a content mistake, not a no-op. Validation-tier (exit 1).
307    pub const TRAP_NO_CONSEQUENCE: DwCode = DwCode::new("DW0440", ExitTier::Build);
308}
309
310crate::dw_code! {
311    /// (spec-0022) A `volley` `projectile` / `collapse` `falling_block` /
312    /// `then_floor` id is not in the pinned 1.21.11 registry (a `projectile`
313    /// must be an ENTITY id, the collapse blocks BLOCK ids).
314    /// Validation-tier (exit 1).
315    pub const TRAP_VERB_ID_UNKNOWN: DwCode = DwCode::new("DW0441", ExitTier::Build);
316}
317
318crate::dw_code! {
319    /// (spec-0022) A `volley`'s `salvos` / `interval` is out of range (`salvos`
320    /// in `1..=16`, `interval` in `1..=200`). A volley fires its whole kill zone
321    /// every salvo, so the entity count is `salvos x cells`; and salvos spread
322    /// wider than the interval cap stop reading as one trap event.
323    /// Validation-tier (exit 1).
324    pub const VOLLEY_CADENCE: DwCode = DwCode::new("DW0443", ExitTier::Build);
325}
326
327/// DSL v0.6 trap validation (spec-0011). Each trap binds to a **point anchor**
328/// an area's prefab provides — any anchor, whatever it is called; a spec-0022
329/// command `payload` needs that cell and nothing else of the piece, because the
330/// compiler emits the detection. Structural failures are `DW0340` (a
331/// malformed/duplicate id, an `at`/`disarm.via` no area's prefab provides, or a
332/// `disarm.via` colliding with the trap's own trigger anchor); a dispense
333/// payload item unknown to the pinned registry is `DW0341`. A trap's
334/// `requires_flags` resolves against the declared-flag set like a trigger's
335/// (`DW0172`). The completability obligation for a *lethal* trap is discharged
336/// later by the compiler nav proof (`DW0342`).
337pub(crate) fn trap_checks(
338    c: &Campaign,
339    items: &dyn ItemRegistry,
340    entities: &dyn EntityRegistry,
341    anchors: &dyn AnchorRegistry,
342    d: &mut Vec<Diagnostic>,
343) {
344    let quests = &c.quests.content;
345    if quests.traps.is_empty() {
346        return;
347    }
348
349    // Area anchor sets (single-prefab areas) + whether any pool area exists, so
350    // resolution stays lenient for pool areas the compiler resolves later — the
351    // same policy as the v0.4 trigger check.
352    let providers = AnchorProviders::build(c, anchors);
353
354    let flags = collect_declared_flags(c);
355
356    let mut seen: BTreeSet<&str> = BTreeSet::new();
357    for (i, t) in quests.traps.iter().enumerate() {
358        if !t.id.is_valid_syntax() {
359            d.push(Diagnostic::error(
360                TRAP_INVALID,
361                "quests",
362                format!("/content/traps/{i}/id"),
363                format!(
364                    "malformed trap id `{}` — trap ids must be lowercase kebab-case with the \
365                     `trap/` prefix (e.g. `trap/dart-hall`)",
366                    t.id
367                ),
368            ));
369        }
370        if !seen.insert(t.id.as_str()) {
371            d.push(Diagnostic::error(
372                TRAP_INVALID,
373                "quests",
374                format!("/content/traps/{i}/id"),
375                format!(
376                    "duplicate trap id `{}` — rename one so every trap id is unique",
377                    t.id
378                ),
379            ));
380        }
381        if let Some(f) = station_kind_diag(
382            &providers,
383            t.at.as_str(),
384            crate::layout::StationKind::Point,
385            "a trap's `at`",
386            "quests",
387            format!("/content/traps/{i}/at"),
388        ) {
389            d.push(f);
390        }
391        if !providers.resolvable(t.at.as_str()) {
392            d.push(Diagnostic::error(
393                TRAP_INVALID,
394                "quests",
395                format!("/content/traps/{i}/at"),
396                format!(
397                    "trap `at` anchor `{}` is not provided by any area's prefab — {}",
398                    t.at,
399                    providers.anchor_remedy(
400                        "bind the trap to a point anchor some area's prefab exposes, whatever \
401                         that anchor is called (names come from prefab metadata; do NOT invent \
402                         one). A `payload` trap needs nothing of the piece but that one cell and \
403                         the trigger block standing in it (`DW0917`) — the compiler emits the \
404                         detection; only a legacy `dispense` effect \
405                         needs the anchor's `dispenser` socket, and only a flag-gated trap \
406                         needs its `trigger_block`"
407                    ),
408                ),
409            ));
410        }
411        if let Some(dis) = &t.disarm {
412            if let Some(f) = station_kind_diag(
413                &providers,
414                dis.via.as_str(),
415                crate::layout::StationKind::Point,
416                "a trap's disarm affordance",
417                "quests",
418                format!("/content/traps/{i}/disarm/via"),
419            ) {
420                d.push(f);
421            }
422            if !providers.resolvable(dis.via.as_str()) {
423                d.push(Diagnostic::error(
424                    TRAP_INVALID,
425                    "quests",
426                    format!("/content/traps/{i}/disarm/via"),
427                    format!(
428                        "trap `disarm.via` anchor `{}` is not provided by any area's prefab — \
429                         {}",
430                        dis.via,
431                        providers.anchor_remedy(
432                            "use an anchor some area's prefab exposes for the disarm affordance"
433                        ),
434                    ),
435                ));
436            }
437            if dis.via == t.at {
438                d.push(Diagnostic::error(
439                    TRAP_INVALID,
440                    "quests",
441                    format!("/content/traps/{i}/disarm/via"),
442                    format!(
443                        "trap `disarm.via` anchor `{}` is the trap's own trigger anchor — the \
444                         disarm must be a distinct, separately-reachable affordance, not the trap \
445                         cell itself",
446                        dis.via
447                    ),
448                ));
449            }
450        }
451        // spec-0022: a trap must actually DO something. Neither the legacy
452        // redstone `effect` nor a command `payload` means mute hardware that
453        // the completability proofs would still reason about — a content
454        // mistake, never a deliberate no-op.
455        if t.effect.is_none() && t.payload.is_empty() {
456            d.push(Diagnostic::error(
457                TRAP_NO_CONSEQUENCE,
458                "quests",
459                format!("/content/traps/{i}"),
460                format!(
461                    "trap `{}` declares no consequence — give it a `payload` (an ordered \
462                     effect list: `volley`, `collapse`, `damage-players`, `play-sound`, \
463                     `narrate`, `set-flag`, `spawn-wave`, …). A trigger with nothing \
464                     downstream of it is scenery, not a trap",
465                    t.id
466                ),
467            ));
468        }
469        // spec-0022 payload validation: the trap-payload verbs' own ids and
470        // cadence, plus the standard flag/wave/item consumer resolution every
471        // other effect root gets.
472        for_each_trap_payload_deep(t, |path, eff| {
473            let base = format!("/content/traps/{i}/{path}");
474            match &eff.verb {
475                Verb::Volley {
476                    projectile,
477                    salvos,
478                    interval,
479                    ..
480                } => {
481                    let proj = projectile
482                        .as_deref()
483                        .unwrap_or(crate::DEFAULT_VOLLEY_PROJECTILE);
484                    if !entities.contains(proj) {
485                        d.push(Diagnostic::error(
486                            TRAP_VERB_ID_UNKNOWN,
487                            "quests",
488                            format!("{base}/projectile"),
489                            format!(
490                                "volley `projectile` `{proj}` is not in the pinned 1.21.11 \
491                                 entity registry — use a projectile entity id (e.g. \
492                                 `minecraft:arrow`, `minecraft:spectral_arrow`)"
493                            ),
494                        ));
495                    }
496                    let n = salvos.unwrap_or(crate::DEFAULT_VOLLEY_SALVOS);
497                    if n == 0 || n > crate::MAX_VOLLEY_SALVOS {
498                        d.push(Diagnostic::error(
499                            VOLLEY_CADENCE,
500                            "quests",
501                            format!("{base}/salvos"),
502                            format!(
503                                "volley `salvos` is {n} — must be 1..={}. A volley fires \
504                                 its whole kill zone every salvo, so the entity count is \
505                                 `salvos x standable cells`; beyond the cap that is a \
506                                 server hazard, not a trap",
507                                crate::MAX_VOLLEY_SALVOS
508                            ),
509                        ));
510                    }
511                    let iv = interval.unwrap_or(crate::DEFAULT_VOLLEY_INTERVAL);
512                    if iv == 0 || iv > crate::MAX_VOLLEY_INTERVAL {
513                        d.push(Diagnostic::error(
514                            VOLLEY_CADENCE,
515                            "quests",
516                            format!("{base}/interval"),
517                            format!(
518                                "volley `interval` is {iv} ticks — must be 1..={}. Salvos \
519                                 spaced wider than that stop reading as one trap event",
520                                crate::MAX_VOLLEY_INTERVAL
521                            ),
522                        ));
523                    }
524                }
525                Verb::Collapse {
526                    falling_block,
527                    then_floor,
528                    ..
529                } => {
530                    let blocks = ItemBackedBlockRegistry::new(items);
531                    let fb = falling_block
532                        .as_deref()
533                        .unwrap_or(crate::DEFAULT_COLLAPSE_FALLING_BLOCK);
534                    for (field, id) in [
535                        ("falling_block", Some(fb)),
536                        ("then_floor", then_floor.as_deref()),
537                    ] {
538                        let Some(id) = id else { continue };
539                        if !blocks.contains(id) {
540                            d.push(Diagnostic::error(
541                                TRAP_VERB_ID_UNKNOWN,
542                                "quests",
543                                format!("{base}/{field}"),
544                                format!(
545                                    "collapse `{field}` `{id}` is not in the pinned 1.21.11 \
546                                     block registry — use a placeable block id (e.g. \
547                                     `minecraft:gravel`, `minecraft:sand`)"
548                                ),
549                            ));
550                        }
551                    }
552                }
553                _ => {}
554            }
555            for (kind, list) in [
556                ("requires_flags", eff.requires_flags()),
557                ("forbids_flags", eff.forbids_flags()),
558            ] {
559                for (n, f) in list.iter().enumerate() {
560                    if !flags.contains(f.as_str()) {
561                        d.push(Diagnostic::error(
562                            codes::FLAG_UNKNOWN,
563                            "quests",
564                            format!("{base}/{kind}/{n}"),
565                            format!(
566                                "trap payload effect `{kind}` references flag `{f}`, which no \
567                                 `set-flag` effect ever produces — add the producing \
568                                 `set-flag {{ flag: \"{f}\" }}`, or correct the flag name"
569                            ),
570                        ));
571                    }
572                }
573            }
574            if let Some(w) = eff.spawn_wave()
575                && !c.quests.content.waves.iter().any(|x| x.id == *w)
576            {
577                d.push(Diagnostic::error(
578                    codes::WAVE_UNKNOWN,
579                    "quests",
580                    format!("{base}/wave"),
581                    format!("trap payload `spawn-wave` references unknown wave `{w}`"),
582                ));
583            }
584            if let Some(item) = eff.give_item()
585                && !items.contains(item)
586            {
587                d.push(Diagnostic::error(
588                    codes::ITEM_UNKNOWN,
589                    "quests",
590                    format!("{base}/item"),
591                    format!(
592                        "trap payload `give-item` item `{item}` is not in the pinned \
593                         1.21.11 item registry"
594                    ),
595                ));
596            }
597        });
598        if let Some((item, count)) = t.dispense() {
599            if !items.contains(item) {
600                d.push(Diagnostic::error(
601                    TRAP_PAYLOAD_UNKNOWN,
602                    "quests",
603                    format!("/content/traps/{i}/effect/dispense/item"),
604                    format!(
605                        "trap dispense payload item `{item}` is not in the pinned 1.21.11 item \
606                         registry — use a valid namespaced item id (e.g. `minecraft:arrow`)"
607                    ),
608                ));
609            }
610            // The dispenser payload is the same single-slot `item replace …
611            // container.0` fill a `loot` entry is, so it carries the same silent
612            // over-cap failure (`DW0436`) — a splash potion caps at 1.
613            check_stack_count(
614                item,
615                count,
616                &format!("trap `{}` dispense payload", t.id),
617                format!("/content/traps/{i}/effect/dispense/count"),
618                items,
619                d,
620            );
621        }
622        for (m, f) in t.requires_flags.iter().enumerate() {
623            if !flags.contains(f.as_str()) {
624                d.push(Diagnostic::error(
625                    codes::FLAG_UNKNOWN,
626                    "quests",
627                    format!("/content/traps/{i}/requires_flags/{m}"),
628                    format!(
629                        "trap `requires_flags` references flag `{f}`, which no `set-flag` effect or \
630                         trap disarm ever produces — add a producer or correct the flag name"
631                    ),
632                ));
633            }
634        }
635        // Trap `forbids_flags` — same unknown-flag treatment (DW0172).
636        for (m, f) in t.forbids_flags.iter().enumerate() {
637            if !flags.contains(f.as_str()) {
638                d.push(Diagnostic::error(
639                    codes::FLAG_UNKNOWN,
640                    "quests",
641                    format!("/content/traps/{i}/forbids_flags/{m}"),
642                    format!(
643                        "trap `forbids_flags` references flag `{f}`, which no `set-flag` effect or \
644                         trap disarm ever produces — the gate can never suppress anything; add a \
645                         producer or correct the flag name"
646                    ),
647                ));
648            }
649        }
650    }
651}