Skip to main content

delvewright_dsl/
wave.rs

1//! Waves: the mobs a fight spawns, their lanes, equipment, drops and attributes.
2
3use std::collections::BTreeMap;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::serde_fields::is_false;
9use crate::{AnchorId, OnKill, WaveId};
10
11/// A combat wave (DSL v0.3): a bundle of mobs spawned at an anchor and slain to
12/// complete a `kill` objective. Emission (spec-0002): a `spawn-wave` effect
13/// summons the mobs tagged `dw_wave_<id>` (AI enabled — they fight); a
14/// `player_killed_entity` advancement per tag decrements a scoreboard countdown,
15/// and the `kill` objective completes when the count reaches zero.
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
17#[serde(deny_unknown_fields)]
18pub struct Wave {
19    /// Unique wave id.
20    pub id: WaveId,
21    /// The anchor the wave's mobs spawn at.
22    pub anchor: AnchorId,
23    /// The mobs that make up the wave (1..N).
24    pub mobs: Vec<WaveMob>,
25    /// Re-seat this wave every time the party rests at (or respawns from) a
26    /// bonfire (spec-0016 §1) — the souls contract: progress is kept, the
27    /// enemies come back. The compiler kills any survivor carrying the wave tag
28    /// and re-runs the wave's own spawn function, so the room is restored to its
29    /// authored composition and spawn cells.
30    ///
31    /// Inert without a `bonfire` in the campaign, which is a compile error
32    /// (`DW0370`) rather than a silent no-op.
33    #[serde(default, skip_serializing_if = "is_false")]
34    pub respawns_on_rest: bool,
35    /// Tower-defense lane routing (spec-0016 §6): march this wave along a
36    /// waypoint polyline while distant, hand it to native AI the instant a
37    /// player is inside `aggro_radius`. Absent = today's behaviour (spawn and
38    /// stand), byte-identical.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub lane: Option<WaveLane>,
41    /// Where the wave's mobs materialize (spec-0016 §6). Absent =
42    /// [`WaveSummon::Anchor`], the pre-0.6 behaviour: standable cells around the
43    /// wave `anchor`.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub summon: Option<WaveSummon>,
46    /// How hard this encounter is *meant* to be (DSL v0.7, spec-0023). Absent =
47    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.7 campaign.
48    ///
49    /// This is a **declaration, not a knob**: the compiler never scales content
50    /// from it (spec-0023 "Out of scope"). It exists because the validation
51    /// ladder's inverted floor gate needs to know which fights the content
52    /// *claims* are hard — an `elite`/`boss` encounter the unassisted bot beats
53    /// on its first attempt is reported as too easy for its billing. Marking it
54    /// is how the author opts into that scrutiny; the alternative — inferring
55    /// "elite" from how tuned a stack looks — is exactly the downstream folklore
56    /// CLAUDE.md's no-hack rule forbids.
57    ///
58    /// **It does reach emission in exactly one place** (spec-0016 §1): in a
59    /// campaign with a `bonfire`, a billed `elite`/
60    /// `boss` wave that does not declare `respawns_on_rest` is refreshed by a
61    /// rest *while it is still standing* — deleted and re-seated at full count
62    /// and full health, so chipping it down one life at a time is never a path.
63    /// Beat it and it stays beaten. `DW0499` forbids billing a wave `boss` and
64    /// `respawns_on_rest` at once.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub tier: Option<EncounterTier>,
67    /// A health bar over this wave's bodies (DSL v0.31, spec-0073): a named bar
68    /// over their total health, drawn for every player within `range` blocks of a
69    /// live one. Absent = no bar, byte-identical. Declared, never derived from
70    /// `tier`; a `boss`-billed wave without one is advised (`DW0912`).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub health_bar: Option<crate::healthbar::HealthBar>,
73    /// What happens each time a player is credited with killing one of this
74    /// wave's bodies (spec-0074) — effect root R9, the same
75    /// [`OnKill`] an actor declares. Absent = no bundle, and the
76    /// wave's emission is byte-identical.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub on_kill: Option<OnKill>,
79}
80
81/// What a wave is billed as (DSL v0.7, spec-0023). Consumed by the validation
82/// ladder (the run's combat plan) and — since spec-0016 §1's undefeated re-seat
83/// — by one emission site: a bonfire refreshes a billed wave that is still
84/// standing. Nothing about the encounter itself is scaled from it.
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "kebab-case")]
87pub enum EncounterTier {
88    /// Trash / pressure. No floor expectation: a bot that wins it cold proves
89    /// nothing either way. The default.
90    #[default]
91    Ordinary,
92    /// A set-piece the content bills as a hard fight (spec-0016's optional-elite
93    /// and spawn-and-unleash vocabulary).
94    Elite,
95    /// A campaign's named fight. Same floor rule as `elite`; the distinction is
96    /// for the run report a human reads.
97    Boss,
98}
99
100impl EncounterTier {
101    /// The kebab tag, as it appears in the DSL and in the emitted combat plan.
102    pub fn token(self) -> &'static str {
103        match self {
104            EncounterTier::Ordinary => "ordinary",
105            EncounterTier::Elite => "elite",
106            EncounterTier::Boss => "boss",
107        }
108    }
109
110    /// Does the inverted floor gate (spec-0023) apply to this tier? A fight the
111    /// content bills as hard carries an expectation the bot can measure; an
112    /// ordinary one does not.
113    pub fn has_floor_expectation(self) -> bool {
114        matches!(self, EncounterTier::Elite | EncounterTier::Boss)
115    }
116}
117
118/// Where a wave's mobs materialize (spec-0016 §6).
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120#[serde(rename_all = "kebab-case")]
121pub enum WaveSummon {
122    /// Standable cells around the wave `anchor`, nearest first — the pre-0.6
123    /// behaviour and the default.
124    Anchor,
125    /// **Spirit-summoned at the edge of perception**:
126    /// each mob appears on the ring at its own `attributes.follow_range` from
127    /// the wave `anchor`, so it acquires a target the instant it exists and
128    /// closes under pure native AI. Species without patrol AI never march a
129    /// lane; this is what they do instead — never a spawn on top of the party,
130    /// never a mob that brushes past.
131    ///
132    /// With this mode the wave `anchor` is the **defended point** (what the ring
133    /// is drawn around), not the spawn point. Each mob stack must declare its
134    /// own `attributes.follow_range` (`DW0385`) — the ring radius is authored,
135    /// never guessed from a vanilla defaults table the compiler cannot verify.
136    AggroEdge,
137}
138
139/// Tower-defense lane routing for a wave (spec-0016 §6), built on vanilla's
140/// **Raider patrol system** — the intended primitive, live-verified on 1.21.11
141/// (`docs/notes/td-routing-spike.md`).
142///
143/// The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and a snake_case
144/// `patrol_target` int-array; a compiler-emitted clock walks the shared waypoint
145/// index forward, and per mob a player-proximity check releases `Patrolling:0b`.
146/// From that instant the mob is a plain native hostile. "Combat preempts
147/// routing" is engine semantics — vanilla's patrol goal is hard-gated on having
148/// no target — so the owner's rule (march while distant, fight with NATIVE AI
149/// once aggroed, never brush past) falls out of the primitive unforced.
150///
151/// Lanes are raider-family only (`DW0382`), squad ≥ 2 (`DW0383`), and a lane
152/// pillager must keep its crossbow (`DW0384`).
153#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields)]
155pub struct WaveLane {
156    /// The lane polyline, in march order: anchors some area's prefab provides.
157    /// At least one; consecutive legs (counting the wave `anchor` as the origin)
158    /// must be more than 10 blocks apart — vanilla re-rolls a patrol target
159    /// within 10 blocks of arrival, so a tighter lane is a lane the engine
160    /// quietly stops following (`DW0386`).
161    pub waypoints: Vec<AnchorId>,
162    /// The release radius, in blocks: the compiler sets every lane mob's
163    /// `follow_range` attribute to exactly this and releases `Patrolling:0b`
164    /// at the same distance. The two MUST be equal — a patrolling raider that
165    /// targets a player it cannot engage holds ground instead of marching, so a
166    /// per-mob `attributes.follow_range` that disagrees is `DW0381`.
167    pub aggro_radius: u32,
168}
169
170/// One mob stack in a [`Wave`].
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
172#[serde(deny_unknown_fields)]
173pub struct WaveMob {
174    /// Vanilla entity id, validated against the pinned 1.21.11 registry.
175    pub entity: String,
176    /// How many to spawn.
177    pub count: u32,
178    /// Optional custom name (shown above the mob).
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub name: Option<String>,
181    /// Optional attribute overrides (DSL v0.4), emitted as 1.21.11 attribute
182    /// components. Enables e.g. a weakened live warden as a survivable stealth
183    /// threat. Omitted = vanilla defaults.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub attributes: Option<MobAttributes>,
186    /// Optional permanent, ambient status effects (DSL v0.4).
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub effects: Vec<MobEffect>,
189    /// Optional worn/held equipment (DSL v0.6). A helmet is the
190    /// sanctioned fix for daylight-burning undead — never
191    /// `set-time`. Item ids validate against the pinned 1.21.11 item registry
192    /// (`DW0143`, the give-item family); every emitted slot carries drop
193    /// chance 0 so players can never farm wave gear (no-grind constitution).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub equipment: Option<MobEquipment>,
196    /// What this mob leaves behind when it dies (DSL v0.9). Only an `elite`/`boss` wave may declare it (`DW0491`)
197    /// — an ordinary mob's kit is never farmable. Empty = drop chance 0 on
198    /// every slot.
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub drops: Vec<MobDrop>,
201}
202
203/// Worn/held equipment for a wave mob (DSL v0.6). Each field is a vanilla item
204/// id for the matching vanilla equipment slot; an unset slot stays empty —
205/// except `main_hand`, where the compiler's armed-mob default (skeleton bow,
206/// wither-skeleton sword) still applies unless overridden. Emitted as the
207/// component-era `equipment`/`drop_chances` summon NBT (1.21.11 silently
208/// ignores legacy `ArmorItems`/`HandItems` on `/summon`), all drop chances 0.
209#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
210#[serde(deny_unknown_fields)]
211pub struct MobEquipment {
212    /// Head slot.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub head: Option<EquipItem>,
215    /// Chest slot.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub chest: Option<EquipItem>,
218    /// Legs slot.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub legs: Option<EquipItem>,
221    /// Feet slot.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub feet: Option<EquipItem>,
224    /// Main-hand slot. Overrides the compiler's armed-mob default.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub main_hand: Option<EquipItem>,
227    /// Off-hand slot.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub off_hand: Option<EquipItem>,
230    /// Body slot: horse armour, wolf armour, a llama's carpet, a nautilus's
231    /// armour, a happy ghast's harness (spec-0067). Shown only on a body whose
232    /// entity type draws it (`DW0898`).
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub body: Option<EquipItem>,
235    /// Saddle slot (spec-0067). Shown only on a body whose entity type draws a
236    /// saddle (`DW0898`).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub saddle: Option<EquipItem>,
239}
240
241impl MobEquipment {
242    /// Every slot as `(dsl_field_name, piece)`, in [`EquipSlot::ALL`]'s order —
243    /// the single iteration source for validation paths and emission.
244    pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); EquipSlot::ALL.len()] {
245        EquipSlot::ALL.map(|s| (s.field(), self.filled(s)))
246    }
247
248    /// Every slot as `(slot, piece)`, in [`EquipSlot::ALL`]'s order.
249    pub fn pieces(&self) -> [(EquipSlot, Option<&EquipItem>); EquipSlot::ALL.len()] {
250        EquipSlot::ALL.map(|s| (s, self.filled(s)))
251    }
252
253    /// The piece this equipment declaration puts in `slot`, if any. The single
254    /// question a `drops[]` `slot` entry asks: a mob can only drop a piece it
255    /// actually wears (`DW0490`).
256    pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
257        match slot {
258            EquipSlot::Head => self.head.as_ref(),
259            EquipSlot::Chest => self.chest.as_ref(),
260            EquipSlot::Legs => self.legs.as_ref(),
261            EquipSlot::Feet => self.feet.as_ref(),
262            EquipSlot::MainHand => self.main_hand.as_ref(),
263            EquipSlot::OffHand => self.off_hand.as_ref(),
264            EquipSlot::Body => self.body.as_ref(),
265            EquipSlot::Saddle => self.saddle.as_ref(),
266        }
267    }
268}
269
270/// One vanilla equipment slot, named exactly as the [`MobEquipment`] field that
271/// fills it. The DSL name and the summon-NBT key differ (`main_hand` vs
272/// `mainhand`), so both live here and nowhere else.
273///
274/// **The set is the pinned game's equipment-slot set** (spec-0067 §2), a
275/// [`crate::metrics::Provenance::VanillaRule`]: the eight values the
276/// `minecraft:equippable` component's `slot` field takes, per the Minecraft
277/// Wiki page *Data component format/equippable* for Java 1.21.11, which are the
278/// serialised names of the client's `EquipmentSlot` enum. The pinned item data
279/// is the cross-check, not the source: every `slot` value an item declares is
280/// asserted to be one of these, and no item declares `mainhand`, because a hand
281/// takes anything.
282#[derive(
283    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
284)]
285#[serde(rename_all = "snake_case")]
286pub enum EquipSlot {
287    /// Head slot.
288    Head,
289    /// Chest slot.
290    Chest,
291    /// Legs slot.
292    Legs,
293    /// Feet slot.
294    Feet,
295    /// Main-hand slot.
296    MainHand,
297    /// Off-hand slot.
298    OffHand,
299    /// Body slot (horse armour, wolf armour, carpet, harness).
300    Body,
301    /// Saddle slot.
302    Saddle,
303}
304
305impl EquipSlot {
306    /// Every slot, in emission order: the two hands, the four armour slots,
307    /// then `body` and `saddle`. The one enumeration every slot list derives
308    /// from — `MobEquipment::slots()`, the summon `equipment` / `drop_chances`
309    /// compounds and the drop-strip line.
310    pub const ALL: [EquipSlot; 8] = [
311        EquipSlot::MainHand,
312        EquipSlot::OffHand,
313        EquipSlot::Head,
314        EquipSlot::Chest,
315        EquipSlot::Legs,
316        EquipSlot::Feet,
317        EquipSlot::Body,
318        EquipSlot::Saddle,
319    ];
320
321    /// The DSL field name (`main_hand`), for diagnostics and JSON pointers.
322    pub fn field(self) -> &'static str {
323        match self {
324            EquipSlot::Head => "head",
325            EquipSlot::Chest => "chest",
326            EquipSlot::Legs => "legs",
327            EquipSlot::Feet => "feet",
328            EquipSlot::MainHand => "main_hand",
329            EquipSlot::OffHand => "off_hand",
330            EquipSlot::Body => "body",
331            EquipSlot::Saddle => "saddle",
332        }
333    }
334
335    /// The 1.21.11 `equipment` / `drop_chances` NBT key (`mainhand`) — also the
336    /// game's own name for the slot, as an `equippable` component spells it.
337    pub fn nbt(self) -> &'static str {
338        match self {
339            EquipSlot::Head => "head",
340            EquipSlot::Chest => "chest",
341            EquipSlot::Legs => "legs",
342            EquipSlot::Feet => "feet",
343            EquipSlot::MainHand => "mainhand",
344            EquipSlot::OffHand => "offhand",
345            EquipSlot::Body => "body",
346            EquipSlot::Saddle => "saddle",
347        }
348    }
349
350    /// The slot the game names `name` (`mainhand`), if it is one.
351    pub fn from_nbt(name: &str) -> Option<EquipSlot> {
352        EquipSlot::ALL.into_iter().find(|s| s.nbt() == name)
353    }
354
355    /// Whether this is a hand: a hand takes any item, so an item's own declared
356    /// slot never contradicts it.
357    pub fn is_hand(self) -> bool {
358        matches!(self, EquipSlot::MainHand | EquipSlot::OffHand)
359    }
360}
361
362/// One declared drop of an elite or boss (DSL v0.9).
363///
364/// A mob may wear many pieces; what it *leaves behind* is a **declared subset**,
365/// usually one piece and never automatically everything. Two forms, told apart
366/// by which field is present:
367///
368/// ```json
369/// { "slot": "main_hand" }                                  // its axe
370/// { "item": "minecraft:tripwire_hook", "name": "Gate Key" } // a quest item
371/// ```
372///
373/// A `slot` entry must name a slot the same entity's `equipment` really fills
374/// (`DW0490`) — the drop is the piece the player has been *looking at* through
375/// the whole fight, so the two declarations cannot disagree. An `item` entry is
376/// a token the fight *yields* rather than wears, and rides the entity's own
377/// death loot table.
378///
379/// Undeclared slots keep drop chance `0.0` — byte-for-byte today's behaviour, and
380/// the no-grind constitution's guarantee that an ordinary kit is never farmable.
381#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
382#[serde(untagged)]
383pub enum MobDrop {
384    /// A worn/held piece, named by its equipment slot.
385    Slot(SlotDrop),
386    /// A quest token the fight yields (not worn).
387    Item(ItemDrop),
388}
389
390/// The worn-piece form of a [`MobDrop`].
391#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
392#[serde(deny_unknown_fields)]
393pub struct SlotDrop {
394    /// The equipment slot whose piece drops. Must be filled by the entity's own
395    /// `equipment` declaration (`DW0490`).
396    pub slot: EquipSlot,
397}
398
399/// The quest-token form of a [`MobDrop`].
400#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
401#[serde(deny_unknown_fields)]
402pub struct ItemDrop {
403    /// Vanilla item id, validated against the pinned 1.21.11 registry
404    /// (`DW0143`, the give-item family).
405    pub item: String,
406    /// Display name the dropped stack carries as a `custom_name` component.
407    /// Player-visible, so it enters the l10n string inventory and translates
408    /// like any other line.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub name: Option<String>,
411}
412
413impl MobDrop {
414    /// The equipment slot this entry names, or `None` for a quest-item drop.
415    pub fn slot(&self) -> Option<EquipSlot> {
416        match self {
417            MobDrop::Slot(s) => Some(s.slot),
418            MobDrop::Item(_) => None,
419        }
420    }
421
422    /// The item id this entry names, or `None` for a worn-piece drop.
423    pub fn item(&self) -> Option<&str> {
424        match self {
425            MobDrop::Slot(_) => None,
426            MobDrop::Item(i) => Some(&i.item),
427        }
428    }
429
430    /// The declared display name of a quest-item drop, when set.
431    pub fn name(&self) -> Option<&String> {
432        match self {
433            MobDrop::Slot(_) => None,
434            MobDrop::Item(i) => i.name.as_ref(),
435        }
436    }
437
438    /// Mutable access to the display name — the l10n traversal's hook.
439    pub fn name_mut(&mut self) -> Option<&mut String> {
440        match self {
441            MobDrop::Slot(_) => None,
442            MobDrop::Item(i) => i.name.as_mut(),
443        }
444    }
445}
446
447/// One equipped item: either a bare item id, or an id carrying enchantments.
448///
449/// The plain form is the common case and stays a plain JSON string, which is
450/// what keeps every campaign written before enchantments existed byte-identical
451/// on re-serialisation:
452///
453/// ```json
454/// "main_hand": "minecraft:netherite_sword"
455/// "head": { "item": "minecraft:netherite_helmet",
456///           "enchantments": { "minecraft:protection": 4 } }
457/// ```
458#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
459#[serde(untagged)]
460pub enum EquipItem {
461    /// A bare item id — no enchantments.
462    Plain(String),
463    /// An item id plus its enchantments.
464    Enchanted(EnchantedItem),
465}
466
467impl EquipItem {
468    /// The item id, whichever form was authored.
469    pub fn item(&self) -> &str {
470        match self {
471            EquipItem::Plain(s) => s,
472            EquipItem::Enchanted(e) => &e.item,
473        }
474    }
475
476    /// The enchantments on this piece — empty for the plain form. `BTreeMap`
477    /// ordered, so emission order is the id order and never hash order
478    /// (ADR-0006).
479    pub fn enchantments(&self) -> &BTreeMap<String, u32> {
480        static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
481            std::sync::LazyLock::new(BTreeMap::new);
482        match self {
483            EquipItem::Plain(_) => &EMPTY,
484            EquipItem::Enchanted(e) => &e.enchantments,
485        }
486    }
487}
488
489/// **The item component a stack's enchantments are written to** — vanilla's own
490/// rule (`EnchantmentHelper.getComponentType` at 1.21.11): an enchanted book
491/// STORES its enchantments (`minecraft:stored_enchantments`, what an anvil
492/// applies to the item it is combined with), and every other item CARRIES them
493/// (`minecraft:enchantments`). Both are in the pinned 1.21.11
494/// `data_component_type` registry.
495///
496/// A property of the item, not of the surface that writes the stack: a book in
497/// a `loot` chest, a book handed over by `give-item` and a book on an equipped
498/// piece are one object, so every emitter asks this one function. An
499/// enchantment map written to the other component is a book that glints and
500/// an anvil ignores, which the game accepts without a word.
501pub fn enchantment_component(item: &str) -> &'static str {
502    match item.strip_prefix("minecraft:").unwrap_or(item) {
503        "enchanted_book" => "minecraft:stored_enchantments",
504        _ => "minecraft:enchantments",
505    }
506}
507
508/// The enchanted form of [`EquipItem`].
509#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
510#[serde(deny_unknown_fields)]
511pub struct EnchantedItem {
512    /// Item id (e.g. `minecraft:netherite_chestplate`).
513    pub item: String,
514    /// Enchantment id → level (e.g. `{"minecraft:protection": 4}`). Emitted as
515    /// the 1.21 `minecraft:enchantments` item component.
516    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
517    pub enchantments: BTreeMap<String, u32>,
518}
519
520/// Attribute overrides for a wave mob (DSL v0.4). Each field maps to a 1.21.11
521/// `minecraft:` attribute component; an unset field keeps the vanilla base value.
522#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
523#[serde(deny_unknown_fields)]
524pub struct MobAttributes {
525    /// `minecraft:max_health` base value.
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub max_health: Option<f64>,
528    /// `minecraft:attack_damage` base value.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub attack_damage: Option<f64>,
531    /// `minecraft:movement_speed` base value.
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub movement_speed: Option<f64>,
534    /// `minecraft:follow_range` base value.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub follow_range: Option<f64>,
537}
538
539/// One permanent status effect on a wave mob (DSL v0.4), emitted as an ambient,
540/// non-expiring `effect give`.
541#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
542#[serde(deny_unknown_fields)]
543pub struct MobEffect {
544    /// Vanilla effect id (e.g. `minecraft:slowness`), validated against the
545    /// pinned registry (`DW0192`).
546    pub effect: String,
547    /// Amplifier (0 = level I).
548    pub amplifier: u32,
549}
550
551// ---------------------------------------------------------------------------
552// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
553// ---------------------------------------------------------------------------
554
555use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
556use crate::envelope::Campaign;
557use crate::registry::{AnchorRegistry, EffectRegistry, EntityRegistry, ItemRegistry};
558use crate::validate::{
559    AnchorProviders, declares_bonfire, for_each_effect_deep, for_each_trap_payload_deep,
560    for_each_trigger_effect_deep, quest_ancestors, station_kind_diag,
561};
562use crate::{Objective, Verb};
563use std::collections::BTreeSet;
564
565crate::dw_code! {
566    /// (spec-0016 §1) A wave declares `respawns_on_rest: true` but the campaign
567    /// declares no `bonfire` — nothing can ever re-seat it, so the field is a
568    /// silent no-op. Either add the bonfire the re-seat is meant to hang off, or
569    /// drop the field.
570    pub const REST_RESEAT_NO_BONFIRE: DwCode = DwCode::new("DW0370", ExitTier::Build);
571}
572
573crate::dw_code! {
574    /// A `drops[]` `slot` entry does not
575    /// name a distinct slot the same entity's `equipment` actually fills — the
576    /// slot is empty, or the same slot is declared twice. A mob can only leave
577    /// behind a piece it wears, and it can only leave it behind once.
578    pub const DROP_SLOT_UNFILLED: DwCode = DwCode::new("DW0490", ExitTier::Build);
579}
580
581crate::dw_code! {
582    /// `drops[]` on an encounter that is
583    /// not billed `elite` or `boss`. Only a named fight leaves anything behind;
584    /// an ordinary mob's kit is never farmable (no-grind constitution), so the
585    /// declaration is refused rather than silently making rank-and-file gear
586    /// lootable.
587    pub const DROP_NOT_TIERED: DwCode = DwCode::new("DW0491", ExitTier::Build);
588}
589
590crate::dw_code! {
591    /// A `collect` `dropped_by` is not backed by the wave it names:
592    /// the wave declares no `{item}` drop of this objective's item, the count
593    /// asks for more copies than the wave's mobs can yield, or the objective
594    /// also declares a `container` (the item cannot come out of a box *and* off
595    /// a body).
596    pub const DROP_COLLECT_UNSOURCED: DwCode = DwCode::new("DW0492", ExitTier::Build);
597}
598
599crate::dw_code! {
600    /// A `collect` `dropped_by` is not ordered after the fight that
601    /// produces it: no `kill` objective for that wave precedes this collect in
602    /// the objective graph. Without that edge "kill the boss, take its key" is
603    /// an authoring intention the quest graph cannot prove, and the collect
604    /// reads as reachable from the campaign's first tick.
605    pub const DROP_COLLECT_UNORDERED: DwCode = DwCode::new("DW0493", ExitTier::Build);
606}
607
608crate::dw_code! {
609    /// (spec-0016 §6) A wave's TD `lane` / `summon` declaration is structurally
610    /// invalid or internally contradictory: an empty `waypoints` list, a
611    /// waypoint anchor no area's prefab provides, a repeated consecutive
612    /// waypoint, an `aggro_radius` outside `4..=64`, a mob whose
613    /// `attributes.follow_range` disagrees with `aggro_radius` (they MUST be
614    /// equal — a patrolling raider holds ground against a target it cannot
615    /// engage), or `lane` together with `summon: aggro-edge` (a lane IS the
616    /// routing; aggro-edge is its opposite).
617    pub const LANE_INVALID: DwCode = DwCode::new("DW0381", ExitTier::Build);
618}
619
620crate::dw_code! {
621    /// (spec-0016 §6) A lane wave contains a non-raider species. `Patrolling` /
622    /// `patrol_target` are Raider NBT: on anything else they are dropped and the
623    /// mob simply stands where it spawned. The admitted set is vanilla's own
624    /// `#minecraft:raiders` tag, read from the vendored tag table — never a
625    /// species list this engine keeps. Non-raiders use `summon: aggro-edge`
626    /// instead.
627    pub const LANE_NOT_RAIDER: DwCode = DwCode::new("DW0382", ExitTier::Build);
628}
629
630crate::dw_code! {
631    /// (spec-0016 §6) A lane wave fields fewer than 2 mobs. A lone patroller
632    /// sets `Patrolling:0b` on itself when it finds no companion within its
633    /// follow range (vanilla), so a one-mob lane cancels itself.
634    pub const LANE_SQUAD_TOO_SMALL: DwCode = DwCode::new("DW0383", ExitTier::Build);
635}
636
637crate::dw_code! {
638    /// (spec-0016 §6) A lane `pillager` is not holding a crossbow. Its only
639    /// attack goal is the crossbow goal, so a pillager that acquires a target it
640    /// has no runnable attack for freezes in place indefinitely — patrol blocked
641    /// by the target, nothing to run instead (live-verified deadlock).
642    pub const LANE_UNARMED: DwCode = DwCode::new("DW0384", ExitTier::Build);
643}
644
645crate::dw_code! {
646    /// (spec-0016 §6) A `summon: aggro-edge` wave mob declares no
647    /// `attributes.follow_range`. That radius IS the summon ring — the distance
648    /// at which the mob perceives the party — so it is authored, never guessed
649    /// from a vanilla defaults table the compiler cannot verify.
650    pub const AGGRO_EDGE_NO_RANGE: DwCode = DwCode::new("DW0385", ExitTier::Build);
651}
652
653crate::dw_code! {
654    /// (v0.6) A campaign fields scripted `actors[]` (an
655    /// ambush desugars into these too) but **no** `waves[]` and no declared
656    /// `world.difficulty`, so the compiler's historical derivation ships
657    /// `difficulty=peaceful` — under which every one of those actors that is a
658    /// hostile species is discarded on the tick it spawns. The compiler cannot
659    /// decide the question for the author: the pinned entity registry is a
660    /// membership set with no mob-category data, so "is this actor a monster" is
661    /// not something it can verify rather than guess. Advisory (warning,
662    /// exit 0) — declaring `world.difficulty` settles it either way.
663    pub const DIFFICULTY_UNDECLARED_ACTORS: DwCode = DwCode::new("DW0469", ExitTier::Build);
664}
665
666crate::dw_code! {
667    /// (spec-0016 §1, spec-0023, souls ruling 5/7: "stage bosses never respawn
668    /// on rest") A wave declares BOTH `tier: boss` and `respawns_on_rest: true`.
669    /// `tier` and `respawns_on_rest` are two fields on the same [`Wave`]
670    /// declaration — the only place a "boss" billing and a "re-seat on rest"
671    /// contract can land on one another; an [`Actor`] carries `tier` too but has
672    /// no `respawns_on_rest` field at all (it is killed by hand, never re-seated
673    /// by a bonfire), so this is the sole structurally expressible violation of
674    /// the ruling. A rest-respawning boss re-fight breaks the retry economy the
675    /// ruling exists to protect: a boss is the campaign's named fight, not
676    /// trash pressure the party grinds back down every rest. Validation-tier
677    /// (exit 1), `dsl::validate`. Prescription: drop `tier: boss` if the
678    /// encounter really is meant to re-seat (bill it `elite` instead), or drop
679    /// `respawns_on_rest` if it really is the boss.
680    ///
681    /// [`Wave`]: crate::Wave
682    /// [`Actor`]: crate::Actor
683    pub const BOSS_RESPAWNS_ON_REST: DwCode = DwCode::new("DW0499", ExitTier::Build);
684}
685
686/// The vanilla `entity_type` tag whose members honour `Patrolling` /
687/// `patrol_target`: `#minecraft:raiders`.
688///
689/// On anything outside it the keys are inert — the mob stands where it spawned —
690/// which is the silent no-op class `DW0382` exists to make loud.
691///
692/// **The species list is Mojang's, never ours**, the same rule `DW0496` follows
693/// for `#minecraft:burn_in_daylight`. In the pinned game these are the same six
694/// types by two independent routes: the data branch publishes them as this tag,
695/// and the code branch makes exactly them subclasses of `PatrollingMonster` —
696/// whose own `registerGoals` adds the `LongDistancePatrolGoal` every one of them
697/// inherits. A hand-written table is how the two come apart, and had: it named
698/// five, omitting `minecraft:illusioner`, so a lane of illusioners was refused a
699/// march the game would have walked.
700const LANE_RAIDER_TAG: &str = "minecraft:raiders";
701
702/// Whether `entity` may be fielded in a lane — membership of [`LANE_RAIDER_TAG`].
703///
704/// `#minecraft:raiders` names only concrete types in the pinned game (no nested
705/// `#tag` member), so [`crate::registry::entity_in_tag`]'s deliberate
706/// non-expansion cannot narrow this set.
707fn is_lane_raider(entity: &str) -> bool {
708    crate::registry::entity_in_tag(entity, LANE_RAIDER_TAG)
709}
710
711/// Species whose ONLY attack goal is gated on holding a specific weapon: they
712/// acquire a target, find no runnable attack goal, and freeze — while the patrol
713/// goal stays blocked by the very target they cannot hit (`DW0384`). A pillager
714/// is a crossbow mob and nothing else, so this table has exactly one row.
715///
716/// The near miss is `minecraft:illusioner`, whose ranged goal is bow-gated the
717/// same way — but it also carries two spell goals that are gated on nothing but
718/// a target, so a bare-handed illusioner has something runnable and does not
719/// freeze. Every other raider melees or casts bare-handed.
720const LANE_WEAPON_GATED: [(&str, &str); 1] = [("pillager", "minecraft:crossbow")];
721
722/// The bare entity id (`minecraft:pillager` → `pillager`).
723fn bare_entity(id: &str) -> &str {
724    id.strip_prefix("minecraft:").unwrap_or(id)
725}
726
727/// The advisory half of the difficulty surface
728/// (`DW0469`): a campaign that stages a **fighting** actor but declares no
729/// `waves[]` and no `world.difficulty` ships the compiler's derived
730/// `difficulty=peaceful` — under which the server discards every
731/// hostile-category mob as it ticks it (`/summon`ed, `NoAI` and
732/// `PersistenceRequired` all irrelevant), so that fighter is gone on the tick it
733/// spawns and the beat that summoned it plays to an empty room.
734///
735/// "Fighting" is read off the campaign's own declarations, never guessed from
736/// the species: an `unleash-actor` (the author asking for a real-AI twin) or
737/// `vulnerable: true` (the author declaring a damageable target). Both are
738/// statements of combat intent the compiler can see. The species question — is
739/// `minecraft:sheep` a monster? — is exactly what it cannot answer, because the
740/// pinned entity registry is a membership set with no mob-category data, which
741/// is also why this is advisory rather than an error.
742///
743/// Gated with the rest of the v0.6 quests surface, where actors live —
744/// deliberately NOT on the world stage's version, so a campaign whose world
745/// stage is older still hears about it.
746pub(crate) fn difficulty_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
747    if c.world.content.difficulty.is_some() || !c.quests.content.waves.is_empty() {
748        return;
749    }
750    let mut fighters: BTreeSet<String> = c
751        .quests
752        .content
753        .actors
754        .iter()
755        .filter(|a| a.vulnerable)
756        .map(|a| a.id.as_str().to_string())
757        .collect();
758    for q in &c.quests.content.quests {
759        for_each_effect_deep(q, |_, eff| {
760            if let Verb::UnleashActor { actor, .. } = &eff.verb {
761                fighters.insert(actor.as_str().to_string());
762            }
763        });
764    }
765    for t in &c.quests.content.triggers {
766        for_each_trigger_effect_deep(t, |_, eff| {
767            if let Verb::UnleashActor { actor, .. } = &eff.verb {
768                fighters.insert(actor.as_str().to_string());
769            }
770        });
771    }
772    for t in &c.quests.content.traps {
773        for_each_trap_payload_deep(t, |_, eff| {
774            if let Verb::UnleashActor { actor, .. } = &eff.verb {
775                fighters.insert(actor.as_str().to_string());
776            }
777        });
778    }
779    if fighters.is_empty() {
780        return;
781    }
782    d.push(Diagnostic::warning(
783        DIFFICULTY_UNDECLARED_ACTORS,
784        "world",
785        "/content/difficulty".to_string(),
786        format!(
787            "this campaign stages {} actor(s) meant to FIGHT ({}) — unleashed into a real-AI twin, \
788             or declared `vulnerable` — but declares no `waves[]` and no `world.difficulty`, so it \
789             ships the compiler's derived `difficulty=peaceful`. On peaceful the server discards \
790             every hostile-category mob as it ticks it, so a monster among these is gone on the \
791             tick it spawns and the beat that summoned it plays to an empty room. Declare \
792             `world.difficulty` on the world stage: `easy` reproduces the halved-damage world \
793             existing combat numbers were tuned in, `normal` is the vanilla baseline. (If every \
794             one of them is a passive species, there is nothing to fix.)",
795            fighters.len(),
796            fighters
797                .iter()
798                .map(String::as_str)
799                .collect::<Vec<_>>()
800                .join(", ")
801        ),
802    ));
803}
804
805/// Validate the spec-0016 §6 wave `lane` / `summon` surface.
806///
807/// Five rules, five codes, each pinned to a live-verified 1.21.11 failure mode:
808/// * `DW0381` — the declaration does not resolve or contradicts itself;
809/// * `DW0382` — a lane species outside the raider family (the NBT is inert);
810/// * `DW0383` — a lane squad below 2 (a lone patroller self-cancels);
811/// * `DW0384` — a lane pillager without its crossbow (target-acquisition deadlock);
812/// * `DW0385` — an aggro-edge mob with no authored `follow_range` (no ring radius).
813///
814/// Anchor resolution stays lenient for pool areas the compiler resolves later —
815/// the same policy as the trap, trigger and shortcut checks. Waypoint *geometry*
816/// (standable, reachable, spaced > 10) is a build-tier proof over the assembled
817/// world (`DW0386`), not a validation-tier one.
818pub(crate) fn lane_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
819    let quests = &c.quests.content;
820    if quests
821        .waves
822        .iter()
823        .all(|w| w.lane.is_none() && w.summon.is_none())
824    {
825        return;
826    }
827    let providers = AnchorProviders::build(c, anchors);
828
829    for (i, w) in quests.waves.iter().enumerate() {
830        let aggro_edge = w.summon == Some(crate::WaveSummon::AggroEdge);
831        if aggro_edge {
832            if w.lane.is_some() {
833                d.push(Diagnostic::error(
834                    LANE_INVALID,
835                    "quests",
836                    format!("/content/waves/{i}/summon"),
837                    format!(
838                        "wave `{}` declares BOTH a `lane` and `summon: aggro-edge` (spec-0016 §6) \
839                         — a lane IS the routing (march while distant, native AI once aggroed), \
840                         and aggro-edge is its opposite (materialize already at the edge of \
841                         perception, no routing at all). Pick one.",
842                        w.id
843                    ),
844                ));
845            }
846            for (k, m) in w.mobs.iter().enumerate() {
847                if m.attributes.and_then(|a| a.follow_range).is_none() {
848                    d.push(Diagnostic::error(
849                        AGGRO_EDGE_NO_RANGE,
850                        "quests",
851                        format!("/content/waves/{i}/mobs/{k}/attributes"),
852                        format!(
853                            "`summon: aggro-edge` mob `{}` in wave `{}` declares no \
854                             `attributes.follow_range` (spec-0016 §6). That radius IS the summon \
855                             ring — the distance at which this mob perceives the party — so it is \
856                             authored, never guessed: the compiler will not fabricate a vanilla \
857                             default it cannot verify against the pinned server.",
858                            m.entity, w.id
859                        ),
860                    ));
861                }
862            }
863        }
864        let Some(lane) = &w.lane else { continue };
865
866        if lane.waypoints.is_empty() {
867            d.push(Diagnostic::error(
868                LANE_INVALID,
869                "quests",
870                format!("/content/waves/{i}/lane/waypoints"),
871                format!(
872                    "wave `{}` declares a `lane` with no waypoints (spec-0016 §6) — a lane is a \
873                     polyline the squad marches; give it at least one waypoint anchor",
874                    w.id
875                ),
876            ));
877        }
878        for (k, wp) in lane.waypoints.iter().enumerate() {
879            if let Some(f) = station_kind_diag(
880                &providers,
881                wp.as_str(),
882                crate::layout::StationKind::Point,
883                "a lane waypoint",
884                "quests",
885                format!("/content/waves/{i}/lane/waypoints/{k}"),
886            ) {
887                d.push(f);
888            }
889            if !providers.resolvable(wp.as_str()) {
890                d.push(Diagnostic::error(
891                    LANE_INVALID,
892                    "quests",
893                    format!("/content/waves/{i}/lane/waypoints/{k}"),
894                    format!(
895                        "lane waypoint anchor `{wp}` is not provided by any area's prefab — {}",
896                        providers.anchor_remedy(
897                            "use an anchor a prefab exposes (anchor names come from prefab \
898                             metadata; do NOT invent one)"
899                        ),
900                    ),
901                ));
902            }
903            if k > 0 && lane.waypoints[k - 1] == *wp {
904                d.push(Diagnostic::error(
905                    LANE_INVALID,
906                    "quests",
907                    format!("/content/waves/{i}/lane/waypoints/{k}"),
908                    format!(
909                        "lane waypoint `{wp}` repeats the one before it — the squad would be told \
910                         to march to where it already stands, and vanilla re-rolls a patrol \
911                         target on arrival. Remove the repeat."
912                    ),
913                ));
914            }
915        }
916        if !(4..=64).contains(&lane.aggro_radius) {
917            d.push(Diagnostic::error(
918                LANE_INVALID,
919                "quests",
920                format!("/content/waves/{i}/lane/aggro_radius"),
921                format!(
922                    "lane `aggro_radius` {} on wave `{}` is outside 4..=64 (spec-0016 §6). It is \
923                     emitted verbatim as the mobs' `follow_range` attribute AND as the release \
924                     radius; below 4 the squad walks into contact before it can see anyone, and \
925                     past 64 it aggroes across the whole delve.",
926                    lane.aggro_radius, w.id
927                ),
928            ));
929        }
930        if w.mobs.iter().map(|m| m.count).sum::<u32>() < 2 {
931            d.push(Diagnostic::error(
932                LANE_SQUAD_TOO_SMALL,
933                "quests",
934                format!("/content/waves/{i}/mobs"),
935                format!(
936                    "lane wave `{}` fields fewer than 2 mobs (spec-0016 §6). A lone patroller \
937                     sets `Patrolling:0b` on ITSELF when it finds no companion within its follow \
938                     range — vanilla behaviour, live-verified — so a one-mob lane cancels its own \
939                     routing and just stands there. Field a squad of at least 2.",
940                    w.id
941                ),
942            ));
943        }
944        for (k, m) in w.mobs.iter().enumerate() {
945            let bare = bare_entity(&m.entity);
946            if !is_lane_raider(&m.entity) {
947                d.push(Diagnostic::error(
948                    LANE_NOT_RAIDER,
949                    "quests",
950                    format!("/content/waves/{i}/mobs/{k}/entity"),
951                    format!(
952                        "lane wave `{}` fields `{}`, which is not raider-family (spec-0016 §6). \
953                         `Patrolling`/`patrol_target` are Raider NBT: on any other species they \
954                         are simply dropped and the mob stands where it spawned. Lane species \
955                         (vanilla's own `#{LANE_RAIDER_TAG}` tag): {}. For anything else use \
956                         `summon: aggro-edge`, which needs no patrol AI.",
957                        w.id,
958                        m.entity,
959                        crate::registry::entity_tag_members_bare(LANE_RAIDER_TAG).join(" / ")
960                    ),
961                ));
962            }
963            if let Some((_, weapon)) = LANE_WEAPON_GATED.iter().find(|(s, _)| *s == bare) {
964                let held = m
965                    .equipment
966                    .as_ref()
967                    .and_then(|e| e.main_hand.as_ref())
968                    .map_or(*weapon, |p| p.item());
969                if held != *weapon {
970                    d.push(Diagnostic::error(
971                        LANE_UNARMED,
972                        "quests",
973                        format!("/content/waves/{i}/mobs/{k}/equipment/main_hand"),
974                        format!(
975                            "lane `{bare}` in wave `{}` holds `{held}` instead of `{weapon}` \
976                             (spec-0016 §6). Its ONLY attack goal is the crossbow goal, so on \
977                             acquiring a target it has nothing runnable to do — and the patrol \
978                             goal is meanwhile blocked BY that target. The mob freezes in place \
979                             indefinitely (live-verified deadlock). Give it the crossbow, or drop \
980                             the `main_hand` override and take the compiler's default.",
981                            w.id
982                        ),
983                    ));
984                }
985            }
986        }
987        if let Some(bad) = w.mobs.iter().enumerate().find(|(_, m)| {
988            m.attributes
989                .and_then(|a| a.follow_range)
990                .is_some_and(|r| r != f64::from(lane.aggro_radius))
991        }) {
992            let (k, m) = bad;
993            d.push(Diagnostic::error(
994                LANE_INVALID,
995                "quests",
996                format!("/content/waves/{i}/mobs/{k}/attributes/follow_range"),
997                format!(
998                    "lane mob `{}` in wave `{}` declares `follow_range` {} but the lane's \
999                     `aggro_radius` is {} (spec-0016 §6). They MUST be equal: the release radius \
1000                     is where routing hands over to native AI, and a patrolling raider that \
1001                     targets a player outside its engagement range HOLDS GROUND instead of \
1002                     marching — the squad stalls mid-lane. Drop the override (the compiler sets \
1003                     `follow_range` from `aggro_radius`) or make the two agree.",
1004                    m.entity,
1005                    w.id,
1006                    m.attributes
1007                        .and_then(|a| a.follow_range)
1008                        .unwrap_or_default(),
1009                    lane.aggro_radius
1010                ),
1011            ));
1012        }
1013    }
1014}
1015
1016/// Declared drops: what an elite or boss
1017/// leaves behind is a **declared subset**, never automatically everything.
1018///
1019/// Four rules, all of them about the gap between what a campaign says and what
1020/// the world can actually produce:
1021///
1022/// * `DW0491` — only an `elite`/`boss` encounter may declare drops. Rank-and-file
1023///   gear stays unfarmable by construction (no-grind constitution).
1024/// * `DW0490` — a `slot` entry must name a **distinct** slot the same entity's
1025///   own `equipment` fills. A body cannot leave behind a piece it never wore.
1026/// * `DW0492` — a `dropped_by` collect must be backed by the wave it names: the
1027///   wave really declares that item, in at least the count the objective asks
1028///   for, and the objective does not also adopt a container.
1029/// * `DW0493` — that collect must be **ordered after** the fight, so the chain
1030///   "kill the boss → take its key → open the door" is a proof rather than an
1031///   intention.
1032pub(crate) fn check_drops(
1033    c: &Campaign,
1034    quests: &crate::QuestsContent,
1035    items: &dyn ItemRegistry,
1036    d: &mut Vec<Diagnostic>,
1037) {
1038    use crate::{EncounterTier, MobDrop};
1039
1040    // --- the declaration side: waves and actors ---------------------------
1041    let tiered =
1042        |t: Option<EncounterTier>| matches!(t, Some(EncounterTier::Elite | EncounterTier::Boss));
1043    for (i, w) in quests.waves.iter().enumerate() {
1044        for (k, m) in w.mobs.iter().enumerate() {
1045            if m.drops.is_empty() {
1046                continue;
1047            }
1048            if !tiered(w.tier) {
1049                d.push(Diagnostic::error(
1050                    DROP_NOT_TIERED,
1051                    "quests",
1052                    format!("/content/waves/{i}/mobs/{k}/drops"),
1053                    format!(
1054                        "wave `{}` declares drops but is not billed `elite` or `boss` — only a \
1055                         named fight leaves anything behind; an ordinary mob's kit is never \
1056                         farmable. Declare the wave's `tier`, or remove the `drops`",
1057                        w.id
1058                    ),
1059                ));
1060            }
1061            check_drop_list(
1062                &m.drops,
1063                m.equipment.as_ref(),
1064                &format!("wave `{}` mob {k}", w.id),
1065                &format!("/content/waves/{i}/mobs/{k}/drops"),
1066                items,
1067                d,
1068            );
1069        }
1070    }
1071    for (i, a) in quests.actors.iter().enumerate() {
1072        if a.drops.is_empty() {
1073            continue;
1074        }
1075        if !tiered(a.tier) {
1076            d.push(Diagnostic::error(
1077                DROP_NOT_TIERED,
1078                "quests",
1079                format!("/content/actors/{i}/drops"),
1080                format!(
1081                    "actor `{}` declares drops but is not billed `elite` or `boss` — only a named \
1082                     fight leaves anything behind; a staged puppet's kit is never farmable. \
1083                     Declare the actor's `tier`, or remove the `drops`",
1084                    a.id
1085                ),
1086            ));
1087        }
1088        check_drop_list(
1089            &a.drops,
1090            a.equipment.as_ref(),
1091            &format!("actor `{}`", a.id),
1092            &format!("/content/actors/{i}/drops"),
1093            items,
1094            d,
1095        );
1096    }
1097
1098    // --- the consumption side: `collect.dropped_by` ------------------------
1099    // How many copies of each item every wave can yield: one per declaring mob
1100    // in the stack, so a pair of elites each dropping a sword yields two.
1101    let mut yielded: BTreeMap<&str, BTreeMap<&str, u32>> = BTreeMap::new();
1102    for w in &quests.waves {
1103        let per = yielded.entry(w.id.as_str()).or_default();
1104        for m in &w.mobs {
1105            for dr in &m.drops {
1106                if let MobDrop::Item(it) = dr {
1107                    *per.entry(it.item.as_str()).or_default() += m.count;
1108                }
1109            }
1110        }
1111    }
1112    let anc = quest_ancestors(c);
1113    // Which quests hold a `kill` objective for each wave, and which objective ids.
1114    let mut kills: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
1115    for q in &quests.quests {
1116        for o in &q.objectives {
1117            if let Objective::Kill { wave, id, .. } = o {
1118                kills
1119                    .entry(wave.as_str())
1120                    .or_default()
1121                    .push((q.id.as_str(), id.as_str()));
1122            }
1123        }
1124    }
1125    for (i, q) in quests.quests.iter().enumerate() {
1126        let after_anc = objective_ancestors(q);
1127        for (j, o) in q.objectives.iter().enumerate() {
1128            let Objective::Collect {
1129                id,
1130                item,
1131                count,
1132                container,
1133                dropped_by: Some(wave),
1134                ..
1135            } = o
1136            else {
1137                continue;
1138            };
1139            let path = format!("/content/quests/{i}/objectives/{j}/dropped_by");
1140            if container.is_some() {
1141                d.push(Diagnostic::error(
1142                    DROP_COLLECT_UNSOURCED,
1143                    "quests",
1144                    path.clone(),
1145                    format!(
1146                        "`collect` `{id}` declares both `dropped_by` (wave `{wave}`) and a \
1147                         `container` — the item comes off a body or out of a box, not both; drop \
1148                         whichever provisioning this beat does not use"
1149                    ),
1150                ));
1151            }
1152            let Some(per) = yielded.get(wave.as_str()) else {
1153                // Unknown wave: the ordinary dangling-reference diagnostic
1154                // (`DW0170`) already names it; nothing to add here.
1155                continue;
1156            };
1157            match per.get(item.as_str()).copied() {
1158                None => d.push(Diagnostic::error(
1159                    DROP_COLLECT_UNSOURCED,
1160                    "quests",
1161                    path.clone(),
1162                    format!(
1163                        "`collect` `{id}` takes `{item}` off wave `{wave}`, but no mob of that \
1164                         wave declares a `{{\"item\": \"{item}\"}}` drop — {}. Declare the drop on \
1165                         the wave's mob, or point `dropped_by` at the wave that really carries it",
1166                        if per.is_empty() {
1167                            "the wave declares no item drops at all".to_string()
1168                        } else {
1169                            format!(
1170                                "it declares {}",
1171                                per.keys().cloned().collect::<Vec<_>>().join(", ")
1172                            )
1173                        }
1174                    ),
1175                )),
1176                Some(have) if have < *count => d.push(Diagnostic::error(
1177                    DROP_COLLECT_UNSOURCED,
1178                    "quests",
1179                    path.clone(),
1180                    format!(
1181                        "`collect` `{id}` asks for {count} × `{item}`, but wave `{wave}` yields \
1182                         only {have} — a body drops its declared item once. Lower the `count`, or \
1183                         raise the declaring mob's `count`"
1184                    ),
1185                )),
1186                Some(_) => {}
1187            }
1188            // The ordering proof: some `kill` objective for this wave must
1189            // strictly precede this collect — in the same quest through the
1190            // `after` graph, or in a quest this one transitively depends on.
1191            let ordered = kills.get(wave.as_str()).is_some_and(|ks| {
1192                ks.iter().any(|(kq, ko)| {
1193                    if *kq == q.id.as_str() {
1194                        after_anc
1195                            .get(id.as_str())
1196                            .is_some_and(|set| set.contains(ko))
1197                    } else {
1198                        anc.get(q.id.as_str()).is_some_and(|set| set.contains(kq))
1199                    }
1200                })
1201            });
1202            if !ordered {
1203                d.push(Diagnostic::error(
1204                    DROP_COLLECT_UNORDERED,
1205                    "quests",
1206                    path,
1207                    format!(
1208                        "`collect` `{id}` takes `{item}` off wave `{wave}`, but no `kill` \
1209                         objective for `{wave}` is proven to run first — the item would be \
1210                         unreachable while the objective reads as active from the campaign's \
1211                         first tick. Add a `kill` objective for `{wave}` and list it in this \
1212                         objective's `after`, or put the kill in a quest this one `depends_on`"
1213                    ),
1214                ));
1215            }
1216        }
1217    }
1218}
1219
1220/// One entity's `drops[]` list: distinct, really-worn slots (`DW0490`) and
1221/// registry-valid quest items (`DW0143`). Shared by wave mobs and actors so the
1222/// two surfaces cannot drift.
1223fn check_drop_list(
1224    drops: &[crate::MobDrop],
1225    equipment: Option<&crate::MobEquipment>,
1226    what: &str,
1227    base_path: &str,
1228    items: &dyn ItemRegistry,
1229    d: &mut Vec<Diagnostic>,
1230) {
1231    use crate::MobDrop;
1232
1233    let mut seen_slots: BTreeSet<&'static str> = BTreeSet::new();
1234    for (n, dr) in drops.iter().enumerate() {
1235        match dr {
1236            MobDrop::Slot(s) => {
1237                let field = s.slot.field();
1238                let filled: Vec<&str> = equipment
1239                    .map(|eq| {
1240                        eq.slots()
1241                            .into_iter()
1242                            .filter(|(_, p)| p.is_some())
1243                            .map(|(name, _)| name)
1244                            .collect()
1245                    })
1246                    .unwrap_or_default();
1247                if equipment.is_none_or(|eq| eq.filled(s.slot).is_none()) {
1248                    d.push(Diagnostic::error(
1249                        DROP_SLOT_UNFILLED,
1250                        "quests",
1251                        format!("{base_path}/{n}/slot"),
1252                        format!(
1253                            "{what} declares a `{field}` drop, but its `equipment` puts nothing \
1254                             in `{field}` — {}. A body can only leave behind a piece it wears: \
1255                             equip the slot, or drop a slot it fills",
1256                            if filled.is_empty() {
1257                                "it declares no equipment at all".to_string()
1258                            } else {
1259                                format!("it fills {}", filled.join(", "))
1260                            }
1261                        ),
1262                    ));
1263                } else if !seen_slots.insert(field) {
1264                    d.push(Diagnostic::error(
1265                        DROP_SLOT_UNFILLED,
1266                        "quests",
1267                        format!("{base_path}/{n}/slot"),
1268                        format!(
1269                            "{what} declares the `{field}` drop twice — a body leaves each piece \
1270                             behind once; remove the duplicate entry"
1271                        ),
1272                    ));
1273                }
1274            }
1275            MobDrop::Item(it) => {
1276                if !items.contains(&it.item) {
1277                    d.push(Diagnostic::error(
1278                        codes::ITEM_UNKNOWN,
1279                        "quests",
1280                        format!("{base_path}/{n}/item"),
1281                        format!(
1282                            "{what} declares a drop of `{}`, which is not in the pinned 1.21.11 \
1283                             item registry — use a valid namespaced item id (e.g. \
1284                             `minecraft:tripwire_hook`)",
1285                            it.item
1286                        ),
1287                    ));
1288                }
1289            }
1290        }
1291    }
1292}
1293
1294/// Per-objective transitive `after` ancestors within one quest: `obj -> {every
1295/// objective that must complete before it}`. Acyclicity is guaranteed by
1296/// `DW0140`; a cyclic quest simply yields a partial set and the cycle's own
1297/// diagnostic fires.
1298fn objective_ancestors(q: &crate::Quest) -> BTreeMap<&str, BTreeSet<&str>> {
1299    let direct: BTreeMap<&str, Vec<&str>> = q
1300        .objectives
1301        .iter()
1302        .map(|o| {
1303            (
1304                o.id().as_str(),
1305                o.after().iter().map(|a| a.as_str()).collect::<Vec<_>>(),
1306            )
1307        })
1308        .collect();
1309    let mut out = BTreeMap::new();
1310    for o in &q.objectives {
1311        let mut anc: BTreeSet<&str> = BTreeSet::new();
1312        let mut stack = vec![o.id().as_str()];
1313        while let Some(cur) = stack.pop() {
1314            if let Some(ds) = direct.get(cur) {
1315                for dep in ds {
1316                    if anc.insert(dep) {
1317                        stack.push(dep);
1318                    }
1319                }
1320            }
1321        }
1322        out.insert(o.id().as_str(), anc);
1323    }
1324    out
1325}
1326
1327/// Validate one [`MobEquipment`] block — item ids against the pinned registry
1328/// (`DW0143`) and every piece's enchantments against the pinned enchantment
1329/// registry (`DW0433`) and level range (`DW0434`).
1330///
1331/// Shared verbatim by wave mobs and actors so the two surfaces cannot drift:
1332/// they are the same schema type and therefore must be the same rules.
1333pub(crate) fn check_equipment(
1334    eq: &crate::MobEquipment,
1335    what: &str,
1336    base_path: &str,
1337    items: &dyn ItemRegistry,
1338    d: &mut Vec<Diagnostic>,
1339) {
1340    let ench_reg = crate::registry::VendoredEnchantmentRegistry::v1_21_11();
1341    for (slot, piece) in eq.slots() {
1342        let Some(piece) = piece else { continue };
1343        let it = piece.item();
1344        if !items.contains(it) {
1345            d.push(Diagnostic::error(
1346                codes::ITEM_UNKNOWN,
1347                "quests",
1348                format!("{base_path}/{slot}"),
1349                format!(
1350                    "{what} equipment `{slot}` item `{it}` is not in the pinned 1.21.11 \
1351                     item registry — use a valid namespaced item id (e.g. \
1352                     `minecraft:iron_helmet`)"
1353                ),
1354            ));
1355        }
1356        check_enchantments(
1357            piece.enchantments(),
1358            &format!("{what} equipment `{slot}`"),
1359            "quests",
1360            &format!("{base_path}/{slot}/enchantments"),
1361            &ench_reg,
1362            d,
1363        );
1364    }
1365}
1366
1367/// Wave-mob `equipment` (spec-0014): item ids and enchantments
1368/// ([`check_equipment`]).
1369pub(crate) fn wave_equipment_checks(
1370    c: &Campaign,
1371    items: &dyn ItemRegistry,
1372    d: &mut Vec<Diagnostic>,
1373) {
1374    let quests = &c.quests.content;
1375    // Wave-mob `equipment` item ids: every present slot must name a
1376    // pinned-1.21.11 item — the same registry and DW family as `give-item`
1377    // (`DW0143`).
1378    for (i, w) in quests.waves.iter().enumerate() {
1379        for (k, m) in w.mobs.iter().enumerate() {
1380            let Some(eq) = &m.equipment else { continue };
1381            check_equipment(
1382                eq,
1383                "wave-mob",
1384                &format!("/content/waves/{i}/mobs/{k}/equipment"),
1385                items,
1386                d,
1387            );
1388        }
1389    }
1390}
1391
1392/// A wave's `respawns_on_rest` (spec-0016 §1): inert with no `bonfire`
1393/// (`DW0370`), and forbidden on a `boss` (`DW0499`).
1394pub(crate) fn rest_reseat_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
1395    let quests = &c.quests.content;
1396    // spec-0016 §1: `respawns_on_rest` is re-seating *by a bonfire*. With no
1397    // `bonfire` anywhere in the campaign nothing can ever fire the re-seat, so
1398    // the field is a silent no-op — the class of defect this compiler always
1399    // turns loud (`DW0370`).
1400    let has_bonfire = declares_bonfire(c);
1401    if !has_bonfire {
1402        for (i, w) in quests.waves.iter().enumerate() {
1403            if w.respawns_on_rest {
1404                d.push(Diagnostic::error(
1405                    REST_RESEAT_NO_BONFIRE,
1406                    "quests",
1407                    format!("/content/waves/{i}/respawns_on_rest"),
1408                    format!(
1409                        "wave `{}` declares `respawns_on_rest: true` but this campaign declares \
1410                         no `bonfire` — nothing can ever re-seat it, so the field is inert. Add \
1411                         the `bonfire` the re-seat hangs off (spec-0016 §1), or drop the field; \
1412                         do NOT leave a silently dead declaration in the DSL.",
1413                        w.id.as_str()
1414                    ),
1415                ));
1416            }
1417        }
1418    }
1419
1420    // spec-0016 §1 + spec-0023, souls ruling 5/7 ("stage bosses never respawn
1421    // on rest"): `tier` and
1422    // `respawns_on_rest` are two fields on the SAME wave declaration — the only
1423    // place a "boss" billing and a "re-seat on rest" contract can land on one
1424    // another (an actor carries `tier` too, but has no `respawns_on_rest` field
1425    // at all, so it cannot express this violation). A rest-respawning boss
1426    // re-fight breaks the retry economy the ruling protects. Checked
1427    // unconditionally of `has_bonfire`: the combination is forbidden on its own
1428    // terms, not merely inert like `DW0370`.
1429    for (i, w) in quests.waves.iter().enumerate() {
1430        if w.respawns_on_rest && w.tier == Some(EncounterTier::Boss) {
1431            d.push(Diagnostic::error(
1432                BOSS_RESPAWNS_ON_REST,
1433                "quests",
1434                format!("/content/waves/{i}/respawns_on_rest"),
1435                format!(
1436                    "wave `{}` declares `tier: boss` AND `respawns_on_rest: true` — souls \
1437                     ruling 5/7 is that stage bosses never respawn on rest, since a \
1438                     rest-respawning boss re-fight breaks the retry economy the ruling \
1439                     protects. Drop `respawns_on_rest` if this really is the boss, or drop \
1440                     `tier: boss` (bill it `elite` instead) if the encounter is meant to \
1441                     re-seat.",
1442                    w.id.as_str()
1443                ),
1444            ));
1445        }
1446    }
1447}
1448
1449/// Wave declarations (spec-0001 v0.3): id syntax (`DW0110`), uniqueness
1450/// (`DW0111`) and every mob's entity id (`DW0173`).
1451pub(crate) fn wave_decl_checks(
1452    c: &Campaign,
1453    entities: &dyn EntityRegistry,
1454    d: &mut Vec<Diagnostic>,
1455) {
1456    let quests = &c.quests.content;
1457
1458    // Wave declarations.
1459    let mut seen_waves: BTreeSet<&str> = BTreeSet::new();
1460    for (i, w) in quests.waves.iter().enumerate() {
1461        if !w.id.is_valid_syntax() {
1462            d.push(Diagnostic::error(
1463                codes::ID_SYNTAX,
1464                "quests",
1465                format!("/content/waves/{i}/id"),
1466                format!(
1467                    "malformed wave id `{}` — wave ids must be lowercase kebab-case with the \
1468                     `wave/` prefix (e.g. `wave/ambush`)",
1469                    w.id
1470                ),
1471            ));
1472        }
1473        if !seen_waves.insert(w.id.as_str()) {
1474            d.push(Diagnostic::error(
1475                codes::ID_DUPLICATE,
1476                "quests",
1477                format!("/content/waves/{i}/id"),
1478                format!(
1479                    "duplicate wave id `{}` — rename one so every wave id is unique",
1480                    w.id
1481                ),
1482            ));
1483        }
1484        for (k, m) in w.mobs.iter().enumerate() {
1485            if !entities.contains(&m.entity) {
1486                d.push(Diagnostic::error(
1487                    codes::ENTITY_UNKNOWN,
1488                    "quests",
1489                    format!("/content/waves/{i}/mobs/{k}/entity"),
1490                    format!(
1491                        "wave-mob entity `{}` is not a known 1.21.11 entity id — use a valid \
1492                         namespaced entity id (e.g. `minecraft:zombie`)",
1493                        m.entity
1494                    ),
1495                ));
1496            }
1497        }
1498    }
1499}
1500
1501/// Every wave mob's status effects name a 1.21.11 effect (`DW0192`).
1502pub(crate) fn mob_effect_checks(
1503    c: &Campaign,
1504    effects: &dyn EffectRegistry,
1505    d: &mut Vec<Diagnostic>,
1506) {
1507    let quests = &c.quests.content;
1508
1509    // --- wave-mob effects + attributes ---
1510    for (i, w) in quests.waves.iter().enumerate() {
1511        for (k, m) in w.mobs.iter().enumerate() {
1512            for (e, eff) in m.effects.iter().enumerate() {
1513                if !effects.contains(&eff.effect) {
1514                    d.push(Diagnostic::error(
1515                        codes::EFFECT_UNKNOWN,
1516                        "quests",
1517                        format!("/content/waves/{i}/mobs/{k}/effects/{e}/effect"),
1518                        format!(
1519                            "wave-mob effect `{}` is not a known 1.21.11 status-effect id — use a \
1520                             valid namespaced effect id (e.g. `minecraft:strength`)",
1521                            eff.effect
1522                        ),
1523                    ));
1524                }
1525            }
1526        }
1527    }
1528}
1529
1530// ---------------------------------------------------------------------------
1531// Validation
1532// ---------------------------------------------------------------------------
1533
1534crate::dw_code! {
1535    /// (spec-0021) An `equipment` or `loot` enchantment id is not in the pinned
1536    /// 1.21.11 enchantment registry.
1537    pub const ENCHANTMENT_UNKNOWN: DwCode = DwCode::new("DW0433", ExitTier::Build);
1538}
1539
1540crate::dw_code! {
1541    /// (spec-0021) An enchantment level is outside the 1..=255 range vanilla's
1542    /// `minecraft:enchantments` component can carry.
1543    pub const ENCHANTMENT_LEVEL: DwCode = DwCode::new("DW0434", ExitTier::Build);
1544}
1545
1546/// Validate an enchantment map: known ids (`DW0433`), legal levels (`DW0434`).
1547///
1548/// Levels are checked against what the `minecraft:enchantments` **component**
1549/// can carry (1..=255), not against each enchantment's survival max. Exceeding
1550/// the survival max from a command is legal vanilla and is a legitimate way to
1551/// build a set-piece elite, so refusing it would be the compiler overruling a
1552/// design decision it cannot second-guess; 0 and >255 are simply not
1553/// representable and would be silently dropped by the game.
1554pub(crate) fn check_enchantments(
1555    ench: &std::collections::BTreeMap<String, u32>,
1556    what: &str,
1557    stage: &'static str,
1558    path: &str,
1559    reg: &dyn crate::registry::EnchantmentRegistry,
1560    d: &mut Vec<Diagnostic>,
1561) {
1562    for (id, level) in ench {
1563        if !reg.contains(id) {
1564            d.push(Diagnostic::error(
1565                ENCHANTMENT_UNKNOWN,
1566                stage,
1567                format!("{path}/{id}"),
1568                format!(
1569                    "{what} enchantment `{id}` is not in the pinned 1.21.11 enchantment \
1570                     registry — use a valid namespaced enchantment id (e.g. \
1571                     `minecraft:protection`, `minecraft:sharpness`). Note the vanilla \
1572                     ids for curses are `minecraft:binding_curse` and \
1573                     `minecraft:vanishing_curse`, NOT `curse_of_binding`."
1574                ),
1575            ));
1576        }
1577        if *level == 0 || *level > 255 {
1578            d.push(Diagnostic::error(
1579                ENCHANTMENT_LEVEL,
1580                stage,
1581                format!("{path}/{id}"),
1582                format!(
1583                    "{what} enchantment `{id}` has level {level}, outside the 1..=255 range \
1584                     the `minecraft:enchantments` component stores. Levels above an \
1585                     enchantment's survival maximum ARE allowed (that is how a set-piece \
1586                     elite is built) — but 0 means \"not enchanted\" and is silently \
1587                     dropped by the game, so declare the level you want or remove the entry."
1588                ),
1589            ));
1590        }
1591    }
1592}