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, ItemRegistry, VendoredBlockRegistry,
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 = VendoredBlockRegistry;
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}